feat: 更新训练计划和打卡功能
- 在训练计划中新增训练项目的添加、更新和删除功能,支持用户灵活管理训练内容 - 优化训练计划排课界面,提升用户体验 - 更新打卡功能,支持按日期加载和展示打卡记录 - 删除不再使用的打卡相关页面,简化代码结构 - 新增今日训练页面,集成今日训练计划和动作展示 - 更新样式以适应新功能的展示和交互
This commit is contained in:
@@ -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<TabType>('list');
|
||||
// Tab 状态管理 - 支持从URL参数设置初始tab
|
||||
const initialTab: TabType = params.tab === 'schedule' ? 'schedule' : 'list';
|
||||
const [activeTab, setActiveTab] = useState<TabType>(initialTab);
|
||||
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(params.planId || currentId || null);
|
||||
|
||||
// 排课相关状态
|
||||
const [exercises, setExercises] = useState<ScheduleExercise[]>([]);
|
||||
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<PlanSchedule | null> => {
|
||||
// 模拟 API 调用延迟
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
|
||||
// 模拟数据 - 在实际应用中,这里应该从后端或本地存储获取数据
|
||||
const mockData: Record<string, PlanSchedule> = {
|
||||
// 示例数据结构,实际应用中应从服务器或本地存储获取
|
||||
// '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() {
|
||||
<ThemedText style={styles.subtitle}>点击计划卡片进入排课模式,或使用底部切换</ThemedText>
|
||||
</Animated.View>
|
||||
|
||||
{error && (
|
||||
{(error || scheduleError) && (
|
||||
<Animated.View entering={FadeInUp.duration(400)} style={styles.errorContainer}>
|
||||
<ThemedText style={styles.errorText}>⚠️ {error}</ThemedText>
|
||||
<ThemedText style={styles.errorText}>⚠️ {error || scheduleError}</ThemedText>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
@@ -567,7 +497,7 @@ export default function TrainingPlanScreen() {
|
||||
{/* 动作列表 */}
|
||||
<FlatList
|
||||
data={exercises}
|
||||
keyExtractor={(item) => item.key}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={styles.scheduleListContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
ListEmptyComponent={
|
||||
@@ -607,7 +537,7 @@ export default function TrainingPlanScreen() {
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={styles.inlineRemoveBtn}
|
||||
onPress={() => handleRemoveExercise(item.key)}
|
||||
onPress={() => handleRemoveExercise(item.id)}
|
||||
hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
|
||||
>
|
||||
<Ionicons name="close-outline" size={16} color="#888F92" />
|
||||
@@ -624,9 +554,9 @@ export default function TrainingPlanScreen() {
|
||||
<View style={styles.exerciseContent}>
|
||||
<View style={styles.exerciseInfo}>
|
||||
<ThemedText style={styles.exerciseName}>{item.name}</ThemedText>
|
||||
<ThemedText style={styles.exerciseCategory}>{item.category}</ThemedText>
|
||||
<ThemedText style={styles.exerciseCategory}>{item.exercise?.categoryName || '运动'}</ThemedText>
|
||||
<ThemedText style={styles.exerciseMeta}>
|
||||
组数 {item.sets}
|
||||
组数 {item.sets || 1}
|
||||
{item.reps ? ` · 每组 ${item.reps} 次` : ''}
|
||||
{item.durationSec ? ` · 每组 ${item.durationSec}s` : ''}
|
||||
</ThemedText>
|
||||
@@ -635,7 +565,7 @@ export default function TrainingPlanScreen() {
|
||||
<View style={styles.exerciseActions}>
|
||||
<TouchableOpacity
|
||||
style={styles.completeBtn}
|
||||
onPress={() => handleToggleCompleted(item.key)}
|
||||
onPress={() => handleToggleCompleted(item.id, item.completed)}
|
||||
hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
|
||||
>
|
||||
<Ionicons
|
||||
@@ -647,7 +577,7 @@ export default function TrainingPlanScreen() {
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.removeBtn}
|
||||
onPress={() => handleRemoveExercise(item.key)}
|
||||
onPress={() => handleRemoveExercise(item.id)}
|
||||
>
|
||||
<Text style={styles.removeBtnText}>移除</Text>
|
||||
</TouchableOpacity>
|
||||
@@ -678,10 +608,6 @@ export default function TrainingPlanScreen() {
|
||||
<TouchableOpacity onPress={() => router.push('/training-plan/create' as any)} style={styles.headerRightBtn}>
|
||||
<ThemedText style={styles.headerRightBtnText}>+ 新建</ThemedText>
|
||||
</TouchableOpacity>
|
||||
) : hasUnsavedChanges ? (
|
||||
<TouchableOpacity onPress={handleSave} style={styles.headerRightBtn}>
|
||||
<ThemedText style={styles.headerRightBtnText}>保存</ThemedText>
|
||||
</TouchableOpacity>
|
||||
) : 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,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user