feat: 更新训练计划和打卡功能

- 在训练计划中新增训练项目的添加、更新和删除功能,支持用户灵活管理训练内容
- 优化训练计划排课界面,提升用户体验
- 更新打卡功能,支持按日期加载和展示打卡记录
- 删除不再使用的打卡相关页面,简化代码结构
- 新增今日训练页面,集成今日训练计划和动作展示
- 更新样式以适应新功能的展示和交互
This commit is contained in:
richarjiang
2025-08-15 17:01:33 +08:00
parent f95401c1ce
commit dacbee197c
19 changed files with 3052 additions and 1197 deletions

View File

@@ -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,
},
});