From 7ad26590e5989803fe804dff85cf4bc700d92142 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Wed, 13 Aug 2025 19:24:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=E4=B8=AA=E4=BA=BA?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=E5=92=8C=E6=89=93=E5=8D=A1=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在个人信息页面中修改用户姓名字段为“name”,并添加注销帐号功能,支持用户删除帐号及相关数据 - 在打卡页面中集成从后端获取当天打卡列表的功能,确保用户数据的实时同步 - 更新 Redux 状态管理,支持打卡记录的同步和更新 - 新增打卡服务,提供创建、更新和删除打卡记录的 API 接口 - 优化样式以适应新功能的展示和交互 --- app/(tabs)/personal.tsx | 45 +++++++++++++++++++++++++-- app/checkin/index.tsx | 32 ++++++++++++++++++-- app/checkin/select.tsx | 17 ++++++++++- app/profile/edit.tsx | 12 ++++---- services/checkins.ts | 58 +++++++++++++++++++++++++++++++++++ store/checkinSlice.ts | 67 ++++++++++++++++++++++++++++++++++++++++- store/userSlice.ts | 16 +++++----- 7 files changed, 225 insertions(+), 22 deletions(-) create mode 100644 services/checkins.ts diff --git a/app/(tabs)/personal.tsx b/app/(tabs)/personal.tsx index a43183f..3e8c2bb 100644 --- a/app/(tabs)/personal.tsx +++ b/app/(tabs)/personal.tsx @@ -2,7 +2,8 @@ import { Colors } from '@/constants/Colors'; import { getTabBarBottomPadding } from '@/constants/TabBar'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useColorScheme } from '@/hooks/useColorScheme'; -import { DEFAULT_MEMBER_NAME, fetchMyProfile } from '@/store/userSlice'; +import { api } from '@/services/api'; +import { DEFAULT_MEMBER_NAME, fetchMyProfile, logout } from '@/store/userSlice'; import { Ionicons } from '@expo/vector-icons'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; @@ -29,7 +30,7 @@ export default function PersonalScreen() { const DEFAULT_AVATAR_URL = 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/avatar/avatarGirl01.jpeg'; type UserProfile = { - fullName?: string; + name?: string; email?: string; gender?: 'male' | 'female' | ''; age?: string; @@ -125,7 +126,34 @@ export default function PersonalScreen() { }; - const displayName = (profile.fullName && profile.fullName.trim()) ? profile.fullName : DEFAULT_MEMBER_NAME; + const displayName = (profile.name && profile.name.trim()) ? profile.name : DEFAULT_MEMBER_NAME; + + const handleDeleteAccount = () => { + Alert.alert( + '确认注销帐号', + '此操作不可恢复,将删除您的帐号及相关数据。确定继续吗?', + [ + { text: '取消', style: 'cancel' }, + { + text: '确认注销', + style: 'destructive', + onPress: async () => { + try { + await api.delete('/api/users/delete-account'); + await AsyncStorage.multiRemove(['@user_personal_info']); + await dispatch(logout()).unwrap(); + Alert.alert('帐号已注销', '您的帐号已成功注销'); + router.replace('/auth/login'); + } catch (err: any) { + const message = err?.message || '注销失败,请稍后重试'; + Alert.alert('注销失败', message); + } + }, + }, + ], + { cancelable: true } + ); + }; const UserInfoSection = () => ( @@ -282,6 +310,16 @@ export default function PersonalScreen() { }, ]; + const securityItems = [ + { + icon: 'trash-outline', + iconBg: '#FFE8E8', + iconColor: '#FF4444', + title: '注销帐号', + onPress: handleDeleteAccount, + }, + ]; + const developerItems = [ { icon: 'refresh-outline', @@ -306,6 +344,7 @@ export default function PersonalScreen() { + {/* 底部浮动按钮 */} diff --git a/app/checkin/index.tsx b/app/checkin/index.tsx index 78f9ebf..ef119a8 100644 --- a/app/checkin/index.tsx +++ b/app/checkin/index.tsx @@ -2,8 +2,10 @@ import { HeaderBar } from '@/components/ui/HeaderBar'; import { Colors } from '@/constants/Colors'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useColorScheme } from '@/hooks/useColorScheme'; -import { removeExercise, setCurrentDate, toggleExerciseCompleted } from '@/store/checkinSlice'; +import type { CheckinExercise } from '@/store/checkinSlice'; +import { getDailyCheckins, removeExercise, setCurrentDate, syncCheckin, toggleExerciseCompleted } from '@/store/checkinSlice'; import { Ionicons } from '@expo/vector-icons'; +import { useFocusEffect } from '@react-navigation/native'; import { useRouter } from 'expo-router'; import React, { useEffect, useMemo } from 'react'; import { Alert, FlatList, SafeAreaView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; @@ -26,8 +28,22 @@ export default function CheckinHome() { useEffect(() => { dispatch(setCurrentDate(today)); + // 进入页面立即从后端获取当天打卡列表,回填本地 + dispatch(getDailyCheckins(today)).unwrap().catch((err: any) => { + Alert.alert('获取打卡失败', err?.message || '请稍后重试'); + }); }, [dispatch, today]); + useFocusEffect( + React.useCallback(() => { + // 返回本页时确保与后端同步(若本地有内容则上报,后台 upsert) + if (record?.items && Array.isArray(record.items)) { + dispatch(syncCheckin({ date: today, items: record.items as CheckinExercise[], note: record?.note })); + } + return () => { }; + }, [dispatch, today, record?.items]) + ); + return ( @@ -68,7 +84,13 @@ export default function CheckinHome() { accessibilityRole="button" accessibilityLabel={item.completed ? '已完成,点击取消完成' : '未完成,点击标记完成'} style={styles.doneIconBtn} - onPress={() => dispatch(toggleExerciseCompleted({ date: today, key: item.key }))} + onPress={() => { + dispatch(toggleExerciseCompleted({ date: today, key: item.key })); + const nextItems: CheckinExercise[] = (record?.items || []).map((it: CheckinExercise) => + it.key === item.key ? { ...it, completed: !it.completed } : it + ); + dispatch(syncCheckin({ date: today, items: nextItems, note: record?.note })); + }} hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }} > dispatch(removeExercise({ date: today, key: item.key })), + onPress: () => { + dispatch(removeExercise({ date: today, key: item.key })); + const nextItems: CheckinExercise[] = (record?.items || []).filter((it: CheckinExercise) => it.key !== item.key); + dispatch(syncCheckin({ date: today, items: nextItems, note: record?.note })); + }, }, ]) } diff --git a/app/checkin/select.tsx b/app/checkin/select.tsx index c36707f..71546ce 100644 --- a/app/checkin/select.tsx +++ b/app/checkin/select.tsx @@ -2,7 +2,7 @@ import { HeaderBar } from '@/components/ui/HeaderBar'; import { Colors } from '@/constants/Colors'; import { useAppDispatch } from '@/hooks/redux'; import { useColorScheme } from '@/hooks/useColorScheme'; -import { addExercise } from '@/store/checkinSlice'; +import { addExercise, syncCheckin } from '@/store/checkinSlice'; import { EXERCISE_LIBRARY, getCategories, searchExercises } from '@/utils/exerciseLibrary'; import { Ionicons } from '@expo/vector-icons'; import * as Haptics from 'expo-haptics'; @@ -79,6 +79,21 @@ export default function SelectExerciseScreen() { reps: reps && reps > 0 ? reps : undefined, }, })); + console.log('addExercise', today, selected.key, sets, reps); + // 同步到后端(读取最新 store 需要在返回后由首页触发 load,或此处直接上报) + // 简单做法:直接上报新增项(其余项由后端合并/覆盖) + dispatch(syncCheckin({ + date: today, + items: [ + { + key: selected.key, + name: selected.name, + category: selected.category, + sets: Math.max(1, sets), + reps: reps && reps > 0 ? reps : undefined, + }, + ], + })); router.back(); }; diff --git a/app/profile/edit.tsx b/app/profile/edit.tsx index f7730a4..a728d44 100644 --- a/app/profile/edit.tsx +++ b/app/profile/edit.tsx @@ -26,7 +26,7 @@ type WeightUnit = 'kg' | 'lb'; type HeightUnit = 'cm' | 'ft'; interface UserProfile { - fullName?: string; + name?: string; gender?: 'male' | 'female' | ''; age?: string; // 存储为字符串,方便非必填 // 以公制为基准存储 @@ -43,7 +43,7 @@ export default function EditProfileScreen() { const insets = useSafeAreaInsets(); const [profile, setProfile] = useState({ - fullName: '', + name: '', gender: '', age: '', weightKg: undefined, @@ -66,7 +66,7 @@ export default function EditProfileScreen() { ]); let next: UserProfile = { - fullName: '', + name: '', gender: '', age: '', weightKg: undefined, @@ -179,11 +179,11 @@ export default function EditProfileScreen() { style={[styles.textInput, { color: textColor }]} placeholder="填写姓名(可选)" placeholderTextColor={placeholderColor} - value={profile.fullName} - onChangeText={(t) => setProfile((p) => ({ ...p, fullName: t }))} + value={profile.name} + onChangeText={(t) => setProfile((p) => ({ ...p, name: t }))} /> {/* 校验勾无需强制,仅装饰 */} - {!!profile.fullName && } + {!!profile.name && } {/* 体重(kg) */} diff --git a/services/checkins.ts b/services/checkins.ts new file mode 100644 index 0000000..18211bd --- /dev/null +++ b/services/checkins.ts @@ -0,0 +1,58 @@ +import { api } from '@/services/api'; + +export type CheckinMetrics = Record; + +export type CreateCheckinDto = { + workoutId?: string; + planId?: string; + title?: string; + checkinDate?: string; // YYYY-MM-DD + startedAt?: string; // ISO + notes?: string; + metrics?: CheckinMetrics; +}; + +export type UpdateCheckinDto = Partial & { + id: string; + status?: string; // 由后端验证为 CheckinStatus + completedAt?: string; // ISO + durationSeconds?: number; +}; + +export type CompleteCheckinDto = { + id: string; + completedAt?: string; // ISO + durationSeconds?: number; + notes?: string; + metrics?: CheckinMetrics; +}; + +export type RemoveCheckinDto = { + id: string; +}; + +export async function createCheckin(dto: CreateCheckinDto): Promise<{ id: string } & Record> { + return await api.post('/api/checkins/create', dto); +} + +export async function updateCheckin(dto: UpdateCheckinDto): Promise<{ id: string } & Record> { + return await api.put('/api/checkins/update', dto); +} + +export async function completeCheckin(dto: CompleteCheckinDto): Promise<{ id: string } & Record> { + return await api.post('/api/checkins/complete', dto); +} + +export async function removeCheckin(dto: RemoveCheckinDto): Promise> { + return await api.delete('/api/checkins/remove', { body: dto }); +} + +// 按天获取打卡列表(后端默认今天,不传 date 也可) +export async function fetchDailyCheckins(date?: string): Promise { + const path = date ? `/api/checkins/daily?date=${encodeURIComponent(date)}` : '/api/checkins/daily'; + const data = await api.get(path); + // 返回值兼容 { data: T } 或直接 T 已由 api 封装处理 + return Array.isArray(data) ? data : []; +} + + diff --git a/store/checkinSlice.ts b/store/checkinSlice.ts index d50f2f1..e7d919a 100644 --- a/store/checkinSlice.ts +++ b/store/checkinSlice.ts @@ -1,4 +1,5 @@ -import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { createCheckin, fetchDailyCheckins, updateCheckin } from '@/services/checkins'; +import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; export type CheckinExercise = { key: string; @@ -70,9 +71,73 @@ const checkinSlice = createSlice({ delete state.byDate[action.payload]; }, }, + extraReducers: (builder) => { + builder + .addCase(syncCheckin.fulfilled, (state, action) => { + if (!action.payload) return; + const { date, items, note, id } = action.payload; + state.byDate[date] = { + id: id || state.byDate[date]?.id || `rec_${date}`, + date, + items: items || [], + note, + }; + }) + .addCase(getDailyCheckins.fulfilled, (state, action) => { + const date = action.payload.date as string | undefined; + const list = action.payload.list || []; + if (!date) return; + let mergedItems: CheckinExercise[] = []; + let note: string | undefined = undefined; + let id: string | undefined = state.byDate[date]?.id; + for (const rec of list) { + if (!rec) continue; + if (rec.id && !id) id = String(rec.id); + const itemsFromMetrics = rec?.metrics?.items ?? rec?.items; + if (Array.isArray(itemsFromMetrics)) { + mergedItems = itemsFromMetrics as CheckinExercise[]; + } + if (typeof rec.notes === 'string') note = rec.notes as string; + } + state.byDate[date] = { + id: id || state.byDate[date]?.id || `rec_${date}`, + date, + items: mergedItems, + note, + }; + }); + }, }); export const { setCurrentDate, addExercise, removeExercise, toggleExerciseCompleted, setNote, resetDate } = checkinSlice.actions; export default checkinSlice.reducer; +// Thunks +export const syncCheckin = createAsyncThunk('checkin/sync', async (record: { date: string; items: CheckinExercise[]; note?: string; id?: string }, { getState }) => { + const state = getState() as any; + const existingId: string | undefined = record.id || state?.checkin?.byDate?.[record.date]?.id; + const metrics = { items: record.items } as any; + if (!existingId || existingId.startsWith('rec_')) { + const created = await createCheckin({ + title: '每日训练打卡', + checkinDate: record.date, + notes: record.note, + metrics, + startedAt: new Date().toISOString(), + }); + const newId = (created as any)?.id; + return { id: newId, date: record.date, items: record.items, note: record.note }; + } + const updated = await updateCheckin({ id: existingId, notes: record.note, metrics }); + const newId = (updated as any)?.id ?? existingId; + return { id: newId, date: record.date, items: record.items, note: record.note }; +}); + +// 获取当天打卡列表(用于进入页面时拉取最新云端数据) +export const getDailyCheckins = createAsyncThunk('checkin/getDaily', async (date?: string) => { + const list = await fetchDailyCheckins(date); + + return { date, list } as { date?: string; list: any[] }; +}); + diff --git a/store/userSlice.ts b/store/userSlice.ts index 908ae79..088753e 100644 --- a/store/userSlice.ts +++ b/store/userSlice.ts @@ -5,7 +5,7 @@ import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; export type Gender = 'male' | 'female' | ''; export type UserProfile = { - fullName?: string; + name?: string; email?: string; gender?: Gender; age?: string; // 个人中心是字符串展示 @@ -29,7 +29,7 @@ export const DEFAULT_MEMBER_NAME = '普拉提星球学员'; const initialState: UserState = { token: null, profile: { - fullName: DEFAULT_MEMBER_NAME, + name: DEFAULT_MEMBER_NAME, }, loading: false, error: null, @@ -164,8 +164,8 @@ const userSlice = createSlice({ state.loading = false; state.token = action.payload.token; state.profile = action.payload.profile; - if (!state.profile?.fullName || !state.profile.fullName.trim()) { - state.profile.fullName = DEFAULT_MEMBER_NAME; + if (!state.profile?.name || !state.profile.name.trim()) { + state.profile.name = DEFAULT_MEMBER_NAME; } }) .addCase(login.rejected, (state, action) => { @@ -175,14 +175,14 @@ const userSlice = createSlice({ .addCase(rehydrateUser.fulfilled, (state, action) => { state.token = action.payload.token; state.profile = action.payload.profile; - if (!state.profile?.fullName || !state.profile.fullName.trim()) { - state.profile.fullName = DEFAULT_MEMBER_NAME; + if (!state.profile?.name || !state.profile.name.trim()) { + state.profile.name = DEFAULT_MEMBER_NAME; } }) .addCase(fetchMyProfile.fulfilled, (state, action) => { state.profile = action.payload || {}; - if (!state.profile?.fullName || !state.profile.fullName.trim()) { - state.profile.fullName = DEFAULT_MEMBER_NAME; + if (!state.profile?.name || !state.profile.name.trim()) { + state.profile.name = DEFAULT_MEMBER_NAME; } }) .addCase(fetchMyProfile.rejected, (state, action) => {