feat: 更新文章功能和相关依赖
- 新增文章详情页面,支持根据文章 ID 加载和展示文章内容 - 添加文章卡片组件,展示推荐文章的标题、封面和阅读量 - 更新文章服务,支持获取文章列表和根据 ID 获取文章详情 - 集成腾讯云 COS SDK,支持文件上传功能 - 优化打卡功能,支持按日期加载和展示打卡记录 - 更新相关依赖,确保项目兼容性和功能完整性 - 调整样式以适应新功能的展示和交互
This commit is contained in:
@@ -6,7 +6,7 @@ import { getTabBarBottomPadding } from '@/constants/TabBar';
|
||||
import { useAppSelector } from '@/hooks/redux';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date';
|
||||
import { ensureHealthPermissions, fetchHealthDataForDate, fetchTodayHealthData } from '@/utils/health';
|
||||
import { ensureHealthPermissions, fetchHealthDataForDate } from '@/utils/health';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
@@ -65,6 +65,11 @@ export default function ExploreScreen() {
|
||||
const [animToken, setAnimToken] = useState(0);
|
||||
const [trainingProgress, setTrainingProgress] = useState(0.8); // 暂定静态80%
|
||||
|
||||
// 记录最近一次请求的“日期键”,避免旧请求覆盖新结果
|
||||
const latestRequestKeyRef = useRef<string | null>(null);
|
||||
|
||||
const getDateKey = (d: Date) => `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`;
|
||||
|
||||
const loadHealthData = async (targetDate?: Date) => {
|
||||
try {
|
||||
console.log('=== 开始HealthKit初始化流程 ===');
|
||||
@@ -77,13 +82,23 @@ export default function ExploreScreen() {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('权限获取成功,开始获取健康数据...');
|
||||
const data = targetDate ? await fetchHealthDataForDate(targetDate) : await fetchTodayHealthData();
|
||||
// 若未显式传入日期,按当前选中索引推导日期
|
||||
const derivedDate = targetDate ?? days[selectedIndex]?.date?.toDate() ?? new Date();
|
||||
const requestKey = getDateKey(derivedDate);
|
||||
latestRequestKeyRef.current = requestKey;
|
||||
|
||||
console.log('权限获取成功,开始获取健康数据...', derivedDate);
|
||||
const data = await fetchHealthDataForDate(derivedDate);
|
||||
|
||||
console.log('设置UI状态:', data);
|
||||
setStepCount(data.steps);
|
||||
setActiveCalories(Math.round(data.activeEnergyBurned));
|
||||
setAnimToken((t) => t + 1);
|
||||
// 仅当该请求仍是最新时,才应用结果
|
||||
if (latestRequestKeyRef.current === requestKey) {
|
||||
setStepCount(data.steps);
|
||||
setActiveCalories(Math.round(data.activeEnergyBurned));
|
||||
setAnimToken((t) => t + 1);
|
||||
} else {
|
||||
console.log('忽略过期健康数据请求结果,key=', requestKey, '最新key=', latestRequestKeyRef.current);
|
||||
}
|
||||
console.log('=== HealthKit数据获取完成 ===');
|
||||
|
||||
} catch (error) {
|
||||
@@ -95,8 +110,9 @@ export default function ExploreScreen() {
|
||||
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
// 聚焦时按当前选中的日期加载,避免与用户手动选择的日期不一致
|
||||
loadHealthData();
|
||||
}, [])
|
||||
}, [selectedIndex])
|
||||
);
|
||||
|
||||
// 日期点击时,加载对应日期数据
|
||||
@@ -147,7 +163,7 @@ export default function ExploreScreen() {
|
||||
|
||||
{/* 打卡入口 */}
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginTop: 24, marginBottom: 8 }}>
|
||||
<Text style={styles.sectionTitle}>今日报告</Text>
|
||||
<Text style={styles.sectionTitle}>每日报告</Text>
|
||||
<TouchableOpacity onPress={() => router.push('/checkin/calendar')} accessibilityRole="button">
|
||||
<Text style={{ color: '#6B7280', fontWeight: '700' }}>查看打卡日历</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { ArticleCard } from '@/components/ArticleCard';
|
||||
import { PlanCard } from '@/components/PlanCard';
|
||||
import { SearchBox } from '@/components/SearchBox';
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { listRecommendedArticles } from '@/services/articles';
|
||||
import { fetchRecommendations, RecommendationType } from '@/services/recommendations';
|
||||
// Removed WorkoutCard import since we no longer use the horizontal carousel
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { getChineseGreeting } from '@/utils/date';
|
||||
@@ -16,7 +19,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
export default function HomeScreen() {
|
||||
const router = useRouter();
|
||||
const { pushIfAuthedElseLogin } = useAuthGuard();
|
||||
const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
const insets = useSafeAreaInsets();
|
||||
@@ -72,6 +75,107 @@ export default function HomeScreen() {
|
||||
});
|
||||
},
|
||||
}), [coachSize.height, coachSize.width, insets.bottom, insets.top, pan, windowHeight, windowWidth, router]);
|
||||
// 推荐项类型(本地 UI 使用)
|
||||
type RecommendItem =
|
||||
| {
|
||||
type: 'plan';
|
||||
key: string;
|
||||
image: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
level?: '初学者' | '中级' | '高级';
|
||||
progress: number;
|
||||
onPress?: () => void;
|
||||
}
|
||||
| {
|
||||
type: 'article';
|
||||
key: string;
|
||||
id: string;
|
||||
title: string;
|
||||
coverImage: string;
|
||||
publishedAt: string;
|
||||
readCount: number;
|
||||
};
|
||||
|
||||
// 打底数据(接口不可用时)
|
||||
const getFallbackItems = React.useCallback((): RecommendItem[] => {
|
||||
return [
|
||||
{
|
||||
type: 'plan',
|
||||
key: 'assess',
|
||||
image:
|
||||
'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/imagedemo.jpeg',
|
||||
title: '体态评估',
|
||||
subtitle: '评估你的体态,制定训练计划',
|
||||
level: '初学者',
|
||||
progress: 0,
|
||||
onPress: () => router.push('/ai-posture-assessment'),
|
||||
},
|
||||
...listRecommendedArticles().map((a) => ({
|
||||
type: 'article' as const,
|
||||
key: `article-${a.id}`,
|
||||
id: a.id,
|
||||
title: a.title,
|
||||
coverImage: a.coverImage,
|
||||
publishedAt: a.publishedAt,
|
||||
readCount: a.readCount,
|
||||
})),
|
||||
];
|
||||
}, [router]);
|
||||
|
||||
const [items, setItems] = React.useState<RecommendItem[]>(() => getFallbackItems());
|
||||
|
||||
// 拉取推荐接口(已登录时)
|
||||
React.useEffect(() => {
|
||||
let canceled = false;
|
||||
async function load() {
|
||||
if (!isLoggedIn) {
|
||||
console.log('fetchRecommendations not logged in');
|
||||
setItems(getFallbackItems());
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const cards = await fetchRecommendations();
|
||||
|
||||
console.log('fetchRecommendations', cards);
|
||||
if (canceled) return;
|
||||
const mapped: RecommendItem[] = [];
|
||||
for (const c of cards || []) {
|
||||
if (c.type === RecommendationType.Article) {
|
||||
const publishedAt = (c.extra && (c.extra.publishedDate || c.extra.published_at)) || new Date().toISOString();
|
||||
const readCount = (c.extra && (c.extra.readCount ?? c.extra.read_count)) || 0;
|
||||
mapped.push({
|
||||
type: 'article',
|
||||
key: c.id,
|
||||
id: c.articleId || c.id,
|
||||
title: c.title || '',
|
||||
coverImage: c.coverUrl,
|
||||
publishedAt,
|
||||
readCount,
|
||||
});
|
||||
} else if (c.type === RecommendationType.Checkin) {
|
||||
mapped.push({
|
||||
type: 'plan',
|
||||
key: c.id || 'checkin',
|
||||
image: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/imagedemo.jpeg',
|
||||
title: c.title || '今日打卡',
|
||||
subtitle: c.subtitle || '完成一次普拉提训练,记录你的坚持',
|
||||
progress: 0,
|
||||
onPress: () => pushIfAuthedElseLogin('/checkin'),
|
||||
});
|
||||
}
|
||||
}
|
||||
// 若接口返回空,也回退到打底
|
||||
setItems(mapped.length > 0 ? mapped : getFallbackItems());
|
||||
} catch (e) {
|
||||
console.error('fetchRecommendations error', e);
|
||||
setItems(getFallbackItems());
|
||||
}
|
||||
}
|
||||
load();
|
||||
return () => { canceled = true; };
|
||||
}, [isLoggedIn, pushIfAuthedElseLogin, getFallbackItems]);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.safeArea, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
|
||||
<ThemedView style={[styles.container, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
|
||||
@@ -124,7 +228,7 @@ export default function HomeScreen() {
|
||||
<ScrollView showsVerticalScrollIndicator={false}>
|
||||
{/* Header Section */}
|
||||
<View style={styles.header}>
|
||||
<ThemedText style={styles.greeting}>{getChineseGreeting()} 🔥</ThemedText>
|
||||
<ThemedText style={styles.greeting}>{getChineseGreeting()}</ThemedText>
|
||||
<ThemedText style={styles.userName}>新学员,欢迎你</ThemedText>
|
||||
</View>
|
||||
|
||||
@@ -142,9 +246,6 @@ export default function HomeScreen() {
|
||||
>
|
||||
<ThemedText style={styles.featureTitle}>AI体态评估</ThemedText>
|
||||
<ThemedText style={styles.featureSubtitle}>3分钟获取体态报告</ThemedText>
|
||||
<View style={styles.featureCta}>
|
||||
<ThemedText style={styles.featureCtaText}>开始评估</ThemedText>
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
@@ -153,9 +254,22 @@ export default function HomeScreen() {
|
||||
>
|
||||
<ThemedText style={styles.featureTitle}>在线教练</ThemedText>
|
||||
<ThemedText style={styles.featureSubtitle}>认证教练 · 1对1即时解答</ThemedText>
|
||||
<View style={styles.featureCta}>
|
||||
<ThemedText style={styles.featureCtaText}>立即咨询</ThemedText>
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={[styles.featureCard, styles.featureCardTertiary]}
|
||||
onPress={() => pushIfAuthedElseLogin('/checkin')}
|
||||
>
|
||||
<ThemedText style={styles.featureTitle}>每日打卡</ThemedText>
|
||||
<ThemedText style={styles.featureSubtitle}>自选动作 · 记录完成</ThemedText>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={[styles.featureCard, styles.featureCardQuaternary]}
|
||||
onPress={() => pushIfAuthedElseLogin('/training-plan')}
|
||||
>
|
||||
<ThemedText style={styles.featureTitle}>训练计划制定</ThemedText>
|
||||
<ThemedText style={styles.featureSubtitle}>按周安排 · 个性化目标</ThemedText>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
@@ -165,40 +279,36 @@ export default function HomeScreen() {
|
||||
<ThemedText style={styles.sectionTitle}>为你推荐</ThemedText>
|
||||
|
||||
<View style={styles.planList}>
|
||||
<PlanCard
|
||||
image={'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/imagedemo.jpeg'}
|
||||
title="体态评估"
|
||||
subtitle="评估你的体态,制定训练计划"
|
||||
level="初学者"
|
||||
progress={0}
|
||||
/>
|
||||
{/* 原“每周打卡”改为进入打卡日历 */}
|
||||
<Pressable onPress={() => router.push('/checkin/calendar')}>
|
||||
<PlanCard
|
||||
image={'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/Image30play@2x.png'}
|
||||
title="打卡日历"
|
||||
subtitle="查看每日打卡记录(点亮日期)"
|
||||
progress={0.75}
|
||||
/>
|
||||
</Pressable>
|
||||
<Pressable onPress={() => pushIfAuthedElseLogin('/training-plan')}>
|
||||
<PlanCard
|
||||
image={'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/Image30play@2x.png'}
|
||||
title="训练计划制定"
|
||||
subtitle="按周安排/次数·常见目标·个性化选项"
|
||||
level="初学者"
|
||||
progress={0}
|
||||
/>
|
||||
</Pressable>
|
||||
<Pressable onPress={() => pushIfAuthedElseLogin('/checkin')}>
|
||||
<PlanCard
|
||||
image={'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/ImageCheck.jpeg'}
|
||||
title="每日打卡(自选动作)"
|
||||
subtitle="选择动作,设置组数/次数,记录完成"
|
||||
level="初学者"
|
||||
progress={0}
|
||||
/>
|
||||
</Pressable>
|
||||
{items.map((item) => {
|
||||
if (item.type === 'article') {
|
||||
return (
|
||||
<ArticleCard
|
||||
key={item.key}
|
||||
id={item.id}
|
||||
title={item.title}
|
||||
coverImage={item.coverImage}
|
||||
publishedAt={item.publishedAt}
|
||||
readCount={item.readCount}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const card = (
|
||||
<PlanCard
|
||||
image={item.image}
|
||||
title={item.title}
|
||||
subtitle={item.subtitle}
|
||||
level={item.level}
|
||||
progress={item.progress}
|
||||
/>
|
||||
);
|
||||
return item.onPress ? (
|
||||
<Pressable key={item.key} onPress={item.onPress}>
|
||||
{card}
|
||||
</Pressable>
|
||||
) : (
|
||||
<View key={item.key}>{card}</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -299,19 +409,20 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: 24,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
featureCard: {
|
||||
width: '48%',
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
backgroundColor: '#FFFFFF',
|
||||
// iOS shadow
|
||||
marginBottom: 12,
|
||||
// 轻量阴影,减少臃肿感
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 12,
|
||||
shadowOffset: { width: 0, height: 6 },
|
||||
// Android shadow
|
||||
elevation: 4,
|
||||
shadowOpacity: 0.04,
|
||||
shadowRadius: 8,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
elevation: 2,
|
||||
},
|
||||
featureCardPrimary: {
|
||||
backgroundColor: '#EEF2FF', // 柔和的靛蓝背景
|
||||
@@ -319,33 +430,26 @@ const styles = StyleSheet.create({
|
||||
featureCardSecondary: {
|
||||
backgroundColor: '#F0FDFA', // 柔和的青绿背景
|
||||
},
|
||||
featureCardTertiary: {
|
||||
backgroundColor: '#FFF7ED', // 柔和的橙色背景
|
||||
},
|
||||
featureCardQuaternary: {
|
||||
backgroundColor: '#F5F3FF', // 柔和的紫色背景
|
||||
},
|
||||
featureIcon: {
|
||||
fontSize: 28,
|
||||
marginBottom: 8,
|
||||
},
|
||||
featureTitle: {
|
||||
fontSize: 18,
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: '#0F172A',
|
||||
marginBottom: 6,
|
||||
marginBottom: 4,
|
||||
},
|
||||
featureSubtitle: {
|
||||
fontSize: 12,
|
||||
fontSize: 11,
|
||||
color: '#6B7280',
|
||||
lineHeight: 16,
|
||||
marginBottom: 12,
|
||||
},
|
||||
featureCta: {
|
||||
alignSelf: 'flex-start',
|
||||
backgroundColor: '#0F172A',
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 999,
|
||||
},
|
||||
featureCtaText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
lineHeight: 15,
|
||||
},
|
||||
planList: {
|
||||
paddingHorizontal: 24,
|
||||
|
||||
Reference in New Issue
Block a user