diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index fd309f9..bd3a5cf 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -4,13 +4,14 @@ import { SearchBox } from '@/components/SearchBox'; import { ThemedText } from '@/components/ThemedText'; import { ThemedView } from '@/components/ThemedView'; import { Colors } from '@/constants/Colors'; +import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useColorScheme } from '@/hooks/useColorScheme'; import { listRecommendedArticles } from '@/services/articles'; import { fetchRecommendations, RecommendationType } from '@/services/recommendations'; +import { loadPlans, type TrainingPlan } from '@/store/trainingPlanSlice'; // Removed WorkoutCard import since we no longer use the horizontal carousel import { useAuthGuard } from '@/hooks/useAuthGuard'; import { getChineseGreeting } from '@/utils/date'; -import dayjs from 'dayjs'; import { useRouter } from 'expo-router'; import React from 'react'; import { Animated, Image, PanResponder, Pressable, SafeAreaView, ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native'; @@ -20,12 +21,17 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; export default function HomeScreen() { const router = useRouter(); + const dispatch = useAppDispatch(); const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard(); const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const colorTokens = Colors[theme]; const insets = useSafeAreaInsets(); const { width: windowWidth, height: windowHeight } = useWindowDimensions(); + // 训练计划状态 + const { plans, currentId } = useAppSelector((s) => s.trainingPlan); + const [activePlan, setActivePlan] = React.useState(null); + // Draggable coach badge state const pan = React.useRef(new Animated.ValueXY()).current; const [coachSize, setCoachSize] = React.useState({ width: 0, height: 0 }); @@ -101,6 +107,17 @@ export default function HomeScreen() { // 打底数据(接口不可用时) const getFallbackItems = React.useCallback((): RecommendItem[] => { return [ + { + type: 'plan', + key: 'today-workout', + image: + 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/imagedemo.jpeg', + title: '今日训练', + subtitle: '完成一次普拉提训练,记录你的坚持', + level: '初学者', + progress: 0, + onPress: () => pushIfAuthedElseLogin('/workout/today'), + }, { type: 'plan', key: 'assess', @@ -122,10 +139,27 @@ export default function HomeScreen() { readCount: a.readCount, })), ]; - }, [router]); + }, [router, pushIfAuthedElseLogin]); const [items, setItems] = React.useState(() => getFallbackItems()); + // 加载训练计划数据 + React.useEffect(() => { + if (isLoggedIn) { + dispatch(loadPlans()); + } + }, [isLoggedIn, dispatch]); + + // 获取激活的训练计划 + React.useEffect(() => { + if (isLoggedIn && currentId && plans.length > 0) { + const currentPlan = plans.find(p => p.id === currentId); + setActivePlan(currentPlan || null); + } else { + setActivePlan(null); + } + }, [isLoggedIn, currentId, plans]); + // 拉取推荐接口(已登录时) React.useEffect(() => { let canceled = false; @@ -158,10 +192,10 @@ export default function HomeScreen() { type: 'plan', key: c.id || 'checkin', image: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/imagedemo.jpeg', - title: c.title || '今日打卡', + title: c.title || '今日训练', subtitle: c.subtitle || '完成一次普拉提训练,记录你的坚持', progress: 0, - onPress: () => pushIfAuthedElseLogin('/checkin?date=' + dayjs().format('YYYY-MM-DD')), + onPress: () => pushIfAuthedElseLogin('/workout/today'), }); } } @@ -176,6 +210,14 @@ export default function HomeScreen() { return () => { canceled = true; }; }, [isLoggedIn, pushIfAuthedElseLogin, getFallbackItems]); + // 处理点击训练计划卡片,跳转到锻炼tab + const handlePlanCardPress = () => { + if (activePlan) { + // 跳转到训练计划页面的锻炼tab,并传递planId参数 + router.push(`/training-plan?planId=${activePlan.id}&tab=schedule` as any); + } + }; + return ( @@ -244,36 +286,37 @@ export default function HomeScreen() { style={[styles.featureCard, styles.featureCardPrimary]} onPress={() => router.push('/ai-posture-assessment')} > + + + AI体态评估 - 3分钟获取体态报告 - - - router.push('/ai-coach-chat?name=Sarah' as any)} - > - 在线教练 - 认证教练 · 1对1即时解答 - - - pushIfAuthedElseLogin('/checkin?date=' + dayjs().format('YYYY-MM-DD'))} - > - 每日打卡 - 自选动作 · 记录完成 pushIfAuthedElseLogin('/training-plan')} > - 训练计划制定 - 按周安排 · 个性化目标 + + + 💪 + + + 计划管理 + {/* My Plan Section - 显示激活的训练计划 */} + {/* {activePlan && ( + + )} */} + {/* Today Plan Section */} 为你推荐 @@ -408,48 +451,89 @@ const styles = StyleSheet.create({ featureGrid: { paddingHorizontal: 24, flexDirection: 'row', - justifyContent: 'space-between', - flexWrap: 'wrap', + gap: 12, }, featureCard: { - width: '48%', + flex: 1, + flexDirection: 'row', + alignItems: 'center', borderRadius: 12, - padding: 12, + paddingHorizontal: 12, + paddingVertical: 10, backgroundColor: '#FFFFFF', - marginBottom: 12, - // 轻量阴影,减少臃肿感 + // 精致的阴影效果 shadowColor: '#000', - shadowOpacity: 0.04, + shadowOpacity: 0.06, shadowRadius: 8, - shadowOffset: { width: 0, height: 4 }, - elevation: 2, + shadowOffset: { width: 0, height: 2 }, + elevation: 3, + // 渐变边框效果 + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.8)', + // 添加微妙的内阴影效果 + position: 'relative', + minHeight: 48, }, featureCardPrimary: { - backgroundColor: '#EEF2FF', // 柔和的靛蓝背景 + // 由于RN不支持CSS渐变,使用渐变色背景 + backgroundColor: '#667eea', }, featureCardSecondary: { - backgroundColor: '#F0FDFA', // 柔和的青绿背景 + backgroundColor: '#4facfe', }, featureCardTertiary: { - backgroundColor: '#FFF7ED', // 柔和的橙色背景 + backgroundColor: '#43e97b', }, featureCardQuaternary: { - backgroundColor: '#F5F3FF', // 柔和的紫色背景 + backgroundColor: '#fa709a', }, - featureIcon: { - fontSize: 28, - marginBottom: 8, + featureIconWrapper: { + width: 32, + height: 32, + borderRadius: 16, + backgroundColor: 'rgba(255, 255, 255, 0.25)', + alignItems: 'center', + justifyContent: 'center', + marginRight: 10, + // 图标容器的阴影 + shadowColor: '#000', + shadowOpacity: 0.06, + shadowRadius: 4, + shadowOffset: { width: 0, height: 1 }, + elevation: 2, }, + featureIconImage: { + width: 20, + height: 20, + borderRadius: 10, + resizeMode: 'cover', + }, + featureIconPlaceholder: { + width: 20, + height: 20, + borderRadius: 10, + backgroundColor: 'rgba(255, 255, 255, 0.3)', + alignItems: 'center', + justifyContent: 'center', + }, + featureIconText: { + fontSize: 12, + }, + featureTitle: { - fontSize: 16, + fontSize: 14, fontWeight: '700', - color: '#0F172A', - marginBottom: 4, + color: '#FFFFFF', + textAlign: 'left', + letterSpacing: 0.2, + flex: 1, }, featureSubtitle: { - fontSize: 11, - color: '#6B7280', - lineHeight: 15, + fontSize: 12, + color: 'rgba(255, 255, 255, 0.85)', + lineHeight: 16, + textAlign: 'center', + fontWeight: '500', }, planList: { paddingHorizontal: 24, diff --git a/app/ai-coach-chat.tsx b/app/ai-coach-chat.tsx index 083c0a1..0ec67c3 100644 --- a/app/ai-coach-chat.tsx +++ b/app/ai-coach-chat.tsx @@ -557,7 +557,7 @@ export default function AICoachChatScreen() { style={styles.weightInput} onSubmitEditing={(e) => handleSubmitWeight(e.nativeEvent.text)} returnKeyType="done" - blurOnSubmit + submitBehavior="blurAndSubmit" /> kg handleSubmitWeight((preset || '').toString())}> @@ -603,7 +603,7 @@ export default function AICoachChatScreen() { // 不阻断对话体验 } // 在对话中插入“确认消息”并发送给教练 - const textMsg = `我记录了今日体重:${val} kg。请基于这一变化给出训练/营养建议。`; + const textMsg = `记录了今日体重:${val} kg。`; await send(textMsg); } catch (e: any) { Alert.alert('保存失败', e?.message || '请稍后重试'); diff --git a/app/checkin/calendar.tsx b/app/checkin/calendar.tsx deleted file mode 100644 index 670641f..0000000 --- a/app/checkin/calendar.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { HeaderBar } from '@/components/ui/HeaderBar'; -import { Colors } from '@/constants/Colors'; -import { useAppDispatch, useAppSelector } from '@/hooks/redux'; -import { useColorScheme } from '@/hooks/useColorScheme'; -import { DailyStatusItem, fetchDailyStatusRange } from '@/services/checkins'; -import { loadMonthCheckins } from '@/store/checkinSlice'; -import { getMonthDaysZh } from '@/utils/date'; -import dayjs from 'dayjs'; -import { useRouter } from 'expo-router'; -import React, { useEffect, useMemo, useState } from 'react'; -import { Dimensions, FlatList, SafeAreaView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; - -function formatDate(d: Date) { - const y = d.getFullYear(); - const m = `${d.getMonth() + 1}`.padStart(2, '0'); - const day = `${d.getDate()}`.padStart(2, '0'); - return `${y}-${m}-${day}`; -} - -export default function CheckinCalendarScreen() { - const router = useRouter(); - const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; - const colorTokens = Colors[theme]; - const dispatch = useAppDispatch(); - const checkin = useAppSelector((s) => (s as any).checkin); - const insets = useSafeAreaInsets(); - - const [cursor, setCursor] = useState(dayjs()); - const days = useMemo(() => getMonthDaysZh(cursor), [cursor]); - const monthTitle = useMemo(() => `${cursor.format('YYYY年M月')} 打卡`, [cursor]); - const [statusMap, setStatusMap] = useState>({}); - - useEffect(() => { - dispatch(loadMonthCheckins({ year: cursor.year(), month1Based: cursor.month() + 1 })); - const y = cursor.year(); - const m = cursor.month() + 1; - const pad = (n: number) => `${n}`.padStart(2, '0'); - const startDate = `${y}-${pad(m)}-01`; - const endDate = `${y}-${pad(m)}-${pad(new Date(y, m, 0).getDate())}`; - fetchDailyStatusRange(startDate, endDate) - .then((list: DailyStatusItem[]) => { - const next: Record = {}; - for (const it of list) { - if (typeof it?.date === 'string') next[it.date] = !!it?.checkedIn; - } - setStatusMap(next); - }) - .catch(() => setStatusMap({})); - }, [cursor, dispatch]); - - const goPrevMonth = () => setCursor((c) => c.subtract(1, 'month')); - const goNextMonth = () => setCursor((c) => c.add(1, 'month')); - - return ( - - - router.back()} withSafeTop={false} transparent /> - - 上一月 - {monthTitle} - 下一月 - - - item.date.format('YYYY-MM-DD')} - numColumns={5} - columnWrapperStyle={{ justifyContent: 'space-between', marginBottom: 12 }} - contentContainerStyle={{ paddingHorizontal: 20, paddingTop: 10, paddingBottom: insets.bottom + 20 }} - renderItem={({ item }) => { - const d = item.date.toDate(); - const dateStr = formatDate(d); - const hasAny = statusMap[dateStr] ?? !!(checkin?.byDate?.[dateStr]?.items?.length); - const isToday = formatDate(new Date()) === dateStr; - return ( - { - // 通过路由参数传入日期,便于目标页初始化 - router.push({ pathname: '/checkin', params: { date: dateStr } }); - }} - activeOpacity={0.8} - style={[styles.dayCell, { backgroundColor: colorTokens.card }, hasAny && styles.dayCellCompleted, isToday && styles.dayCellToday]} - > - {item.dayOfMonth} - {hasAny && } - - ); - }} - /> - - - ); -} - -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' }, - headerRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 20, paddingTop: 6 }, - monthTitle: { fontSize: 18, fontWeight: '800' }, - monthBtn: { paddingHorizontal: 12, paddingVertical: 8, borderRadius: 999 }, - monthBtnText: { fontWeight: '700' }, - dayCell: { - width: cellSize, - height: cellSize, - borderRadius: 16, - alignItems: 'center', - justifyContent: 'center', - shadowColor: '#000', shadowOpacity: 0.06, shadowRadius: 12, shadowOffset: { width: 0, height: 6 }, elevation: 3, - position: 'relative', - }, - dayCellCompleted: { backgroundColor: '#ECFDF5', borderWidth: 1, borderColor: '#A7F3D0' }, - dayCellToday: { borderWidth: 1, borderColor: '#BBF246' }, - dayNumber: { fontWeight: '800', fontSize: 16 }, - dot: { position: 'absolute', top: 6, right: 6, width: 8, height: 8, borderRadius: 4, backgroundColor: '#10B981' }, -}); - - diff --git a/app/checkin/index.tsx b/app/checkin/index.tsx deleted file mode 100644 index 092fcd6..0000000 --- a/app/checkin/index.tsx +++ /dev/null @@ -1,385 +0,0 @@ -import { HeaderBar } from '@/components/ui/HeaderBar'; -import { Colors } from '@/constants/Colors'; -import { useAppDispatch, useAppSelector } from '@/hooks/redux'; -import { useColorScheme } from '@/hooks/useColorScheme'; -import type { CheckinExercise } from '@/store/checkinSlice'; -import { getDailyCheckins, removeExercise, replaceExercises, setCurrentDate, toggleExerciseCompleted } from '@/store/checkinSlice'; -import { loadPlans, type TrainingPlan } from '@/store/trainingPlanSlice'; -import { buildClassicalSession } from '@/utils/classicalSession'; -import { Ionicons } from '@expo/vector-icons'; -import { useLocalSearchParams, useRouter } from 'expo-router'; -import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { Alert, FlatList, Modal, SafeAreaView, StyleSheet, Switch, Text, TextInput, TouchableOpacity, View } from 'react-native'; - -function formatDate(d: Date) { - const y = d.getFullYear(); - const m = `${d.getMonth() + 1}`.padStart(2, '0'); - const day = `${d.getDate()}`.padStart(2, '0'); - return `${y}-${m}-${day}`; -} - -export default function CheckinHome() { - const dispatch = useAppDispatch(); - const router = useRouter(); - const params = useLocalSearchParams<{ date?: string }>(); - const today = useMemo(() => formatDate(new Date()), []); - const checkin = useAppSelector((s) => (s as any).checkin); - const training = useAppSelector((s) => (s as any).trainingPlan); - const routeDateParam = typeof params?.date === 'string' && params.date ? params.date : undefined; - const currentDate: string = routeDateParam || (checkin?.currentDate as string) || today; - const record = checkin?.byDate?.[currentDate] as (undefined | { items?: CheckinExercise[]; note?: string; raw?: any[] }); - const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; - const colorTokens = Colors[theme]; - - console.log('CheckinHome render', { - currentDate, - routeDateParam, - itemsCount: record?.items?.length || 0, - rawCount: (record as any)?.raw?.length || 0, - }); - - const lastFetchedRef = useRef(null); - useEffect(() => { - // 初始化当前日期:路由参数优先,其次 store,最后今天 - if (currentDate && checkin?.currentDate !== currentDate) { - dispatch(setCurrentDate(currentDate)); - } - // 仅当切换日期时获取一次,避免重复请求 - if (currentDate && lastFetchedRef.current !== currentDate) { - lastFetchedRef.current = currentDate; - dispatch(getDailyCheckins(currentDate)).unwrap().catch((err: any) => { - Alert.alert('获取打卡失败', err?.message || '请稍后重试'); - }); - } - }, [dispatch, currentDate]); - - // 加载训练计划列表:仅在页面挂载时尝试一次,避免因失败导致的重复请求 - const hasLoadedPlansRef = useRef(false); - useEffect(() => { - if (hasLoadedPlansRef.current) return; - hasLoadedPlansRef.current = true; - dispatch(loadPlans()); - }, [dispatch]); - - // 同步触发逻辑改为显式操作处调用,避免页面渲染期间的副作用 - - const [genVisible, setGenVisible] = useState(false); - const [genLevel, setGenLevel] = useState<'beginner' | 'intermediate' | 'advanced'>('beginner'); - const [genWithRests, setGenWithRests] = useState(true); - const [genWithNotes, setGenWithNotes] = useState(true); - const [genRest, setGenRest] = useState('30'); - - // 计算“进行中的训练计划”(startDate <= 当前日期)。若 currentId 存在,优先该计划。 - const activePlan: TrainingPlan | null = useMemo(() => { - const plans: TrainingPlan[] = training?.plans || []; - if (!plans.length) return null; - const current = training?.currentId ? plans.find((p) => p.id === training.currentId) : null; - const dateObj = new Date(`${currentDate}T00:00:00`); - if (current && new Date(current.startDate) <= dateObj) return current; - const ongoing = plans - .filter((p) => new Date(p.startDate) <= dateObj) - .sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime()); - return ongoing[0] ?? null; - }, [training?.plans, training?.currentId, currentDate]); - - const planStartText = useMemo(() => { - if (!activePlan?.startDate) return ''; - const d = new Date(activePlan.startDate); - const y = d.getFullYear(); - const m = `${d.getMonth() + 1}`.padStart(2, '0'); - const day = `${d.getDate()}`.padStart(2, '0'); - return `${y}-${m}-${day}`; - }, [activePlan?.startDate]); - - const onGenerate = () => { - const restSec = Math.max(10, Math.min(120, parseInt(genRest || '30', 10))); - const { items, note } = buildClassicalSession({ withSectionRests: genWithRests, restSeconds: restSec, withNotes: genWithNotes, level: genLevel }); - dispatch(replaceExercises({ date: currentDate, items, note })); - // 自动同步将由中间件处理 - setGenVisible(false); - Alert.alert('排课已生成', '已为你生成经典普拉提序列,可继续调整。'); - }; - - return ( - - - - - - - - router.back()} withSafeTop={false} transparent /> - - {currentDate} - 请选择动作并记录完成情况 - - - {/* 训练计划提示(非强制) */} - - {activePlan ? ( - - - 已有训练计划进行中 - {!!planStartText && ( - 开始于 {planStartText} - )} - - router.push('/training-plan' as any)} - accessibilityRole="button" - accessibilityLabel="查看训练计划" - > - 查看训练计划 - - - ) : ( - - - 你还没有训练计划 - 创建计划可明确每周节奏与目标(可跳过) - - router.push('/training-plan/create' as any)} - accessibilityRole="button" - accessibilityLabel="创建训练计划" - > - 创建训练计划 - - - )} - - - - router.push({ pathname: '/checkin/select', params: { date: currentDate } })} - > - 新增动作 - - - setGenVisible(true)} - > - 一键排课(经典序列) - - - - 0) - ? record.items - : ((record as any)?.raw || [])} - keyExtractor={(item, index) => (item?.key || item?.id || `${currentDate}_${index}`)} - contentContainerStyle={{ paddingHorizontal: 20, paddingBottom: 20 }} - ListEmptyComponent={ - - 还没有选择任何动作,点击“新增动作”开始吧。 - - } - renderItem={({ item }) => { - // 若为后端原始项(无 key),以标题/时间为卡片,禁用交互 - const isRaw = !item?.key; - if (isRaw) { - const title = item?.title || '每日训练打卡'; - const status = item?.status || ''; - const startedAt = item?.startedAt ? new Date(item.startedAt).toLocaleString() : ''; - return ( - - - {title} - {!!status && {status}} - {!!startedAt && {startedAt}} - - - ); - } - const exercise = item as CheckinExercise; - const type = exercise.itemType ?? 'exercise'; - const isRest = type === 'rest'; - const isNote = type === 'note'; - const cardStyle = [styles.card, { backgroundColor: colorTokens.card }]; - if (isRest || isNote) { - return ( - - - - - {isRest ? `间隔休息 ${exercise.restSec ?? 30}s` : (exercise.note || '提示')} - - - - Alert.alert('确认移除', '确定要移除该条目吗?', [ - { text: '取消', style: 'cancel' }, - { - text: '移除', - style: 'destructive', - onPress: () => { - dispatch(removeExercise({ date: currentDate, key: exercise.key })); - // 自动同步将由中间件处理 - }, - }, - ]) - } - hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }} - > - - - - ); - } - return ( - - - {exercise.name} - {exercise.category} - {isNote && ( - {exercise.note || '提示'} - )} - {!isNote && ( - - {isRest - ? `建议休息 ${exercise.restSec ?? 30}s` - : `组数 ${exercise.sets}${exercise.reps ? ` · 每组 ${exercise.reps} 次` : ''}${exercise.durationSec ? ` · 每组 ${exercise.durationSec}s` : ''}`} - - )} - - {type === 'exercise' && ( - { - dispatch(toggleExerciseCompleted({ date: currentDate, key: exercise.key })); - // 自动同步将由中间件处理 - }} - hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }} - > - - - )} - - Alert.alert('确认移除', '确定要移除该动作吗?', [ - { text: '取消', style: 'cancel' }, - { - text: '移除', - style: 'destructive', - onPress: () => { - dispatch(removeExercise({ date: currentDate, key: exercise.key })); - // 自动同步将由中间件处理 - }, - }, - ]) - } - > - 移除 - - - ); - }} - /> - {/* 生成配置弹窗 */} - setGenVisible(false)}> - setGenVisible(false)}> - e.stopPropagation() as any}> - 经典排课配置 - 强度水平 - - {(['beginner', 'intermediate', 'advanced'] as const).map((lv) => ( - setGenLevel(lv)}> - - {lv === 'beginner' ? '入门' : lv === 'intermediate' ? '进阶' : '高级'} - - - ))} - - - 段间休息 - - - - 插入操作提示 - - - - 休息秒数 - - - - - 生成今日计划 - - - - - - - ); -} - -const styles = StyleSheet.create({ - safeArea: { flex: 1, backgroundColor: '#F7F8FA' }, - container: { flex: 1, backgroundColor: '#F7F8FA' }, - header: { paddingHorizontal: 20, paddingTop: 12, paddingBottom: 8 }, - headerRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', zIndex: 2 }, - backButton: { width: 32, height: 32, borderRadius: 16, alignItems: 'center', justifyContent: 'center', backgroundColor: '#E5E7EB' }, - hero: { backgroundColor: 'rgba(187,242,70,0.18)', borderRadius: 16, padding: 14 }, - title: { fontSize: 24, fontWeight: '800', color: '#111827' }, - subtitle: { marginTop: 6, fontSize: 12, color: '#6B7280' }, - bgOrnaments: { position: 'absolute', left: 0, right: 0, top: 0, bottom: 0 }, - blob: { position: 'absolute', width: 260, height: 260, borderRadius: 999 }, - blobPrimary: { backgroundColor: '#00000000' }, - blobPurple: { backgroundColor: '#00000000' }, - actionRow: { paddingHorizontal: 20, marginTop: 8 }, - primaryBtn: { backgroundColor: '#111827', paddingVertical: 10, borderRadius: 10, alignItems: 'center' }, - primaryBtnText: { color: '#FFFFFF', fontWeight: '800' }, - secondaryBtn: { borderWidth: 2, paddingVertical: 10, borderRadius: 10, alignItems: 'center' }, - secondaryBtnText: { fontWeight: '800' }, - // 训练计划提示卡片 - planHintCard: { flexDirection: 'row', alignItems: 'center', gap: 10, borderRadius: 14, paddingHorizontal: 14, paddingVertical: 12, borderWidth: 1 }, - planHintTitle: { fontSize: 14, fontWeight: '800' }, - planHintSub: { marginTop: 4, fontSize: 12 }, - hintBtn: { paddingHorizontal: 10, paddingVertical: 8, borderRadius: 10, borderWidth: 1 }, - hintBtnText: { fontWeight: '800' }, - hintPrimaryBtn: { paddingHorizontal: 12, paddingVertical: 10, borderRadius: 10 }, - hintPrimaryBtnText: { fontWeight: '800' }, - emptyBox: { marginTop: 16, backgroundColor: '#FFFFFF', borderRadius: 16, padding: 16, marginHorizontal: 0 }, - emptyText: { color: '#6B7280' }, - card: { marginTop: 12, marginHorizontal: 0, backgroundColor: '#FFFFFF', borderRadius: 16, padding: 16, flexDirection: 'row', alignItems: 'center', gap: 12, shadowColor: '#000', shadowOpacity: 0.06, shadowRadius: 12, shadowOffset: { width: 0, height: 6 }, elevation: 3 }, - cardTitle: { fontSize: 16, fontWeight: '800', color: '#111827' }, - cardMeta: { marginTop: 4, fontSize: 12, color: '#6B7280' }, - cardMetaItalic: { marginTop: 4, fontSize: 12, color: '#6B7280', fontStyle: 'italic' }, - removeBtn: { backgroundColor: '#F3F4F6', paddingHorizontal: 10, paddingVertical: 6, borderRadius: 8 }, - removeBtnText: { color: '#111827', fontWeight: '700' }, - doneIconBtn: { paddingHorizontal: 4, paddingVertical: 4, borderRadius: 16, marginRight: 8 }, - inlineRow: { marginTop: 10, marginHorizontal: 20, flexDirection: 'row', alignItems: 'center' }, - inlineBadge: { marginLeft: 6, borderWidth: 1, borderRadius: 999, paddingVertical: 6, paddingHorizontal: 10 }, - inlineBadgeRest: { backgroundColor: '#F8FAFC' }, - inlineBadgeNote: { backgroundColor: '#F9FAFB' }, - inlineText: { fontSize: 12, fontWeight: '700' }, - inlineTextItalic: { fontSize: 12, fontStyle: 'italic' }, - inlineRemoveBtn: { marginLeft: 6, padding: 4, borderRadius: 999 }, - modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.35)', alignItems: 'center', justifyContent: 'flex-end' }, - modalSheet: { width: '100%', borderTopLeftRadius: 16, borderTopRightRadius: 16, paddingHorizontal: 16, paddingTop: 14, paddingBottom: 24 }, - modalTitle: { fontSize: 16, fontWeight: '800', marginBottom: 8 }, - modalLabel: { fontSize: 12, marginBottom: 6 }, - segmentedRow: { flexDirection: 'row', gap: 8, marginBottom: 8 }, - segment: { flex: 1, borderRadius: 999, borderWidth: 1, borderColor: '#E5E7EB', paddingVertical: 8, alignItems: 'center' }, - segmentText: { fontWeight: '700' }, - switchRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginTop: 8 }, - switchLabel: { fontWeight: '700' }, - inputRow: { marginTop: 8 }, - input: { height: 40, borderWidth: 1, borderRadius: 10, paddingHorizontal: 12 }, - -}); - - diff --git a/app/checkin/select.tsx b/app/checkin/select.tsx deleted file mode 100644 index d3ba81c..0000000 --- a/app/checkin/select.tsx +++ /dev/null @@ -1,407 +0,0 @@ -import { HeaderBar } from '@/components/ui/HeaderBar'; -import { Colors } from '@/constants/Colors'; -import { useAppDispatch } from '@/hooks/redux'; -import { useColorScheme } from '@/hooks/useColorScheme'; -import { fetchExerciseConfig, normalizeToLibraryItems } from '@/services/exercises'; -import { addExercise } from '@/store/checkinSlice'; -import { EXERCISE_LIBRARY, getCategories } from '@/utils/exerciseLibrary'; -import { Ionicons } from '@expo/vector-icons'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import * as Haptics from 'expo-haptics'; -import { useLocalSearchParams, useRouter } from 'expo-router'; -import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { Animated, FlatList, LayoutAnimation, Modal, Platform, SafeAreaView, StyleSheet, Text, TextInput, TouchableOpacity, UIManager, View } from 'react-native'; - -function formatDate(d: Date) { - const y = d.getFullYear(); - const m = `${d.getMonth() + 1}`.padStart(2, '0'); - const day = `${d.getDate()}`.padStart(2, '0'); - return `${y}-${m}-${day}`; -} - -export default function SelectExerciseScreen() { - const dispatch = useAppDispatch(); - const router = useRouter(); - const params = useLocalSearchParams<{ date?: string }>(); - const today = useMemo(() => formatDate(new Date()), []); - const currentDate = (typeof params?.date === 'string' && params.date) ? params.date : today; - const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; - const colorTokens = Colors[theme]; - - const [keyword, setKeyword] = useState(''); - const [category, setCategory] = useState('全部'); - const [selectedKey, setSelectedKey] = useState(null); - const [sets, setSets] = useState(3); - const [reps, setReps] = useState(undefined); - const [showCustomReps, setShowCustomReps] = useState(false); - const [customRepsInput, setCustomRepsInput] = useState(''); - const [showCategoryPicker, setShowCategoryPicker] = useState(false); - const [serverLibrary, setServerLibrary] = useState<{ key: string; name: string; description: string; category: string }[] | null>(null); - const [serverCategories, setServerCategories] = useState(null); - - const controlsOpacity = useRef(new Animated.Value(0)).current; - - useEffect(() => { - if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) { - UIManager.setLayoutAnimationEnabledExperimental(true); - } - }, []); - useEffect(() => { - let aborted = false; - const CACHE_KEY = '@exercise_config_v1'; - (async () => { - try { - const cached = await AsyncStorage.getItem(CACHE_KEY); - if (cached && !aborted) { - const parsed = JSON.parse(cached); - const items = normalizeToLibraryItems(parsed); - if (items.length) { - setServerLibrary(items); - const cats = Array.from(new Set(items.map((i) => i.category))); - setServerCategories(cats); - } - } - } catch {} - try { - const resp = await fetchExerciseConfig(); - console.log('fetchExerciseConfig', resp); - if (aborted) return; - const items = normalizeToLibraryItems(resp); - setServerLibrary(items); - const cats = Array.from(new Set(items.map((i) => i.category))); - setServerCategories(cats); - try { await AsyncStorage.setItem(CACHE_KEY, JSON.stringify(resp)); } catch {} - } catch (err) {} - })(); - return () => { aborted = true; }; - }, []); - - const categories = useMemo(() => { - const base = serverCategories ?? getCategories(); - return ['全部', ...base]; - }, [serverCategories]); - const mainCategories = useMemo(() => { - const preferred = ['全部', '核心与腹部', '脊柱与后链', '侧链与髋', '平衡与支撑']; - const exists = (name: string) => categories.includes(name); - const picked = preferred.filter(exists); - // 兜底:若某些偏好分类不存在,补足其他分类 - const rest = categories.filter((c) => !picked.includes(c)); - while (picked.length < 5 && rest.length) picked.push(rest.shift() as string); - return picked; - }, [categories]); - const library = useMemo(() => serverLibrary ?? EXERCISE_LIBRARY, [serverLibrary]); - const filtered = useMemo(() => { - const kw = keyword.trim().toLowerCase(); - const base = kw - ? library.filter((e) => e.name.toLowerCase().includes(kw) || (e.description || '').toLowerCase().includes(kw)) - : library; - if (category === '全部') return base; - return base.filter((e) => e.category === category); - }, [keyword, category, library]); - - const selected = useMemo(() => library.find((e) => e.key === selectedKey) || null, [selectedKey, library]); - - useEffect(() => { - Animated.timing(controlsOpacity, { - toValue: selected ? 1 : 0, - duration: selected ? 220 : 160, - useNativeDriver: true, - }).start(); - }, [selected, controlsOpacity]); - - const handleAdd = () => { - if (!selected) return; - dispatch(addExercise({ - date: currentDate, - item: { - key: selected.key, - name: selected.name, - category: selected.category, - sets: Math.max(1, sets), - reps: reps && reps > 0 ? reps : undefined, - }, - })); - console.log('addExercise', currentDate, selected.key, sets, reps); - // 自动同步将由中间件处理,无需手动调用 syncCheckin - router.back(); - }; - - const onSelectItem = (key: string) => { - LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); - if (selectedKey === key) { - setSelectedKey(null); - return; - } - setSets(3); - setReps(undefined); - setShowCustomReps(false); - setCustomRepsInput(''); - setSelectedKey(key); - }; - - return ( - - - - - - - - router.back()} withSafeTop={false} transparent /> - - 从动作库里选择一个动作,设置组数与每组次数 - - - {/* 大分类宫格(无横向滚动) */} - - {[...mainCategories, '更多'].map((item) => { - const active = category === item; - const meta: Record = { - 全部: { bg: 'rgba(187,242,70,0.22)' }, - 核心与腹部: { bg: 'rgba(187,242,70,0.18)' }, - 脊柱与后链: { bg: 'rgba(149,204,227,0.20)' }, - 侧链与髋: { bg: 'rgba(164,138,237,0.20)' }, - 平衡与支撑: { bg: 'rgba(252,196,111,0.22)' }, - 进阶控制: { bg: 'rgba(237,71,71,0.18)' }, - 柔韧与拉伸: { bg: 'rgba(149,204,227,0.18)' }, - 更多: { bg: 'rgba(24,24,27,0.06)' }, - }; - const scale = new Animated.Value(1); - const onPressIn = () => Animated.spring(scale, { toValue: 0.96, useNativeDriver: true, speed: 20, bounciness: 6 }).start(); - const onPressOut = () => Animated.spring(scale, { toValue: 1, useNativeDriver: true, speed: 20, bounciness: 6 }).start(); - const handlePress = () => { - onPressOut(); - if (item === '更多') { - setShowCategoryPicker(true); - Haptics.selectionAsync(); - } else { - setCategory(item); - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - } - }; - return ( - - - {item} - - - ); - })} - - - {/* 分类选择弹层(更多) */} - setShowCategoryPicker(false)} - > - setShowCategoryPicker(false)}> - e.stopPropagation() as any} - > - 选择分类 - - {categories.filter((c) => c !== '全部').map((c) => { - const scale = new Animated.Value(1); - const onPressIn = () => Animated.spring(scale, { toValue: 0.96, useNativeDriver: true, speed: 20, bounciness: 6 }).start(); - const onPressOut = () => Animated.spring(scale, { toValue: 1, useNativeDriver: true, speed: 20, bounciness: 6 }).start(); - return ( - - { - onPressOut(); - setCategory(c); - setShowCategoryPicker(false); - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - }} - activeOpacity={0.9} - style={[styles.catTile, { backgroundColor: 'rgba(24,24,27,0.06)' }]} - > - {c} - - - ); - })} - - - - - - - - - - item.key} - contentContainerStyle={{ paddingHorizontal: 20, paddingBottom: 40 }} - renderItem={({ item }) => { - const isSelected = item.key === selectedKey; - return ( - onSelectItem(item.key)} - activeOpacity={0.9} - > - - {item.name} - {item.category} - {item.description} - - {isSelected && } - {isSelected && ( - - - - 组数 - - setSets(Math.max(1, sets - 1))}>- - {sets} - setSets(Math.min(20, sets + 1))}>+ - - - - - 每组次数 - - {[6, 8, 10, 12, 15, 20, 25, 30].map((v) => { - const active = reps === v; - return ( - { - setReps(v); - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - }} - > - {v} - - ); - })} - { - setShowCustomReps((s) => !s); - Haptics.selectionAsync(); - }} - > - 自定义 - - - {showCustomReps && ( - - - { - const n = Math.max(1, Math.min(100, parseInt(customRepsInput || '0', 10))); - if (!Number.isNaN(n)) { - setReps(n); - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); - } - }} - > - 确定 - - - )} - - - - - 添加到今日打卡 - - - )} - - ); - }} - /> - - - - - ); -} - -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', zIndex: 2 }, - backButton: { width: 32, height: 32, borderRadius: 16, alignItems: 'center', justifyContent: 'center', backgroundColor: '#E5E7EB' }, - headerTitle: { fontSize: 18, fontWeight: '800', color: '#1A1A1A' }, - subtitle: { marginTop: 6, fontSize: 12, color: '#6B7280' }, - catCard: { paddingHorizontal: 14, paddingVertical: 10, borderRadius: 14, flexDirection: 'row', alignItems: 'center' }, - catCardActive: { borderWidth: 2, borderColor: '#BBF246' }, - catEmoji: { fontSize: 16, marginRight: 6 }, - catText: { fontSize: 13, fontWeight: '800' }, - hero: { backgroundColor: 'rgba(187,242,70,0.18)', borderRadius: 16, padding: 14, marginTop: 8 }, - bgOrnaments: { position: 'absolute', left: 0, right: 0, top: 0, bottom: 0 }, - blob: { position: 'absolute', width: 260, height: 260, borderRadius: 999 }, - catGrid: { paddingHorizontal: 16, paddingTop: 10, flexDirection: 'row', flexWrap: 'wrap' }, - catTileWrapper: { width: '33.33%', padding: 6 }, - catTile: { borderRadius: 14, paddingVertical: 16, paddingHorizontal: 8, alignItems: 'center', justifyContent: 'center' }, - catTileActive: { borderWidth: 2, borderColor: '#BBF246' }, - searchRow: { paddingHorizontal: 20, marginTop: 8 }, - searchInput: { backgroundColor: '#FFFFFF', borderRadius: 12, paddingHorizontal: 12, paddingVertical: 10, color: '#111827' }, - itemCard: { backgroundColor: '#FFFFFF', borderRadius: 16, padding: 16, marginTop: 12, shadowColor: '#000', shadowOpacity: 0.06, shadowRadius: 12, shadowOffset: { width: 0, height: 6 }, elevation: 3 }, - itemCardSelected: { borderWidth: 2, borderColor: '#10B981' }, - itemTitle: { fontSize: 16, fontWeight: '800', color: '#111827' }, - itemMeta: { marginTop: 4, fontSize: 12, color: '#6B7280' }, - itemDesc: { marginTop: 6, fontSize: 12, color: '#6B7280' }, - expandedBox: { marginTop: 12 }, - controlsRow: { flexDirection: 'row', alignItems: 'center', gap: 12, flexWrap: 'wrap', marginBottom: 10 }, - 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' }, - repsChipsRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 6 }, - repChip: { paddingHorizontal: 12, paddingVertical: 8, borderRadius: 999, backgroundColor: '#F3F4F6', borderWidth: 1, borderColor: '#E5E7EB' }, - repChipText: { color: '#111827', fontWeight: '700' }, - repChipGhost: { paddingHorizontal: 12, paddingVertical: 8, borderRadius: 999, borderWidth: 1, backgroundColor: 'transparent' }, - repChipGhostText: { fontWeight: '700' }, - customRepsRow: { flexDirection: 'row', alignItems: 'center', gap: 10, marginTop: 8 }, - customRepsInput: { flex: 1, height: 40, borderWidth: 1, borderRadius: 10, paddingHorizontal: 12 }, - customRepsBtn: { paddingHorizontal: 12, paddingVertical: 10, borderRadius: 10 }, - customRepsBtnText: { fontWeight: '800' }, - modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.35)', alignItems: 'center', justifyContent: 'flex-end' }, - modalSheet: { width: '100%', borderTopLeftRadius: 16, borderTopRightRadius: 16, paddingHorizontal: 16, paddingTop: 14, paddingBottom: 24 }, - modalTitle: { fontSize: 16, fontWeight: '800', marginBottom: 8 }, - catGridModal: { flexDirection: 'row', flexWrap: 'wrap' }, - primaryBtn: { backgroundColor: '#111827', paddingVertical: 12, borderRadius: 12, alignItems: 'center' }, - primaryBtnText: { color: '#FFFFFF', fontWeight: '800' }, -}); - - diff --git a/app/training-plan.tsx b/app/training-plan.tsx index d2d9ea0..6fffb81 100644 --- a/app/training-plan.tsx +++ b/app/training-plan.tsx @@ -21,31 +21,17 @@ import { HeaderBar } from '@/components/ui/HeaderBar'; import { Colors, palette } from '@/constants/Colors'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useColorScheme } from '@/hooks/useColorScheme'; +import { + addExercise, + clearExercises, + clearError as clearScheduleError, + deleteExercise, + loadExercises, + toggleCompletion +} from '@/store/scheduleExerciseSlice'; import { activatePlan, clearError, deletePlan, loadPlans, type TrainingPlan } from '@/store/trainingPlanSlice'; import { buildClassicalSession } from '@/utils/classicalSession'; -// 训练计划排课项目类型 -export interface ScheduleExercise { - key: string; - name: string; - category: string; - sets: number; - reps?: number; - durationSec?: number; - restSec?: number; - note?: string; - itemType?: 'exercise' | 'rest' | 'note'; - completed?: boolean; -} - -// 训练计划排课数据 -export interface PlanSchedule { - planId: string; - exercises: ScheduleExercise[]; - note?: string; - lastModified: string; -} - // Tab 类型定义 type TabType = 'list' | 'schedule'; @@ -258,18 +244,15 @@ function BottomTabs({ activeTab, onTabChange, selectedPlan }: { export default function TrainingPlanScreen() { const router = useRouter(); const dispatch = useAppDispatch(); - const params = useLocalSearchParams<{ planId?: string; newExercise?: string }>(); + const params = useLocalSearchParams<{ planId?: string; tab?: string }>(); const { plans, currentId, loading, error } = useAppSelector((s) => s.trainingPlan); + const { exercises, error: scheduleError } = useAppSelector((s) => s.scheduleExercise); - // Tab 状态管理 - const [activeTab, setActiveTab] = useState('list'); + // Tab 状态管理 - 支持从URL参数设置初始tab + const initialTab: TabType = params.tab === 'schedule' ? 'schedule' : 'list'; + const [activeTab, setActiveTab] = useState(initialTab); const [selectedPlanId, setSelectedPlanId] = useState(params.planId || currentId || null); - // 排课相关状态 - const [exercises, setExercises] = useState([]); - const [scheduleNote, setScheduleNote] = useState(''); - const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); - // 一键排课配置 const [genVisible, setGenVisible] = useState(false); const [genLevel, setGenLevel] = useState<'beginner' | 'intermediate' | 'advanced'>('beginner'); @@ -279,54 +262,14 @@ export default function TrainingPlanScreen() { const selectedPlan = useMemo(() => plans.find(p => p.id === selectedPlanId), [plans, selectedPlanId]); - // 模拟加载排课数据的函数 - const loadScheduleData = async (planId: string): Promise => { - // 模拟 API 调用延迟 - await new Promise(resolve => setTimeout(resolve, 300)); - - // 模拟数据 - 在实际应用中,这里应该从后端或本地存储获取数据 - const mockData: Record = { - // 示例数据结构,实际应用中应从服务器或本地存储获取 - // 'plan1': { - // planId: 'plan1', - // exercises: [...], - // note: '示例备注', - // lastModified: new Date().toISOString() - // } - }; - - return mockData[planId] || null; - }; - - // 监听 selectedPlan 变化,加载对应的排课数据 + // 监听选中计划变化,加载对应的排课数据 useEffect(() => { - const loadSchedule = async () => { - if (selectedPlan) { - try { - const scheduleData = await loadScheduleData(selectedPlan.id); - if (scheduleData) { - setExercises(scheduleData.exercises); - setScheduleNote(scheduleData.note || ''); - } else { - // 如果没有保存的排课数据,重置为默认空状态 - setExercises([]); - setScheduleNote(''); - } - } catch (error) { - console.error('加载排课数据失败:', error); - // 出错时重置为默认空状态 - setExercises([]); - setScheduleNote(''); - } - } else { - // 没有选中计划时,重置为默认空状态 - setExercises([]); - setScheduleNote(''); - } - }; - - loadSchedule(); - }, [selectedPlan]); + if (selectedPlanId) { + dispatch(loadExercises(selectedPlanId)); + } else { + dispatch(clearExercises()); + } + }, [selectedPlanId, dispatch]); useEffect(() => { dispatch(loadPlans()); @@ -342,19 +285,15 @@ export default function TrainingPlanScreen() { } }, [error, dispatch]); - // 处理从选择页面传回的新动作 useEffect(() => { - if (params.newExercise) { - try { - const newExercise: ScheduleExercise = JSON.parse(params.newExercise); - setExercises(prev => [...prev, newExercise]); - setHasUnsavedChanges(true); - router.setParams({ newExercise: undefined } as any); - } catch (error) { - console.error('解析新动作数据失败:', error); - } + if (scheduleError) { + console.error('排课错误:', scheduleError); + const timer = setTimeout(() => { + dispatch(clearScheduleError()); + }, 3000); + return () => clearTimeout(timer); } - }, [params.newExercise]); + }, [scheduleError, dispatch]); const handleActivate = async (planId: string) => { try { @@ -367,7 +306,6 @@ export default function TrainingPlanScreen() { const handlePlanSelect = (plan: TrainingPlan) => { setSelectedPlanId(plan.id); setActiveTab('schedule'); - // TODO: 加载该计划的排课数据 } const handleTabChange = (tab: TabType) => { @@ -380,78 +318,70 @@ export default function TrainingPlanScreen() { } // 排课相关方法 - const handleSave = async () => { - if (!selectedPlan) return; - - try { - const scheduleData: PlanSchedule = { - planId: selectedPlan.id, - exercises, - note: scheduleNote, - lastModified: new Date().toISOString(), - }; - - console.log('保存排课数据:', scheduleData); - setHasUnsavedChanges(false); - Alert.alert('保存成功', '训练计划排课已保存'); - } catch (error) { - console.error('保存排课失败:', error); - Alert.alert('保存失败', '请稍后重试'); - } - }; - const handleAddExercise = () => { router.push(`/training-plan/schedule/select?planId=${selectedPlanId}` as any); }; - const handleRemoveExercise = (key: string) => { + const handleRemoveExercise = (exerciseId: string) => { + if (!selectedPlanId) return; + Alert.alert('确认移除', '确定要移除该动作吗?', [ { text: '取消', style: 'cancel' }, { text: '移除', style: 'destructive', onPress: () => { - setExercises(prev => prev.filter(ex => ex.key !== key)); - setHasUnsavedChanges(true); + dispatch(deleteExercise({ planId: selectedPlanId, exerciseId })); }, }, ]); }; - const handleToggleCompleted = (key: string) => { - setExercises(prev => prev.map(ex => - ex.key === key ? { ...ex, completed: !ex.completed } : ex - )); - setHasUnsavedChanges(true); + const handleToggleCompleted = (exerciseId: string, currentCompleted: boolean) => { + if (!selectedPlanId) return; + + dispatch(toggleCompletion({ + planId: selectedPlanId, + exerciseId, + completed: !currentCompleted + })); }; - const onGenerate = () => { + const onGenerate = async () => { + if (!selectedPlanId) return; + const restSec = Math.max(10, Math.min(120, parseInt(genRest || '30', 10))); - const { items, note } = buildClassicalSession({ + const { items } = buildClassicalSession({ withSectionRests: genWithRests, restSeconds: restSec, withNotes: genWithNotes, level: genLevel }); - const scheduleItems: ScheduleExercise[] = items.map((item, index) => ({ - key: `generated_${Date.now()}_${index}`, - name: item.name, - category: item.category, - sets: item.sets, - reps: item.reps, - durationSec: item.durationSec, - restSec: item.restSec, - note: item.note, - itemType: item.itemType, - completed: false, - })); - - setExercises(scheduleItems); - setScheduleNote(note || ''); - setHasUnsavedChanges(true); setGenVisible(false); - Alert.alert('排课已生成', '已为你生成经典普拉提序列,可继续调整。'); + + try { + // 按顺序添加每个生成的训练项目 + for (const item of items) { + const dto = { + exerciseKey: item.key, // 使用key作为exerciseKey + name: item.name, + sets: item.sets, + reps: item.reps, + durationSec: item.durationSec, + restSec: item.restSec, + note: item.note, + itemType: item.itemType || 'exercise' as const, + }; + + await dispatch(addExercise({ planId: selectedPlanId, dto })).unwrap(); + } + + Alert.alert('排课已生成', '已为你生成经典普拉提序列,可继续调整。'); + } catch (error) { + console.error('生成排课失败:', error); + Alert.alert('生成失败', '请稍后重试'); + } }; // 渲染训练计划列表 @@ -462,9 +392,9 @@ export default function TrainingPlanScreen() { 点击计划卡片进入排课模式,或使用底部切换 - {error && ( + {(error || scheduleError) && ( - ⚠️ {error} + ⚠️ {error || scheduleError} )} @@ -567,7 +497,7 @@ export default function TrainingPlanScreen() { {/* 动作列表 */} item.key} + keyExtractor={(item) => item.id} contentContainerStyle={styles.scheduleListContent} showsVerticalScrollIndicator={false} ListEmptyComponent={ @@ -607,7 +537,7 @@ export default function TrainingPlanScreen() { handleRemoveExercise(item.key)} + onPress={() => handleRemoveExercise(item.id)} hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }} > @@ -624,9 +554,9 @@ export default function TrainingPlanScreen() { {item.name} - {item.category} + {item.exercise?.categoryName || '运动'} - 组数 {item.sets} + 组数 {item.sets || 1} {item.reps ? ` · 每组 ${item.reps} 次` : ''} {item.durationSec ? ` · 每组 ${item.durationSec}s` : ''} @@ -635,7 +565,7 @@ export default function TrainingPlanScreen() { handleToggleCompleted(item.key)} + onPress={() => handleToggleCompleted(item.id, item.completed)} hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }} > handleRemoveExercise(item.key)} + onPress={() => handleRemoveExercise(item.id)} > 移除 @@ -678,10 +608,6 @@ export default function TrainingPlanScreen() { router.push('/training-plan/create' as any)} style={styles.headerRightBtn}> + 新建 - ) : hasUnsavedChanges ? ( - - 保存 - ) : undefined } /> @@ -1462,4 +1388,17 @@ const styles = StyleSheet.create({ fontWeight: '800', fontSize: 10, }, + + // 统计显示 + statsContainer: { + paddingHorizontal: 12, + paddingVertical: 4, + backgroundColor: 'rgba(187,242,70,0.2)', + borderRadius: 16, + }, + statsText: { + fontSize: 12, + fontWeight: '800', + color: palette.ink, + }, }); diff --git a/app/training-plan/schedule/select.tsx b/app/training-plan/schedule/select.tsx index 5ce8fa1..e9bf6a5 100644 --- a/app/training-plan/schedule/select.tsx +++ b/app/training-plan/schedule/select.tsx @@ -1,18 +1,18 @@ import { HeaderBar } from '@/components/ui/HeaderBar'; import { palette } from '@/constants/Colors'; -import { useAppSelector } from '@/hooks/redux'; -import { fetchExerciseConfig, normalizeToLibraryItems } from '@/services/exercises'; +import { useAppDispatch, useAppSelector } from '@/hooks/redux'; +import { loadExerciseLibrary } from '@/store/exerciseLibrarySlice'; import { EXERCISE_LIBRARY, getCategories } from '@/utils/exerciseLibrary'; import { Ionicons } from '@expo/vector-icons'; -import AsyncStorage from '@react-native-async-storage/async-storage'; import * as Haptics from 'expo-haptics'; import { LinearGradient } from 'expo-linear-gradient'; import { useLocalSearchParams, useRouter } from 'expo-router'; import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { Animated, FlatList, LayoutAnimation, Modal, Platform, SafeAreaView, StyleSheet, Text, TextInput, TouchableOpacity, UIManager, View } from 'react-native'; +import { Alert, Animated, FlatList, LayoutAnimation, Modal, Platform, SafeAreaView, StyleSheet, Text, TextInput, TouchableOpacity, UIManager, View } from 'react-native'; import { ThemedText } from '@/components/ThemedText'; -import type { ScheduleExercise } from './index'; +import { addExercise } from '@/store/scheduleExerciseSlice'; +import { addWorkoutExercise } from '@/store/workoutSlice'; const GOAL_TEXT: Record = { postpartum_recovery: { title: '产后恢复', color: '#9BE370', description: '温和激活,核心重建' }, @@ -40,12 +40,20 @@ function DynamicBackground({ color }: { color: string }) { export default function SelectExerciseForScheduleScreen() { const router = useRouter(); - const params = useLocalSearchParams<{ planId?: string }>(); + const dispatch = useAppDispatch(); + const params = useLocalSearchParams<{ planId?: string; sessionId?: string }>(); const { plans } = useAppSelector((s) => s.trainingPlan); + const { currentSession } = useAppSelector((s) => s.workout); const planId = params.planId; + const sessionId = params.sessionId; const plan = useMemo(() => plans.find(p => p.id === planId), [plans, planId]); - const goalConfig = plan ? (GOAL_TEXT[plan.goal] || { title: '训练计划', color: palette.primary, description: '开始你的训练之旅' }) : null; + const session = useMemo(() => sessionId ? currentSession : null, [sessionId, currentSession]); + + // 根据是否有sessionId来确定是训练计划模式还是训练会话模式 + const isSessionMode = !!sessionId; + const targetGoal = plan?.goal || session?.trainingPlan?.goal; + const goalConfig = targetGoal ? (GOAL_TEXT[targetGoal] || { title: isSessionMode ? '添加动作' : '训练计划', color: palette.primary, description: isSessionMode ? '选择要添加的动作' : '开始你的训练之旅' }) : null; const [keyword, setKeyword] = useState(''); const [category, setCategory] = useState('全部'); @@ -55,8 +63,12 @@ export default function SelectExerciseForScheduleScreen() { const [showCustomReps, setShowCustomReps] = useState(false); const [customRepsInput, setCustomRepsInput] = useState(''); const [showCategoryPicker, setShowCategoryPicker] = useState(false); - const [serverLibrary, setServerLibrary] = useState<{ key: string; name: string; description: string; category: string }[] | null>(null); - const [serverCategories, setServerCategories] = useState(null); + const [showRestModal, setShowRestModal] = useState(false); + const [showNoteModal, setShowNoteModal] = useState(false); + const [restDuration, setRestDuration] = useState(30); + const [noteContent, setNoteContent] = useState(''); + const { categories: serverCategoryDtos, exercises: serverExercises } = useAppSelector((s) => s.exerciseLibrary); + const [adding, setAdding] = useState(false); const controlsOpacity = useRef(new Animated.Value(0)).current; @@ -67,39 +79,16 @@ export default function SelectExerciseForScheduleScreen() { }, []); useEffect(() => { - let aborted = false; - const CACHE_KEY = '@exercise_config_v1'; - (async () => { - try { - const cached = await AsyncStorage.getItem(CACHE_KEY); - if (cached && !aborted) { - const parsed = JSON.parse(cached); - const items = normalizeToLibraryItems(parsed); - if (items.length) { - setServerLibrary(items); - const cats = Array.from(new Set(items.map((i) => i.category))); - setServerCategories(cats); - } - } - } catch { } - try { - const resp = await fetchExerciseConfig(); - console.log('fetchExerciseConfig', resp); - if (aborted) return; - const items = normalizeToLibraryItems(resp); - setServerLibrary(items); - const cats = Array.from(new Set(items.map((i) => i.category))); - setServerCategories(cats); - try { await AsyncStorage.setItem(CACHE_KEY, JSON.stringify(resp)); } catch { } - } catch (err) { } - })(); - return () => { aborted = true; }; - }, []); + dispatch(loadExerciseLibrary()); + }, [dispatch]); const categories = useMemo(() => { - const base = serverCategories ?? getCategories(); - return ['全部', ...base]; - }, [serverCategories]); + const base = serverCategoryDtos && serverCategoryDtos.length + ? serverCategoryDtos.map((c) => c.name) + : getCategories(); + const unique = Array.from(new Set(base)); + return ['全部', ...unique]; + }, [serverCategoryDtos]); const mainCategories = useMemo(() => { const preferred = ['全部', '核心与腹部', '脊柱与后链', '侧链与髋', '平衡与支撑']; @@ -110,7 +99,7 @@ export default function SelectExerciseForScheduleScreen() { return picked; }, [categories]); - const library = useMemo(() => serverLibrary ?? EXERCISE_LIBRARY, [serverLibrary]); + const library = useMemo(() => (serverExercises && serverExercises.length ? serverExercises : EXERCISE_LIBRARY), [serverExercises]); const filtered = useMemo(() => { const kw = keyword.trim().toLowerCase(); @@ -131,29 +120,126 @@ export default function SelectExerciseForScheduleScreen() { }).start(); }, [selected, controlsOpacity]); - const handleAdd = () => { - if (!selected || !plan) return; + const handleAdd = async () => { + if (!selected || adding) return; - const exerciseData: ScheduleExercise = { - key: `${selected.key}_${Date.now()}`, + console.log('选择动作:', selected); + + const newExerciseDto = { + exerciseKey: selected.key, name: selected.name, - category: selected.category, - sets: Math.max(1, sets), - reps: reps && reps > 0 ? reps : undefined, - itemType: 'exercise', - completed: false, + plannedSets: sets, + plannedReps: reps, + itemType: 'exercise' as const, + note: `${selected.category}训练`, }; - console.log('添加动作到排课:', exerciseData); - - // 通过路由参数传递数据回到排课页面 - router.push({ - pathname: '/training-plan/schedule', - params: { - planId: planId, - newExercise: JSON.stringify(exerciseData) + setAdding(true); + try { + if (isSessionMode && sessionId) { + // 训练会话模式:添加到训练会话 + await dispatch(addWorkoutExercise({ sessionId, dto: newExerciseDto })).unwrap(); + } else if (plan) { + // 训练计划模式:添加到训练计划 + const planExerciseDto = { + exerciseKey: selected.key, + name: selected.name, + sets: sets, + reps: reps, + itemType: 'exercise' as const, + note: `${selected.category}训练`, + }; + await dispatch(addExercise({ planId: plan.id, dto: planExerciseDto })).unwrap(); + } else { + throw new Error('缺少必要的参数'); } - } as any); + + // 返回到上一页 + router.back(); + } catch (error) { + console.error('添加动作失败:', error); + Alert.alert('添加失败', '添加动作时出现错误,请稍后重试'); + } finally { + setAdding(false); + } + }; + + // 添加休息项目 + const handleAddRest = () => { + setShowRestModal(true); + Haptics.selectionAsync(); + }; + + // 添加备注项目 + const handleAddNote = () => { + setShowNoteModal(true); + Haptics.selectionAsync(); + }; + + // 确认添加休息 + const confirmAddRest = async () => { + if (adding) return; + + const restDto = { + name: `间隔休息 ${restDuration}s`, + restSec: restDuration, + itemType: 'rest' as const, + }; + + setAdding(true); + try { + if (isSessionMode && sessionId) { + // 训练会话模式 + await dispatch(addWorkoutExercise({ sessionId, dto: restDto })).unwrap(); + } else if (plan) { + // 训练计划模式 + await dispatch(addExercise({ planId: plan.id, dto: restDto })).unwrap(); + } else { + throw new Error('缺少必要的参数'); + } + + setShowRestModal(false); + setRestDuration(30); + router.back(); + } catch (error) { + console.error('添加休息失败:', error); + Alert.alert('添加失败', '添加休息时出现错误,请稍后重试'); + } finally { + setAdding(false); + } + }; + + // 确认添加备注 + const confirmAddNote = async () => { + if (adding || !noteContent.trim()) return; + + const noteDto = { + name: '训练提示', + note: noteContent.trim(), + itemType: 'note' as const, + }; + + setAdding(true); + try { + if (isSessionMode && sessionId) { + // 训练会话模式 + await dispatch(addWorkoutExercise({ sessionId, dto: noteDto })).unwrap(); + } else if (plan) { + // 训练计划模式 + await dispatch(addExercise({ planId: plan.id, dto: noteDto })).unwrap(); + } else { + throw new Error('缺少必要的参数'); + } + + setShowNoteModal(false); + setNoteContent(''); + router.back(); + } catch (error) { + console.error('添加备注失败:', error); + Alert.alert('添加失败', '添加备注时出现错误,请稍后重试'); + } finally { + setAdding(false); + } }; const onSelectItem = (key: string) => { @@ -162,19 +248,22 @@ export default function SelectExerciseForScheduleScreen() { setSelectedKey(null); return; } - setSets(3); - setReps(undefined); + const sel = library.find((e) => e.key === key) as any; + setSets(sel?.beginnerSets ?? 3); + setReps(sel?.beginnerReps); setShowCustomReps(false); setCustomRepsInput(''); setSelectedKey(key); }; - if (!plan || !goalConfig) { + if (!goalConfig || (!plan && !isSessionMode)) { return ( router.back()} /> - 找不到指定的训练计划 + + {isSessionMode ? '找不到指定的训练会话' : '找不到指定的训练计划'} + ); @@ -187,7 +276,7 @@ export default function SelectExerciseForScheduleScreen() { router.back()} withSafeTop={false} transparent={true} @@ -200,10 +289,31 @@ export default function SelectExerciseForScheduleScreen() { {goalConfig.title} - 从动作库里选择一个动作,设置组数与每组次数 + + {isSessionMode ? '为当前训练会话添加动作' : '选择动作或添加休息、备注项目'} + + {/* 快捷添加区域 */} + + + + 添加休息 + + + + + 添加备注 + + + {/* 大分类宫格 */} {[...mainCategories, '更多'].map((item) => { @@ -327,6 +437,16 @@ export default function SelectExerciseForScheduleScreen() { {item.name} {item.category} + {((item as any).targetMuscleGroups || (item as any).equipmentName) && ( + + {[(item as any).targetMuscleGroups, (item as any).equipmentName].filter(Boolean).join(' · ')} + + )} + {(((item as any).beginnerSets || (item as any).beginnerReps)) && ( + + 建议 {(item as any).beginnerSets ?? '-'} 组 × {(item as any).beginnerReps ?? '-'} 次 + + )} {item.description} {isSelected && } @@ -414,12 +534,14 @@ export default function SelectExerciseForScheduleScreen() { style={[ styles.addBtn, { backgroundColor: goalConfig.color }, - (!reps || reps <= 0) && { opacity: 0.5 } + ((!reps || reps <= 0) || adding) && { opacity: 0.5 } ]} - disabled={!reps || reps <= 0} + disabled={!reps || reps <= 0 || adding} onPress={handleAdd} > - 添加到训练计划 + + {adding ? '添加中...' : (isSessionMode ? '添加到训练会话' : '添加到训练计划')} + )} @@ -429,6 +551,104 @@ export default function SelectExerciseForScheduleScreen() { /> + + {/* 休息时间配置模态框 */} + setShowRestModal(false)}> + setShowRestModal(false)}> + e.stopPropagation() as any}> + 设置休息时间 + + + {[15, 30, 45, 60, 90, 120].map((v) => { + const active = restDuration === v; + return ( + { + setRestDuration(v); + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + }} + > + {v}s + + ); + })} + + + + 自定义时间 + + { + const num = parseInt(text) || 30; + setRestDuration(Math.max(10, Math.min(300, num))); + }} + keyboardType="number-pad" + style={styles.customRestInput} + /> + + + + + + {adding ? '添加中...' : '确认添加'} + + + + + + {/* 备注配置模态框 */} + setShowNoteModal(false)}> + setShowNoteModal(false)}> + e.stopPropagation() as any}> + 添加训练提示 + + + + + {noteContent.length}/100 + {noteContent.length > 0 && ( + setNoteContent('')} + style={styles.noteClearBtn} + > + + + )} + + + + {adding ? '添加中...' : '确认添加'} + + + + ); } @@ -492,6 +712,28 @@ const styles = StyleSheet.create({ opacity: 0.8, }, + // 快捷添加区域 + quickAddSection: { + flexDirection: 'row', + gap: 12, + marginBottom: 16, + }, + quickAddBtn: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 12, + paddingHorizontal: 16, + borderRadius: 12, + borderWidth: 1, + gap: 8, + }, + quickAddText: { + fontSize: 14, + fontWeight: '700', + }, + // 分类网格 catGrid: { paddingTop: 10, @@ -671,7 +913,104 @@ const styles = StyleSheet.create({ color: '#FFFFFF', fontSize: 12, }, + + // 休息时间配置 + restTimeRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + marginBottom: 16, + }, + restChip: { + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 16, + borderWidth: 1, + borderColor: '#E5E7EB', + backgroundColor: '#FFFFFF', + }, + restChipText: { + fontSize: 12, + fontWeight: '700', + color: '#384046', + }, + + // 模态框自定义休息时间 + customRestSection: { + marginBottom: 20, + }, + sectionLabel: { + fontSize: 14, + fontWeight: '700', + color: '#384046', + marginBottom: 10, + }, + customRestRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + }, + customRestInput: { + flex: 1, + height: 40, + borderWidth: 1, + borderColor: '#E5E7EB', + borderRadius: 10, + paddingHorizontal: 12, + color: '#384046', + fontSize: 16, + textAlign: 'center', + }, + customRestUnit: { + fontSize: 14, + color: '#384046', + fontWeight: '600', + }, + + // 备注模态框 + noteModalInput: { + minHeight: 100, + maxHeight: 150, + borderWidth: 1, + borderColor: '#E5E7EB', + borderRadius: 12, + paddingHorizontal: 12, + paddingVertical: 10, + color: '#384046', + fontSize: 14, + textAlignVertical: 'top', + backgroundColor: '#FFFFFF', + marginBottom: 10, + }, + noteModalInfo: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 20, + }, + noteCounter: { + fontSize: 11, + color: '#888F92', + }, + noteClearBtn: { + padding: 4, + }, + + // 确认按钮 + confirmBtn: { + paddingVertical: 12, + borderRadius: 12, + alignItems: 'center', + marginTop: 10, + }, + confirmBtnText: { + color: '#FFFFFF', + fontWeight: '800', + fontSize: 14, + }, + addBtn: { + marginTop: 20, paddingVertical: 12, borderRadius: 12, alignItems: 'center', diff --git a/app/workout/_layout.tsx b/app/workout/_layout.tsx new file mode 100644 index 0000000..5e82535 --- /dev/null +++ b/app/workout/_layout.tsx @@ -0,0 +1,15 @@ +import { Stack } from 'expo-router'; + +export default function WorkoutLayout() { + return ( + + + + ); +} diff --git a/app/workout/today.tsx b/app/workout/today.tsx new file mode 100644 index 0000000..56fc9ad --- /dev/null +++ b/app/workout/today.tsx @@ -0,0 +1,1066 @@ +import { Ionicons } from '@expo/vector-icons'; +import * as Haptics from 'expo-haptics'; +import { LinearGradient } from 'expo-linear-gradient'; +import { useRouter } from 'expo-router'; +import React, { useEffect, useMemo, useState } from 'react'; +import { Alert, FlatList, Modal, SafeAreaView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import Animated, { FadeInUp } from 'react-native-reanimated'; + +import { CircularRing } from '@/components/CircularRing'; +import { ThemedText } from '@/components/ThemedText'; +import { HeaderBar } from '@/components/ui/HeaderBar'; +import { palette } from '@/constants/Colors'; +import { useAppDispatch, useAppSelector } from '@/hooks/redux'; +import type { WorkoutExercise } from '@/services/workoutsApi'; +import { + clearWorkoutError, + completeWorkoutExercise, + deleteWorkoutSession, + loadTodayWorkout, + skipWorkoutExercise, + startWorkoutExercise, + startWorkoutSession +} from '@/store/workoutSlice'; + +// ==================== 工具函数 ==================== + +// 计算两个时间之间的耗时(秒) +const calculateDuration = (startTime: string, endTime: string): number => { + const start = new Date(startTime); + const end = new Date(endTime); + return Math.floor((end.getTime() - start.getTime()) / 1000); +}; + +// 格式化耗时显示(分钟:秒) +const formatDuration = (seconds: number): string => { + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${minutes}分${remainingSeconds.toString().padStart(2, '0')}秒`; +}; + +// 获取动作的耗时信息 +const getExerciseDuration = (exercise: WorkoutExercise): { duration: number; formatted: string } | null => { + if (exercise.status === 'completed' && exercise.startedAt && exercise.completedAt) { + const duration = calculateDuration(exercise.startedAt, exercise.completedAt); + return { + duration, + formatted: formatDuration(duration) + }; + } + return null; +}; + +const GOAL_TEXT: Record = { + postpartum_recovery: { title: '产后恢复', color: '#9BE370', description: '温和激活,核心重建' }, + fat_loss: { title: '减脂塑形', color: '#FFB86B', description: '全身燃脂,线条雕刻' }, + posture_correction: { title: '体态矫正', color: '#95CCE3', description: '打开胸肩,改善圆肩驼背' }, + core_strength: { title: '核心力量', color: '#A48AED', description: '核心稳定,提升运动表现' }, + flexibility: { title: '柔韧灵活', color: '#B0F2A7', description: '拉伸延展,释放紧张' }, + rehab: { title: '康复保健', color: '#FF8E9E', description: '循序渐进,科学修复' }, + stress_relief: { title: '释压放松', color: '#9BD1FF', description: '舒缓身心,改善睡眠' }, +}; + +// 动态背景组件 +function DynamicBackground({ color }: { color: string }) { + return ( + + + + + + ); +} + +export default function TodayWorkoutScreen() { + const router = useRouter(); + const dispatch = useAppDispatch(); + const { currentSession, exercises, loading, exerciseLoading, error } = useAppSelector((s) => s.workout); + + // 本地状态 + const [completionModal, setCompletionModal] = useState<{ + visible: boolean; + exercise: WorkoutExercise | null; + sets: number; + reps: number; + }>({ + visible: false, + exercise: null, + sets: 0, + reps: 0, + }); + + const goalConfig = currentSession?.trainingPlan + ? (GOAL_TEXT[currentSession.trainingPlan.goal] || { title: '今日训练', color: palette.primary, description: '开始你的训练之旅' }) + : { title: '今日训练', color: palette.primary, description: '开始你的训练之旅' }; + + // 加载今日训练数据 + useEffect(() => { + dispatch(loadTodayWorkout()); + }, [dispatch]); + + // 错误处理 + useEffect(() => { + if (error) { + Alert.alert('错误', error, [ + { text: '确定', onPress: () => dispatch(clearWorkoutError()) } + ]); + } + }, [error, dispatch]); + + // 训练状态统计 + const workoutStats = useMemo(() => { + const exerciseItems = exercises.filter(ex => ex.itemType === 'exercise'); + return { + total: exerciseItems.length, + completed: exerciseItems.filter(ex => ex.status === 'completed').length, + inProgress: exerciseItems.filter(ex => ex.status === 'in_progress').length, + pending: exerciseItems.filter(ex => ex.status === 'pending').length, + skipped: exerciseItems.filter(ex => ex.status === 'skipped').length, + }; + }, [exercises]); + + const completionPercentage = workoutStats.total > 0 + ? Math.round((workoutStats.completed / workoutStats.total) * 100) + : 0; + + // 开始训练会话 + const handleStartWorkout = () => { + if (!currentSession) return; + + Alert.alert( + '开始训练', + '准备好开始今日的训练了吗?', + [ + { text: '取消', style: 'cancel' }, + { + text: '开始', + onPress: () => { + dispatch(startWorkoutSession({ sessionId: currentSession.id })); + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + } + } + ] + ); + }; + + // 开始动作 + const handleStartExercise = (exercise: WorkoutExercise) => { + if (!currentSession || exercise.status !== 'pending') return; + + dispatch(startWorkoutExercise({ + sessionId: currentSession.id, + exerciseId: exercise.id + })); + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + }; + + // 显示完成动作模态框 + const handleShowCompleteModal = (exercise: WorkoutExercise) => { + setCompletionModal({ + visible: true, + exercise, + sets: exercise.completedSets || exercise.plannedSets || 0, + reps: exercise.completedReps || exercise.plannedReps || 0, + }); + }; + + // 完成动作 + const handleCompleteExercise = () => { + const { exercise, sets, reps } = completionModal; + if (!currentSession || !exercise) return; + + dispatch(completeWorkoutExercise({ + sessionId: currentSession.id, + exerciseId: exercise.id, + dto: { + completedSets: sets, + completedReps: reps, + } + })); + + setCompletionModal({ visible: false, exercise: null, sets: 0, reps: 0 }); + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + }; + + // 跳过动作 + const handleSkipExercise = (exercise: WorkoutExercise) => { + if (!currentSession) return; + + Alert.alert( + '跳过动作', + `确定要跳过"${exercise.name}"吗?`, + [ + { text: '取消', style: 'cancel' }, + { + text: '跳过', + style: 'destructive', + onPress: () => { + dispatch(skipWorkoutExercise({ + sessionId: currentSession.id, + exerciseId: exercise.id + })); + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + } + } + ] + ); + }; + + // 删除训练会话 + const handleDeleteSession = () => { + if (!currentSession) return; + + Alert.alert( + '删除训练会话', + '确定要删除这个训练会话吗?删除后无法恢复。', + [ + { text: '取消', style: 'cancel' }, + { + text: '删除', + style: 'destructive', + onPress: () => { + dispatch(deleteWorkoutSession(currentSession.id)); + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning); + // 删除成功后返回上一页 + router.back(); + } + } + ] + ); + }; + + // 获取动作状态文本和颜色 + const getExerciseStatusConfig = (exercise: WorkoutExercise) => { + switch (exercise.status) { + case 'completed': + return { text: '已完成', color: '#22C55E', backgroundColor: '#22C55E15' }; + case 'in_progress': + return { text: '进行中', color: '#F59E0B', backgroundColor: '#F59E0B15' }; + case 'skipped': + return { text: '已跳过', color: '#6B7280', backgroundColor: '#6B728015' }; + default: + return { text: '待开始', color: '#6B7280', backgroundColor: '#6B728015' }; + } + }; + + // 渲染动作卡片 + const renderExerciseItem = ({ item, index }: { item: WorkoutExercise; index: number }) => { + const statusConfig = getExerciseStatusConfig(item); + const isLoading = exerciseLoading === item.id; + + if (item.itemType === 'rest') { + return ( + + + + + + {item.name} + {item.restSec}秒 + + + ); + } + + if (item.itemType === 'note') { + return ( + + + + + + {item.name} + {item.note && {item.note}} + + + ); + } + + return ( + + + + {item.name} + {item.exercise && ( + {item.exercise.categoryName} + )} + + + + {statusConfig.text} + + + + + + {item.plannedSets && ( + + {item.plannedSets} 组 × {item.plannedReps || '-'} 次 + + )} + {item.plannedDurationSec && ( + + 持续 {item.plannedDurationSec} 秒 + + )} + + + {item.status === 'completed' && ( + + + + 实际完成: {item.completedSets || '-'} 组 × {item.completedReps || '-'} 次 + + {(() => { + const durationInfo = getExerciseDuration(item); + return durationInfo ? ( + + + + {durationInfo.formatted} + + + ) : null; + })()} + + + )} + + + {item.status === 'pending' && currentSession?.status === 'in_progress' && ( + handleStartExercise(item)} + disabled={isLoading} + > + + {isLoading ? '开始中...' : '开始'} + + + )} + + {item.status === 'in_progress' && ( + <> + handleShowCompleteModal(item)} + disabled={isLoading} + > + + {isLoading ? '完成中...' : '完成'} + + + handleSkipExercise(item)} + disabled={isLoading} + > + 跳过 + + + )} + + {item.status === 'pending' && ( + handleSkipExercise(item)} + disabled={isLoading} + > + 跳过 + + )} + + + ); + }; + + if (loading && !currentSession) { + return ( + + router.back()} /> + + 加载中... + + + ); + } + + if (!currentSession) { + return ( + + router.back()} /> + + + 暂无今日训练 + 请先激活一个训练计划 + router.push('/training-plan' as any)} + > + 去创建训练计划 + + + + ); + } + + return ( + + {/* 动态背景 */} + + + + router.back()} + withSafeTop={false} + transparent={true} + tone="light" + right={ + currentSession?.status === 'in_progress' ? ( + router.push(`/training-plan/schedule/select?sessionId=${currentSession.id}` as any)} + disabled={loading} + > + + + ) : null + } + /> + + + {/* 训练计划信息头部 */} + + {/* 删除按钮 - 右上角 */} + + + + + + + {goalConfig.title} + + {currentSession.trainingPlan?.name || '今日训练'} + + {/* 进度统计文字 */} + {currentSession.status !== 'planned' && ( + + {workoutStats.completed}/{workoutStats.total} 个动作已完成 + + )} + + + {/* 右侧区域:圆环进度或开始按钮 */} + {currentSession.status === 'planned' ? ( + + + + ) : ( + + + + + {completionPercentage}% + + + + )} + + + {/* 训练完成提示 */} + {currentSession.status === 'completed' && ( + + + 训练已完成! + + )} + + {/* 动作列表 */} + item.id} + renderItem={renderExerciseItem} + contentContainerStyle={styles.listContent} + showsVerticalScrollIndicator={false} + /> + + + + {/* 完成动作模态框 */} + setCompletionModal({ visible: false, exercise: null, sets: 0, reps: 0 })} + > + setCompletionModal({ visible: false, exercise: null, sets: 0, reps: 0 })} + > + e.stopPropagation()} + > + 完成动作 + {completionModal.exercise?.name} + + + + 完成组数 + + setCompletionModal(prev => ({ + ...prev, + sets: Math.max(0, prev.sets - 1) + }))} + > + - + + {completionModal.sets} + setCompletionModal(prev => ({ + ...prev, + sets: Math.min(20, prev.sets + 1) + }))} + > + + + + + + + + 每组次数 + + setCompletionModal(prev => ({ + ...prev, + reps: Math.max(0, prev.reps - 1) + }))} + > + - + + {completionModal.reps} + setCompletionModal(prev => ({ + ...prev, + reps: Math.min(50, prev.reps + 1) + }))} + > + + + + + + + + + 确认完成 + + + + + + ); +} + +const styles = StyleSheet.create({ + safeArea: { + flex: 1, + }, + contentWrapper: { + flex: 1, + }, + content: { + flex: 1, + paddingHorizontal: 20, + }, + + // 动态背景 + backgroundOrb: { + position: 'absolute', + width: 300, + height: 300, + borderRadius: 150, + top: -150, + right: -100, + }, + backgroundOrb2: { + position: 'absolute', + width: 400, + height: 400, + borderRadius: 200, + bottom: -200, + left: -150, + }, + + // 计划信息头部 + planHeader: { + flexDirection: 'row', + alignItems: 'center', + padding: 16, + borderRadius: 16, + marginBottom: 16, + }, + planColorIndicator: { + width: 4, + height: 40, + borderRadius: 2, + marginRight: 12, + }, + planInfo: { + flex: 1, + }, + planTitle: { + fontSize: 18, + fontWeight: '800', + color: '#192126', + marginBottom: 4, + }, + planDescription: { + fontSize: 13, + color: '#5E6468', + opacity: 0.8, + marginBottom: 4, + }, + planProgressStats: { + fontSize: 12, + color: '#6B7280', + marginTop: 4, + }, + + // 圆环进度容器 + circularProgressContainer: { + alignItems: 'center', + justifyContent: 'center', + marginRight: 32, + position: 'relative', + }, + circularProgressText: { + position: 'absolute', + alignItems: 'center', + justifyContent: 'center', + width: 60, + height: 60, + }, + circularProgressPercentage: { + fontSize: 14, + fontWeight: '800', + textAlign: 'center', + }, + + // 删除按钮 + deleteBtn: { + position: 'absolute', + top: 8, + right: 8, + width: 32, + height: 32, + borderRadius: 16, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(255, 255, 255, 0.9)', + zIndex: 10, + shadowColor: '#000', + shadowOpacity: 0.1, + shadowRadius: 4, + shadowOffset: { width: 0, height: 2 }, + elevation: 2, + }, + + // 开始训练按钮 + planStartBtn: { + width: 44, + height: 44, + borderRadius: 22, + alignItems: 'center', + justifyContent: 'center', + marginRight: 32, + }, + + // 完成提示 + completedBanner: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#F0FDF4', + paddingVertical: 12, + paddingHorizontal: 16, + borderRadius: 12, + marginBottom: 16, + gap: 8, + }, + completedBannerText: { + color: '#22C55E', + fontSize: 16, + fontWeight: '700', + }, + + // 列表 + listContent: { + paddingBottom: 40, + }, + + // 动作卡片 + exerciseCard: { + backgroundColor: '#FFFFFF', + borderRadius: 16, + padding: 16, + marginBottom: 12, + shadowColor: '#000', + shadowOpacity: 0.06, + shadowRadius: 12, + shadowOffset: { width: 0, height: 6 }, + elevation: 3, + }, + exerciseHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + marginBottom: 8, + }, + exerciseInfo: { + flex: 1, + }, + exerciseName: { + fontSize: 16, + fontWeight: '800', + color: '#192126', + marginBottom: 4, + }, + exerciseCategory: { + fontSize: 12, + color: '#888F92', + }, + statusBadge: { + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 8, + }, + statusText: { + fontSize: 12, + fontWeight: '600', + }, + exerciseDetails: { + marginBottom: 12, + }, + exerciseParams: { + fontSize: 14, + color: '#5E6468', + marginBottom: 2, + }, + completedInfo: { + backgroundColor: '#F0FDF4', + borderRadius: 8, + }, + completedRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + completedText: { + fontSize: 12, + color: '#22C55E', + fontWeight: '600', + flex: 1, + }, + durationBadge: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 6, + paddingVertical: 3, + borderRadius: 6, + gap: 3, + }, + durationText: { + fontSize: 11, + fontWeight: '600', + }, + exerciseActions: { + flexDirection: 'row', + gap: 8, + }, + actionBtn: { + flex: 1, + paddingVertical: 10, + paddingHorizontal: 16, + borderRadius: 8, + alignItems: 'center', + }, + startBtn: { + backgroundColor: '#22C55E', + }, + startBtnText: { + color: '#FFFFFF', + fontSize: 14, + fontWeight: '700', + }, + completeBtn: { + backgroundColor: '#22C55E', + }, + completeBtnText: { + color: '#FFFFFF', + fontSize: 14, + fontWeight: '700', + }, + skipBtn: { + backgroundColor: '#F3F4F6', + borderWidth: 1, + borderColor: '#E5E7EB', + }, + skipBtnText: { + color: '#6B7280', + fontSize: 14, + fontWeight: '600', + }, + + // 休息卡片 + restCard: { + backgroundColor: '#FFFFFF', + borderRadius: 16, + padding: 16, + marginBottom: 12, + borderLeftWidth: 4, + flexDirection: 'row', + alignItems: 'center', + shadowColor: '#000', + shadowOpacity: 0.06, + shadowRadius: 8, + shadowOffset: { width: 0, height: 4 }, + elevation: 2, + }, + restIconContainer: { + marginRight: 12, + }, + restContent: { + flex: 1, + }, + restTitle: { + fontSize: 16, + fontWeight: '700', + color: '#192126', + marginBottom: 4, + }, + restDuration: { + fontSize: 14, + color: '#5E6468', + }, + + // 备注卡片 + noteCard: { + backgroundColor: '#FFFFFF', + borderRadius: 16, + padding: 16, + marginBottom: 12, + borderLeftWidth: 4, + flexDirection: 'row', + alignItems: 'flex-start', + shadowColor: '#000', + shadowOpacity: 0.06, + shadowRadius: 8, + shadowOffset: { width: 0, height: 4 }, + elevation: 2, + }, + noteIconContainer: { + marginRight: 12, + marginTop: 2, + }, + noteContent: { + flex: 1, + }, + noteTitle: { + fontSize: 16, + fontWeight: '700', + color: '#192126', + marginBottom: 4, + }, + noteText: { + fontSize: 14, + color: '#5E6468', + lineHeight: 20, + }, + + // 空状态 + emptyContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + padding: 20, + }, + emptyTitle: { + fontSize: 18, + fontWeight: '700', + color: '#192126', + marginTop: 16, + marginBottom: 8, + }, + emptyText: { + fontSize: 14, + color: '#6B7280', + textAlign: 'center', + marginBottom: 24, + }, + createPlanBtn: { + backgroundColor: '#22C55E', + paddingVertical: 12, + paddingHorizontal: 24, + borderRadius: 8, + }, + createPlanBtnText: { + color: '#FFFFFF', + fontSize: 14, + fontWeight: '700', + }, + + // 加载状态 + loadingContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + loadingText: { + fontSize: 16, + color: '#6B7280', + }, + + // 模态框 + modalOverlay: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.35)', + alignItems: 'center', + justifyContent: 'flex-end', + }, + modalSheet: { + width: '100%', + backgroundColor: '#FFFFFF', + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + paddingHorizontal: 16, + paddingTop: 14, + paddingBottom: 24, + }, + modalTitle: { + fontSize: 18, + fontWeight: '800', + marginBottom: 8, + color: '#192126', + textAlign: 'center', + }, + modalSubtitle: { + fontSize: 14, + color: '#6B7280', + textAlign: 'center', + marginBottom: 24, + }, + inputRow: { + flexDirection: 'row', + gap: 16, + marginBottom: 24, + }, + inputBox: { + flex: 1, + }, + inputLabel: { + fontSize: 14, + fontWeight: '600', + color: '#192126', + marginBottom: 12, + }, + counterRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + backgroundColor: '#F3F4F6', + borderRadius: 8, + padding: 4, + }, + counterBtn: { + backgroundColor: '#FFFFFF', + width: 32, + height: 32, + borderRadius: 6, + alignItems: 'center', + justifyContent: 'center', + shadowColor: '#000', + shadowOpacity: 0.05, + shadowRadius: 2, + shadowOffset: { width: 0, height: 1 }, + elevation: 1, + }, + counterBtnText: { + fontWeight: '800', + color: '#192126', + fontSize: 16, + }, + counterValue: { + fontWeight: '700', + color: '#192126', + fontSize: 16, + minWidth: 40, + textAlign: 'center', + }, + confirmBtn: { + paddingVertical: 16, + borderRadius: 12, + alignItems: 'center', + }, + confirmBtnText: { + color: '#FFFFFF', + fontWeight: '800', + fontSize: 16, + }, + + // 添加动作按钮 + addExerciseBtn: { + width: 28, + height: 28, + borderRadius: 14, + alignItems: 'center', + justifyContent: 'center', + shadowColor: '#000', + shadowOpacity: 0.1, + shadowRadius: 4, + shadowOffset: { width: 0, height: 2 }, + elevation: 2, + }, +}); diff --git a/assets/images/demo/imageBody.jpeg b/assets/images/demo/imageBody.jpeg new file mode 100644 index 0000000..49b0cfa Binary files /dev/null and b/assets/images/demo/imageBody.jpeg differ diff --git a/components/MyPlanCard.tsx b/components/MyPlanCard.tsx new file mode 100644 index 0000000..1cf7457 --- /dev/null +++ b/components/MyPlanCard.tsx @@ -0,0 +1,223 @@ +import { type TrainingPlan } from '@/store/trainingPlanSlice'; +import { Ionicons } from '@expo/vector-icons'; +import React from 'react'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; + +interface MyPlanCardProps { + plan: TrainingPlan; + onPress: () => void; +} + +const GOAL_TEXT: Record = { + postpartum_recovery: { title: '产后恢复', color: '#9BE370', description: '温和激活,核心重建' }, + fat_loss: { title: '减脂塑形', color: '#FFB86B', description: '全身燃脂,线条雕刻' }, + posture_correction: { title: '体态矫正', color: '#95CCE3', description: '打开胸肩,改善圆肩驼背' }, + core_strength: { title: '核心力量', color: '#A48AED', description: '核心稳定,提升运动表现' }, + flexibility: { title: '柔韧灵活', color: '#B0F2A7', description: '拉伸延展,释放紧张' }, + rehab: { title: '康复保健', color: '#FF8E9E', description: '循序渐进,科学修复' }, + stress_relief: { title: '释压放松', color: '#9BD1FF', description: '舒缓身心,改善睡眠' }, +}; + +export function MyPlanCard({ plan, onPress }: MyPlanCardProps) { + const goalConfig = GOAL_TEXT[plan.goal] || { title: '训练计划', color: '#FF6B47', description: '开始你的训练之旅' }; + + // 格式化日期 + const formatDate = (dateStr: string) => { + const date = new Date(dateStr); + const months = ['January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December']; + return `${months[date.getMonth()]}, ${date.getFullYear()}`; + }; + + // 获取训练类型显示文本 + const getWorkoutTypeText = () => { + if (plan.goal === 'core_strength') return 'Body Weight'; + if (plan.goal === 'flexibility') return 'Flexibility'; + if (plan.goal === 'posture_correction') return 'Posture'; + return 'Body Weight'; + }; + + // 获取周数和训练次数 + const getWeekInfo = () => { + const startDate = new Date(plan.startDate); + const now = new Date(); + const diffTime = Math.abs(now.getTime() - startDate.getTime()); + const diffWeeks = Math.ceil(diffTime / (1000 * 60 * 60 * 24 * 7)); + const currentWeek = Math.min(diffWeeks, 12); // 假设最多12周 + + const totalSessions = plan.mode === 'daysOfWeek' + ? plan.daysOfWeek.length * currentWeek + : plan.sessionsPerWeek * currentWeek; + + return { + week: currentWeek, + session: Math.min(totalSessions, 60), // 假设最多60次训练 + totalSessions: 60 + }; + }; + + const weekInfo = getWeekInfo(); + + return ( + + {/* Header */} + + 我的计划 + + + + + + + + {/* Date */} + {formatDate(plan.startDate)} + + {/* Main Card */} + + {/* Icon */} + + + + + + + {/* Content */} + + {/* Week indicator */} + 周 {weekInfo.week} + + {/* Workout type */} + {getWorkoutTypeText()} + + {/* Session info */} + 训练 {weekInfo.session} 次 + + {/* Next exercise section */} + + + + + + + 下一个动作 + 下蹲 + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + marginHorizontal: 24, + marginBottom: 24, + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 8, + }, + title: { + fontSize: 24, + fontWeight: 'bold', + color: '#1A1A1A', + paddingHorizontal: 24, + marginBottom: 18, + }, + menuButton: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + }, + menuDot: { + width: 4, + height: 4, + borderRadius: 2, + backgroundColor: '#999999', + }, + date: { + fontSize: 16, + color: '#999999', + marginBottom: 20, + }, + card: { + backgroundColor: '#F5E6E0', + borderRadius: 24, + padding: 24, + flexDirection: 'row', + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 4, + }, + shadowOpacity: 0.1, + shadowRadius: 12, + elevation: 5, + }, + iconContainer: { + marginRight: 20, + }, + iconCircle: { + width: 64, + height: 64, + borderRadius: 32, + alignItems: 'center', + justifyContent: 'center', + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 4, + }, + shadowOpacity: 0.2, + shadowRadius: 8, + elevation: 4, + }, + content: { + flex: 1, + }, + weekText: { + fontSize: 12, + color: '#999999', + fontWeight: '600', + letterSpacing: 1, + marginBottom: 4, + }, + workoutType: { + fontSize: 24, + fontWeight: 'bold', + color: '#1A1A1A', + marginBottom: 4, + }, + sessionInfo: { + fontSize: 14, + color: '#666666', + marginBottom: 20, + }, + nextExerciseContainer: { + flexDirection: 'row', + alignItems: 'center', + }, + nextExerciseIcon: { + flexDirection: 'row', + alignItems: 'center', + marginRight: 12, + }, + nextExerciseText: { + flex: 1, + }, + nextExerciseLabel: { + fontSize: 12, + color: '#999999', + marginBottom: 2, + }, + nextExerciseName: { + fontSize: 16, + fontWeight: '600', + color: '#1A1A1A', + }, +}); \ No newline at end of file diff --git a/services/exercises.ts b/services/exercises.ts index 72b3418..532d203 100644 --- a/services/exercises.ts +++ b/services/exercises.ts @@ -1,31 +1,48 @@ import { api } from '@/services/api'; -export type ExerciseCategoryDto = { +// 与后端保持一致的数据结构定义 +export interface ExerciseLibraryItem { key: string; name: string; - sortOrder: number; -}; + description?: string; + category: string; // 中文分类名(显示用) + targetMuscleGroups: string; + equipmentName?: string; + beginnerReps?: number; + beginnerSets?: number; + breathingCycles?: number; + holdDuration?: number; + specialInstructions?: string; +} -export type ExerciseDto = { +export interface ExerciseCategoryDto { + key: string; // 英文 key + name: string; // 中文名 + type: 'mat_pilates' | 'equipment_pilates'; + equipmentName?: string; + sortOrder?: number; +} + +export interface ExerciseDto { key: string; name: string; - description: string; + description?: string; categoryKey: string; categoryName: string; - sortOrder: number; -}; + targetMuscleGroups: string; + equipmentName?: string; + beginnerReps?: number; + beginnerSets?: number; + breathingCycles?: number; + holdDuration?: number; + specialInstructions?: string; + sortOrder?: number; +} -export type ExerciseConfigResponse = { +export interface ExerciseConfigResponse { categories: ExerciseCategoryDto[]; exercises: ExerciseDto[]; -}; - -export type ExerciseLibraryItem = { - key: string; - name: string; - description: string; - category: string; // display name -}; +} export async function fetchExerciseConfig(): Promise { return await api.get('/exercises/config'); @@ -38,7 +55,13 @@ export function normalizeToLibraryItems(resp: ExerciseConfigResponse | null | un name: e.name, description: e.description, category: e.categoryName || e.categoryKey, + targetMuscleGroups: e.targetMuscleGroups, + equipmentName: e.equipmentName, + beginnerReps: e.beginnerReps, + beginnerSets: e.beginnerSets, + breathingCycles: e.breathingCycles, + holdDuration: e.holdDuration, + specialInstructions: e.specialInstructions, })); } - diff --git a/services/scheduleExerciseApi.ts b/services/scheduleExerciseApi.ts new file mode 100644 index 0000000..02e49c6 --- /dev/null +++ b/services/scheduleExerciseApi.ts @@ -0,0 +1,106 @@ +import { api } from './api'; + +// 训练项目数据结构 +export interface ScheduleExercise { + id: string; + trainingPlanId: string; + userId: string; + exerciseKey?: string; + name: string; + sets?: number; + reps?: number; + durationSec?: number; + restSec?: number; + note?: string; + itemType: 'exercise' | 'rest' | 'note'; + completed: boolean; + sortOrder: number; + createdAt: string; + updatedAt: string; + deleted: boolean; + + // 关联的动作信息(仅exercise类型时存在) + exercise?: { + key: string; + name: string; + description: string; + categoryKey: string; + categoryName: string; + }; +} + +// 创建训练项目的请求体 +export interface CreateScheduleExerciseDto { + exerciseKey?: string; + name: string; + sets?: number; + reps?: number; + durationSec?: number; + restSec?: number; + note?: string; + itemType: 'exercise' | 'rest' | 'note'; +} + +// 更新训练项目的请求体 +export interface UpdateScheduleExerciseDto { + exerciseKey?: string; + name?: string; + sets?: number; + reps?: number; + durationSec?: number; + restSec?: number; + note?: string; + itemType?: 'exercise' | 'rest' | 'note'; + completed?: boolean; +} + +// 完成状态统计 +export interface CompletionStats { + total: number; + completed: number; + percentage: number; +} + +// 重新排序请求体 +export interface ReorderExercisesDto { + exerciseIds: string[]; +} + +class ScheduleExerciseApi { + // 获取训练计划的所有项目 + async list(planId: string): Promise { + return api.get(`/training-plans/${planId}/exercises`); + } + + // 获取训练项目详情 + async detail(planId: string, exerciseId: string): Promise { + return api.get(`/training-plans/${planId}/exercises/${exerciseId}`); + } + + // 添加训练项目 + async create(planId: string, dto: CreateScheduleExerciseDto): Promise { + return api.post(`/training-plans/${planId}/exercises`, dto); + } + + // 更新训练项目 + async update(planId: string, exerciseId: string, dto: UpdateScheduleExerciseDto): Promise { + return api.put(`/training-plans/${planId}/exercises/${exerciseId}`, dto); + } + + // 删除训练项目 + async delete(planId: string, exerciseId: string): Promise<{ success: boolean }> { + return api.delete<{ success: boolean }>(`/training-plans/${planId}/exercises/${exerciseId}`); + } + + // 更新训练项目排序 + async reorder(planId: string, dto: ReorderExercisesDto): Promise<{ success: boolean }> { + return api.put<{ success: boolean }>(`/training-plans/${planId}/exercises/order`, dto); + } + + // 标记训练项目完成状态 + async updateCompletion(planId: string, exerciseId: string, completed: boolean): Promise { + return api.put(`/training-plans/${planId}/exercises/${exerciseId}/complete`, { completed }); + } +} + +export const scheduleExerciseApi = new ScheduleExerciseApi(); diff --git a/services/trainingPlanApi.ts b/services/trainingPlanApi.ts index c2f4f25..e34a534 100644 --- a/services/trainingPlanApi.ts +++ b/services/trainingPlanApi.ts @@ -70,6 +70,15 @@ class TrainingPlanApi { async activate(id: string): Promise<{ success: boolean }> { return api.post<{ success: boolean }>(`/training-plans/${id}/activate`); } + + async getActivePlan(): Promise { + try { + return api.get('/training-plans/active'); + } catch (error) { + // 如果没有激活的计划,返回null + return null; + } + } } export const trainingPlanApi = new TrainingPlanApi(); \ No newline at end of file diff --git a/services/workoutsApi.ts b/services/workoutsApi.ts new file mode 100644 index 0000000..cddc956 --- /dev/null +++ b/services/workoutsApi.ts @@ -0,0 +1,204 @@ +import { api } from './api'; + +// ==================== 数据类型定义 ==================== + +export interface WorkoutSession { + id: string; + userId: string; + trainingPlanId?: string; + name: string; + scheduledDate: string; + startedAt?: string; + completedAt?: string; + status: 'planned' | 'in_progress' | 'completed' | 'cancelled'; + totalDurationSec?: number; + caloriesBurned?: number; + stats?: { + totalExercises: number; + completedExercises: number; + totalSets: number; + completedSets: number; + totalReps: number; + completedReps: number; + }; + createdAt: string; + updatedAt: string; + deleted: boolean; + + // 关联数据 + trainingPlan?: { + id: string; + name: string; + goal: string; + }; + exercises?: WorkoutExercise[]; +} + +export interface WorkoutExercise { + id: string; + workoutSessionId: string; + userId: string; + exerciseKey?: string; + name: string; + plannedSets?: number; + plannedReps?: number; + plannedDurationSec?: number; + completedSets?: number; + completedReps?: number; + actualDurationSec?: number; + restSec?: number; + note?: string; + itemType: 'exercise' | 'rest' | 'note'; + status: 'pending' | 'in_progress' | 'completed' | 'skipped'; + sortOrder: number; + startedAt?: string; + completedAt?: string; + performanceData?: Record; + createdAt: string; + updatedAt: string; + deleted: boolean; + + // 关联的动作信息 + exercise?: { + key: string; + name: string; + description: string; + categoryKey: string; + categoryName: string; + }; +} + +// ==================== DTO 类型定义 ==================== + +export interface StartWorkoutDto { + startedAt?: string; +} + +export interface StartWorkoutExerciseDto { + startedAt?: string; +} + +export interface CompleteWorkoutExerciseDto { + completedAt?: string; + completedSets?: number; + completedReps?: number; + actualDurationSec?: number; + performanceData?: Record; +} + +export interface AddWorkoutExerciseDto { + exerciseKey?: string; + name: string; + plannedSets?: number; + plannedReps?: number; + plannedDurationSec?: number; + restSec?: number; + note?: string; + itemType?: 'exercise' | 'rest' | 'note'; +} + +export interface UpdateWorkoutExerciseDto { + name?: string; + plannedSets?: number; + plannedReps?: number; + plannedDurationSec?: number; + restSec?: number; + note?: string; + itemType?: 'exercise' | 'rest' | 'note'; +} + +export interface WorkoutSessionListResponse { + sessions: WorkoutSession[]; + pagination: { + page: number; + limit: number; + total: number; + totalPages: number; + }; +} + +export interface WorkoutSessionStatsResponse { + status: string; + duration?: number; + calories?: number; + stats?: { + totalExercises: number; + completedExercises: number; + totalSets: number; + completedSets: number; + totalReps: number; + completedReps: number; + }; + exerciseCount: number; + completedExercises: number; +} + +// ==================== API 服务类 ==================== + +class WorkoutsApi { + // ==================== 训练会话管理 ==================== + + async getSessions(page: number = 1, limit: number = 10): Promise { + return api.get(`/workouts/sessions?page=${page}&limit=${limit}`); + } + + async getSessionDetail(sessionId: string): Promise { + return api.get(`/workouts/sessions/${sessionId}`); + } + + async startSession(sessionId: string, dto: StartWorkoutDto = {}): Promise { + return api.post(`/workouts/sessions/${sessionId}/start`, dto); + } + + async deleteSession(sessionId: string): Promise<{ success: boolean }> { + return api.delete<{ success: boolean }>(`/workouts/sessions/${sessionId}`); + } + + // ==================== 训练动作管理 ==================== + + async getSessionExercises(sessionId: string): Promise { + return api.get(`/workouts/sessions/${sessionId}/exercises`); + } + + async getExerciseDetail(sessionId: string, exerciseId: string): Promise { + return api.get(`/workouts/sessions/${sessionId}/exercises/${exerciseId}`); + } + + async startExercise(sessionId: string, exerciseId: string, dto: StartWorkoutExerciseDto = {}): Promise { + return api.post(`/workouts/sessions/${sessionId}/exercises/${exerciseId}/start`, dto); + } + + async completeExercise(sessionId: string, exerciseId: string, dto: CompleteWorkoutExerciseDto): Promise { + return api.post(`/workouts/sessions/${sessionId}/exercises/${exerciseId}/complete`, dto); + } + + async skipExercise(sessionId: string, exerciseId: string): Promise { + return api.post(`/workouts/sessions/${sessionId}/exercises/${exerciseId}/skip`); + } + + async updateExercise(sessionId: string, exerciseId: string, dto: UpdateWorkoutExerciseDto): Promise { + return api.put(`/workouts/sessions/${sessionId}/exercises/${exerciseId}`, dto); + } + + async addExercise(sessionId: string, dto: AddWorkoutExerciseDto): Promise { + return api.post(`/workouts/sessions/${sessionId}/exercises`, dto); + } + + // ==================== 统计和分析 ==================== + + async getSessionStats(sessionId: string): Promise { + return api.get(`/workouts/sessions/${sessionId}/stats`); + } + + // ==================== 快捷操作 ==================== + + async getTodayWorkout(): Promise { + return api.get('/workouts/today'); + } + + async getRecentWorkouts(days: number = 7, limit: number = 10): Promise<{ sessions: WorkoutSession[]; period: string }> { + return api.get<{ sessions: WorkoutSession[]; period: string }>(`/workouts/recent?days=${days}&limit=${limit}`); + } +} + +export const workoutsApi = new WorkoutsApi(); diff --git a/store/exerciseLibrarySlice.ts b/store/exerciseLibrarySlice.ts new file mode 100644 index 0000000..9e1dbfd --- /dev/null +++ b/store/exerciseLibrarySlice.ts @@ -0,0 +1,126 @@ +import { + fetchExerciseConfig, + normalizeToLibraryItems, + type ExerciseCategoryDto, + type ExerciseConfigResponse, + type ExerciseLibraryItem, +} from '@/services/exercises'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; + +export interface ExerciseLibraryState { + categories: ExerciseCategoryDto[]; + exercises: ExerciseLibraryItem[]; + loading: boolean; + error: string | null; + lastUpdatedAt: number | null; +} + +const CACHE_KEY = '@exercise_config_v2'; + +const initialState: ExerciseLibraryState = { + categories: [], + exercises: [], + loading: false, + error: null, + lastUpdatedAt: null, +}; + +export const loadExerciseLibrary = createAsyncThunk( + 'exerciseLibrary/load', + async (_: void, { rejectWithValue }) => { + // 先读本地缓存(最佳体验),随后静默刷新远端 + try { + const cached = await AsyncStorage.getItem(CACHE_KEY); + if (cached) { + const data = JSON.parse(cached) as ExerciseConfigResponse; + return { source: 'cache' as const, data }; + } + } catch { } + + try { + const fresh = await fetchExerciseConfig(); + try { await AsyncStorage.setItem(CACHE_KEY, JSON.stringify(fresh)); } catch { } + return { source: 'network' as const, data: fresh }; + } catch (error: any) { + return rejectWithValue(error.message || '加载动作库失败'); + } + } +); + +export const refreshExerciseLibrary = createAsyncThunk( + 'exerciseLibrary/refresh', + async (_: void, { rejectWithValue }) => { + try { + const fresh = await fetchExerciseConfig(); + try { await AsyncStorage.setItem(CACHE_KEY, JSON.stringify(fresh)); } catch { } + return fresh; + } catch (error: any) { + return rejectWithValue(error.message || '刷新动作库失败'); + } + } +); + +const exerciseLibrarySlice = createSlice({ + name: 'exerciseLibrary', + initialState, + reducers: { + clearExerciseLibraryError(state) { + state.error = null; + }, + setExerciseLibraryFromData( + state, + action: PayloadAction + ) { + const data = action.payload; + state.categories = (data.categories || []).slice().sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)); + const sorted: ExerciseConfigResponse = { + ...data, + exercises: (data.exercises || []).slice().sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)), + }; + state.exercises = normalizeToLibraryItems(sorted); + state.lastUpdatedAt = Date.now(); + }, + }, + extraReducers: (builder) => { + builder + .addCase(loadExerciseLibrary.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(loadExerciseLibrary.fulfilled, (state, action) => { + const { data } = action.payload as { source: 'cache' | 'network'; data: ExerciseConfigResponse }; + state.loading = false; + state.categories = (data.categories || []).slice().sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)); + const sorted: ExerciseConfigResponse = { + ...data, + exercises: (data.exercises || []).slice().sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)), + }; + state.exercises = normalizeToLibraryItems(sorted); + state.lastUpdatedAt = Date.now(); + }) + .addCase(loadExerciseLibrary.rejected, (state, action) => { + state.loading = false; + state.error = action.payload as string; + }) + + .addCase(refreshExerciseLibrary.fulfilled, (state, action) => { + const data = action.payload as ExerciseConfigResponse; + state.categories = (data.categories || []).slice().sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)); + const sorted: ExerciseConfigResponse = { + ...data, + exercises: (data.exercises || []).slice().sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)), + }; + state.exercises = normalizeToLibraryItems(sorted); + state.lastUpdatedAt = Date.now(); + }) + .addCase(refreshExerciseLibrary.rejected, (state, action) => { + state.error = action.payload as string; + }); + }, +}); + +export const { clearExerciseLibraryError, setExerciseLibraryFromData } = exerciseLibrarySlice.actions; +export default exerciseLibrarySlice.reducer; + + diff --git a/store/index.ts b/store/index.ts index 6e26222..fd95f56 100644 --- a/store/index.ts +++ b/store/index.ts @@ -1,8 +1,11 @@ import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit'; import challengeReducer from './challengeSlice'; import checkinReducer, { addExercise, autoSyncCheckin, removeExercise, replaceExercises, setNote, toggleExerciseCompleted } from './checkinSlice'; +import exerciseLibraryReducer from './exerciseLibrarySlice'; +import scheduleExerciseReducer from './scheduleExerciseSlice'; import trainingPlanReducer from './trainingPlanSlice'; import userReducer from './userSlice'; +import workoutReducer from './workoutSlice'; // 创建监听器中间件来处理自动同步 const listenerMiddleware = createListenerMiddleware(); @@ -15,16 +18,16 @@ syncActions.forEach(action => { effect: async (action, listenerApi) => { const state = listenerApi.getState() as any; const date = action.payload?.date; - + if (!date) return; - + // 延迟一下,避免在同一事件循环中重复触发 await new Promise(resolve => setTimeout(resolve, 100)); - + // 检查是否还有待同步的日期 const currentState = listenerApi.getState() as any; const pendingSyncDates = currentState?.checkin?.pendingSyncDates || []; - + if (pendingSyncDates.includes(date)) { listenerApi.dispatch(autoSyncCheckin({ date })); } @@ -38,6 +41,9 @@ export const store = configureStore({ challenge: challengeReducer, checkin: checkinReducer, trainingPlan: trainingPlanReducer, + scheduleExercise: scheduleExerciseReducer, + exerciseLibrary: exerciseLibraryReducer, + workout: workoutReducer, }, middleware: (getDefaultMiddleware) => getDefaultMiddleware().prepend(listenerMiddleware.middleware), diff --git a/store/scheduleExerciseSlice.ts b/store/scheduleExerciseSlice.ts new file mode 100644 index 0000000..7c1f0b5 --- /dev/null +++ b/store/scheduleExerciseSlice.ts @@ -0,0 +1,233 @@ +import { + scheduleExerciseApi, + type CreateScheduleExerciseDto, + type ReorderExercisesDto, + type ScheduleExercise, + type UpdateScheduleExerciseDto +} from '@/services/scheduleExerciseApi'; +import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; + +export type ScheduleExerciseState = { + exercises: ScheduleExercise[]; + loading: boolean; + error: string | null; + currentPlanId: string | null; +}; + +const initialState: ScheduleExerciseState = { + exercises: [], + loading: false, + error: null, + currentPlanId: null, +}; + +// 加载训练计划的所有项目 +export const loadExercises = createAsyncThunk( + 'scheduleExercise/loadExercises', + async (planId: string, { rejectWithValue }) => { + try { + const exercises = await scheduleExerciseApi.list(planId); + return { exercises, planId }; + } catch (error: any) { + return rejectWithValue(error.message || '加载训练项目失败'); + } + } +); + +// 添加训练项目 +export const addExercise = createAsyncThunk( + 'scheduleExercise/addExercise', + async ({ planId, dto }: { planId: string; dto: CreateScheduleExerciseDto }, { rejectWithValue }) => { + try { + const exercise = await scheduleExerciseApi.create(planId, dto); + return { exercise }; + } catch (error: any) { + return rejectWithValue(error.message || '添加训练项目失败'); + } + } +); + +// 更新训练项目 +export const updateExercise = createAsyncThunk( + 'scheduleExercise/updateExercise', + async ({ + planId, + exerciseId, + dto + }: { + planId: string; + exerciseId: string; + dto: UpdateScheduleExerciseDto; + }, { rejectWithValue }) => { + try { + const exercise = await scheduleExerciseApi.update(planId, exerciseId, dto); + return { exercise }; + } catch (error: any) { + return rejectWithValue(error.message || '更新训练项目失败'); + } + } +); + +// 删除训练项目 +export const deleteExercise = createAsyncThunk( + 'scheduleExercise/deleteExercise', + async ({ planId, exerciseId }: { planId: string; exerciseId: string }, { rejectWithValue }) => { + try { + await scheduleExerciseApi.delete(planId, exerciseId); + return { exerciseId }; + } catch (error: any) { + return rejectWithValue(error.message || '删除训练项目失败'); + } + } +); + +// 重新排序训练项目 +export const reorderExercises = createAsyncThunk( + 'scheduleExercise/reorderExercises', + async ({ planId, dto }: { planId: string; dto: ReorderExercisesDto }, { rejectWithValue }) => { + try { + await scheduleExerciseApi.reorder(planId, dto); + // 重新加载排序后的列表 + const exercises = await scheduleExerciseApi.list(planId); + return { exercises }; + } catch (error: any) { + return rejectWithValue(error.message || '重新排序失败'); + } + } +); + +// 更新完成状态 +export const toggleCompletion = createAsyncThunk( + 'scheduleExercise/toggleCompletion', + async ({ + planId, + exerciseId, + completed + }: { + planId: string; + exerciseId: string; + completed: boolean; + }, { rejectWithValue }) => { + try { + const exercise = await scheduleExerciseApi.updateCompletion(planId, exerciseId, completed); + return { exercise }; + } catch (error: any) { + return rejectWithValue(error.message || '更新完成状态失败'); + } + } +); + +const scheduleExerciseSlice = createSlice({ + name: 'scheduleExercise', + initialState, + reducers: { + clearError(state) { + state.error = null; + }, + clearExercises(state) { + state.exercises = []; + state.currentPlanId = null; + }, + // 本地更新排序(用于拖拽等即时反馈) + updateLocalOrder(state, action: PayloadAction) { + const newOrder = action.payload; + const orderedExercises = newOrder.map(id => + state.exercises.find(ex => ex.id === id) + ).filter(Boolean) as ScheduleExercise[]; + state.exercises = orderedExercises; + }, + }, + extraReducers: (builder) => { + builder + // loadExercises + .addCase(loadExercises.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(loadExercises.fulfilled, (state, action) => { + state.loading = false; + state.exercises = action.payload.exercises; + state.currentPlanId = action.payload.planId; + }) + .addCase(loadExercises.rejected, (state, action) => { + state.loading = false; + state.error = action.payload as string; + }) + + // addExercise + .addCase(addExercise.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(addExercise.fulfilled, (state, action) => { + state.loading = false; + state.exercises.push(action.payload.exercise); + }) + .addCase(addExercise.rejected, (state, action) => { + state.loading = false; + state.error = action.payload as string; + }) + + // updateExercise + .addCase(updateExercise.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(updateExercise.fulfilled, (state, action) => { + state.loading = false; + const index = state.exercises.findIndex(ex => ex.id === action.payload.exercise.id); + if (index !== -1) { + state.exercises[index] = action.payload.exercise; + } + }) + .addCase(updateExercise.rejected, (state, action) => { + state.loading = false; + state.error = action.payload as string; + }) + + // deleteExercise + .addCase(deleteExercise.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(deleteExercise.fulfilled, (state, action) => { + state.loading = false; + state.exercises = state.exercises.filter(ex => ex.id !== action.payload.exerciseId); + }) + .addCase(deleteExercise.rejected, (state, action) => { + state.loading = false; + state.error = action.payload as string; + }) + + // reorderExercises + .addCase(reorderExercises.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(reorderExercises.fulfilled, (state, action) => { + state.loading = false; + state.exercises = action.payload.exercises; + }) + .addCase(reorderExercises.rejected, (state, action) => { + state.loading = false; + state.error = action.payload as string; + }) + + // toggleCompletion + .addCase(toggleCompletion.pending, (state) => { + state.error = null; + }) + .addCase(toggleCompletion.fulfilled, (state, action) => { + const index = state.exercises.findIndex(ex => ex.id === action.payload.exercise.id); + if (index !== -1) { + state.exercises[index] = action.payload.exercise; + } + }) + .addCase(toggleCompletion.rejected, (state, action) => { + state.error = action.payload as string; + }); + }, +}); + +export const { clearError, clearExercises, updateLocalOrder } = scheduleExerciseSlice.actions; +export default scheduleExerciseSlice.reducer; diff --git a/store/workoutSlice.ts b/store/workoutSlice.ts new file mode 100644 index 0000000..58653cb --- /dev/null +++ b/store/workoutSlice.ts @@ -0,0 +1,395 @@ +import { + workoutsApi, + type AddWorkoutExerciseDto, + type CompleteWorkoutExerciseDto, + type StartWorkoutDto, + type StartWorkoutExerciseDto, + type UpdateWorkoutExerciseDto, + type WorkoutExercise, + type WorkoutSession, + type WorkoutSessionStatsResponse, +} from '@/services/workoutsApi'; +import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; + +export interface WorkoutState { + // 当前训练会话 + currentSession: WorkoutSession | null; + exercises: WorkoutExercise[]; + stats: WorkoutSessionStatsResponse | null; + + // 历史训练会话 + sessions: WorkoutSession[]; + sessionsPagination: { + page: number; + limit: number; + total: number; + totalPages: number; + } | null; + + // 加载状态 + loading: boolean; + exerciseLoading: string | null; // 正在操作的exercise ID + error: string | null; +} + +const initialState: WorkoutState = { + currentSession: null, + exercises: [], + stats: null, + sessions: [], + sessionsPagination: null, + loading: false, + exerciseLoading: null, + error: null, +}; + +// ==================== 异步Action定义 ==================== + +// 获取今日训练 +export const loadTodayWorkout = createAsyncThunk( + 'workout/loadTodayWorkout', + async (_, { rejectWithValue }) => { + try { + const session = await workoutsApi.getTodayWorkout(); + return session; + } catch (error: any) { + return rejectWithValue(error.message || '获取今日训练失败'); + } + } +); + +// 开始训练会话 +export const startWorkoutSession = createAsyncThunk( + 'workout/startSession', + async ({ sessionId, dto }: { sessionId: string; dto?: StartWorkoutDto }, { rejectWithValue }) => { + try { + const session = await workoutsApi.startSession(sessionId, dto); + return session; + } catch (error: any) { + return rejectWithValue(error.message || '开始训练失败'); + } + } +); + +// 开始训练动作 +export const startWorkoutExercise = createAsyncThunk( + 'workout/startExercise', + async ({ sessionId, exerciseId, dto }: { + sessionId: string; + exerciseId: string; + dto?: StartWorkoutExerciseDto + }, { rejectWithValue }) => { + try { + const exercise = await workoutsApi.startExercise(sessionId, exerciseId, dto); + return exercise; + } catch (error: any) { + return rejectWithValue(error.message || '开始动作失败'); + } + } +); + +// 完成训练动作 +export const completeWorkoutExercise = createAsyncThunk( + 'workout/completeExercise', + async ({ sessionId, exerciseId, dto }: { + sessionId: string; + exerciseId: string; + dto: CompleteWorkoutExerciseDto + }, { rejectWithValue, getState }) => { + try { + const exercise = await workoutsApi.completeExercise(sessionId, exerciseId, dto); + + // 完成动作后重新获取会话详情(检查是否自动完成) + const updatedSession = await workoutsApi.getSessionDetail(sessionId); + + return { exercise, updatedSession }; + } catch (error: any) { + return rejectWithValue(error.message || '完成动作失败'); + } + } +); + +// 跳过训练动作 +export const skipWorkoutExercise = createAsyncThunk( + 'workout/skipExercise', + async ({ sessionId, exerciseId }: { sessionId: string; exerciseId: string }, { rejectWithValue }) => { + try { + const exercise = await workoutsApi.skipExercise(sessionId, exerciseId); + + // 跳过动作后重新获取会话详情(检查是否自动完成) + const updatedSession = await workoutsApi.getSessionDetail(sessionId); + + return { exercise, updatedSession }; + } catch (error: any) { + return rejectWithValue(error.message || '跳过动作失败'); + } + } +); + +// 更新训练动作 +export const updateWorkoutExercise = createAsyncThunk( + 'workout/updateExercise', + async ({ sessionId, exerciseId, dto }: { + sessionId: string; + exerciseId: string; + dto: UpdateWorkoutExerciseDto + }, { rejectWithValue }) => { + try { + const exercise = await workoutsApi.updateExercise(sessionId, exerciseId, dto); + return exercise; + } catch (error: any) { + return rejectWithValue(error.message || '更新动作失败'); + } + } +); + +// 获取训练统计 +export const loadWorkoutStats = createAsyncThunk( + 'workout/loadStats', + async (sessionId: string, { rejectWithValue }) => { + try { + const stats = await workoutsApi.getSessionStats(sessionId); + return stats; + } catch (error: any) { + return rejectWithValue(error.message || '获取统计数据失败'); + } + } +); + +// 获取训练会话列表 +export const loadWorkoutSessions = createAsyncThunk( + 'workout/loadSessions', + async ({ page = 1, limit = 10 }: { page?: number; limit?: number } = {}, { rejectWithValue }) => { + try { + const result = await workoutsApi.getSessions(page, limit); + return result; + } catch (error: any) { + return rejectWithValue(error.message || '获取训练列表失败'); + } + } +); + +// 添加动作到训练会话 +export const addWorkoutExercise = createAsyncThunk( + 'workout/addExercise', + async ({ sessionId, dto }: { sessionId: string; dto: AddWorkoutExerciseDto }, { rejectWithValue }) => { + try { + const exercise = await workoutsApi.addExercise(sessionId, dto); + return exercise; + } catch (error: any) { + return rejectWithValue(error.message || '添加动作失败'); + } + } +); + +// 删除训练会话 +export const deleteWorkoutSession = createAsyncThunk( + 'workout/deleteSession', + async (sessionId: string, { rejectWithValue }) => { + try { + await workoutsApi.deleteSession(sessionId); + return sessionId; + } catch (error: any) { + return rejectWithValue(error.message || '删除训练会话失败'); + } + } +); + +// ==================== Slice定义 ==================== + +const workoutSlice = createSlice({ + name: 'workout', + initialState, + reducers: { + clearWorkoutError(state) { + state.error = null; + }, + clearCurrentWorkout(state) { + state.currentSession = null; + state.exercises = []; + state.stats = null; + }, + // 本地更新exercise状态(用于乐观更新) + updateExerciseLocally(state, action: PayloadAction & { id: string }>) { + const { id, ...updates } = action.payload; + const exerciseIndex = state.exercises.findIndex(ex => ex.id === id); + if (exerciseIndex !== -1) { + Object.assign(state.exercises[exerciseIndex], updates); + } + }, + }, + extraReducers: (builder) => { + builder + // loadTodayWorkout + .addCase(loadTodayWorkout.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(loadTodayWorkout.fulfilled, (state, action) => { + state.loading = false; + state.currentSession = action.payload; + state.exercises = action.payload.exercises || []; + }) + .addCase(loadTodayWorkout.rejected, (state, action) => { + state.loading = false; + state.error = action.payload as string; + }) + + // startWorkoutSession + .addCase(startWorkoutSession.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(startWorkoutSession.fulfilled, (state, action) => { + state.loading = false; + state.currentSession = action.payload; + }) + .addCase(startWorkoutSession.rejected, (state, action) => { + state.loading = false; + state.error = action.payload as string; + }) + + // startWorkoutExercise + .addCase(startWorkoutExercise.pending, (state, action) => { + state.exerciseLoading = action.meta.arg.exerciseId; + state.error = null; + }) + .addCase(startWorkoutExercise.fulfilled, (state, action) => { + state.exerciseLoading = null; + const exerciseIndex = state.exercises.findIndex(ex => ex.id === action.payload.id); + if (exerciseIndex !== -1) { + state.exercises[exerciseIndex] = action.payload; + } + }) + .addCase(startWorkoutExercise.rejected, (state, action) => { + state.exerciseLoading = null; + state.error = action.payload as string; + }) + + // completeWorkoutExercise + .addCase(completeWorkoutExercise.pending, (state, action) => { + state.exerciseLoading = action.meta.arg.exerciseId; + state.error = null; + }) + .addCase(completeWorkoutExercise.fulfilled, (state, action) => { + state.exerciseLoading = null; + const { exercise, updatedSession } = action.payload; + + // 更新exercise + const exerciseIndex = state.exercises.findIndex(ex => ex.id === exercise.id); + if (exerciseIndex !== -1) { + state.exercises[exerciseIndex] = exercise; + } + + // 更新session(可能已自动完成) + state.currentSession = updatedSession; + }) + .addCase(completeWorkoutExercise.rejected, (state, action) => { + state.exerciseLoading = null; + state.error = action.payload as string; + }) + + // skipWorkoutExercise + .addCase(skipWorkoutExercise.pending, (state, action) => { + state.exerciseLoading = action.meta.arg.exerciseId; + state.error = null; + }) + .addCase(skipWorkoutExercise.fulfilled, (state, action) => { + state.exerciseLoading = null; + const { exercise, updatedSession } = action.payload; + + // 更新exercise + const exerciseIndex = state.exercises.findIndex(ex => ex.id === exercise.id); + if (exerciseIndex !== -1) { + state.exercises[exerciseIndex] = exercise; + } + + // 更新session(可能已自动完成) + state.currentSession = updatedSession; + }) + .addCase(skipWorkoutExercise.rejected, (state, action) => { + state.exerciseLoading = null; + state.error = action.payload as string; + }) + + // updateWorkoutExercise + .addCase(updateWorkoutExercise.pending, (state, action) => { + state.exerciseLoading = action.meta.arg.exerciseId; + state.error = null; + }) + .addCase(updateWorkoutExercise.fulfilled, (state, action) => { + state.exerciseLoading = null; + const exerciseIndex = state.exercises.findIndex(ex => ex.id === action.payload.id); + if (exerciseIndex !== -1) { + state.exercises[exerciseIndex] = action.payload; + } + }) + .addCase(updateWorkoutExercise.rejected, (state, action) => { + state.exerciseLoading = null; + state.error = action.payload as string; + }) + + // loadWorkoutStats + .addCase(loadWorkoutStats.fulfilled, (state, action) => { + state.stats = action.payload; + }) + + // loadWorkoutSessions + .addCase(loadWorkoutSessions.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(loadWorkoutSessions.fulfilled, (state, action) => { + state.loading = false; + state.sessions = action.payload.sessions; + state.sessionsPagination = action.payload.pagination; + }) + .addCase(loadWorkoutSessions.rejected, (state, action) => { + state.loading = false; + state.error = action.payload as string; + }) + + // addWorkoutExercise + .addCase(addWorkoutExercise.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(addWorkoutExercise.fulfilled, (state, action) => { + state.loading = false; + // 将新添加的动作添加到exercises列表末尾 + state.exercises.push(action.payload); + }) + .addCase(addWorkoutExercise.rejected, (state, action) => { + state.loading = false; + state.error = action.payload as string; + }) + + // deleteWorkoutSession + .addCase(deleteWorkoutSession.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(deleteWorkoutSession.fulfilled, (state, action) => { + state.loading = false; + const deletedSessionId = action.payload; + + // 如果删除的是当前会话,清空当前会话数据 + if (state.currentSession?.id === deletedSessionId) { + state.currentSession = null; + state.exercises = []; + state.stats = null; + } + + // 从会话列表中移除已删除的会话 + state.sessions = state.sessions.filter(session => session.id !== deletedSessionId); + }) + .addCase(deleteWorkoutSession.rejected, (state, action) => { + state.loading = false; + state.error = action.payload as string; + }); + }, +}); + +export const { clearWorkoutError, clearCurrentWorkout, updateExerciseLocally } = workoutSlice.actions; +export { addWorkoutExercise, deleteWorkoutSession }; +export default workoutSlice.reducer;