feat: 更新应用配置和引入新依赖

- 修改 app.json,禁用平板支持以优化用户体验
- 在 package.json 和 package-lock.json 中新增 react-native-toast-message 依赖,支持消息提示功能
- 在多个组件中集成 Toast 组件,提升用户交互反馈
- 更新训练计划相关逻辑,优化状态管理和数据处理
- 调整样式以适应新功能的展示和交互
This commit is contained in:
2025-08-16 09:42:33 +08:00
parent 3312250f2d
commit 5a4d86ff7d
16 changed files with 192 additions and 255 deletions

View File

@@ -10,7 +10,7 @@
"newArchEnabled": true, "newArchEnabled": true,
"jsEngine": "jsc", "jsEngine": "jsc",
"ios": { "ios": {
"supportsTablet": true, "supportsTablet": false,
"bundleIdentifier": "com.anonymous.digitalpilates", "bundleIdentifier": "com.anonymous.digitalpilates",
"infoPlist": { "infoPlist": {
"ITSAppUsesNonExemptEncryption": false, "ITSAppUsesNonExemptEncryption": false,

View File

@@ -160,11 +160,6 @@ export default function HomeScreen() {
React.useEffect(() => { React.useEffect(() => {
let canceled = false; let canceled = false;
async function load() { async function load() {
if (!isLoggedIn) {
console.log('fetchRecommendations not logged in');
setItems(getFallbackItems());
return;
}
try { try {
const cards = await fetchRecommendations(); const cards = await fetchRecommendations();
@@ -295,7 +290,7 @@ export default function HomeScreen() {
<Pressable <Pressable
style={[styles.featureCard, styles.featureCardPrimary]} style={[styles.featureCard, styles.featureCardPrimary]}
onPress={() => router.push('/ai-posture-assessment')} onPress={() => pushIfAuthedElseLogin('/ai-posture-assessment')}
> >
<View style={styles.featureIconWrapper}> <View style={styles.featureIconWrapper}>
<Image <Image

View File

@@ -12,6 +12,7 @@ import { store } from '@/store';
import { rehydrateUser, setPrivacyAgreed } from '@/store/userSlice'; import { rehydrateUser, setPrivacyAgreed } from '@/store/userSlice';
import React from 'react'; import React from 'react';
import RNExitApp from 'react-native-exit-app'; import RNExitApp from 'react-native-exit-app';
import Toast from 'react-native-toast-message';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
@@ -56,6 +57,7 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
onAgree={handlePrivacyAgree} onAgree={handlePrivacyAgree}
onDisagree={handlePrivacyDisagree} onDisagree={handlePrivacyDisagree}
/> />
<Toast />
</> </>
); );
} }
@@ -91,6 +93,7 @@ export default function RootLayout() {
<Stack.Screen name="+not-found" /> <Stack.Screen name="+not-found" />
</Stack> </Stack>
<StatusBar style="dark" /> <StatusBar style="dark" />
<Toast />
</ThemeProvider> </ThemeProvider>
</Bootstrapper> </Bootstrapper>
</Provider> </Provider>

View File

@@ -13,6 +13,7 @@ import { Colors } from '@/constants/Colors';
import { useAppDispatch } from '@/hooks/redux'; import { useAppDispatch } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { login } from '@/store/userSlice'; import { login } from '@/store/userSlice';
import Toast from 'react-native-toast-message';
export default function LoginScreen() { export default function LoginScreen() {
const router = useRouter(); const router = useRouter();
@@ -111,6 +112,11 @@ export default function LoginScreen() {
throw new Error('未获取到 Apple 身份令牌'); throw new Error('未获取到 Apple 身份令牌');
} }
await dispatch(login({ appleIdentityToken: identityToken })).unwrap(); await dispatch(login({ appleIdentityToken: identityToken })).unwrap();
Toast.show({
text1: '登录成功',
type: 'success',
});
// 登录成功后处理重定向 // 登录成功后处理重定向
const to = searchParams?.redirectTo as string | undefined; const to = searchParams?.redirectTo as string | undefined;
const paramsJson = searchParams?.redirectParams as string | undefined; const paramsJson = searchParams?.redirectParams as string | undefined;

View File

@@ -31,7 +31,6 @@ export default function SplashScreen() {
// 如果出现错误,默认显示引导页面 // 如果出现错误,默认显示引导页面
// setTimeout(() => { // setTimeout(() => {
// router.replace('/onboarding'); // router.replace('/onboarding');
// setIsLoading(false);
// }, 1000); // }, 1000);
} }
setIsLoading(false); setIsLoading(false);

View File

@@ -29,7 +29,6 @@ import {
TouchableOpacity, TouchableOpacity,
View, View,
} from 'react-native'; } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
type WeightUnit = 'kg' | 'lb'; type WeightUnit = 'kg' | 'lb';
type HeightUnit = 'cm' | 'ft'; type HeightUnit = 'cm' | 'ft';
@@ -50,7 +49,6 @@ const STORAGE_KEY = '@user_profile';
export default function EditProfileScreen() { export default function EditProfileScreen() {
const colorScheme = useColorScheme(); const colorScheme = useColorScheme();
const colors = Colors[colorScheme ?? 'light']; const colors = Colors[colorScheme ?? 'light'];
const insets = useSafeAreaInsets();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const accountProfile = useAppSelector((s) => (s as any)?.user?.profile as any); const accountProfile = useAppSelector((s) => (s as any)?.user?.profile as any);
const userId: string | undefined = useMemo(() => { const userId: string | undefined = useMemo(() => {

View File

@@ -1,8 +1,8 @@
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import MaskedView from '@react-native-masked-view/masked-view'; import MaskedView from '@react-native-masked-view/masked-view';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { useLocalSearchParams, useRouter } from 'expo-router'; import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router';
import React, { useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Alert, FlatList, Modal, Pressable, SafeAreaView, ScrollView, StyleSheet, Switch, Text, TextInput, TouchableOpacity, View } from 'react-native'; import { Alert, FlatList, Modal, Pressable, SafeAreaView, ScrollView, StyleSheet, Switch, Text, TextInput, TouchableOpacity, View } from 'react-native';
import Animated, { import Animated, {
FadeInUp, FadeInUp,
@@ -21,6 +21,7 @@ import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors, palette } from '@/constants/Colors'; import { Colors, palette } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { TrainingPlan } from '@/services/trainingPlanApi';
import { import {
addExercise, addExercise,
clearExercises, clearExercises,
@@ -29,7 +30,7 @@ import {
loadExercises, loadExercises,
toggleCompletion toggleCompletion
} from '@/store/scheduleExerciseSlice'; } from '@/store/scheduleExerciseSlice';
import { activatePlan, clearError, deletePlan, loadPlans, type TrainingPlan } from '@/store/trainingPlanSlice'; import { activatePlan, clearError, deletePlan, loadPlans } from '@/store/trainingPlanSlice';
import { buildClassicalSession } from '@/utils/classicalSession'; import { buildClassicalSession } from '@/utils/classicalSession';
// Tab 类型定义 // Tab 类型定义
@@ -248,13 +249,14 @@ export default function TrainingPlanScreen() {
const router = useRouter(); const router = useRouter();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const params = useLocalSearchParams<{ planId?: string; tab?: string }>(); const params = useLocalSearchParams<{ planId?: string; tab?: string }>();
const { plans, currentId, loading, error } = useAppSelector((s) => s.trainingPlan); const { plans, loading, error } = useAppSelector((s) => s.trainingPlan);
const { exercises, error: scheduleError } = useAppSelector((s) => s.scheduleExercise); const { exercises, error: scheduleError } = useAppSelector((s) => s.scheduleExercise);
console.log('plans', plans);
// Tab 状态管理 - 支持从URL参数设置初始tab // Tab 状态管理 - 支持从URL参数设置初始tab
const initialTab: TabType = params.tab === 'schedule' ? 'schedule' : 'list'; const initialTab: TabType = params.tab === 'schedule' ? 'schedule' : 'list';
const [activeTab, setActiveTab] = useState<TabType>(initialTab); const [activeTab, setActiveTab] = useState<TabType>(initialTab);
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(params.planId || currentId || null); const [selectedPlanId, setSelectedPlanId] = useState<string | null>(params.planId || null);
// 一键排课配置 // 一键排课配置
const [genVisible, setGenVisible] = useState(false); const [genVisible, setGenVisible] = useState(false);
@@ -274,9 +276,21 @@ export default function TrainingPlanScreen() {
} }
}, [selectedPlanId, dispatch]); }, [selectedPlanId, dispatch]);
useEffect(() => { // 每次页面获得焦点时,如果当前有选中的计划,重新加载其排课数据
useFocusEffect(
useCallback(() => {
if (selectedPlanId) {
dispatch(loadExercises(selectedPlanId));
}
}, [selectedPlanId, dispatch])
);
// 每次页面获得焦点时重新加载训练计划数据
useFocusEffect(
useCallback(() => {
dispatch(loadPlans()); dispatch(loadPlans());
}, [dispatch]); }, [dispatch])
);
useEffect(() => { useEffect(() => {
if (error) { if (error) {
@@ -314,7 +328,7 @@ export default function TrainingPlanScreen() {
const handleTabChange = (tab: TabType) => { const handleTabChange = (tab: TabType) => {
if (tab === 'schedule' && !selectedPlanId && plans.length > 0) { if (tab === 'schedule' && !selectedPlanId && plans.length > 0) {
// 如果没有选中计划但要切换到排课页面,自动选择当前激活的计划或第一个计划 // 如果没有选中计划但要切换到排课页面,自动选择当前激活的计划或第一个计划
const targetPlan = plans.find(p => p.id === currentId) || plans[0]; const targetPlan = plans.find(p => p.isActive) || plans[0];
setSelectedPlanId(targetPlan.id); setSelectedPlanId(targetPlan.id);
} }
setActiveTab(tab); setActiveTab(tab);
@@ -426,7 +440,7 @@ export default function TrainingPlanScreen() {
key={p.id} key={p.id}
plan={p} plan={p}
index={index} index={index}
isActive={p.id === currentId} isActive={p.isActive}
onPress={() => handlePlanSelect(p)} onPress={() => handlePlanSelect(p)}
onDelete={() => dispatch(deletePlan(p.id))} onDelete={() => dispatch(deletePlan(p.id))}
onActivate={() => handleActivate(p.id)} onActivate={() => handleActivate(p.id)}
@@ -1098,7 +1112,7 @@ const styles = StyleSheet.create({
// 主内容区域 // 主内容区域
mainContent: { mainContent: {
flex: 1, flex: 1,
paddingBottom: 100, // 为底部 tab 留出空间 paddingBottom: 60, // 为底部 tab 留出空间
}, },
// 排课页面样式 // 排课页面样式

View File

@@ -8,6 +8,7 @@ import { ThemedView } from '@/components/ThemedView';
import { HeaderBar } from '@/components/ui/HeaderBar'; import { HeaderBar } from '@/components/ui/HeaderBar';
import { palette } from '@/constants/Colors'; import { palette } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { PlanGoal } from '@/services/trainingPlanApi';
import { import {
clearError, clearError,
loadPlans, loadPlans,
@@ -21,7 +22,6 @@ import {
setStartDateNextMonday, setStartDateNextMonday,
setStartWeight, setStartWeight,
toggleDayOfWeek, toggleDayOfWeek,
type PlanGoal
} from '@/store/trainingPlanSlice'; } from '@/store/trainingPlanSlice';
const WEEK_DAYS = ['日', '一', '二', '三', '四', '五', '六']; const WEEK_DAYS = ['日', '一', '二', '三', '四', '五', '六'];

View File

@@ -1,3 +1,4 @@
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { import {
BMI_CATEGORIES, BMI_CATEGORIES,
canCalculateBMI, canCalculateBMI,
@@ -5,7 +6,6 @@ import {
type BMIResult type BMIResult
} from '@/utils/bmi'; } from '@/utils/bmi';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { import {
Modal, Modal,
@@ -16,6 +16,7 @@ import {
TouchableOpacity, TouchableOpacity,
View, View,
} from 'react-native'; } from 'react-native';
import Toast from 'react-native-toast-message';
interface BMICardProps { interface BMICardProps {
weight?: number; weight?: number;
@@ -24,7 +25,7 @@ interface BMICardProps {
} }
export function BMICard({ weight, height, style }: BMICardProps) { export function BMICard({ weight, height, style }: BMICardProps) {
const router = useRouter(); const { pushIfAuthedElseLogin } = useAuthGuard();
const [showInfoModal, setShowInfoModal] = useState(false); const [showInfoModal, setShowInfoModal] = useState(false);
const canCalculate = canCalculateBMI(weight, height); const canCalculate = canCalculateBMI(weight, height);
@@ -41,7 +42,11 @@ export function BMICard({ weight, height, style }: BMICardProps) {
} }
const handleGoToProfile = () => { const handleGoToProfile = () => {
router.push('/profile/edit'); Toast.show({
text1: '请先登录',
type: 'info',
});
pushIfAuthedElseLogin('/profile/edit');
}; };
const handleShowInfoModal = () => { const handleShowInfoModal = () => {

View File

@@ -64,7 +64,7 @@ export function useAuthGuard() {
await dispatch(logoutAction()).unwrap(); await dispatch(logoutAction()).unwrap();
// 跳转到登录页面 // 跳转到登录页面
router.replace('/auth/login'); router.push('/auth/login');
} catch (error) { } catch (error) {
console.error('退出登录失败:', error); console.error('退出登录失败:', error);
Alert.alert('错误', '退出登录失败,请稍后重试'); Alert.alert('错误', '退出登录失败,请稍后重试');
@@ -105,7 +105,7 @@ export function useAuthGuard() {
Alert.alert('账号已注销', '您的账号已成功注销', [ Alert.alert('账号已注销', '您的账号已成功注销', [
{ {
text: '确定', text: '确定',
onPress: () => router.replace('/auth/login'), onPress: () => router.push('/auth/login'),
}, },
]); ]);
} catch (error: any) { } catch (error: any) {

View File

@@ -344,10 +344,14 @@
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
PRODUCT_BUNDLE_IDENTIFIER = com.anonymous.digitalpilates; PRODUCT_BUNDLE_IDENTIFIER = com.anonymous.digitalpilates;
PRODUCT_NAME = digitalpilates; PRODUCT_NAME = digitalpilates;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_OBJC_BRIDGING_HEADER = "digitalpilates/digitalpilates-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "digitalpilates/digitalpilates-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = 1;
VERSIONING_SYSTEM = "apple-generic"; VERSIONING_SYSTEM = "apple-generic";
}; };
name = Debug; name = Debug;
@@ -377,9 +381,13 @@
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = com.anonymous.digitalpilates; PRODUCT_BUNDLE_IDENTIFIER = com.anonymous.digitalpilates;
PRODUCT_NAME = digitalpilates; PRODUCT_NAME = digitalpilates;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_OBJC_BRIDGING_HEADER = "digitalpilates/digitalpilates-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "digitalpilates/digitalpilates-Bridging-Header.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = 1;
VERSIONING_SYSTEM = "apple-generic"; VERSIONING_SYSTEM = "apple-generic";
}; };
name = Release; name = Release;
@@ -439,10 +447,7 @@
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\""; LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = YES; MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = "$(inherited) ";
"$(inherited)",
" ",
);
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos; SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
@@ -497,10 +502,7 @@
); );
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\""; LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = "$(inherited) ";
"$(inherited)",
" ",
);
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos; SDKROOT = iphoneos;
USE_HERMES = false; USE_HERMES = false;

View File

@@ -76,13 +76,6 @@
<string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string> <string>UIInterfaceOrientationPortraitUpsideDown</string>
</array> </array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIUserInterfaceStyle</key> <key>UIUserInterfaceStyle</key>
<string>Light</string> <string>Light</string>
<key>UIViewControllerBasedStatusBarAppearance</key> <key>UIViewControllerBasedStatusBarAppearance</key>

11
package-lock.json generated
View File

@@ -49,6 +49,7 @@
"react-native-safe-area-context": "5.4.0", "react-native-safe-area-context": "5.4.0",
"react-native-screens": "~4.11.1", "react-native-screens": "~4.11.1",
"react-native-svg": "^15.12.1", "react-native-svg": "^15.12.1",
"react-native-toast-message": "^2.3.3",
"react-native-web": "~0.20.0", "react-native-web": "~0.20.0",
"react-native-webview": "13.13.5", "react-native-webview": "13.13.5",
"react-redux": "^9.2.0" "react-redux": "^9.2.0"
@@ -11227,6 +11228,16 @@
"react-native": "*" "react-native": "*"
} }
}, },
"node_modules/react-native-toast-message": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/react-native-toast-message/-/react-native-toast-message-2.3.3.tgz",
"integrity": "sha512-4IIUHwUPvKHu4gjD0Vj2aGQzqPATiblL1ey8tOqsxOWRPGGu52iIbL8M/mCz4uyqecvPdIcMY38AfwRuUADfQQ==",
"license": "MIT",
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-uuid": { "node_modules/react-native-uuid": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://mirrors.tencent.com/npm/react-native-uuid/-/react-native-uuid-2.0.3.tgz", "resolved": "https://mirrors.tencent.com/npm/react-native-uuid/-/react-native-uuid-2.0.3.tgz",

View File

@@ -52,6 +52,7 @@
"react-native-safe-area-context": "5.4.0", "react-native-safe-area-context": "5.4.0",
"react-native-screens": "~4.11.1", "react-native-screens": "~4.11.1",
"react-native-svg": "^15.12.1", "react-native-svg": "^15.12.1",
"react-native-toast-message": "^2.3.3",
"react-native-web": "~0.20.0", "react-native-web": "~0.20.0",
"react-native-webview": "13.13.5", "react-native-webview": "13.13.5",
"react-redux": "^9.2.0" "react-redux": "^9.2.0"

View File

@@ -11,56 +11,52 @@ export interface CreateTrainingPlanDto {
preferredTimeOfDay?: 'morning' | 'noon' | 'evening' | ''; preferredTimeOfDay?: 'morning' | 'noon' | 'evening' | '';
} }
export interface TrainingPlanResponse { export type PlanMode = 'daysOfWeek' | 'sessionsPerWeek';
id: string;
userId: string;
name: string;
createdAt: string;
startDate: string;
mode: 'daysOfWeek' | 'sessionsPerWeek';
daysOfWeek: number[];
sessionsPerWeek: number;
goal: string;
startWeightKg: number | null;
preferredTimeOfDay: 'morning' | 'noon' | 'evening' | '';
updatedAt: string;
deleted: boolean;
}
export interface TrainingPlanSummary { export type PlanGoal =
id: string; | 'postpartum_recovery' // 产后恢复
createdAt: string; | 'fat_loss' // 减脂塑形
startDate: string; | 'posture_correction' // 体态矫正
goal: string; | 'core_strength' // 核心力量
mode: 'daysOfWeek' | 'sessionsPerWeek'; | 'flexibility' // 柔韧灵活
daysOfWeek: number[]; | 'rehab' // 康复保健
sessionsPerWeek: number; | 'stress_relief'; // 释压放松
preferredTimeOfDay: 'morning' | 'noon' | 'evening' | '';
startWeightKg: number | null;
}
export type TrainingPlan = {
id: string;
createdAt: string; // ISO
startDate: string; // ISO (当天或下周一)
mode: PlanMode;
daysOfWeek: number[]; // 0(日) - 6(六)
sessionsPerWeek: number; // 1..7
goal: PlanGoal | '';
startWeightKg?: number;
preferredTimeOfDay?: 'morning' | 'noon' | 'evening' | '';
name?: string;
isActive?: boolean;
};
export interface TrainingPlanListResponse { export interface TrainingPlanListResponse {
list: TrainingPlanSummary[]; list: TrainingPlan[];
total: number; total: number;
page: number; page: number;
limit: number; limit: number;
} }
class TrainingPlanApi { class TrainingPlanApi {
async create(dto: CreateTrainingPlanDto): Promise<TrainingPlanResponse> { async create(dto: CreateTrainingPlanDto): Promise<TrainingPlan> {
return api.post<TrainingPlanResponse>('/training-plans', dto); return api.post<TrainingPlan>('/training-plans', dto);
} }
async list(page: number = 1, limit: number = 10): Promise<TrainingPlanListResponse> { async list(page: number = 1, limit: number = 10): Promise<TrainingPlanListResponse> {
return api.get<TrainingPlanListResponse>(`/training-plans?page=${page}&limit=${limit}`); return api.get<TrainingPlanListResponse>(`/training-plans?page=${page}&limit=${limit}`);
} }
async detail(id: string): Promise<TrainingPlanResponse> { async detail(id: string): Promise<TrainingPlan> {
return api.get<TrainingPlanResponse>(`/training-plans/${id}`); return api.get<TrainingPlan>(`/training-plans/${id}`);
} }
async update(id: string, dto: CreateTrainingPlanDto): Promise<TrainingPlanResponse> { async update(id: string, dto: CreateTrainingPlanDto): Promise<TrainingPlan> {
return api.post<TrainingPlanResponse>(`/training-plans/${id}/update`, dto); return api.post<TrainingPlan>(`/training-plans/${id}/update`, dto);
} }
async delete(id: string): Promise<{ success: boolean }> { async delete(id: string): Promise<{ success: boolean }> {
@@ -71,9 +67,9 @@ class TrainingPlanApi {
return api.post<{ success: boolean }>(`/training-plans/${id}/activate`); return api.post<{ success: boolean }>(`/training-plans/${id}/activate`);
} }
async getActivePlan(): Promise<TrainingPlanResponse | null> { async getActivePlan(): Promise<TrainingPlan | null> {
try { try {
return api.get<TrainingPlanResponse>('/training-plans/active'); return api.get<TrainingPlan>('/training-plans/active');
} catch (error) { } catch (error) {
// 如果没有激活的计划返回null // 如果没有激活的计划返回null
return null; return null;

View File

@@ -1,41 +1,17 @@
import { CreateTrainingPlanDto, trainingPlanApi, TrainingPlanSummary } from '@/services/trainingPlanApi'; import { CreateTrainingPlanDto, PlanGoal, PlanMode, TrainingPlan, trainingPlanApi } from '@/services/trainingPlanApi';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
export type PlanMode = 'daysOfWeek' | 'sessionsPerWeek';
export type PlanGoal =
| 'postpartum_recovery' // 产后恢复
| 'fat_loss' // 减脂塑形
| 'posture_correction' // 体态矫正
| 'core_strength' // 核心力量
| 'flexibility' // 柔韧灵活
| 'rehab' // 康复保健
| 'stress_relief'; // 释压放松
export type TrainingPlan = {
id: string;
createdAt: string; // ISO
startDate: string; // ISO (当天或下周一)
mode: PlanMode;
daysOfWeek: number[]; // 0(日) - 6(六)
sessionsPerWeek: number; // 1..7
goal: PlanGoal | '';
startWeightKg?: number;
preferredTimeOfDay?: 'morning' | 'noon' | 'evening' | '';
name?: string;
};
export type TrainingPlanState = { export type TrainingPlanState = {
plans: TrainingPlan[]; plans: TrainingPlan[];
currentId?: string | null;
editingId?: string | null; editingId?: string | null;
draft: Omit<TrainingPlan, 'id' | 'createdAt'>; draft: Omit<TrainingPlan, 'id' | 'createdAt'>;
loading: boolean; loading: boolean;
error: string | null; error: string | null;
}; };
const STORAGE_KEY_LEGACY_SINGLE = '@training_plan';
const STORAGE_KEY_LIST = '@training_plans'; const STORAGE_KEY_LIST = '@training_plans';
function nextMondayISO(): string { function nextMondayISO(): string {
@@ -50,7 +26,6 @@ function nextMondayISO(): string {
const initialState: TrainingPlanState = { const initialState: TrainingPlanState = {
plans: [], plans: [],
currentId: null,
editingId: null, editingId: null,
loading: false, loading: false,
error: null, error: null,
@@ -73,13 +48,9 @@ export const loadPlans = createAsyncThunk('trainingPlan/loadPlans', async (_, {
try { try {
// 尝试从服务器获取数据 // 尝试从服务器获取数据
const response = await trainingPlanApi.list(1, 100); // 获取所有计划 const response = await trainingPlanApi.list(1, 100); // 获取所有计划
console.log('response', response); const plans = response.list;
const plans: TrainingPlanSummary[] = response.list;
// 读取最后一次使用的 currentId从本地存储 return { plans };
const currentId = (await AsyncStorage.getItem(`${STORAGE_KEY_LIST}__currentId`)) || null;
return { plans, currentId } as { plans: TrainingPlan[]; currentId: string | null };
} catch (error: any) { } catch (error: any) {
// 如果API调用失败回退到本地存储 // 如果API调用失败回退到本地存储
console.warn('API调用失败使用本地存储:', error.message); console.warn('API调用失败使用本地存储:', error.message);
@@ -89,27 +60,13 @@ export const loadPlans = createAsyncThunk('trainingPlan/loadPlans', async (_, {
if (listStr) { if (listStr) {
try { try {
const plans = JSON.parse(listStr) as TrainingPlan[]; const plans = JSON.parse(listStr) as TrainingPlan[];
const currentId = (await AsyncStorage.getItem(`${STORAGE_KEY_LIST}__currentId`)) || null; return { plans } as { plans: TrainingPlan[] };
return { plans, currentId } as { plans: TrainingPlan[]; currentId: string | null };
} catch { } catch {
// 解析失败则视为无数据 // 解析失败则视为无数据
} }
} }
// 旧版:单计划 return { plans: [] } as { plans: TrainingPlan[] };
const legacyStr = await AsyncStorage.getItem(STORAGE_KEY_LEGACY_SINGLE);
if (legacyStr) {
try {
const legacy = JSON.parse(legacyStr) as TrainingPlan;
const plans = [legacy];
const currentId = legacy.id;
return { plans, currentId } as { plans: TrainingPlan[]; currentId: string | null };
} catch {
// ignore
}
}
return { plans: [], currentId: null } as { plans: TrainingPlan[]; currentId: string | null };
} }
}); });
@@ -134,28 +91,9 @@ export const saveDraftAsPlan = createAsyncThunk(
preferredTimeOfDay: draft.preferredTimeOfDay, preferredTimeOfDay: draft.preferredTimeOfDay,
}; };
const response = await trainingPlanApi.create(createDto); const newPlan = await trainingPlanApi.create(createDto);
const plan: TrainingPlan = { return newPlan;
id: response.id,
createdAt: response.createdAt,
startDate: response.startDate,
mode: response.mode,
daysOfWeek: response.daysOfWeek,
sessionsPerWeek: response.sessionsPerWeek,
goal: response.goal as PlanGoal,
startWeightKg: response.startWeightKg || undefined,
preferredTimeOfDay: response.preferredTimeOfDay,
name: response.name,
};
const nextPlans = [...(s.plans || []), plan];
// 同时保存到本地存储作为缓存
await AsyncStorage.setItem(STORAGE_KEY_LIST, JSON.stringify(nextPlans));
await AsyncStorage.setItem(`${STORAGE_KEY_LIST}__currentId`, plan.id);
return { plans: nextPlans, currentId: plan.id } as { plans: TrainingPlan[]; currentId: string };
} catch (error: any) { } catch (error: any) {
return rejectWithValue(error.message || '创建训练计划失败'); return rejectWithValue(error.message || '创建训练计划失败');
} }
@@ -257,16 +195,11 @@ export const deletePlan = createAsyncThunk(
// 更新本地状态 // 更新本地状态
const nextPlans = (s.plans || []).filter((p) => p.id !== planId); const nextPlans = (s.plans || []).filter((p) => p.id !== planId);
let nextCurrentId = s.currentId || null;
if (nextCurrentId === planId) {
nextCurrentId = nextPlans.length > 0 ? nextPlans[nextPlans.length - 1].id : null;
}
// 同时更新本地存储 // 同时更新本地存储
await AsyncStorage.setItem(STORAGE_KEY_LIST, JSON.stringify(nextPlans)); await AsyncStorage.setItem(STORAGE_KEY_LIST, JSON.stringify(nextPlans));
await AsyncStorage.setItem(`${STORAGE_KEY_LIST}__currentId`, nextCurrentId ?? '');
return { plans: nextPlans, currentId: nextCurrentId } as { plans: TrainingPlan[]; currentId: string | null }; return { plans: nextPlans } as { plans: TrainingPlan[] };
} catch (error: any) { } catch (error: any) {
return rejectWithValue(error.message || '删除训练计划失败'); return rejectWithValue(error.message || '删除训练计划失败');
} }
@@ -277,11 +210,6 @@ const trainingPlanSlice = createSlice({
name: 'trainingPlan', name: 'trainingPlan',
initialState, initialState,
reducers: { reducers: {
setCurrentPlan(state, action: PayloadAction<string | null | undefined>) {
state.currentId = action.payload ?? null;
// 保存到本地存储
AsyncStorage.setItem(`${STORAGE_KEY_LIST}__currentId`, action.payload ?? '');
},
setMode(state, action: PayloadAction<PlanMode>) { setMode(state, action: PayloadAction<PlanMode>) {
state.draft.mode = action.payload; state.draft.mode = action.payload;
}, },
@@ -333,13 +261,6 @@ const trainingPlanSlice = createSlice({
.addCase(loadPlans.fulfilled, (state, action) => { .addCase(loadPlans.fulfilled, (state, action) => {
state.loading = false; state.loading = false;
state.plans = action.payload.plans; state.plans = action.payload.plans;
state.currentId = action.payload.currentId;
// 若存在历史计划,初始化 draft 基于该计划(便于编辑)
const current = state.plans.find((p) => p.id === state.currentId) || state.plans[state.plans.length - 1];
if (current) {
const { id, createdAt, ...rest } = current;
state.draft = { ...rest };
}
}) })
.addCase(loadPlans.rejected, (state, action) => { .addCase(loadPlans.rejected, (state, action) => {
state.loading = false; state.loading = false;
@@ -352,8 +273,6 @@ const trainingPlanSlice = createSlice({
}) })
.addCase(saveDraftAsPlan.fulfilled, (state, action) => { .addCase(saveDraftAsPlan.fulfilled, (state, action) => {
state.loading = false; state.loading = false;
state.plans = action.payload.plans;
state.currentId = action.payload.currentId;
}) })
.addCase(saveDraftAsPlan.rejected, (state, action) => { .addCase(saveDraftAsPlan.rejected, (state, action) => {
state.loading = false; state.loading = false;
@@ -393,9 +312,6 @@ const trainingPlanSlice = createSlice({
}) })
.addCase(activatePlan.fulfilled, (state, action) => { .addCase(activatePlan.fulfilled, (state, action) => {
state.loading = false; state.loading = false;
state.currentId = action.payload.id;
// 保存到本地存储
AsyncStorage.setItem(`${STORAGE_KEY_LIST}__currentId`, action.payload.id);
}) })
.addCase(activatePlan.rejected, (state, action) => { .addCase(activatePlan.rejected, (state, action) => {
state.loading = false; state.loading = false;
@@ -409,7 +325,6 @@ const trainingPlanSlice = createSlice({
.addCase(deletePlan.fulfilled, (state, action) => { .addCase(deletePlan.fulfilled, (state, action) => {
state.loading = false; state.loading = false;
state.plans = action.payload.plans; state.plans = action.payload.plans;
state.currentId = action.payload.currentId;
}) })
.addCase(deletePlan.rejected, (state, action) => { .addCase(deletePlan.rejected, (state, action) => {
state.loading = false; state.loading = false;
@@ -419,7 +334,6 @@ const trainingPlanSlice = createSlice({
}); });
export const { export const {
setCurrentPlan,
setMode, setMode,
toggleDayOfWeek, toggleDayOfWeek,
setSessionsPerWeek, setSessionsPerWeek,