From 0a8b20f0ecb10c194d27a81aadcce26dd7eafced Mon Sep 17 00:00:00 2001 From: richarjiang Date: Tue, 26 Aug 2025 22:34:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E7=9B=AE=E6=A0=87?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD=E5=8F=8A=E7=9B=B8=E5=85=B3?= =?UTF-8?q?=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 GoalsListScreen 中新增目标编辑功能,支持用户编辑现有目标 - 更新 CreateGoalModal 组件,支持编辑模式下的目标更新 - 在 NutritionRecordsScreen 中新增删除营养记录功能,允许用户删除不需要的记录 - 更新 NutritionRecordCard 组件,增加操作选项,支持删除记录 - 修改 dietRecords 服务,添加删除营养记录的 API 调用 - 优化 goalsSlice,确保目标更新逻辑与 Redux 状态管理一致 --- app/goals-list.tsx | 92 +++++++++++++++-- app/nutrition/records.tsx | 15 ++- components/NutritionRecordCard.tsx | 148 +++++++++++++++++---------- components/model/CreateGoalModal.tsx | 71 ++++++++----- package.json | 1 + services/dietRecords.ts | 4 + services/goalsApi.ts | 35 +------ store/goalsSlice.ts | 9 +- 8 files changed, 244 insertions(+), 131 deletions(-) diff --git a/app/goals-list.tsx b/app/goals-list.tsx index 6155be7..ebd112a 100644 --- a/app/goals-list.tsx +++ b/app/goals-list.tsx @@ -1,10 +1,12 @@ import { GoalCard } from '@/components/GoalCard'; +import { CreateGoalModal } from '@/components/model/CreateGoalModal'; +import { useGlobalDialog } from '@/components/ui/DialogProvider'; import { Colors } from '@/constants/Colors'; import { TAB_BAR_BOTTOM_OFFSET, TAB_BAR_HEIGHT } from '@/constants/TabBar'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useColorScheme } from '@/hooks/useColorScheme'; -import { deleteGoal, fetchGoals, loadMoreGoals } from '@/store/goalsSlice'; -import { GoalListItem } from '@/types/goals'; +import { deleteGoal, fetchGoals, loadMoreGoals, updateGoal } from '@/store/goalsSlice'; +import { CreateGoalRequest, GoalListItem, UpdateGoalRequest } from '@/types/goals'; import MaterialIcons from '@expo/vector-icons/MaterialIcons'; import { useFocusEffect } from '@react-navigation/native'; import { LinearGradient } from 'expo-linear-gradient'; @@ -18,6 +20,8 @@ export default function GoalsListScreen() { const colorTokens = Colors[theme]; const dispatch = useAppDispatch(); const router = useRouter(); + + const { showConfirm } = useGlobalDialog(); // Redux状态 const { @@ -25,9 +29,15 @@ export default function GoalsListScreen() { goalsLoading, goalsError, goalsPagination, + updateLoading, + updateError, } = useAppSelector((state) => state.goals); const [refreshing, setRefreshing] = useState(false); + + // 编辑目标相关状态 + const [showEditModal, setShowEditModal] = useState(false); + const [editingGoal, setEditingGoal] = useState(null); // 页面聚焦时重新加载数据 useFocusEffect( @@ -162,16 +172,11 @@ export default function GoalsListScreen() { if (goalsError) { Alert.alert('错误', goalsError); } - }, [goalsError]); + if (updateError) { + Alert.alert('更新失败', updateError); + } + }, [goalsError, updateError]); - // 计算各状态的目标数量 - const goalCounts = useMemo(() => ({ - all: goals.length, - active: goals.filter(goal => goal.status === 'active').length, - paused: goals.filter(goal => goal.status === 'paused').length, - completed: goals.filter(goal => goal.status === 'completed').length, - cancelled: goals.filter(goal => goal.status === 'cancelled').length, - }), [goals]); // 根据筛选条件过滤目标 const filteredGoals = useMemo(() => { @@ -182,6 +187,58 @@ export default function GoalsListScreen() { // 处理目标点击 const handleGoalPress = (goal: GoalListItem) => { + setEditingGoal(goal); + setShowEditModal(true); + }; + + // 将 GoalListItem 转换为 CreateGoalRequest 格式 + const convertGoalToModalData = (goal: GoalListItem): Partial => { + return { + title: goal.title, + description: goal.description, + repeatType: goal.repeatType, + frequency: goal.frequency, + category: goal.category, + priority: goal.priority, + hasReminder: goal.hasReminder, + reminderTime: goal.reminderTime, + customRepeatRule: goal.customRepeatRule, + endDate: goal.endDate, + }; + }; + + // 处理更新目标 + const handleUpdateGoal = async (goalId: string, goalData: UpdateGoalRequest) => { + try { + await dispatch(updateGoal({ goalId, goalData })).unwrap(); + setShowEditModal(false); + setEditingGoal(null); + + // 使用全局弹窗显示成功消息 + showConfirm( + { + title: '目标更新成功', + message: '恭喜!您的目标已成功更新。', + confirmText: '确定', + cancelText: '', + icon: 'checkmark-circle', + iconColor: '#10B981', + }, + () => { + console.log('用户确认了目标更新成功'); + } + ); + } catch (error) { + console.error('Failed to update goal:', error); + Alert.alert('错误', '更新目标失败,请重试'); + // 更新失败时不关闭弹窗,保持编辑状态 + } + }; + + // 处理编辑弹窗关闭 + const handleCloseEditModal = () => { + setShowEditModal(false); + setEditingGoal(null); }; // 渲染目标项 @@ -292,6 +349,19 @@ export default function GoalsListScreen() { ListFooterComponent={renderLoadMore} /> + + {/* 编辑目标弹窗 */} + {editingGoal && ( + {}} // 编辑模式下不使用这个回调 + onUpdate={handleUpdateGoal} + loading={updateLoading} + initialData={convertGoalToModalData(editingGoal)} + editGoalId={editingGoal.id} + /> + )} ); diff --git a/app/nutrition/records.tsx b/app/nutrition/records.tsx index 34a442b..337118f 100644 --- a/app/nutrition/records.tsx +++ b/app/nutrition/records.tsx @@ -2,7 +2,7 @@ import { NutritionRecordCard } from '@/components/NutritionRecordCard'; import { HeaderBar } from '@/components/ui/HeaderBar'; import { Colors } from '@/constants/Colors'; import { useColorScheme } from '@/hooks/useColorScheme'; -import { DietRecord, getDietRecords } from '@/services/dietRecords'; +import { DietRecord, deleteDietRecord, getDietRecords } from '@/services/dietRecords'; import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date'; import { Ionicons } from '@expo/vector-icons'; import dayjs from 'dayjs'; @@ -136,6 +136,18 @@ export default function NutritionRecordsScreen() { } }; + // 删除记录 + const handleDeleteRecord = async (recordId: number) => { + try { + await deleteDietRecord(recordId); + // 从本地状态中移除已删除的记录 + setRecords(prev => prev.filter(record => record.id !== recordId)); + } catch (error) { + console.error('删除营养记录失败:', error); + // 可以添加错误提示 + } + }; + // 渲染视图模式切换器 const renderViewModeToggle = () => ( @@ -275,6 +287,7 @@ export default function NutritionRecordsScreen() { showTimeline={true} isFirst={index === 0} isLast={index === records.length - 1} + onDelete={() => handleDeleteRecord(item.id)} /> ); diff --git a/components/NutritionRecordCard.tsx b/components/NutritionRecordCard.tsx index c03d67e..cb640bd 100644 --- a/components/NutritionRecordCard.tsx +++ b/components/NutritionRecordCard.tsx @@ -1,14 +1,16 @@ import { ThemedText } from '@/components/ThemedText'; +import { ActionSheet } from '@/components/ui/ActionSheet'; import { useThemeColor } from '@/hooks/useThemeColor'; import { DietRecord } from '@/services/dietRecords'; import { Ionicons } from '@expo/vector-icons'; import dayjs from 'dayjs'; -import React, { useMemo } from 'react'; +import React, { useMemo, useState } from 'react'; import { Image, StyleSheet, TouchableOpacity, View } from 'react-native'; export type NutritionRecordCardProps = { record: DietRecord; onPress?: () => void; + onDelete?: () => void; showTimeline?: boolean; isFirst?: boolean; isLast?: boolean; @@ -41,6 +43,7 @@ const MEAL_TYPE_COLORS = { export function NutritionRecordCard({ record, onPress, + onDelete, showTimeline = false, isFirst = false, isLast = false @@ -50,6 +53,9 @@ export function NutritionRecordCard({ const textSecondaryColor = useThemeColor({}, 'textSecondary'); const primaryColor = useThemeColor({}, 'primary'); + // ActionSheet 状态管理 + const [showActionSheet, setShowActionSheet] = useState(false); + // 营养数据统计 const nutritionStats = useMemo(() => { return [ @@ -117,38 +123,36 @@ export function NutritionRecordCard({ > {/* 主要内容区域 */} - {/* 左侧:食物图片 */} - - {record.imageUrl ? ( - - ) : ( - - )} - - - {/* 右侧:食物信息 */} {/* 餐次和操作按钮 */} - - - {mealTypeLabel} - - + {!showTimeline && ( {record.mealTime ? dayjs(record.mealTime).format('HH:mm') : '时间未设置'} )} - + setShowActionSheet(true)} + > {/* 食物名称和分量 */} + {/* 左侧:食物图片 */} + + {record.imageUrl ? ( + + ) : ( + + )} + + {record.foodName} @@ -157,19 +161,22 @@ export function NutritionRecordCard({ {record.portionDescription || `${record.weightGrams}g`} )} + + + {mealTypeLabel} + + - {/* 营养信息网格 */} - - {nutritionStats.map((stat) => ( - - - - - {stat.label} - - - + {/* 营养信息 - 紧凑标签布局 */} + + {nutritionStats.map((stat, index) => ( + + + + {stat.label} + + {stat.value} @@ -187,6 +194,25 @@ export function NutritionRecordCard({ + + {/* ActionSheet for more options */} + setShowActionSheet(false)} + title="选择操作" + options={[ + { + id: 'delete', + title: '删除记录', + subtitle: '删除后无法恢复', + icon: 'trash-outline', + destructive: true, + onPress: () => { + onDelete?.(); + } + } + ]} + /> ); } @@ -248,10 +274,10 @@ const styles = StyleSheet.create({ flexDirection: 'row', }, foodImageContainer: { - width: 80, - height: 80, + width: 28, + height: 28, borderRadius: 16, - marginRight: 16, + marginRight: 8, overflow: 'hidden', }, foodImage: { @@ -271,19 +297,20 @@ const styles = StyleSheet.create({ justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8, + position: 'absolute', + top: 0, + right: 0, }, mealTypeContainer: { flex: 1, }, mealTypeBadge: { - alignSelf: 'flex-start', - paddingHorizontal: 8, - paddingVertical: 4, + paddingHorizontal: 4, borderRadius: 8, - marginBottom: 4, + marginLeft: 4, }, mealTypeText: { - fontSize: 12, + fontSize: 8, fontWeight: '600', }, mealTime: { @@ -296,6 +323,9 @@ const styles = StyleSheet.create({ }, foodNameSection: { marginBottom: 12, + flexDirection: 'row', + gap: 4, + alignItems: 'center', }, foodName: { fontSize: 18, @@ -304,32 +334,38 @@ const styles = StyleSheet.create({ marginBottom: 2, }, portionInfo: { - fontSize: 14, - fontWeight: '500', - }, - nutritionGrid: { - flexDirection: 'row', - flexWrap: 'wrap', - marginBottom: 8, - }, - nutritionItem: { - width: '50%', - marginBottom: 8, - paddingRight: 8, - }, - nutritionItemHeader: { - flexDirection: 'row', - alignItems: 'center', - marginBottom: 2, - }, - nutritionLabel: { fontSize: 12, fontWeight: '500', + }, + nutritionContainer: { + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'space-between', + }, + nutritionTag: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: 'rgba(248, 249, 250, 0.8)', + borderRadius: 8, + paddingHorizontal: 8, + paddingVertical: 2, + marginBottom: 4, + width: '48%', + overflow: 'hidden', + }, + nutritionText: { + fontSize: 9, + fontWeight: '500', marginLeft: 4, + marginRight: 2, + flexShrink: 0, }, nutritionValue: { - fontSize: 14, + fontSize: 10, fontWeight: '700', + flexShrink: 1, + textAlign: 'right', + flex: 1, }, notesSection: { marginTop: 8, diff --git a/components/model/CreateGoalModal.tsx b/components/model/CreateGoalModal.tsx index d119dce..fa52d29 100644 --- a/components/model/CreateGoalModal.tsx +++ b/components/model/CreateGoalModal.tsx @@ -1,6 +1,6 @@ import { Colors } from '@/constants/Colors'; import { useColorScheme } from '@/hooks/useColorScheme'; -import { CreateGoalRequest, GoalPriority, RepeatType } from '@/types/goals'; +import { CreateGoalRequest, GoalPriority, RepeatType, UpdateGoalRequest } from '@/types/goals'; import { Ionicons } from '@expo/vector-icons'; import DateTimePicker from '@react-native-community/datetimepicker'; import { LinearGradient } from 'expo-linear-gradient'; @@ -24,9 +24,11 @@ interface CreateGoalModalProps { visible: boolean; onClose: () => void; onSubmit: (goalData: CreateGoalRequest) => void; + onUpdate?: (goalId: string, goalData: UpdateGoalRequest) => void; onSuccess?: () => void; loading?: boolean; initialData?: Partial; + editGoalId?: string; } const REPEAT_TYPE_OPTIONS: { value: RepeatType; label: string }[] = [ @@ -41,9 +43,11 @@ export const CreateGoalModal: React.FC = ({ visible, onClose, onSubmit, + onUpdate, onSuccess, loading = false, initialData, + editGoalId, }) => { const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const colorTokens = Colors[theme]; @@ -130,28 +134,49 @@ export const CreateGoalModal: React.FC = ({ startTime = hours * 60 + minutes; } + // 根据是否是编辑模式决定数据结构 + if (editGoalId && onUpdate) { + // 更新模式:使用 UpdateGoalRequest 结构 + const updateData: UpdateGoalRequest = { + title: title.trim(), + description: description.trim() || undefined, + repeatType, + frequency, + category: category.trim() || undefined, + priority, + hasReminder, + reminderTime: hasReminder ? reminderTime : undefined, + customRepeatRule: { + weekdays: repeatType === 'weekly' ? selectedWeekdays : [1, 2, 3, 4, 5, 6, 0], + dayOfMonth: repeatType === 'monthly' ? selectedMonthDays : undefined, + }, + endDate: endDate || undefined, + }; + console.log('updateData', updateData); + onUpdate(editGoalId, updateData); + } else { + // 创建模式:使用 CreateGoalRequest 结构 + const goalData: CreateGoalRequest = { + title: title.trim(), + description: description.trim() || undefined, + repeatType, + frequency, + category: category.trim() || undefined, + priority, + hasReminder, + reminderTime: hasReminder ? reminderTime : undefined, + customRepeatRule: { + weekdays: repeatType === 'weekly' ? selectedWeekdays : [1, 2, 3, 4, 5, 6, 0], + dayOfMonth: repeatType === 'monthly' ? selectedMonthDays : undefined, + }, + startTime, + endDate: endDate || undefined, + }; - const goalData: CreateGoalRequest = { - title: title.trim(), - description: description.trim() || undefined, - repeatType, - frequency, - category: category.trim() || undefined, - priority, - hasReminder, - reminderTime: hasReminder ? reminderTime : undefined, - customRepeatRule: { - weekdays: repeatType === 'weekly' ? selectedWeekdays : [1, 2, 3, 4, 5, 6, 0], // 根据重复类型设置周几 - dayOfMonth: repeatType === 'monthly' ? selectedMonthDays : undefined, // 根据重复类型设置月几 - }, - startTime, - endDate: endDate || undefined, - }; - - console.log('goalData', goalData); - - onSubmit(goalData); + console.log('goalData', goalData); + onSubmit(goalData); + } // 通知父组件提交成功 if (onSuccess) { @@ -278,7 +303,7 @@ export const CreateGoalModal: React.FC = ({ - 创建新目标 + {editGoalId ? '编辑目标' : '创建新目标'} @@ -717,7 +742,7 @@ export const CreateGoalModal: React.FC = ({ disabled={loading || !title.trim()} > - {loading ? '保存中...' : '保存'} + {loading ? (editGoalId ? '更新中...' : '保存中...') : (editGoalId ? '更新' : '保存')} diff --git a/package.json b/package.json index e3168ca..a04d3cb 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "reset-project": "node ./scripts/reset-project.js", "android": "expo run:android", "ios": "expo run:ios", + "ios-device": "expo run:ios --device", "web": "expo start --web", "lint": "expo lint" }, diff --git a/services/dietRecords.ts b/services/dietRecords.ts index 0ce811a..2e707a4 100644 --- a/services/dietRecords.ts +++ b/services/dietRecords.ts @@ -68,6 +68,10 @@ export async function getDietRecords({ }>(`/users/diet-records${params}`); } +export async function deleteDietRecord(recordId: number): Promise { + await api.delete(`/users/diet-records/${recordId}`); +} + export function calculateNutritionSummary(records: DietRecord[]): NutritionSummary { if (records?.length === 0) { return { diff --git a/services/goalsApi.ts b/services/goalsApi.ts index a900fd2..c823a89 100644 --- a/services/goalsApi.ts +++ b/services/goalsApi.ts @@ -53,8 +53,8 @@ export const getGoalById = async (goalId: string): Promise> => { - return api.put>(`/goals/${goalId}`, goalData); +export const updateGoal = async (goalId: string, goalData: UpdateGoalRequest): Promise => { + return api.put(`/goals/${goalId}`, goalData); }; /** @@ -106,33 +106,6 @@ export const batchOperateGoals = async (operationData: BatchGoalOperationRequest return api.post>('/goals/batch', operationData); }; -/** - * 暂停目标 - */ -export const pauseGoal = async (goalId: string): Promise> => { - return updateGoal(goalId, { status: 'paused' }); -}; - -/** - * 恢复目标 - */ -export const resumeGoal = async (goalId: string): Promise> => { - return updateGoal(goalId, { status: 'active' }); -}; - -/** - * 完成目标 - */ -export const markGoalCompleted = async (goalId: string): Promise> => { - return updateGoal(goalId, { status: 'completed' }); -}; - -/** - * 取消目标 - */ -export const cancelGoal = async (goalId: string): Promise> => { - return updateGoal(goalId, { status: 'cancelled' }); -}; // 导出所有API方法 export const goalsApi = { @@ -145,10 +118,6 @@ export const goalsApi = { getGoalCompletions, getGoalStats, batchOperateGoals, - pauseGoal, - resumeGoal, - markGoalCompleted, - cancelGoal, }; export default goalsApi; \ No newline at end of file diff --git a/store/goalsSlice.ts b/store/goalsSlice.ts index dc12058..72517a6 100644 --- a/store/goalsSlice.ts +++ b/store/goalsSlice.ts @@ -195,7 +195,7 @@ export const updateGoal = createAsyncThunk( async ({ goalId, goalData }: { goalId: string; goalData: UpdateGoalRequest }, { rejectWithValue }) => { try { const response = await goalsApi.updateGoal(goalId, goalData); - return response.data; + return response; } catch (error: any) { return rejectWithValue(error.message || '更新目标失败'); } @@ -457,10 +457,7 @@ const goalsSlice = createSlice({ state.updateLoading = false; const updatedGoal = action.payload; - // 计算进度百分比 - const progressPercentage = updatedGoal.targetCount && updatedGoal.targetCount > 0 - ? Math.round((updatedGoal.completedCount / updatedGoal.targetCount) * 100) - : 0; + console.log('updateGoal.fulfilled', updatedGoal); // 更新目标列表中的目标 const goalIndex = state.goals.findIndex(goal => goal.id === updatedGoal.id); @@ -468,7 +465,6 @@ const goalsSlice = createSlice({ state.goals[goalIndex] = { ...state.goals[goalIndex], ...updatedGoal, - progressPercentage, }; } @@ -477,7 +473,6 @@ const goalsSlice = createSlice({ state.currentGoal = { ...state.currentGoal, ...updatedGoal, - progressPercentage, }; } })