feat: 新增营养记录页面及相关组件
- 在应用中新增营养记录页面,展示用户的饮食记录 - 引入营养记录卡片组件,优化记录展示效果 - 更新路由常量,添加营养记录相关路径 - 修改布局文件,整合营养记录功能 - 优化数据加载逻辑,支持分页和日期过滤
This commit is contained in:
@@ -16,7 +16,6 @@ import Toast from 'react-native-toast-message';
|
||||
|
||||
import { DialogProvider } from '@/components/ui/DialogProvider';
|
||||
import { Provider } from 'react-redux';
|
||||
import { ROUTES } from '@/constants/Routes';
|
||||
|
||||
function Bootstrapper({ children }: { children: React.ReactNode }) {
|
||||
const dispatch = useAppDispatch();
|
||||
@@ -29,7 +28,7 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
|
||||
await dispatch(rehydrateUser());
|
||||
setUserDataLoaded(true);
|
||||
};
|
||||
|
||||
|
||||
loadUserData();
|
||||
// 冷启动时清空 AI 教练会话缓存
|
||||
clearAiCoachSessionCache();
|
||||
@@ -48,7 +47,7 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
|
||||
};
|
||||
|
||||
const handlePrivacyDisagree = () => {
|
||||
RNExitApp.exitApp();
|
||||
RNExitApp.exitApp();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -93,6 +92,7 @@ export default function RootLayout() {
|
||||
<Stack.Screen name="legal/user-agreement" options={{ headerShown: true, title: '用户协议' }} />
|
||||
<Stack.Screen name="legal/privacy-policy" options={{ headerShown: true, title: '隐私政策' }} />
|
||||
<Stack.Screen name="article/[id]" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="nutrition/records" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
<StatusBar style="dark" />
|
||||
|
||||
9
app/nutrition/_layout.tsx
Normal file
9
app/nutrition/_layout.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function NutritionLayout() {
|
||||
return (
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="records" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
466
app/nutrition/records.tsx
Normal file
466
app/nutrition/records.tsx
Normal file
@@ -0,0 +1,466 @@
|
||||
import { NutritionRecordCard } from '@/components/NutritionRecordCard';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { DietRecord } from '@/services/dietRecords';
|
||||
import { getMockDietRecords } from '@/services/mockDietRecords';
|
||||
import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
// import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
||||
import dayjs from 'dayjs';
|
||||
import { router } from 'expo-router';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
type ViewMode = 'daily' | 'all';
|
||||
|
||||
export default function NutritionRecordsScreen() {
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
// const tabBarHeight = useBottomTabBarHeight();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
// 日期相关状态 - 使用与统计页面相同的日期逻辑
|
||||
const days = getMonthDaysZh();
|
||||
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
|
||||
const monthTitle = getMonthTitleZh();
|
||||
|
||||
// 视图模式:按天查看 vs 全部查看
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('daily');
|
||||
|
||||
// 数据状态
|
||||
const [records, setRecords] = useState<DietRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [hasMoreData, setHasMoreData] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
// 日期滚动相关
|
||||
const daysScrollRef = useRef<ScrollView | null>(null);
|
||||
const [scrollWidth, setScrollWidth] = useState(0);
|
||||
const DAY_PILL_WIDTH = 68;
|
||||
const DAY_PILL_SPACING = 12;
|
||||
|
||||
// 日期滚动控制
|
||||
const scrollToIndex = (index: number, animated = true) => {
|
||||
const baseOffset = index * (DAY_PILL_WIDTH + DAY_PILL_SPACING);
|
||||
const centerOffset = Math.max(0, baseOffset - (scrollWidth / 2 - DAY_PILL_WIDTH / 2));
|
||||
daysScrollRef.current?.scrollTo({ x: centerOffset, animated });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollWidth > 0) {
|
||||
scrollToIndex(selectedIndex, false);
|
||||
}
|
||||
}, [scrollWidth, selectedIndex]);
|
||||
|
||||
// 加载记录数据
|
||||
const loadRecords = async (isRefresh = false, loadMore = false) => {
|
||||
try {
|
||||
if (isRefresh) {
|
||||
setRefreshing(true);
|
||||
setPage(1);
|
||||
} else if (loadMore) {
|
||||
// 加载更多时不显示loading
|
||||
} else {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
const currentPage = isRefresh ? 1 : (loadMore ? page + 1 : 1);
|
||||
|
||||
let startDate: string | undefined;
|
||||
let endDate: string | undefined;
|
||||
|
||||
if (viewMode === 'daily') {
|
||||
// 按天查看时,获取选中日期的数据
|
||||
const selectedDate = days[selectedIndex]?.date?.format('YYYY-MM-DD') ?? dayjs().format('YYYY-MM-DD');
|
||||
startDate = selectedDate;
|
||||
endDate = selectedDate;
|
||||
}
|
||||
// viewMode === 'all' 时不设置日期范围,获取所有数据
|
||||
|
||||
// 使用模拟数据进行测试
|
||||
// const data = await getDietRecords({
|
||||
// startDate,
|
||||
// endDate,
|
||||
// page: currentPage,
|
||||
// limit: 10,
|
||||
// });
|
||||
|
||||
// 模拟网络延迟
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
|
||||
const data = getMockDietRecords({
|
||||
startDate,
|
||||
endDate,
|
||||
page: currentPage,
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
if (isRefresh || currentPage === 1) {
|
||||
setRecords(data.records);
|
||||
} else {
|
||||
setRecords(prev => [...prev, ...data.records]);
|
||||
}
|
||||
|
||||
setHasMoreData(data.records.length === 10); // 如果返回的记录数少于limit,说明没有更多数据
|
||||
setPage(currentPage);
|
||||
} catch (error) {
|
||||
console.error('加载营养记录失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 当选中日期或视图模式变化时重新加载数据
|
||||
useEffect(() => {
|
||||
loadRecords();
|
||||
}, [selectedIndex, viewMode]);
|
||||
|
||||
const onRefresh = () => {
|
||||
loadRecords(true);
|
||||
};
|
||||
|
||||
const loadMoreRecords = () => {
|
||||
if (hasMoreData && !loading && !refreshing) {
|
||||
loadRecords(false, true);
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染视图模式切换器
|
||||
const renderViewModeToggle = () => (
|
||||
<View style={[styles.viewModeContainer, { backgroundColor: colorTokens.surface }]}>
|
||||
<Text style={[styles.monthTitle, { color: colorTokens.text }]}>{monthTitle}</Text>
|
||||
<View style={[styles.toggleContainer, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.toggleButton,
|
||||
viewMode === 'daily' && { backgroundColor: colorTokens.primary }
|
||||
]}
|
||||
onPress={() => setViewMode('daily')}
|
||||
>
|
||||
<Text style={[
|
||||
styles.toggleText,
|
||||
{ color: viewMode === 'daily' ? colorTokens.onPrimary : colorTokens.textSecondary }
|
||||
]}>
|
||||
按天查看
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.toggleButton,
|
||||
viewMode === 'all' && { backgroundColor: colorTokens.primary }
|
||||
]}
|
||||
onPress={() => setViewMode('all')}
|
||||
>
|
||||
<Text style={[
|
||||
styles.toggleText,
|
||||
{ color: viewMode === 'all' ? colorTokens.onPrimary : colorTokens.textSecondary }
|
||||
]}>
|
||||
全部记录
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
// 渲染日期选择器(仅在按天查看模式下显示)
|
||||
const renderDateSelector = () => {
|
||||
if (viewMode !== 'daily') return null;
|
||||
|
||||
return (
|
||||
<View style={styles.daysContainer}>
|
||||
<ScrollView
|
||||
ref={daysScrollRef}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.daysScrollContainer}
|
||||
onLayout={(e) => setScrollWidth(e.nativeEvent.layout.width)}
|
||||
>
|
||||
{days.map((day, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
const isToday = day.isToday;
|
||||
const isDisabled = day.date?.isAfter(dayjs(), 'day') ?? false;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
style={[
|
||||
styles.dayPill,
|
||||
{
|
||||
backgroundColor: isSelected
|
||||
? colorTokens.primary
|
||||
: colorTokens.surface,
|
||||
borderColor: isToday && !isSelected
|
||||
? colorTokens.primary
|
||||
: 'transparent',
|
||||
borderWidth: isToday && !isSelected ? 1 : 0,
|
||||
opacity: isDisabled ? 0.4 : 1,
|
||||
},
|
||||
]}
|
||||
onPress={() => {
|
||||
if (!isDisabled) {
|
||||
setSelectedIndex(index);
|
||||
scrollToIndex(index);
|
||||
}
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.dayNumber,
|
||||
{
|
||||
color: isSelected
|
||||
? colorTokens.onPrimary
|
||||
: colorTokens.text,
|
||||
fontWeight: isSelected || isToday ? '700' : '600',
|
||||
},
|
||||
]}
|
||||
>
|
||||
{day.date?.date() ?? ''}
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.dayLabel,
|
||||
{
|
||||
color: isSelected
|
||||
? colorTokens.onPrimary
|
||||
: colorTokens.textSecondary,
|
||||
fontWeight: isSelected ? '600' : '500',
|
||||
},
|
||||
]}
|
||||
>
|
||||
{day.dayAbbr}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const renderEmptyState = () => (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Ionicons name="restaurant-outline" size={64} color={colorTokens.textSecondary} />
|
||||
<Text style={[styles.emptyTitle, { color: colorTokens.text }]}>
|
||||
{viewMode === 'daily' ? '今天还没有记录' : '暂无营养记录'}
|
||||
</Text>
|
||||
<Text style={[styles.emptySubtitle, { color: colorTokens.textSecondary }]}>
|
||||
{viewMode === 'daily' ? '点击右上角添加今日营养摄入' : '开始记录你的营养摄入吧'}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderRecord = ({ item }: { item: DietRecord }) => (
|
||||
<NutritionRecordCard record={item} />
|
||||
);
|
||||
|
||||
const renderFooter = () => {
|
||||
if (!hasMoreData) {
|
||||
return (
|
||||
<View style={styles.footerContainer}>
|
||||
<Text style={[styles.footerText, { color: colorTokens.textSecondary }]}>
|
||||
没有更多数据了
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (viewMode === 'all' && records.length > 0) {
|
||||
return (
|
||||
<TouchableOpacity style={styles.loadMoreButton} onPress={loadMoreRecords}>
|
||||
<Text style={[styles.loadMoreText, { color: colorTokens.primary }]}>
|
||||
加载更多
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
|
||||
<HeaderBar
|
||||
title="营养记录"
|
||||
onBack={() => router.back()}
|
||||
right={
|
||||
<TouchableOpacity
|
||||
style={[styles.addButton, { backgroundColor: colorTokens.primary }]}
|
||||
onPress={() => {
|
||||
// TODO: 跳转到添加营养记录页面
|
||||
console.log('添加营养记录');
|
||||
}}
|
||||
>
|
||||
<Ionicons name="add" size={20} color={colorTokens.onPrimary} />
|
||||
</TouchableOpacity>
|
||||
}
|
||||
/>
|
||||
|
||||
{renderViewModeToggle()}
|
||||
{renderDateSelector()}
|
||||
|
||||
{loading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={colorTokens.primary} />
|
||||
<Text style={[styles.loadingText, { color: colorTokens.textSecondary }]}>
|
||||
加载中...
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={records}
|
||||
renderItem={renderRecord}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
contentContainerStyle={[
|
||||
styles.listContainer,
|
||||
{ paddingBottom: 40 }
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
tintColor={colorTokens.primary}
|
||||
colors={[colorTokens.primary]}
|
||||
/>
|
||||
}
|
||||
ListEmptyComponent={renderEmptyState}
|
||||
ListFooterComponent={renderFooter}
|
||||
onEndReached={viewMode === 'all' ? loadMoreRecords : undefined}
|
||||
onEndReachedThreshold={0.1}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
viewModeContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
marginBottom: 8,
|
||||
},
|
||||
monthTitle: {
|
||||
fontSize: 22,
|
||||
fontWeight: '800',
|
||||
},
|
||||
toggleContainer: {
|
||||
flexDirection: 'row',
|
||||
borderRadius: 20,
|
||||
padding: 2,
|
||||
},
|
||||
toggleButton: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 18,
|
||||
minWidth: 80,
|
||||
alignItems: 'center',
|
||||
},
|
||||
toggleText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
daysContainer: {
|
||||
marginBottom: 12,
|
||||
},
|
||||
daysScrollContainer: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
dayPill: {
|
||||
width: 68,
|
||||
height: 68,
|
||||
borderRadius: 34,
|
||||
marginRight: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
dayNumber: {
|
||||
fontSize: 18,
|
||||
textAlign: 'center',
|
||||
},
|
||||
dayLabel: {
|
||||
fontSize: 12,
|
||||
marginTop: 2,
|
||||
textAlign: 'center',
|
||||
},
|
||||
addButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: 12,
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
listContainer: {
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 8,
|
||||
},
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 60,
|
||||
},
|
||||
emptyTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
marginTop: 16,
|
||||
marginBottom: 8,
|
||||
},
|
||||
emptySubtitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
textAlign: 'center',
|
||||
lineHeight: 22,
|
||||
},
|
||||
footerContainer: {
|
||||
paddingVertical: 20,
|
||||
alignItems: 'center',
|
||||
},
|
||||
footerText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
loadMoreButton: {
|
||||
paddingVertical: 16,
|
||||
alignItems: 'center',
|
||||
},
|
||||
loadMoreText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
import { ROUTES } from '@/constants/Routes';
|
||||
import { NutritionSummary } from '@/services/dietRecords';
|
||||
import Feather from '@expo/vector-icons/Feather';
|
||||
import dayjs from 'dayjs';
|
||||
import { router } from 'expo-router';
|
||||
import React, { useMemo } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { RadarCategory, RadarChart } from './RadarChart';
|
||||
|
||||
export type NutritionRadarCardProps = {
|
||||
@@ -55,8 +57,12 @@ export function NutritionRadarCard({ nutritionSummary, isLoading = false }: Nutr
|
||||
];
|
||||
}, [nutritionSummary]);
|
||||
|
||||
const handleNavigateToRecords = () => {
|
||||
router.push(ROUTES.NUTRITION_RECORDS);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.card}>
|
||||
<TouchableOpacity style={styles.card} onPress={handleNavigateToRecords} activeOpacity={0.8}>
|
||||
<View style={styles.cardHeader}>
|
||||
<Text style={styles.cardTitle}>营养摄入分析</Text>
|
||||
<View style={styles.cardRightContainer}>
|
||||
@@ -92,7 +98,7 @@ export function NutritionRadarCard({ nutritionSummary, isLoading = false }: Nutr
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
358
components/NutritionRecordCard.tsx
Normal file
358
components/NutritionRecordCard.tsx
Normal file
@@ -0,0 +1,358 @@
|
||||
import { RadarChart } from '@/components/RadarChart';
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { useThemeColor } from '@/hooks/useThemeColor';
|
||||
import { DietRecord, calculateNutritionSummary, convertToRadarData } from '@/services/dietRecords';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import React, { useMemo } from 'react';
|
||||
import { Image, StyleSheet, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
export type NutritionRecordCardProps = {
|
||||
record: DietRecord;
|
||||
onPress?: () => void;
|
||||
};
|
||||
|
||||
const NUTRITION_DIMENSIONS = [
|
||||
{ key: 'calories', label: '热量' },
|
||||
{ key: 'protein', label: '蛋白质' },
|
||||
{ key: 'carbohydrate', label: '碳水' },
|
||||
{ key: 'fat', label: '脂肪' },
|
||||
{ key: 'fiber', label: '纤维' },
|
||||
{ key: 'sodium', label: '钠' },
|
||||
];
|
||||
|
||||
const MEAL_TYPE_LABELS = {
|
||||
breakfast: '早餐',
|
||||
lunch: '午餐',
|
||||
dinner: '晚餐',
|
||||
snack: '加餐',
|
||||
other: '其他',
|
||||
} as const;
|
||||
|
||||
const MEAL_TYPE_ICONS = {
|
||||
breakfast: 'sunny-outline',
|
||||
lunch: 'partly-sunny-outline',
|
||||
dinner: 'moon-outline',
|
||||
snack: 'cafe-outline',
|
||||
other: 'restaurant-outline',
|
||||
} as const;
|
||||
|
||||
const MEAL_TYPE_COLORS = {
|
||||
breakfast: '#FFB366',
|
||||
lunch: '#4ECDC4',
|
||||
dinner: '#5D5FEF',
|
||||
snack: '#FF6B6B',
|
||||
other: '#9AA3AE',
|
||||
} as const;
|
||||
|
||||
export function NutritionRecordCard({ record, onPress }: NutritionRecordCardProps) {
|
||||
const surfaceColor = useThemeColor({}, 'surface');
|
||||
const textColor = useThemeColor({}, 'text');
|
||||
const textSecondaryColor = useThemeColor({}, 'textSecondary');
|
||||
const primaryColor = useThemeColor({}, 'primary');
|
||||
|
||||
// 计算单条记录的营养摘要
|
||||
const nutritionSummary = useMemo(() => {
|
||||
return calculateNutritionSummary([record]);
|
||||
}, [record]);
|
||||
|
||||
// 计算雷达图数据
|
||||
const radarValues = useMemo(() => {
|
||||
return convertToRadarData(nutritionSummary);
|
||||
}, [nutritionSummary]);
|
||||
|
||||
// 营养维度数据
|
||||
const nutritionStats = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
label: '热量',
|
||||
value: record.estimatedCalories ? `${Math.round(record.estimatedCalories)} 千卡` : '-',
|
||||
color: '#FF6B6B'
|
||||
},
|
||||
{
|
||||
label: '蛋白质',
|
||||
value: record.proteinGrams ? `${record.proteinGrams.toFixed(1)} g` : '-',
|
||||
color: '#4ECDC4'
|
||||
},
|
||||
{
|
||||
label: '碳水',
|
||||
value: record.carbohydrateGrams ? `${record.carbohydrateGrams.toFixed(1)} g` : '-',
|
||||
color: '#45B7D1'
|
||||
},
|
||||
{
|
||||
label: '脂肪',
|
||||
value: record.fatGrams ? `${record.fatGrams.toFixed(1)} g` : '-',
|
||||
color: '#FFA07A'
|
||||
},
|
||||
{
|
||||
label: '纤维',
|
||||
value: record.fiberGrams ? `${record.fiberGrams.toFixed(1)} g` : '-',
|
||||
color: '#98D8C8'
|
||||
},
|
||||
{
|
||||
label: '钠',
|
||||
value: record.sodiumMg ? `${Math.round(record.sodiumMg)} mg` : '-',
|
||||
color: '#F7DC6F'
|
||||
},
|
||||
];
|
||||
}, [record]);
|
||||
|
||||
const mealTypeColor = MEAL_TYPE_COLORS[record.mealType];
|
||||
const mealTypeIcon = MEAL_TYPE_ICONS[record.mealType];
|
||||
const mealTypeLabel = MEAL_TYPE_LABELS[record.mealType];
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.card, { backgroundColor: surfaceColor }]}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{/* 卡片头部 */}
|
||||
<View style={styles.cardHeader}>
|
||||
<View style={styles.mealInfo}>
|
||||
<View style={[styles.mealTypeIndicator, { backgroundColor: mealTypeColor }]}>
|
||||
<Ionicons name={mealTypeIcon as any} size={16} color="#FFFFFF" />
|
||||
</View>
|
||||
<View style={styles.mealDetails}>
|
||||
<ThemedText style={[styles.mealType, { color: textColor }]}>
|
||||
{mealTypeLabel}
|
||||
</ThemedText>
|
||||
<ThemedText style={[styles.mealTime, { color: textSecondaryColor }]}>
|
||||
{record.mealTime ? dayjs(record.mealTime).format('HH:mm') : '时间未设置'}
|
||||
</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity style={styles.moreButton}>
|
||||
<Ionicons name="ellipsis-horizontal" size={20} color={textSecondaryColor} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 食物信息 */}
|
||||
<View style={styles.foodSection}>
|
||||
<View style={[styles.foodImageContainer, !record.imageUrl && styles.foodImagePlaceholder]}>
|
||||
{record.imageUrl ? (
|
||||
<Image source={{ uri: record.imageUrl }} style={styles.foodImage} />
|
||||
) : (
|
||||
<Ionicons name="restaurant" size={24} color={textSecondaryColor} />
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.foodInfo}>
|
||||
<ThemedText style={[styles.foodName, { color: textColor }]}>
|
||||
{record.foodName}
|
||||
</ThemedText>
|
||||
{record.foodDescription && (
|
||||
<ThemedText style={[styles.foodDescription, { color: textSecondaryColor }]}>
|
||||
{record.foodDescription}
|
||||
</ThemedText>
|
||||
)}
|
||||
{(record.weightGrams || record.portionDescription) && (
|
||||
<ThemedText style={[styles.portionInfo, { color: textSecondaryColor }]}>
|
||||
{record.weightGrams ? `${record.weightGrams}g` : ''}
|
||||
{record.weightGrams && record.portionDescription ? ' • ' : ''}
|
||||
{record.portionDescription || ''}
|
||||
</ThemedText>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 营养分析区域 */}
|
||||
<View style={styles.nutritionSection}>
|
||||
<View style={styles.radarContainer}>
|
||||
<RadarChart
|
||||
categories={NUTRITION_DIMENSIONS}
|
||||
values={radarValues}
|
||||
size="small"
|
||||
maxValue={5}
|
||||
showLabels={false}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.statsContainer}>
|
||||
{nutritionStats.slice(0, 4).map((stat) => (
|
||||
<View key={stat.label} style={styles.statItem}>
|
||||
<View style={[styles.statDot, { backgroundColor: stat.color }]} />
|
||||
<ThemedText style={[styles.statLabel, { color: textSecondaryColor }]}>
|
||||
{stat.label}
|
||||
</ThemedText>
|
||||
<ThemedText style={[styles.statValue, { color: textColor }]}>
|
||||
{stat.value}
|
||||
</ThemedText>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 额外的营养信息 */}
|
||||
<View style={styles.additionalStats}>
|
||||
{nutritionStats.slice(4).map((stat) => (
|
||||
<View key={stat.label} style={styles.additionalStatItem}>
|
||||
<View style={[styles.statDot, { backgroundColor: stat.color }]} />
|
||||
<ThemedText style={[styles.statLabel, { color: textSecondaryColor }]}>
|
||||
{stat.label}
|
||||
</ThemedText>
|
||||
<ThemedText style={[styles.statValue, { color: textColor }]}>
|
||||
{stat.value}
|
||||
</ThemedText>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* 备注信息 */}
|
||||
{record.notes && (
|
||||
<View style={styles.notesSection}>
|
||||
<ThemedText style={[styles.notesLabel, { color: textSecondaryColor }]}>
|
||||
备注
|
||||
</ThemedText>
|
||||
<ThemedText style={[styles.notesText, { color: textColor }]}>
|
||||
{record.notes}
|
||||
</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
borderRadius: 22,
|
||||
padding: 20,
|
||||
marginBottom: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 6,
|
||||
elevation: 3,
|
||||
},
|
||||
cardHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
mealInfo: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
mealTypeIndicator: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
mealDetails: {
|
||||
justifyContent: 'center',
|
||||
},
|
||||
mealType: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
},
|
||||
mealTime: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
marginTop: 2,
|
||||
},
|
||||
moreButton: {
|
||||
padding: 4,
|
||||
},
|
||||
foodSection: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 20,
|
||||
},
|
||||
foodImageContainer: {
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 12,
|
||||
marginRight: 12,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
foodImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
foodImagePlaceholder: {
|
||||
backgroundColor: '#F5F5F5',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
foodInfo: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
foodName: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
marginBottom: 4,
|
||||
},
|
||||
foodDescription: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
lineHeight: 20,
|
||||
marginBottom: 4,
|
||||
},
|
||||
portionInfo: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
},
|
||||
nutritionSection: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
radarContainer: {
|
||||
marginRight: 16,
|
||||
},
|
||||
statsContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
statItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
statDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
marginRight: 8,
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
flex: 1,
|
||||
},
|
||||
statValue: {
|
||||
fontSize: 13,
|
||||
fontWeight: '700',
|
||||
},
|
||||
additionalStats: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 12,
|
||||
},
|
||||
additionalStatItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
},
|
||||
notesSection: {
|
||||
marginTop: 12,
|
||||
paddingTop: 16,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: 'rgba(0,0,0,0.08)',
|
||||
},
|
||||
notesLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
marginBottom: 6,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
notesText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
lineHeight: 20,
|
||||
},
|
||||
});
|
||||
@@ -36,6 +36,9 @@ export const ROUTES = {
|
||||
// 引导页路由
|
||||
ONBOARDING: '/onboarding',
|
||||
ONBOARDING_PERSONAL_INFO: '/onboarding/personal-info',
|
||||
|
||||
// 营养相关路由
|
||||
NUTRITION_RECORDS: '/nutrition/records',
|
||||
} as const;
|
||||
|
||||
// 路由参数常量
|
||||
|
||||
@@ -40,16 +40,26 @@ export type NutritionSummary = {
|
||||
export async function getDietRecords({
|
||||
startDate,
|
||||
endDate,
|
||||
page = 1,
|
||||
limit = 10,
|
||||
}: {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}): Promise<{
|
||||
records: DietRecord[]
|
||||
total: number
|
||||
page: number
|
||||
limit: number
|
||||
}> {
|
||||
const params = startDate && endDate ? `?startDate=${startDate}&endDate=${endDate}` : '';
|
||||
const searchParams = new URLSearchParams();
|
||||
if (startDate) searchParams.append('startDate', startDate);
|
||||
if (endDate) searchParams.append('endDate', endDate);
|
||||
searchParams.append('page', page.toString());
|
||||
searchParams.append('limit', limit.toString());
|
||||
|
||||
const params = searchParams.toString() ? `?${searchParams.toString()}` : '';
|
||||
return await api.get<{
|
||||
records: DietRecord[]
|
||||
total: number
|
||||
|
||||
211
services/mockDietRecords.ts
Normal file
211
services/mockDietRecords.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { DietRecord } from './dietRecords';
|
||||
|
||||
// 模拟营养记录数据,用于测试UI效果
|
||||
export const mockDietRecords: DietRecord[] = [
|
||||
// 今天的记录
|
||||
{
|
||||
id: 1,
|
||||
mealType: 'breakfast',
|
||||
foodName: '燕麦粥配蓝莓',
|
||||
foodDescription: '有机燕麦片,新鲜蓝莓,低脂牛奶',
|
||||
weightGrams: 300,
|
||||
portionDescription: '1大碗',
|
||||
estimatedCalories: 280,
|
||||
proteinGrams: 12.5,
|
||||
carbohydrateGrams: 45.2,
|
||||
fatGrams: 6.8,
|
||||
fiberGrams: 8.5,
|
||||
sugarGrams: 15.3,
|
||||
sodiumMg: 120,
|
||||
source: 'manual',
|
||||
mealTime: dayjs().hour(7).minute(30).toISOString(),
|
||||
imageUrl: 'https://images.unsplash.com/photo-1511690743698-d9d85f2fbf38?w=300&h=300&fit=crop',
|
||||
notes: '营养丰富的早餐,富含膳食纤维和抗氧化物质',
|
||||
createdAt: dayjs().hour(7).minute(35).toISOString(),
|
||||
updatedAt: dayjs().hour(7).minute(35).toISOString(),
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
mealType: 'lunch',
|
||||
foodName: '鸡胸肉沙拉',
|
||||
foodDescription: '烤鸡胸肉,混合蔬菜,橄榄油调味',
|
||||
weightGrams: 250,
|
||||
portionDescription: '1份',
|
||||
estimatedCalories: 320,
|
||||
proteinGrams: 35.6,
|
||||
carbohydrateGrams: 8.4,
|
||||
fatGrams: 15.2,
|
||||
fiberGrams: 6.2,
|
||||
sugarGrams: 5.8,
|
||||
sodiumMg: 480,
|
||||
source: 'manual',
|
||||
mealTime: dayjs().hour(12).minute(15).toISOString(),
|
||||
imageUrl: 'https://images.unsplash.com/photo-1546069901-ba9599a7e63c?w=300&h=300&fit=crop',
|
||||
notes: '高蛋白低碳水,适合健身人群',
|
||||
createdAt: dayjs().hour(12).minute(20).toISOString(),
|
||||
updatedAt: dayjs().hour(12).minute(20).toISOString(),
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
mealType: 'snack',
|
||||
foodName: '混合坚果',
|
||||
foodDescription: '杏仁,核桃,腰果混合装',
|
||||
weightGrams: 30,
|
||||
portionDescription: '1小包',
|
||||
estimatedCalories: 180,
|
||||
proteinGrams: 6.5,
|
||||
carbohydrateGrams: 6.8,
|
||||
fatGrams: 15.5,
|
||||
fiberGrams: 3.2,
|
||||
sugarGrams: 2.1,
|
||||
sodiumMg: 5,
|
||||
source: 'manual',
|
||||
mealTime: dayjs().hour(15).minute(30).toISOString(),
|
||||
notes: '健康的下午茶零食',
|
||||
createdAt: dayjs().hour(15).minute(35).toISOString(),
|
||||
updatedAt: dayjs().hour(15).minute(35).toISOString(),
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
mealType: 'dinner',
|
||||
foodName: '三文鱼配蒸蔬菜',
|
||||
foodDescription: '挪威三文鱼,西兰花,胡萝卜',
|
||||
weightGrams: 350,
|
||||
portionDescription: '1份',
|
||||
estimatedCalories: 420,
|
||||
proteinGrams: 42.3,
|
||||
carbohydrateGrams: 12.5,
|
||||
fatGrams: 22.8,
|
||||
fiberGrams: 5.6,
|
||||
sugarGrams: 8.2,
|
||||
sodiumMg: 380,
|
||||
source: 'vision',
|
||||
mealTime: dayjs().hour(19).minute(0).toISOString(),
|
||||
imageUrl: 'https://images.unsplash.com/photo-1467003909585-2f8a72700288?w=300&h=300&fit=crop',
|
||||
notes: '富含Omega-3脂肪酸,有益心血管健康',
|
||||
createdAt: dayjs().hour(19).minute(10).toISOString(),
|
||||
updatedAt: dayjs().hour(19).minute(10).toISOString(),
|
||||
},
|
||||
|
||||
// 昨天的记录
|
||||
{
|
||||
id: 5,
|
||||
mealType: 'breakfast',
|
||||
foodName: '希腊酸奶杯',
|
||||
foodDescription: '无糖希腊酸奶,草莓,燕麦片',
|
||||
weightGrams: 200,
|
||||
portionDescription: '1杯',
|
||||
estimatedCalories: 220,
|
||||
proteinGrams: 20.4,
|
||||
carbohydrateGrams: 18.6,
|
||||
fatGrams: 8.2,
|
||||
fiberGrams: 4.1,
|
||||
sugarGrams: 12.5,
|
||||
sodiumMg: 85,
|
||||
source: 'manual',
|
||||
mealTime: dayjs().subtract(1, 'day').hour(8).minute(0).toISOString(),
|
||||
imageUrl: 'https://images.unsplash.com/photo-1488477181946-6428a0291777?w=300&h=300&fit=crop',
|
||||
notes: '高蛋白早餐,饱腹感强',
|
||||
createdAt: dayjs().subtract(1, 'day').hour(8).minute(5).toISOString(),
|
||||
updatedAt: dayjs().subtract(1, 'day').hour(8).minute(5).toISOString(),
|
||||
},
|
||||
|
||||
// 更多历史记录,用于测试分页
|
||||
{
|
||||
id: 6,
|
||||
mealType: 'lunch',
|
||||
foodName: '牛肉面',
|
||||
foodDescription: '手拉面条,牛肉汤底,青菜',
|
||||
weightGrams: 400,
|
||||
portionDescription: '1碗',
|
||||
estimatedCalories: 580,
|
||||
proteinGrams: 28.0,
|
||||
carbohydrateGrams: 65.0,
|
||||
fatGrams: 18.5,
|
||||
fiberGrams: 4.8,
|
||||
sugarGrams: 6.2,
|
||||
sodiumMg: 1200,
|
||||
source: 'manual',
|
||||
mealTime: dayjs().subtract(2, 'day').hour(13).minute(0).toISOString(),
|
||||
notes: '传统中式午餐',
|
||||
createdAt: dayjs().subtract(2, 'day').hour(13).minute(10).toISOString(),
|
||||
updatedAt: dayjs().subtract(2, 'day').hour(13).minute(10).toISOString(),
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
mealType: 'breakfast',
|
||||
foodName: '全麦吐司配牛油果',
|
||||
foodDescription: '全麦面包,新鲜牛油果,煎蛋',
|
||||
weightGrams: 180,
|
||||
portionDescription: '2片吐司',
|
||||
estimatedCalories: 350,
|
||||
proteinGrams: 15.2,
|
||||
carbohydrateGrams: 28.5,
|
||||
fatGrams: 22.0,
|
||||
fiberGrams: 8.0,
|
||||
sugarGrams: 3.5,
|
||||
sodiumMg: 220,
|
||||
source: 'manual',
|
||||
mealTime: dayjs().subtract(3, 'day').hour(8).minute(30).toISOString(),
|
||||
imageUrl: 'https://images.unsplash.com/photo-1541519227354-08fa5d50c44d?w=300&h=300&fit=crop',
|
||||
notes: '健康脂肪和蛋白质的完美组合',
|
||||
createdAt: dayjs().subtract(3, 'day').hour(8).minute(35).toISOString(),
|
||||
updatedAt: dayjs().subtract(3, 'day').hour(8).minute(35).toISOString(),
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
mealType: 'dinner',
|
||||
foodName: '蒸蛋羹',
|
||||
foodDescription: '鸡蛋,温水,少许盐',
|
||||
weightGrams: 150,
|
||||
portionDescription: '1小碗',
|
||||
estimatedCalories: 140,
|
||||
proteinGrams: 12.0,
|
||||
carbohydrateGrams: 1.0,
|
||||
fatGrams: 10.0,
|
||||
fiberGrams: 0,
|
||||
sugarGrams: 1.0,
|
||||
sodiumMg: 180,
|
||||
source: 'manual',
|
||||
mealTime: dayjs().subtract(4, 'day').hour(18).minute(45).toISOString(),
|
||||
notes: '清淡易消化的晚餐',
|
||||
createdAt: dayjs().subtract(4, 'day').hour(19).minute(0).toISOString(),
|
||||
updatedAt: dayjs().subtract(4, 'day').hour(19).minute(0).toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
// 模拟API响应,支持分页和日期过滤
|
||||
export function getMockDietRecords({
|
||||
startDate,
|
||||
endDate,
|
||||
page = 1,
|
||||
limit = 10,
|
||||
}: {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
} = {}) {
|
||||
let filteredRecords = mockDietRecords;
|
||||
|
||||
// 如果有日期范围,则过滤
|
||||
if (startDate && endDate) {
|
||||
filteredRecords = mockDietRecords.filter(record => {
|
||||
const recordDate = dayjs(record.mealTime).format('YYYY-MM-DD');
|
||||
return recordDate >= startDate && recordDate <= endDate;
|
||||
});
|
||||
}
|
||||
|
||||
// 分页
|
||||
const startIndex = (page - 1) * limit;
|
||||
const endIndex = startIndex + limit;
|
||||
const paginatedRecords = filteredRecords.slice(startIndex, endIndex);
|
||||
|
||||
return {
|
||||
records: paginatedRecords,
|
||||
total: filteredRecords.length,
|
||||
page,
|
||||
limit,
|
||||
};
|
||||
}
|
||||
@@ -29,10 +29,14 @@ export function getMonthTitleZh(date: Dayjs = dayjs()): string {
|
||||
export type MonthDay = {
|
||||
/** 中文星期:日/一/二/三/四/五/六 */
|
||||
weekdayZh: string;
|
||||
/** 简化的星期,用于显示 */
|
||||
dayAbbr: string;
|
||||
/** 月内第几日(1-31) */
|
||||
dayOfMonth: number;
|
||||
/** 对应的 dayjs 对象 */
|
||||
date: Dayjs;
|
||||
/** 是否是今天 */
|
||||
isToday: boolean;
|
||||
};
|
||||
|
||||
/** 获取某月的所有日期(中文星期+日号) */
|
||||
@@ -41,12 +45,18 @@ export function getMonthDaysZh(date: Dayjs = dayjs()): MonthDay[] {
|
||||
const monthIndex = date.month();
|
||||
const daysInMonth = date.daysInMonth();
|
||||
const zhWeek = ['日', '一', '二', '三', '四', '五', '六'];
|
||||
const today = dayjs();
|
||||
|
||||
return Array.from({ length: daysInMonth }, (_, i) => {
|
||||
const d = dayjs(new Date(year, monthIndex, i + 1));
|
||||
const isToday = d.isSame(today, 'day');
|
||||
|
||||
return {
|
||||
weekdayZh: zhWeek[d.day()],
|
||||
dayAbbr: zhWeek[d.day()],
|
||||
dayOfMonth: i + 1,
|
||||
date: d,
|
||||
isToday,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user