feat: 更新营养雷达图组件,优化营养分析卡片
- 在营养分析卡片中引入更新日期和图标,提升信息展示 - 修改营养维度标签,简化文本内容 - 更新雷达图组件,支持自定义尺寸和标签显示 - 增加尺寸配置,优化图表显示效果 - 更新饮食记录服务,添加更新时间字段以支持新功能
This commit is contained in:
@@ -1,4 +1,6 @@
|
|||||||
import { NutritionSummary } from '@/services/dietRecords';
|
import { NutritionSummary } from '@/services/dietRecords';
|
||||||
|
import Feather from '@expo/vector-icons/Feather';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { StyleSheet, Text, View } from 'react-native';
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
import { RadarCategory, RadarChart } from './RadarChart';
|
import { RadarCategory, RadarChart } from './RadarChart';
|
||||||
@@ -14,8 +16,8 @@ const NUTRITION_DIMENSIONS: RadarCategory[] = [
|
|||||||
{ key: 'protein', label: '蛋白质' },
|
{ key: 'protein', label: '蛋白质' },
|
||||||
{ key: 'carbohydrate', label: '碳水' },
|
{ key: 'carbohydrate', label: '碳水' },
|
||||||
{ key: 'fat', label: '脂肪' },
|
{ key: 'fat', label: '脂肪' },
|
||||||
{ key: 'fiber', label: '膳食纤维' },
|
{ key: 'fiber', label: '纤维' },
|
||||||
{ key: 'sodium', label: '钠含量' },
|
{ key: 'sodium', label: '钠' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function NutritionRadarCard({ nutritionSummary, isLoading = false }: NutritionRadarCardProps) {
|
export function NutritionRadarCard({ nutritionSummary, isLoading = false }: NutritionRadarCardProps) {
|
||||||
@@ -48,14 +50,21 @@ export function NutritionRadarCard({ nutritionSummary, isLoading = false }: Nutr
|
|||||||
{ label: '蛋白质', value: nutritionSummary ? `${nutritionSummary.totalProtein.toFixed(1)} g` : '0.0 g', color: '#4ECDC4' },
|
{ label: '蛋白质', value: nutritionSummary ? `${nutritionSummary.totalProtein.toFixed(1)} g` : '0.0 g', color: '#4ECDC4' },
|
||||||
{ label: '碳水', value: nutritionSummary ? `${nutritionSummary.totalCarbohydrate.toFixed(1)} g` : '0.0 g', color: '#45B7D1' },
|
{ label: '碳水', value: nutritionSummary ? `${nutritionSummary.totalCarbohydrate.toFixed(1)} g` : '0.0 g', color: '#45B7D1' },
|
||||||
{ label: '脂肪', value: nutritionSummary ? `${nutritionSummary.totalFat.toFixed(1)} g` : '0.0 g', color: '#FFA07A' },
|
{ label: '脂肪', value: nutritionSummary ? `${nutritionSummary.totalFat.toFixed(1)} g` : '0.0 g', color: '#FFA07A' },
|
||||||
{ label: '膳食纤维', value: nutritionSummary ? `${nutritionSummary.totalFiber.toFixed(1)} g` : '0.0 g', color: '#98D8C8' },
|
{ label: '纤维', value: nutritionSummary ? `${nutritionSummary.totalFiber.toFixed(1)} g` : '0.0 g', color: '#98D8C8' },
|
||||||
{ label: '钠', value: nutritionSummary ? `${Math.round(nutritionSummary.totalSodium)} mg` : '0 mg', color: '#F7DC6F' },
|
{ label: '钠', value: nutritionSummary ? `${Math.round(nutritionSummary.totalSodium)} mg` : '0 mg', color: '#F7DC6F' },
|
||||||
];
|
];
|
||||||
}, [nutritionSummary]);
|
}, [nutritionSummary]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.card}>
|
<View style={styles.card}>
|
||||||
<Text style={styles.cardTitle}>营养摄入分析</Text>
|
<View style={styles.cardHeader}>
|
||||||
|
<Text style={styles.cardTitle}>营养摄入分析</Text>
|
||||||
|
<View style={styles.cardRightContainer}>
|
||||||
|
<Text style={styles.cardSubtitle}>更新: {dayjs(nutritionSummary?.updatedAt).format('YYYY-MM-DD HH:mm')}</Text>
|
||||||
|
<Feather name="more-vertical" size={16} color="#9AA3AE" />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<View style={styles.loadingContainer}>
|
<View style={styles.loadingContainer}>
|
||||||
@@ -67,7 +76,7 @@ export function NutritionRadarCard({ nutritionSummary, isLoading = false }: Nutr
|
|||||||
<RadarChart
|
<RadarChart
|
||||||
categories={NUTRITION_DIMENSIONS}
|
categories={NUTRITION_DIMENSIONS}
|
||||||
values={radarValues}
|
values={radarValues}
|
||||||
size={100}
|
size="small"
|
||||||
maxValue={5}
|
maxValue={5}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -102,11 +111,26 @@ const styles = StyleSheet.create({
|
|||||||
shadowRadius: 3.84,
|
shadowRadius: 3.84,
|
||||||
elevation: 5,
|
elevation: 5,
|
||||||
},
|
},
|
||||||
|
cardHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
cardTitle: {
|
cardTitle: {
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: '800',
|
fontWeight: '800',
|
||||||
color: '#192126',
|
color: '#192126',
|
||||||
marginBottom: 16,
|
},
|
||||||
|
cardRightContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
cardSubtitle: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#9AA3AE',
|
||||||
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
contentContainer: {
|
contentContainer: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -114,19 +138,20 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
radarContainer: {
|
radarContainer: {
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
marginRight: 20,
|
marginRight: 6,
|
||||||
},
|
},
|
||||||
statsContainer: {
|
statsContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
|
marginLeft: 4
|
||||||
},
|
},
|
||||||
statItem: {
|
statItem: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
width: '48%',
|
width: '48%',
|
||||||
marginBottom: 8,
|
marginBottom: 16,
|
||||||
},
|
},
|
||||||
statDot: {
|
statDot: {
|
||||||
width: 8,
|
width: 8,
|
||||||
|
|||||||
@@ -4,25 +4,75 @@ import Svg, * as SvgLib from 'react-native-svg';
|
|||||||
|
|
||||||
export type RadarCategory = { key: string; label: string };
|
export type RadarCategory = { key: string; label: string };
|
||||||
|
|
||||||
|
export type RadarChartSize = 'small' | 'medium' | 'large' | number;
|
||||||
|
|
||||||
export type RadarChartProps = {
|
export type RadarChartProps = {
|
||||||
categories: RadarCategory[];
|
categories: RadarCategory[];
|
||||||
values: number[]; // 与 categories 一一对应
|
values: number[]; // 与 categories 一一对应
|
||||||
maxValue?: number; // 默认 5
|
maxValue?: number; // 默认 5
|
||||||
size?: number; // 组件宽高,默认 260
|
size?: RadarChartSize; // 组件宽高,默认 medium
|
||||||
levels?: number; // 网格层数,默认 5
|
levels?: number; // 网格层数,默认 5
|
||||||
showGrid?: boolean;
|
showGrid?: boolean;
|
||||||
|
showLabels?: boolean; // 是否显示指标文字
|
||||||
};
|
};
|
||||||
|
|
||||||
export function RadarChart({ categories, values, maxValue = 5, size = 260, levels = 5, showGrid = true }: RadarChartProps) {
|
// 预设尺寸配置
|
||||||
const radius = size * 0.38;
|
const SIZE_CONFIG = {
|
||||||
const cx = size / 2;
|
small: { size: 116, fontSize: 8, labelOffset: -4, radiusRatio: 0.35 },
|
||||||
const cy = size / 2;
|
medium: { size: 200, fontSize: 11, labelOffset: 15, radiusRatio: 0.38 },
|
||||||
|
large: { size: 280, fontSize: 13, labelOffset: 18, radiusRatio: 0.4 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RadarChart({
|
||||||
|
categories,
|
||||||
|
values,
|
||||||
|
maxValue = 5,
|
||||||
|
size = 'medium',
|
||||||
|
levels = 5,
|
||||||
|
showGrid = true,
|
||||||
|
showLabels = true
|
||||||
|
}: RadarChartProps) {
|
||||||
|
// 解析尺寸配置
|
||||||
|
const config = useMemo(() => {
|
||||||
|
if (typeof size === 'number') {
|
||||||
|
// 自定义尺寸
|
||||||
|
const baseSize = Math.max(80, Math.min(400, size));
|
||||||
|
let fontSize = 11;
|
||||||
|
let labelOffset = 15;
|
||||||
|
let radiusRatio = 0.38;
|
||||||
|
|
||||||
|
// 根据尺寸自适应调整
|
||||||
|
if (baseSize < 140) {
|
||||||
|
fontSize = 9;
|
||||||
|
labelOffset = 12;
|
||||||
|
radiusRatio = 0.35;
|
||||||
|
} else if (baseSize > 240) {
|
||||||
|
fontSize = 13;
|
||||||
|
labelOffset = 18;
|
||||||
|
radiusRatio = 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { size: baseSize, fontSize, labelOffset, radiusRatio };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预设尺寸
|
||||||
|
return SIZE_CONFIG[size] || SIZE_CONFIG.medium;
|
||||||
|
}, [size]);
|
||||||
|
|
||||||
|
const { size: actualSize, fontSize, labelOffset, radiusRatio } = config;
|
||||||
|
const radius = actualSize * radiusRatio;
|
||||||
|
const cx = actualSize / 2;
|
||||||
|
const cy = actualSize / 2;
|
||||||
const count = categories.length;
|
const count = categories.length;
|
||||||
|
|
||||||
const points = useMemo(() => {
|
const points = useMemo(() => {
|
||||||
return categories.map((_, i) => {
|
return categories.map((_, i) => {
|
||||||
const angle = (Math.PI * 2 * i) / count - Math.PI / 2; // 顶部起点
|
const angle = (Math.PI * 2 * i) / count - Math.PI / 2; // 顶部起点
|
||||||
return { angle, x: (v: number) => cx + Math.cos(angle) * v, y: (v: number) => cy + Math.sin(angle) * v };
|
return {
|
||||||
|
angle,
|
||||||
|
x: (v: number) => cx + Math.cos(angle) * v,
|
||||||
|
y: (v: number) => cy + Math.sin(angle) * v
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}, [categories, count, cx, cy]);
|
}, [categories, count, cx, cy]);
|
||||||
|
|
||||||
@@ -44,9 +94,12 @@ export function RadarChart({ categories, values, maxValue = 5, size = 260, level
|
|||||||
return polys;
|
return polys;
|
||||||
}, [levels, radius, points]);
|
}, [levels, radius, points]);
|
||||||
|
|
||||||
|
// 检查是否有足够的空间显示文字
|
||||||
|
const hasEnoughSpace = actualSize >= 100;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ width: size, height: size }}>
|
<View style={{ width: actualSize, height: actualSize }}>
|
||||||
<Svg width={size} height={size}>
|
<Svg width={actualSize} height={actualSize}>
|
||||||
<SvgLib.Defs>
|
<SvgLib.Defs>
|
||||||
<SvgLib.LinearGradient id="radarFill" x1="0" y1="0" x2="1" y2="1">
|
<SvgLib.LinearGradient id="radarFill" x1="0" y1="0" x2="1" y2="1">
|
||||||
<SvgLib.Stop offset="0%" stopColor="#BBF246" stopOpacity={0.32} />
|
<SvgLib.Stop offset="0%" stopColor="#BBF246" stopOpacity={0.32} />
|
||||||
@@ -79,14 +132,22 @@ export function RadarChart({ categories, values, maxValue = 5, size = 260, level
|
|||||||
</SvgLib.G>
|
</SvgLib.G>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{categories.map((c, i) => {
|
{showLabels && hasEnoughSpace && categories.map((c, i) => {
|
||||||
const r = radius + 18;
|
const r = radius + labelOffset;
|
||||||
const x = points[i].x(r);
|
const x = points[i].x(r);
|
||||||
const y = points[i].y(r);
|
const y = points[i].y(r);
|
||||||
const anchor = Math.cos(points[i].angle) > 0.2 ? 'start' : Math.cos(points[i].angle) < -0.2 ? 'end' : 'middle';
|
const anchor = Math.cos(points[i].angle) > 0.2 ? 'start' : Math.cos(points[i].angle) < -0.2 ? 'end' : 'middle';
|
||||||
const dy = Math.sin(points[i].angle) > 0.6 ? 10 : Math.sin(points[i].angle) < -0.6 ? -4 : 4;
|
const dy = Math.sin(points[i].angle) > 0.6 ? 10 : Math.sin(points[i].angle) < -0.6 ? -4 : 4;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SvgLib.Text key={`label-${c.key}`} x={x} y={y + dy} fontSize={11} fill="#192126" textAnchor={anchor}>
|
<SvgLib.Text
|
||||||
|
key={`label-${c.key}`}
|
||||||
|
x={x}
|
||||||
|
y={y + dy}
|
||||||
|
fontSize={fontSize}
|
||||||
|
fill="#192126"
|
||||||
|
textAnchor={anchor}
|
||||||
|
>
|
||||||
{c.label}
|
{c.label}
|
||||||
</SvgLib.Text>
|
</SvgLib.Text>
|
||||||
);
|
);
|
||||||
@@ -99,6 +160,4 @@ export function RadarChart({ categories, values, maxValue = 5, size = 260, level
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({});
|
const styles = StyleSheet.create({});
|
||||||
|
|
||||||
|
|
||||||
@@ -34,6 +34,7 @@ export type NutritionSummary = {
|
|||||||
totalFiber: number;
|
totalFiber: number;
|
||||||
totalSugar: number;
|
totalSugar: number;
|
||||||
totalSodium: number;
|
totalSodium: number;
|
||||||
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getDietRecords({
|
export async function getDietRecords({
|
||||||
@@ -67,6 +68,7 @@ export function calculateNutritionSummary(records: DietRecord[]): NutritionSumma
|
|||||||
totalFiber: 0,
|
totalFiber: 0,
|
||||||
totalSugar: 0,
|
totalSugar: 0,
|
||||||
totalSodium: 0,
|
totalSodium: 0,
|
||||||
|
updatedAt: '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,6 +81,7 @@ export function calculateNutritionSummary(records: DietRecord[]): NutritionSumma
|
|||||||
totalFiber: summary.totalFiber + (record.fiberGrams || 0),
|
totalFiber: summary.totalFiber + (record.fiberGrams || 0),
|
||||||
totalSugar: summary.totalSugar + (record.sugarGrams || 0),
|
totalSugar: summary.totalSugar + (record.sugarGrams || 0),
|
||||||
totalSodium: summary.totalSodium + (record.sodiumMg || 0),
|
totalSodium: summary.totalSodium + (record.sodiumMg || 0),
|
||||||
|
updatedAt: record.updatedAt,
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
totalCalories: 0,
|
totalCalories: 0,
|
||||||
@@ -88,6 +91,7 @@ export function calculateNutritionSummary(records: DietRecord[]): NutritionSumma
|
|||||||
totalFiber: 0,
|
totalFiber: 0,
|
||||||
totalSugar: 0,
|
totalSugar: 0,
|
||||||
totalSodium: 0,
|
totalSodium: 0,
|
||||||
|
updatedAt: '',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user