feat: 更新应用名称和图标,优化用户界面

- 将应用名称修改为“Health Bot”,提升品牌识别度
- 更新应用图标为 logo.png,确保视觉一致性
- 删除不再使用的 ai-coach-chat 页面,简化代码结构
- 更新多个页面的导航和按钮文本,提升用户体验
- 添加体重历史记录功能,支持用户追踪健康数据
- 优化 Redux 状态管理,确保数据处理的准确性和稳定性
This commit is contained in:
2025-08-17 21:34:04 +08:00
parent b2c4f76c39
commit 6a67fb21f7
25 changed files with 1511 additions and 1428 deletions

View File

@@ -1,10 +1,10 @@
{
"expo": {
"name": "普拉提助手",
"name": "Health Bot",
"slug": "digital-pilates",
"version": "1.0.4",
"orientation": "portrait",
"icon": "./assets/images/logo.jpeg",
"icon": "./assets/images/logo.png",
"scheme": "digitalpilates",
"userInterfaceStyle": "light",
"newArchEnabled": true,
@@ -21,7 +21,7 @@
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/images/logo.jpeg",
"foregroundImage": "./assets/images/logo.png",
"backgroundColor": "#ffffff"
},
"edgeToEdgeEnabled": true,
@@ -30,14 +30,14 @@
"web": {
"bundler": "metro",
"output": "static",
"favicon": "./assets/images/logo.jpeg"
"favicon": "./assets/images/logo.png"
},
"plugins": [
"expo-router",
[
"expo-splash-screen",
{
"image": "./assets/images/logo.jpeg",
"image": "./assets/images/logo.png",
"imageWidth": 200,
"resizeMode": "contain",
"backgroundColor": "#ffffff"

View File

@@ -41,7 +41,7 @@ export default function TabLayout() {
case 'index':
return { icon: 'house.fill', title: '首页' } as const;
case 'coach':
return { icon: 'person.3.fill', title: '教练' } as const;
return { icon: 'person.3.fill', title: 'Bot' } as const;
case 'explore':
return { icon: 'paperplane.fill', title: '探索' } as const;
case 'personal':
@@ -156,7 +156,7 @@ export default function TabLayout() {
<Tabs.Screen
name="coach"
options={{
title: '教练',
title: 'Bot',
tabBarIcon: ({ color }) => {
const isCoachSelected = pathname === '/coach';
return (
@@ -173,7 +173,7 @@ export default function TabLayout() {
textAlign: 'center',
flexShrink: 0,
}}>
Bot
</Text>
)}
</View>

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@ import { AnimatedNumber } from '@/components/AnimatedNumber';
import { BMICard } from '@/components/BMICard';
import { CircularRing } from '@/components/CircularRing';
import { ProgressBar } from '@/components/ProgressBar';
import { WeightHistoryCard } from '@/components/WeightHistoryCard';
import { Colors } from '@/constants/Colors';
import { getTabBarBottomPadding } from '@/constants/TabBar';
import { useAppSelector } from '@/hooks/redux';
@@ -137,6 +138,10 @@ export default function ExploreScreen() {
contentContainerStyle={{ paddingBottom: bottomPadding }}
showsVerticalScrollIndicator={false}
>
{/* 体重历史记录卡片 */}
<Text style={styles.sectionTitle}></Text>
<WeightHistoryCard />
{/* 标题与日期选择 */}
<Text style={styles.monthTitle}>{monthTitle}</Text>
<ScrollView
@@ -224,6 +229,8 @@ export default function ExploreScreen() {
weight={userProfile?.weight ? parseFloat(userProfile.weight) : undefined}
height={userProfile?.height ? parseFloat(userProfile.height) : undefined}
/>
</ScrollView>
</SafeAreaView>
</View>

View File

@@ -8,9 +8,10 @@ import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { listRecommendedArticles } from '@/services/articles';
import { fetchRecommendations, RecommendationType } from '@/services/recommendations';
import { loadPlans, type TrainingPlan } from '@/store/trainingPlanSlice';
import { loadPlans } from '@/store/trainingPlanSlice';
// Removed WorkoutCard import since we no longer use the horizontal carousel
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { TrainingPlan } from '@/services/trainingPlanApi';
import { getChineseGreeting } from '@/utils/date';
import { useRouter } from 'expo-router';
import React from 'react';
@@ -29,7 +30,7 @@ export default function HomeScreen() {
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
// 训练计划状态
const { plans, currentId } = useAppSelector((s) => s.trainingPlan);
const { plans } = useAppSelector((s) => s.trainingPlan);
const [activePlan, setActivePlan] = React.useState<TrainingPlan | null>(null);
// Draggable coach badge state
@@ -148,13 +149,13 @@ export default function HomeScreen() {
// 获取激活的训练计划
React.useEffect(() => {
if (isLoggedIn && currentId && plans.length > 0) {
const currentPlan = plans.find(p => p.id === currentId);
if (isLoggedIn && plans.length > 0) {
const currentPlan = plans.find(p => p.isActive);
setActivePlan(currentPlan || null);
} else {
setActivePlan(null);
}
}, [isLoggedIn, currentId, plans]);
}, [isLoggedIn, plans]);
// 拉取推荐接口(已登录时)
React.useEffect(() => {

View File

@@ -86,7 +86,7 @@ export default function RootLayout() {
<Stack.Screen name="workout" options={{ headerShown: false }} />
<Stack.Screen name="profile/edit" />
<Stack.Screen name="profile/goals" options={{ headerShown: false }} />
<Stack.Screen name="ai-coach-chat" options={{ headerShown: false }} />
<Stack.Screen name="ai-posture-assessment" />
<Stack.Screen name="auth/login" options={{ headerShown: false }} />
<Stack.Screen name="legal/user-agreement" options={{ headerShown: true, title: '用户协议' }} />

File diff suppressed because it is too large Load Diff

View File

@@ -153,7 +153,7 @@ export default function AIPostureResultScreen() {
<Ionicons name="checkmark-circle" size={18} color={theme.onPrimary} />
<Text style={[styles.primaryBtnText, { color: theme.onPrimary }]}></Text>
</TouchableOpacity>
<TouchableOpacity style={[styles.secondaryBtn, { borderColor: theme.border }]} onPress={() => router.push('/ai-coach-chat')}>
<TouchableOpacity style={[styles.secondaryBtn, { borderColor: theme.border }]} onPress={() => router.push('/(tabs)/coach')}>
<Text style={[styles.secondaryBtnText, { color: theme.text }]}></Text>
</TouchableOpacity>
</View>

BIN
assets/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 KiB

View File

@@ -8,16 +8,18 @@ import {
import { Ionicons } from '@expo/vector-icons';
import React, { useState } from 'react';
import {
Dimensions,
Modal,
Pressable,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
View
} from 'react-native';
import Toast from 'react-native-toast-message';
const { width: screenWidth, height: screenHeight } = Dimensions.get('window');
interface BMICardProps {
weight?: number;
height?: number;
@@ -31,8 +33,6 @@ export function BMICard({ weight, height, style }: BMICardProps) {
const canCalculate = canCalculateBMI(weight, height);
let bmiResult: BMIResult | null = null;
if (canCalculate && weight && height) {
try {
bmiResult = getBMIResult(weight, height);
@@ -156,56 +156,88 @@ export function BMICard({ weight, height, style }: BMICardProps) {
style={styles.modalBackdrop}
onPress={handleHideInfoModal}
>
<Pressable style={styles.modalContainer} onPress={(e) => e.stopPropagation()}>
<View style={styles.modalContent}>
<View style={styles.modalHeader}>
<Pressable
style={styles.modalContainer}
onPress={(e) => e.stopPropagation()}
>
{/* 弹窗头部 */}
<View style={styles.modalHeader}>
<View style={styles.modalTitleContainer}>
<View style={styles.modalIconContainer}>
<Ionicons name="fitness" size={24} color="#3B82F6" />
</View>
<Text style={styles.modalTitle}>BMI </Text>
<TouchableOpacity
onPress={handleHideInfoModal}
style={styles.closeButton}
activeOpacity={0.7}
>
<Ionicons name="close" size={20} color="#6B7280" />
</TouchableOpacity>
</View>
<TouchableOpacity
onPress={handleHideInfoModal}
style={styles.closeButton}
activeOpacity={0.7}
>
<Ionicons name="close" size={20} color="#6B7280" />
</TouchableOpacity>
</View>
{/* 内容区域 - 去除滚动,精简设计 */}
<View style={styles.modalContent}>
{/* 介绍部分 */}
<View style={styles.introSection}>
<Text style={styles.modalDescription}>
BMI
</Text>
<View style={styles.formulaContainer}>
<Text style={styles.formulaText}>
(kg) ÷ ²(m)
</Text>
</View>
</View>
<ScrollView
style={styles.modalBody}
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: 20 }}
>
<Text style={styles.modalDescription}>
BMI(kg) ÷ ²(m)
</Text>
{/* BMI 分类标准 - 紧凑设计 */}
<View style={styles.categoriesSection}>
<Text style={styles.sectionTitle}></Text>
<View style={styles.categoriesGrid}>
{BMI_CATEGORIES.map((category, index) => {
const colors = index === 0 ? { bg: '#FEF3C7', text: '#B45309' } :
index === 1 ? { bg: '#E8F5E8', text: '#2D5016' } :
index === 2 ? { bg: '#FEF3C7', text: '#B45309' } :
{ bg: '#FEE2E2', text: '#B91C1C' };
<Text style={styles.sectionTitle}>BMI </Text>
{BMI_CATEGORIES.map((category, index) => {
const colors = index === 0 ? { bg: '#FFF4E6', text: '#8B7355' } :
index === 1 ? { bg: '#E8F5E8', text: '#2D5016' } :
index === 2 ? { bg: '#FEF3C7', text: '#B45309' } :
{ bg: '#FEE2E2', text: '#B91C1C' };
return (
<View key={index} style={[styles.categoryItem, { backgroundColor: colors.bg }]}>
<View style={styles.categoryHeader}>
<Text style={[styles.categoryName, { color: colors.text }]}>
{category.name}
</Text>
<Text style={[styles.categoryRange, { color: colors.text }]}>
{category.range}
return (
<View key={index} style={[styles.categoryCompact, { backgroundColor: colors.bg }]}>
<View style={styles.categoryCompactHeader}>
<Text style={[styles.categoryCompactName, { color: colors.text }]}>
{category.name}
</Text>
<Text style={[styles.categoryCompactRange, { color: colors.text }]}>
{category.range}
</Text>
</View>
<Text style={[styles.categoryCompactAdvice, { color: colors.text }]} numberOfLines={2}>
{category.advice}
</Text>
</View>
<Text style={styles.categoryAdvice}>
{category.advice}
</Text>
</View>
);
})}
);
})}
</View>
</View>
<Text style={styles.disclaimer}>
* BMI
{/* 健康提示 - 简化版 */}
<View style={styles.healthTips}>
<View style={styles.tipsHeader}>
<Ionicons name="heart" size={16} color="#EF4444" />
<Text style={styles.tipsTitle}></Text>
</View>
<Text style={styles.tipsContent}>
</Text>
</ScrollView>
</View>
{/* 免责声明 - 精简版 */}
<View style={styles.disclaimerCompact}>
<Ionicons name="information-circle" size={14} color="#6B7280" />
<Text style={styles.disclaimerCompactText}>
BMI
</Text>
</View>
</View>
</Pressable>
</Pressable>
@@ -336,8 +368,7 @@ const styles = StyleSheet.create({
padding: 20,
},
modalContainer: {
width: '90%',
maxHeight: '85%',
width: screenWidth * 0.92,
backgroundColor: '#FFFFFF',
borderRadius: 24,
overflow: 'hidden',
@@ -347,98 +378,161 @@ const styles = StyleSheet.create({
shadowOpacity: 0.3,
shadowRadius: 20,
},
modalContent: {
maxHeight: '100%',
},
modalHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: 24,
padding: 20,
borderBottomWidth: 1,
borderBottomColor: '#F3F4F6',
backgroundColor: '#FAFAFA',
},
modalTitleContainer: {
flexDirection: 'row',
alignItems: 'center',
},
modalIconContainer: {
width: 40,
height: 40,
borderRadius: 12,
backgroundColor: '#EFF6FF',
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
},
modalTitle: {
fontSize: 22,
fontSize: 20,
fontWeight: '800',
color: '#111827',
letterSpacing: -0.5,
},
closeButton: {
padding: 8,
borderRadius: 20,
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: '#F3F4F6',
alignItems: 'center',
justifyContent: 'center',
},
modalBody: {
paddingHorizontal: 24,
paddingVertical: 20,
// 内容区域样式
modalContent: {
paddingHorizontal: 20,
paddingBottom: 24,
},
// 介绍部分
introSection: {
marginBottom: 20,
},
modalDescription: {
fontSize: 16,
color: '#4B5563',
lineHeight: 26,
marginBottom: 28,
textAlign: 'center',
backgroundColor: '#F8FAFC',
padding: 16,
borderRadius: 12,
borderLeftWidth: 4,
borderLeftColor: '#3B82F6',
},
sectionTitle: {
fontSize: 20,
fontWeight: '800',
color: '#111827',
marginBottom: 16,
letterSpacing: -0.3,
},
categoryItem: {
borderRadius: 16,
padding: 20,
marginBottom: 16,
borderWidth: 1,
borderColor: 'rgba(0,0,0,0.05)',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
},
categoryHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
},
categoryName: {
fontSize: 18,
fontWeight: '800',
letterSpacing: -0.2,
},
categoryRange: {
fontSize: 15,
fontWeight: '700',
backgroundColor: 'rgba(255,255,255,0.8)',
paddingHorizontal: 12,
paddingVertical: 4,
borderRadius: 20,
},
categoryAdvice: {
fontSize: 15,
color: '#374151',
lineHeight: 22,
fontWeight: '500',
textAlign: 'center',
marginBottom: 12,
},
disclaimer: {
formulaContainer: {
backgroundColor: '#F8FAFC',
borderRadius: 12,
padding: 16,
borderLeftWidth: 4,
borderLeftColor: '#3B82F6',
},
formulaText: {
fontSize: 15,
color: '#1F2937',
fontWeight: '600',
textAlign: 'center',
},
// 分类部分
categoriesSection: {
marginBottom: 18,
},
sectionTitle: {
fontSize: 16,
fontWeight: '800',
color: '#111827',
marginBottom: 12,
textAlign: 'center',
},
categoriesGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
},
categoryCompact: {
flex: 1,
minWidth: '48%',
borderRadius: 12,
padding: 12,
marginBottom: 8,
},
categoryCompactHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 6,
},
categoryCompactName: {
fontSize: 14,
fontWeight: '700',
},
categoryCompactRange: {
fontSize: 12,
fontWeight: '600',
opacity: 0.8,
},
categoryCompactAdvice: {
fontSize: 11,
lineHeight: 16,
fontWeight: '500',
opacity: 0.9,
},
// 健康提示
healthTips: {
backgroundColor: '#F9FAFB',
borderRadius: 12,
padding: 14,
marginBottom: 16,
borderLeftWidth: 3,
borderLeftColor: '#EF4444',
},
tipsHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 6,
},
tipsTitle: {
fontSize: 14,
fontWeight: '700',
color: '#111827',
marginLeft: 6,
},
tipsContent: {
fontSize: 13,
color: '#374151',
lineHeight: 18,
},
// 免责声明紧凑版
disclaimerCompact: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#F9FAFB',
borderRadius: 8,
padding: 12,
marginTop: 4,
},
disclaimerCompactText: {
fontSize: 12,
color: '#6B7280',
fontStyle: 'italic',
marginTop: 24,
textAlign: 'center',
backgroundColor: '#F9FAFB',
padding: 16,
borderRadius: 12,
lineHeight: 20,
lineHeight: 16,
marginLeft: 6,
flex: 1,
},
});

View File

@@ -0,0 +1,487 @@
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { fetchWeightHistory } from '@/store/userSlice';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import { useRouter } from 'expo-router';
import React, { useEffect, useState } from 'react';
import {
Dimensions,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import Svg, { Circle, Line, Path, Text as SvgText } from 'react-native-svg';
const { width: screenWidth } = Dimensions.get('window');
const CARD_WIDTH = screenWidth - 40; // 减去左右边距
const CHART_WIDTH = CARD_WIDTH - 36; // 减去卡片内边距
const CHART_HEIGHT = 100;
const PADDING = 10;
type WeightHistoryItem = {
weight: string;
source: string;
createdAt: string;
};
export function WeightHistoryCard() {
const router = useRouter();
const dispatch = useAppDispatch();
const userProfile = useAppSelector((s) => s.user.profile);
const weightHistory = useAppSelector((s) => s.user.weightHistory);
const [isLoading, setIsLoading] = useState(false);
const [showChart, setShowChart] = useState(false);
const hasWeight = userProfile?.weight && parseFloat(userProfile.weight) > 0;
useEffect(() => {
if (hasWeight) {
loadWeightHistory();
}
}, [hasWeight]);
const loadWeightHistory = async () => {
try {
setIsLoading(true);
await dispatch(fetchWeightHistory() as any);
} catch (error) {
console.error('加载体重历史失败:', error);
} finally {
setIsLoading(false);
}
};
const navigateToCoach = () => {
router.push('/(tabs)/coach');
};
// 如果正在加载,显示加载状态
if (isLoading) {
return (
<View style={styles.card}>
<View style={styles.cardHeader}>
<View style={styles.iconSquare}>
<Ionicons name="scale-outline" size={18} color="#192126" />
</View>
<Text style={styles.cardTitle}></Text>
</View>
<View style={styles.emptyContent}>
<Text style={styles.emptyDescription}>...</Text>
</View>
</View>
);
}
// 如果没有体重数据,显示引导卡片
if (!hasWeight) {
return (
<View style={styles.card}>
<View style={styles.cardHeader}>
<View style={styles.iconSquare}>
<Ionicons name="scale-outline" size={18} color="#192126" />
</View>
<Text style={styles.cardTitle}></Text>
</View>
<View style={styles.emptyContent}>
<View style={styles.emptyIconContainer}>
<Ionicons name="scale-outline" size={32} color="#BBF246" />
</View>
<Text style={styles.emptyTitle}></Text>
<Text style={styles.emptyDescription}>
</Text>
<TouchableOpacity
style={styles.recordButton}
onPress={navigateToCoach}
activeOpacity={0.8}
>
<Ionicons name="add" size={18} color="#FFFFFF" />
<Text style={styles.recordButtonText}></Text>
</TouchableOpacity>
</View>
</View>
);
}
// 处理体重历史数据
const sortedHistory = [...weightHistory]
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
.slice(-7); // 只显示最近7条记录
if (sortedHistory.length === 0) {
return (
<View style={styles.card}>
<View style={styles.cardHeader}>
<View style={styles.iconSquare}>
<Ionicons name="scale-outline" size={18} color="#192126" />
</View>
<Text style={styles.cardTitle}></Text>
</View>
<View style={styles.emptyContent}>
<Text style={styles.emptyDescription}>
</Text>
<TouchableOpacity
style={styles.recordButton}
onPress={navigateToCoach}
activeOpacity={0.8}
>
<Ionicons name="add" size={18} color="#FFFFFF" />
<Text style={styles.recordButtonText}></Text>
</TouchableOpacity>
</View>
</View>
);
}
// 生成图表数据
const weights = sortedHistory.map(item => parseFloat(item.weight));
const minWeight = Math.min(...weights);
const maxWeight = Math.max(...weights);
const weightRange = maxWeight - minWeight || 1;
const points = sortedHistory.map((item, index) => {
const x = PADDING + (index / Math.max(sortedHistory.length - 1, 1)) * (CHART_WIDTH - 2 * PADDING);
const normalizedWeight = (parseFloat(item.weight) - minWeight) / weightRange;
// 减少顶部边距,压缩留白
const y = PADDING + 15 + (1 - normalizedWeight) * (CHART_HEIGHT - 2 * PADDING - 30);
return { x, y, weight: item.weight, date: item.createdAt };
});
// 生成路径
const pathData = points.map((point, index) => {
if (index === 0) return `M ${point.x} ${point.y}`;
return `L ${point.x} ${point.y}`;
}).join(' ');
// 如果只有一个数据点,显示为水平线
const singlePointPath = points.length === 1 ?
`M ${PADDING} ${points[0].y} L ${CHART_WIDTH - PADDING} ${points[0].y}` :
pathData;
return (
<View style={styles.card}>
<View style={styles.cardHeader}>
<View style={styles.iconSquare}>
<Ionicons name="scale-outline" size={18} color="#192126" />
</View>
<Text style={styles.cardTitle}></Text>
<View style={styles.headerButtons}>
<TouchableOpacity
style={styles.chartToggleButton}
onPress={() => setShowChart(!showChart)}
activeOpacity={0.8}
>
<Ionicons
name={showChart ? "chevron-up" : "chevron-down"}
size={16}
color="#BBF246"
/>
</TouchableOpacity>
<TouchableOpacity
style={styles.addButton}
onPress={navigateToCoach}
activeOpacity={0.8}
>
<Ionicons name="add" size={16} color="#BBF246" />
</TouchableOpacity>
</View>
</View>
{/* 默认信息显示 */}
{!showChart && sortedHistory.length > 0 && (
<View style={styles.summaryInfo}>
<View style={styles.summaryRow}>
<View style={styles.summaryItem}>
<Text style={styles.summaryLabel}></Text>
<Text style={styles.summaryValue}>{userProfile.weight}kg</Text>
</View>
<View style={styles.summaryItem}>
<Text style={styles.summaryLabel}></Text>
<Text style={styles.summaryValue}>{sortedHistory.length}</Text>
</View>
<View style={styles.summaryItem}>
<Text style={styles.summaryLabel}></Text>
<Text style={styles.summaryValue}>
{minWeight.toFixed(1)}-{maxWeight.toFixed(1)}kg
</Text>
</View>
</View>
<TouchableOpacity
style={styles.viewTrendButton}
onPress={() => setShowChart(true)}
activeOpacity={0.8}
>
<Ionicons name="trending-up" size={14} color="#BBF246" />
<Text style={styles.viewTrendText}></Text>
</TouchableOpacity>
</View>
)}
{/* 图表容器 - 可折叠 */}
{showChart && (
<View style={styles.chartContainer}>
<Svg width={CHART_WIDTH} height={CHART_HEIGHT + 15}>
{/* 背景网格线 */}
{[0, 1, 2, 3, 4].map(i => (
<Line
key={`grid-${i}`}
x1={PADDING}
y1={PADDING + 15 + i * (CHART_HEIGHT - 2 * PADDING - 30) / 4}
x2={CHART_WIDTH - PADDING}
y2={PADDING + 15 + i * (CHART_HEIGHT - 2 * PADDING - 30) / 4}
stroke="#F0F0F0"
strokeWidth={1}
/>
))}
{/* 折线 */}
<Path
d={singlePointPath}
stroke="#BBF246"
strokeWidth={3}
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* 数据点和标签 */}
{points.map((point, index) => {
const isLastPoint = index === points.length - 1;
const isFirstPoint = index === 0;
const showLabel = isFirstPoint || isLastPoint || points.length <= 3 || points.length === 1;
return (
<React.Fragment key={index}>
<Circle
cx={point.x}
cy={point.y}
r={isLastPoint ? 6 : 4}
fill="#BBF246"
stroke="#FFFFFF"
strokeWidth={2}
/>
{/* 体重标签 - 只在关键点显示 */}
{showLabel && (
<>
<Circle
cx={point.x}
cy={point.y - 15}
r={10}
fill="rgba(255,255,255,0.9)"
stroke="#BBF246"
strokeWidth={1}
/>
<SvgText
x={point.x}
y={point.y - 12}
fontSize="9"
fill="#192126"
textAnchor="middle"
>
{point.weight}
</SvgText>
</>
)}
</React.Fragment>
);
})}
</Svg>
{/* 图表底部信息 */}
<View style={styles.chartInfo}>
<View style={styles.infoItem}>
<Text style={styles.infoLabel}></Text>
<Text style={styles.infoValue}>{userProfile.weight}kg</Text>
</View>
<View style={styles.infoItem}>
<Text style={styles.infoLabel}></Text>
<Text style={styles.infoValue}>{sortedHistory.length}</Text>
</View>
<View style={styles.infoItem}>
<Text style={styles.infoLabel}></Text>
<Text style={styles.infoValue}>
{minWeight.toFixed(1)}-{maxWeight.toFixed(1)}kg
</Text>
</View>
</View>
{/* 最近记录时间 */}
{sortedHistory.length > 0 && (
<Text style={styles.lastRecordText}>
{dayjs(sortedHistory[sortedHistory.length - 1].createdAt).format('MM/DD HH:mm')}
</Text>
)}
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
card: {
backgroundColor: '#FFFFFF',
borderRadius: 22,
padding: 18,
marginBottom: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 3,
},
cardHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 16,
},
iconSquare: {
width: 30,
height: 30,
borderRadius: 8,
backgroundColor: '#F0F8E0',
alignItems: 'center',
justifyContent: 'center',
marginRight: 10,
},
cardTitle: {
fontSize: 18,
fontWeight: '800',
color: '#192126',
flex: 1,
},
headerButtons: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
chartToggleButton: {
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: '#F0F8E0',
alignItems: 'center',
justifyContent: 'center',
},
addButton: {
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: '#F0F8E0',
alignItems: 'center',
justifyContent: 'center',
},
emptyContent: {
alignItems: 'center',
paddingVertical: 20,
},
emptyIconContainer: {
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: '#F0F8E0',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 12,
},
emptyTitle: {
fontSize: 16,
fontWeight: '700',
color: '#192126',
marginBottom: 6,
},
emptyDescription: {
fontSize: 14,
color: '#687076',
textAlign: 'center',
marginBottom: 16,
lineHeight: 20,
},
recordButton: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#BBF246',
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 20,
gap: 6,
},
recordButtonText: {
color: '#192126',
fontSize: 14,
fontWeight: '700',
},
chartContainer: {
alignItems: 'center',
minHeight: 100,
},
chartInfo: {
flexDirection: 'row',
justifyContent: 'space-around',
width: '100%',
paddingTop: 16,
borderTopWidth: 1,
borderTopColor: '#F0F0F0',
},
infoItem: {
alignItems: 'center',
},
infoLabel: {
fontSize: 12,
color: '#687076',
marginBottom: 4,
},
infoValue: {
fontSize: 14,
fontWeight: '700',
color: '#192126',
},
lastRecordText: {
fontSize: 12,
color: '#687076',
textAlign: 'center',
marginTop: 8,
},
summaryInfo: {
paddingVertical: 12,
},
summaryRow: {
flexDirection: 'row',
justifyContent: 'space-around',
marginBottom: 12,
},
summaryItem: {
alignItems: 'center',
},
summaryLabel: {
fontSize: 12,
color: '#687076',
marginBottom: 4,
},
summaryValue: {
fontSize: 14,
fontWeight: '700',
color: '#192126',
},
viewTrendButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#F0F8E0',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 16,
gap: 6,
alignSelf: 'center',
},
viewTrendText: {
fontSize: 13,
fontWeight: '600',
color: '#192126',
},
});

View File

@@ -1,14 +1,14 @@
{
"images": [
"images" : [
{
"filename": "logo.jpeg",
"idiom": "universal",
"platform": "ios",
"size": "1024x1024"
"filename" : "logo.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info": {
"version": 1,
"author": "expo"
"info" : {
"author" : "xcode",
"version" : 1
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 441 KiB

View File

@@ -1,6 +1,6 @@
{
"info" : {
"version" : 1,
"author" : "expo"
"author" : "xcode",
"version" : 1
}
}

View File

@@ -1,17 +1,17 @@
{
"images" : [
{
"filename" : "logo.jpeg",
"filename" : "logo.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "logo 1.jpeg",
"filename" : "logo 1.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "logo 2.jpeg",
"filename" : "logo 2.png",
"idiom" : "universal",
"scale" : "3x"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 441 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 441 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 441 KiB

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "logo.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 KiB

View File

@@ -23,6 +23,13 @@ export type UserState = {
loading: boolean;
error: string | null;
privacyAgreed: boolean;
weightHistory: WeightHistoryItem[];
};
export type WeightHistoryItem = {
weight: string;
source: string;
createdAt: string;
};
export const DEFAULT_MEMBER_NAME = '普拉提星球学员';
@@ -35,6 +42,7 @@ const initialState: UserState = {
loading: false,
error: null,
privacyAgreed: false,
weightHistory: [],
};
export type LoginPayload = Record<string, any> & {
@@ -151,6 +159,18 @@ export const fetchMyProfile = createAsyncThunk('user/fetchMyProfile', async (_,
}
});
// 获取用户体重历史记录
export const fetchWeightHistory = createAsyncThunk('user/fetchWeightHistory', async (_, { rejectWithValue }) => {
try {
const data: WeightHistoryItem[] = await api.get('/api/users/weight-history');
console.log('fetchWeightHistory', data);
return data;
} catch (err: any) {
return rejectWithValue(err?.message ?? '获取用户体重历史记录失败');
}
});
const userSlice = createSlice({
name: 'user',
initialState,
@@ -208,6 +228,12 @@ const userSlice = createSlice({
})
.addCase(setPrivacyAgreed.fulfilled, (state) => {
state.privacyAgreed = true;
})
.addCase(fetchWeightHistory.fulfilled, (state, action) => {
state.weightHistory = action.payload;
})
.addCase(fetchWeightHistory.rejected, (state, action) => {
state.error = (action.payload as string) ?? '获取用户体重历史记录失败';
});
},
});