feat: 更新文章功能和相关依赖

- 新增文章详情页面,支持根据文章 ID 加载和展示文章内容
- 添加文章卡片组件,展示推荐文章的标题、封面和阅读量
- 更新文章服务,支持获取文章列表和根据 ID 获取文章详情
- 集成腾讯云 COS SDK,支持文件上传功能
- 优化打卡功能,支持按日期加载和展示打卡记录
- 更新相关依赖,确保项目兼容性和功能完整性
- 调整样式以适应新功能的展示和交互
This commit is contained in:
richarjiang
2025-08-14 16:03:19 +08:00
parent 532cf251e2
commit 5d09cc05dc
24 changed files with 1953 additions and 513 deletions

View File

@@ -6,7 +6,7 @@ import { getTabBarBottomPadding } from '@/constants/TabBar';
import { useAppSelector } from '@/hooks/redux'; import { useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date'; 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 { Ionicons } from '@expo/vector-icons';
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
import { useFocusEffect } from '@react-navigation/native'; import { useFocusEffect } from '@react-navigation/native';
@@ -65,6 +65,11 @@ export default function ExploreScreen() {
const [animToken, setAnimToken] = useState(0); const [animToken, setAnimToken] = useState(0);
const [trainingProgress, setTrainingProgress] = useState(0.8); // 暂定静态80% 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) => { const loadHealthData = async (targetDate?: Date) => {
try { try {
console.log('=== 开始HealthKit初始化流程 ==='); console.log('=== 开始HealthKit初始化流程 ===');
@@ -77,13 +82,23 @@ export default function ExploreScreen() {
return; 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); console.log('设置UI状态:', data);
// 仅当该请求仍是最新时,才应用结果
if (latestRequestKeyRef.current === requestKey) {
setStepCount(data.steps); setStepCount(data.steps);
setActiveCalories(Math.round(data.activeEnergyBurned)); setActiveCalories(Math.round(data.activeEnergyBurned));
setAnimToken((t) => t + 1); setAnimToken((t) => t + 1);
} else {
console.log('忽略过期健康数据请求结果key=', requestKey, '最新key=', latestRequestKeyRef.current);
}
console.log('=== HealthKit数据获取完成 ==='); console.log('=== HealthKit数据获取完成 ===');
} catch (error) { } catch (error) {
@@ -95,8 +110,9 @@ export default function ExploreScreen() {
useFocusEffect( useFocusEffect(
React.useCallback(() => { React.useCallback(() => {
// 聚焦时按当前选中的日期加载,避免与用户手动选择的日期不一致
loadHealthData(); loadHealthData();
}, []) }, [selectedIndex])
); );
// 日期点击时,加载对应日期数据 // 日期点击时,加载对应日期数据
@@ -147,7 +163,7 @@ export default function ExploreScreen() {
{/* 打卡入口 */} {/* 打卡入口 */}
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginTop: 24, marginBottom: 8 }}> <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"> <TouchableOpacity onPress={() => router.push('/checkin/calendar')} accessibilityRole="button">
<Text style={{ color: '#6B7280', fontWeight: '700' }}></Text> <Text style={{ color: '#6B7280', fontWeight: '700' }}></Text>
</TouchableOpacity> </TouchableOpacity>

View File

@@ -1,9 +1,12 @@
import { ArticleCard } from '@/components/ArticleCard';
import { PlanCard } from '@/components/PlanCard'; import { PlanCard } from '@/components/PlanCard';
import { SearchBox } from '@/components/SearchBox'; import { SearchBox } from '@/components/SearchBox';
import { ThemedText } from '@/components/ThemedText'; import { ThemedText } from '@/components/ThemedText';
import { ThemedView } from '@/components/ThemedView'; import { ThemedView } from '@/components/ThemedView';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme'; 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 // Removed WorkoutCard import since we no longer use the horizontal carousel
import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useAuthGuard } from '@/hooks/useAuthGuard';
import { getChineseGreeting } from '@/utils/date'; import { getChineseGreeting } from '@/utils/date';
@@ -16,7 +19,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
export default function HomeScreen() { export default function HomeScreen() {
const router = useRouter(); const router = useRouter();
const { pushIfAuthedElseLogin } = useAuthGuard(); const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme]; const colorTokens = Colors[theme];
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
@@ -72,6 +75,107 @@ export default function HomeScreen() {
}); });
}, },
}), [coachSize.height, coachSize.width, insets.bottom, insets.top, pan, windowHeight, windowWidth, router]); }), [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 ( return (
<SafeAreaView style={[styles.safeArea, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}> <SafeAreaView style={[styles.safeArea, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
<ThemedView style={[styles.container, { 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}> <ScrollView showsVerticalScrollIndicator={false}>
{/* Header Section */} {/* Header Section */}
<View style={styles.header}> <View style={styles.header}>
<ThemedText style={styles.greeting}>{getChineseGreeting()} 🔥</ThemedText> <ThemedText style={styles.greeting}>{getChineseGreeting()}</ThemedText>
<ThemedText style={styles.userName}></ThemedText> <ThemedText style={styles.userName}></ThemedText>
</View> </View>
@@ -142,9 +246,6 @@ export default function HomeScreen() {
> >
<ThemedText style={styles.featureTitle}>AI体态评估</ThemedText> <ThemedText style={styles.featureTitle}>AI体态评估</ThemedText>
<ThemedText style={styles.featureSubtitle}>3</ThemedText> <ThemedText style={styles.featureSubtitle}>3</ThemedText>
<View style={styles.featureCta}>
<ThemedText style={styles.featureCtaText}></ThemedText>
</View>
</Pressable> </Pressable>
<Pressable <Pressable
@@ -153,9 +254,22 @@ export default function HomeScreen() {
> >
<ThemedText style={styles.featureTitle}>线</ThemedText> <ThemedText style={styles.featureTitle}>线</ThemedText>
<ThemedText style={styles.featureSubtitle}> · 11</ThemedText> <ThemedText style={styles.featureSubtitle}> · 11</ThemedText>
<View style={styles.featureCta}> </Pressable>
<ThemedText style={styles.featureCtaText}></ThemedText>
</View> <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> </Pressable>
</View> </View>
</View> </View>
@@ -165,40 +279,36 @@ export default function HomeScreen() {
<ThemedText style={styles.sectionTitle}></ThemedText> <ThemedText style={styles.sectionTitle}></ThemedText>
<View style={styles.planList}> <View style={styles.planList}>
<PlanCard {items.map((item) => {
image={'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/imagedemo.jpeg'} if (item.type === 'article') {
title="体态评估" return (
subtitle="评估你的体态,制定训练计划" <ArticleCard
level="初学者" key={item.key}
progress={0} id={item.id}
title={item.title}
coverImage={item.coverImage}
publishedAt={item.publishedAt}
readCount={item.readCount}
/> />
{/* 原“每周打卡”改为进入打卡日历 */} );
<Pressable onPress={() => router.push('/checkin/calendar')}> }
const card = (
<PlanCard <PlanCard
image={'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/Image30play@2x.png'} image={item.image}
title="打卡日历" title={item.title}
subtitle="查看每日打卡记录(点亮日期)" subtitle={item.subtitle}
progress={0.75} level={item.level}
/> progress={item.progress}
</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}
/> />
);
return item.onPress ? (
<Pressable key={item.key} onPress={item.onPress}>
{card}
</Pressable> </Pressable>
) : (
<View key={item.key}>{card}</View>
);
})}
</View> </View>
</View> </View>
@@ -299,19 +409,20 @@ const styles = StyleSheet.create({
paddingHorizontal: 24, paddingHorizontal: 24,
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',
flexWrap: 'wrap',
}, },
featureCard: { featureCard: {
width: '48%', width: '48%',
borderRadius: 16, borderRadius: 12,
padding: 16, padding: 12,
backgroundColor: '#FFFFFF', backgroundColor: '#FFFFFF',
// iOS shadow marginBottom: 12,
// 轻量阴影,减少臃肿感
shadowColor: '#000', shadowColor: '#000',
shadowOpacity: 0.08, shadowOpacity: 0.04,
shadowRadius: 12, shadowRadius: 8,
shadowOffset: { width: 0, height: 6 }, shadowOffset: { width: 0, height: 4 },
// Android shadow elevation: 2,
elevation: 4,
}, },
featureCardPrimary: { featureCardPrimary: {
backgroundColor: '#EEF2FF', // 柔和的靛蓝背景 backgroundColor: '#EEF2FF', // 柔和的靛蓝背景
@@ -319,33 +430,26 @@ const styles = StyleSheet.create({
featureCardSecondary: { featureCardSecondary: {
backgroundColor: '#F0FDFA', // 柔和的青绿背景 backgroundColor: '#F0FDFA', // 柔和的青绿背景
}, },
featureCardTertiary: {
backgroundColor: '#FFF7ED', // 柔和的橙色背景
},
featureCardQuaternary: {
backgroundColor: '#F5F3FF', // 柔和的紫色背景
},
featureIcon: { featureIcon: {
fontSize: 28, fontSize: 28,
marginBottom: 8, marginBottom: 8,
}, },
featureTitle: { featureTitle: {
fontSize: 18, fontSize: 16,
fontWeight: '700', fontWeight: '700',
color: '#0F172A', color: '#0F172A',
marginBottom: 6, marginBottom: 4,
}, },
featureSubtitle: { featureSubtitle: {
fontSize: 12, fontSize: 11,
color: '#6B7280', color: '#6B7280',
lineHeight: 16, lineHeight: 15,
marginBottom: 12,
},
featureCta: {
alignSelf: 'flex-start',
backgroundColor: '#0F172A',
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 999,
},
featureCtaText: {
color: '#FFFFFF',
fontSize: 12,
fontWeight: '600',
}, },
planList: { planList: {
paddingHorizontal: 24, paddingHorizontal: 24,

View File

@@ -49,6 +49,7 @@ export default function RootLayout() {
<Stack.Screen name="auth/login" options={{ headerShown: false }} /> <Stack.Screen name="auth/login" options={{ headerShown: false }} />
<Stack.Screen name="legal/user-agreement" options={{ headerShown: true, title: '用户协议' }} /> <Stack.Screen name="legal/user-agreement" options={{ headerShown: true, title: '用户协议' }} />
<Stack.Screen name="legal/privacy-policy" 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="+not-found" /> <Stack.Screen name="+not-found" />
</Stack> </Stack>
<StatusBar style="auto" /> <StatusBar style="auto" />

View File

@@ -1,5 +1,6 @@
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { BlurView } from 'expo-blur'; import { BlurView } from 'expo-blur';
import * as ImagePicker from 'expo-image-picker';
import { useLocalSearchParams, useRouter } from 'expo-router'; import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
@@ -7,7 +8,7 @@ import {
Alert, Alert,
FlatList, FlatList,
Image, Image,
KeyboardAvoidingView, Keyboard,
Modal, Modal,
Platform, Platform,
ScrollView, ScrollView,
@@ -17,17 +18,22 @@ import {
TouchableOpacity, TouchableOpacity,
View, View,
} from 'react-native'; } from 'react-native';
import Markdown from 'react-native-markdown-display';
import Animated, { FadeInDown, FadeInUp, Layout } from 'react-native-reanimated'; import Animated, { FadeInDown, FadeInUp, Layout } from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { HeaderBar } from '@/components/ui/HeaderBar'; import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useAppSelector } from '@/hooks/redux'; import { buildCosKey, buildPublicUrl } from '@/constants/Cos';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { deleteConversation, getConversationDetail, listConversations, type AiConversationListItem } from '@/services/aiCoach'; import { deleteConversation, getConversationDetail, listConversations, type AiConversationListItem } from '@/services/aiCoach';
import { loadAiCoachSessionCache, saveAiCoachSessionCache } from '@/services/aiCoachSession'; import { loadAiCoachSessionCache, saveAiCoachSessionCache } from '@/services/aiCoachSession';
import { api, getAuthToken, postTextStream } from '@/services/api'; import { api, getAuthToken, postTextStream } from '@/services/api';
import { uploadWithRetry } from '@/services/cos';
import { updateUser as updateUserApi } from '@/services/users';
import type { CheckinRecord } from '@/store/checkinSlice'; import type { CheckinRecord } from '@/store/checkinSlice';
import { fetchMyProfile, updateProfile } from '@/store/userSlice';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
type Role = 'user' | 'assistant'; type Role = 'user' | 'assistant';
@@ -67,14 +73,30 @@ export default function AICoachChatScreen() {
const didInitialScrollRef = useRef(false); const didInitialScrollRef = useRef(false);
const [composerHeight, setComposerHeight] = useState<number>(80); const [composerHeight, setComposerHeight] = useState<number>(80);
const shouldAutoScrollRef = useRef(false); const shouldAutoScrollRef = useRef(false);
const [keyboardOffset, setKeyboardOffset] = useState(0);
const pendingAssistantIdRef = useRef<string | null>(null);
const [selectedImages, setSelectedImages] = useState<Array<{
id: string;
localUri: string;
width?: number;
height?: number;
progress: number;
uploadedKey?: string;
uploadedUrl?: string;
error?: string;
}>>([]);
const [previewImageUri, setPreviewImageUri] = useState<string | null>(null);
const planDraft = useAppSelector((s) => s.trainingPlan?.draft); const planDraft = useAppSelector((s) => s.trainingPlan?.draft);
const checkin = useAppSelector((s) => (s as any).checkin); const checkin = useAppSelector((s) => (s as any).checkin);
const dispatch = useAppDispatch();
const userProfile = useAppSelector((s) => (s as any)?.user?.profile);
const chips = useMemo(() => [ const chips = useMemo(() => [
{ key: 'posture', label: '体态评估', action: () => router.push('/ai-posture-assessment') }, { key: 'posture', label: '体态评估', action: () => router.push('/ai-posture-assessment') },
{ key: 'plan', label: 'AI制定训练计划', action: () => handleQuickPlan() }, { key: 'plan', label: 'AI制定训练计划', action: () => handleQuickPlan() },
{ key: 'analyze', label: '分析运动记录', action: () => handleAnalyzeRecords() }, { key: 'analyze', label: '分析运动记录', action: () => handleAnalyzeRecords() },
{ key: 'weight', label: '记体重', action: () => insertWeightInputCard() },
], [router, planDraft, checkin]); ], [router, planDraft, checkin]);
const scrollToEnd = useCallback(() => { const scrollToEnd = useCallback(() => {
@@ -132,6 +154,29 @@ export default function AICoachChatScreen() {
} }
}, [composerHeight, isAtBottom, scrollToEnd]); }, [composerHeight, isAtBottom, scrollToEnd]);
// 键盘事件:在键盘弹出时,将输入区与悬浮按钮一起上移,避免遮挡
useEffect(() => {
let showSub: any = null;
let hideSub: any = null;
if (Platform.OS === 'ios') {
showSub = Keyboard.addListener('keyboardWillChangeFrame', (e: any) => {
try {
const height = Math.max(0, (e.endCoordinates?.height ?? 0) - insets.bottom);
setKeyboardOffset(height);
} catch { setKeyboardOffset(0); }
});
} else {
showSub = Keyboard.addListener('keyboardDidShow', (e: any) => {
try { setKeyboardOffset(Math.max(0, e.endCoordinates?.height ?? 0)); } catch { setKeyboardOffset(0); }
});
hideSub = Keyboard.addListener('keyboardDidHide', () => setKeyboardOffset(0));
}
return () => {
try { showSub?.remove?.(); } catch { }
try { hideSub?.remove?.(); } catch { }
};
}, [insets.bottom]);
const streamAbortRef = useRef<{ abort: () => void } | null>(null); const streamAbortRef = useRef<{ abort: () => void } | null>(null);
useEffect(() => { useEffect(() => {
@@ -246,6 +291,7 @@ export default function AICoachChatScreen() {
const userMsg: ChatMessage = { id: userMsgId, role: 'user', content: text }; const userMsg: ChatMessage = { id: userMsgId, role: 'user', content: text };
shouldAutoScrollRef.current = isAtBottom; shouldAutoScrollRef.current = isAtBottom;
setMessages((m) => [...m, userMsg, { id: assistantId, role: 'assistant', content: '' }]); setMessages((m) => [...m, userMsg, { id: assistantId, role: 'assistant', content: '' }]);
pendingAssistantIdRef.current = assistantId;
setIsSending(true); setIsSending(true);
setIsStreaming(true); setIsStreaming(true);
@@ -281,6 +327,7 @@ export default function AICoachChatScreen() {
setIsStreaming(false); setIsStreaming(false);
streamAbortRef.current = null; streamAbortRef.current = null;
if (cidFromHeader && !conversationId) setConversationId(cidFromHeader); if (cidFromHeader && !conversationId) setConversationId(cidFromHeader);
pendingAssistantIdRef.current = null;
try { console.log('[AI_CHAT][api] end', { cidFromHeader, hadChunks: receivedAnyChunk }); } catch { } try { console.log('[AI_CHAT][api] end', { cidFromHeader, hadChunks: receivedAnyChunk }); } catch { }
}; };
@@ -289,6 +336,7 @@ export default function AICoachChatScreen() {
setIsSending(false); setIsSending(false);
setIsStreaming(false); setIsStreaming(false);
streamAbortRef.current = null; streamAbortRef.current = null;
pendingAssistantIdRef.current = null;
// 流式失败时的降级:尝试一次性非流式 // 流式失败时的降级:尝试一次性非流式
try { try {
const bodyNoStream = { ...body, stream: false }; const bodyNoStream = { ...body, stream: false };
@@ -314,10 +362,59 @@ export default function AICoachChatScreen() {
} }
async function send(text: string) { async function send(text: string) {
if (!text.trim() || isSending) return; if (isSending) return;
const trimmed = text.trim(); const trimmed = text.trim();
if (!trimmed && selectedImages.length === 0) return;
async function ensureImagesUploaded(): Promise<string[]> {
const urls: string[] = [];
for (const img of selectedImages) {
if (img.uploadedUrl) {
urls.push(img.uploadedUrl);
continue;
}
try {
const resp = await fetch(img.localUri);
const blob = await resp.blob();
const ext = (() => {
const t = (blob.type || '').toLowerCase();
if (t.includes('png')) return 'png';
if (t.includes('webp')) return 'webp';
if (t.includes('heic')) return 'heic';
if (t.includes('heif')) return 'heif';
return 'jpg';
})();
const key = buildCosKey({ prefix: 'images/chat', ext });
const res = await uploadWithRetry({
key,
body: blob,
contentType: blob.type || 'image/jpeg',
onProgress: ({ percent }: { percent?: number }) => {
const p = typeof percent === 'number' ? percent : 0;
setSelectedImages((prev) => prev.map((it) => it.id === img.id ? { ...it, progress: p } : it));
},
} as any);
const url = buildPublicUrl(res.key);
urls.push(url);
setSelectedImages((prev) => prev.map((it) => it.id === img.id ? { ...it, uploadedKey: res.key, uploadedUrl: url, progress: 1 } : it));
} catch (e: any) {
setSelectedImages((prev) => prev.map((it) => it.id === img.id ? { ...it, error: e?.message || '上传失败' } : it));
throw e;
}
}
return urls;
}
try {
const urls = await ensureImagesUploaded();
const mdImages = urls.map((u) => `![image](${u})`).join('\n\n');
const composed = [trimmed, mdImages].filter(Boolean).join('\n\n');
setInput(''); setInput('');
await sendStream(trimmed); setSelectedImages([]);
await sendStream(composed);
} catch (e: any) {
Alert.alert('上传失败', e?.message || '图片上传失败,请稍后重试');
}
} }
function handleQuickPlan() { function handleQuickPlan() {
@@ -378,6 +475,37 @@ export default function AICoachChatScreen() {
send(prompt); send(prompt);
} }
const pickImages = useCallback(async () => {
try {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsMultipleSelection: true,
selectionLimit: 4,
quality: 0.9,
} as any);
if ((result as any).canceled) return;
const assets = (result as any).assets || [];
const next = assets.map((a: any) => ({
id: `${a.assetId || a.fileName || a.uri}_${Math.random().toString(36).slice(2, 8)}`,
localUri: a.uri,
width: a.width,
height: a.height,
progress: 0,
}));
setSelectedImages((prev) => {
const merged = [...prev, ...next];
return merged.slice(0, 4);
});
setTimeout(scrollToEnd, 0);
} catch (e: any) {
Alert.alert('错误', e?.message || '选择图片失败');
}
}, [scrollToEnd]);
const removeSelectedImage = useCallback((id: string) => {
setSelectedImages((prev) => prev.filter((it) => it.id !== id));
}, []);
function renderItem({ item }: { item: ChatMessage }) { function renderItem({ item }: { item: ChatMessage }) {
const isUser = item.role === 'user'; const isUser = item.role === 'user';
return ( return (
@@ -399,12 +527,88 @@ export default function AICoachChatScreen() {
}, },
]} ]}
> >
<Text style={[styles.bubbleText, { color: isUser ? theme.onPrimary : '#192126' }]}>{item.content}</Text> {renderBubbleContent(item)}
</View> </View>
{false}
</Animated.View> </Animated.View>
); );
} }
function renderBubbleContent(item: ChatMessage) {
if (!item.content?.trim() && isStreaming && pendingAssistantIdRef.current === item.id) {
return <Text style={[styles.bubbleText, { color: '#687076' }]}></Text>;
}
if (item.content?.startsWith('__WEIGHT_INPUT_CARD__')) {
const preset = (() => {
const m = item.content.split('\n')?.[1];
const v = parseFloat(m || '');
return isNaN(v) ? '' : String(v);
})();
return (
<View style={{ gap: 8 }}>
<Text style={[styles.bubbleText, { color: '#192126', fontWeight: '700' }]}></Text>
<View style={styles.weightRow}>
<TextInput
placeholder="例如 60.5"
keyboardType="decimal-pad"
defaultValue={preset}
placeholderTextColor={'#687076'}
style={styles.weightInput}
onSubmitEditing={(e) => handleSubmitWeight(e.nativeEvent.text)}
returnKeyType="done"
blurOnSubmit
/>
<Text style={styles.weightUnit}>kg</Text>
<TouchableOpacity accessibilityRole="button" style={styles.weightSaveBtn} onPress={() => handleSubmitWeight((preset || '').toString())}>
<Text style={{ color: '#192126', fontWeight: '700' }}></Text>
</TouchableOpacity>
</View>
<Text style={{ color: '#687076', fontSize: 12 }}></Text>
</View>
);
}
return (
<Markdown style={markdownStyles} mergeStyle>
{item.content}
</Markdown>
);
}
function insertWeightInputCard() {
const id = `wcard_${Date.now()}`;
const preset = userProfile?.weight ? Number(userProfile.weight) : undefined;
const payload = `__WEIGHT_INPUT_CARD__\n${preset ?? ''}`;
setMessages((prev) => [...prev, { id, role: 'assistant', content: payload }]);
setTimeout(scrollToEnd, 0);
}
async function handleSubmitWeight(text?: string) {
const val = parseFloat(String(text ?? '').trim());
if (isNaN(val) || val <= 0 || val > 500) {
Alert.alert('请输入有效体重', '请填写合理的公斤数,例如 60.5');
return;
}
try {
// 本地更新
dispatch(updateProfile({ weight: val }));
// 后端同步(若有 userId 则更稳妥;后端实现容错)
try {
const userId = (userProfile as any)?.userId || (userProfile as any)?.id || (userProfile as any)?._id;
if (userId) {
await updateUserApi({ userId, weight: val });
await dispatch(fetchMyProfile() as any);
}
} catch (e) {
// 不阻断对话体验
}
// 在对话中插入“确认消息”并发送给教练
const textMsg = `我记录了今日体重:${val} kg。请基于这一变化给出训练/营养建议。`;
await send(textMsg);
} catch (e: any) {
Alert.alert('保存失败', e?.message || '请稍后重试');
}
}
return ( return (
<View style={[styles.screen, { backgroundColor: theme.background }]}> <View style={[styles.screen, { backgroundColor: theme.background }]}>
<HeaderBar <HeaderBar
@@ -434,7 +638,7 @@ export default function AICoachChatScreen() {
}} }}
contentContainerStyle={{ paddingHorizontal: 14, paddingTop: 8 }} contentContainerStyle={{ paddingHorizontal: 14, paddingTop: 8 }}
ListFooterComponent={() => ( ListFooterComponent={() => (
<View style={{ height: insets.bottom + composerHeight + (isAtBottom ? 0 : 56) + 16 }} /> <View style={{ height: insets.bottom + keyboardOffset + composerHeight + (isAtBottom ? 0 : 56) + 16 }} />
)} )}
onContentSizeChange={() => { onContentSizeChange={() => {
// 首次内容变化强制滚底,其余仅在接近底部时滚动 // 首次内容变化强制滚底,其余仅在接近底部时滚动
@@ -454,25 +658,63 @@ export default function AICoachChatScreen() {
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
/> />
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'} keyboardVerticalOffset={insets.top}>
<BlurView <BlurView
intensity={18} intensity={18}
tint={'light'} tint={'light'}
style={[styles.composerWrap, { paddingBottom: insets.bottom + 10 }]} style={[styles.composerWrap, { paddingBottom: insets.bottom + 10, bottom: keyboardOffset }]}
onLayout={(e) => { onLayout={(e) => {
const h = e.nativeEvent.layout.height; const h = e.nativeEvent.layout.height;
if (h && Math.abs(h - composerHeight) > 0.5) setComposerHeight(h); if (h && Math.abs(h - composerHeight) > 0.5) setComposerHeight(h);
}} }}
> >
<View style={styles.chipsRow}> <ScrollView
horizontal
showsHorizontalScrollIndicator={false}
decelerationRate="fast"
snapToAlignment="start"
style={styles.chipsRowScroll}
contentContainerStyle={{ paddingHorizontal: 6, gap: 8 }}
>
{chips.map((c) => ( {chips.map((c) => (
<TouchableOpacity key={c.key} style={[styles.chip, { borderColor: 'rgba(187,242,70,0.35)', backgroundColor: 'rgba(187,242,70,0.12)' }]} onPress={c.action}> <TouchableOpacity key={c.key} style={[styles.chip, { borderColor: 'rgba(187,242,70,0.35)', backgroundColor: 'rgba(187,242,70,0.12)' }]} onPress={c.action}>
<Text style={[styles.chipText, { color: '#192126' }]}>{c.label}</Text> <Text style={[styles.chipText, { color: '#192126' }]}>{c.label}</Text>
</TouchableOpacity> </TouchableOpacity>
))} ))}
</ScrollView>
{!!selectedImages.length && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.imagesRow}
contentContainerStyle={{ paddingHorizontal: 6, gap: 8 }}
>
{selectedImages.map((img) => (
<View key={img.id} style={styles.imageThumbWrap}>
<TouchableOpacity accessibilityRole="imagebutton" onPress={() => setPreviewImageUri(img.uploadedUrl || img.localUri)}>
<Image source={{ uri: img.uploadedUrl || img.localUri }} style={styles.imageThumb} />
</TouchableOpacity>
{!!(img.progress > 0 && img.progress < 1) && (
<View style={styles.imageProgressOverlay}>
<Text style={styles.imageProgressText}>{Math.round((img.progress || 0) * 100)}%</Text>
</View> </View>
)}
<TouchableOpacity accessibilityRole="button" onPress={() => removeSelectedImage(img.id)} style={styles.imageRemoveBtn}>
<Ionicons name="close" size={12} color="#fff" />
</TouchableOpacity>
</View>
))}
</ScrollView>
)}
<View style={[styles.inputRow, { borderColor: 'rgba(187,242,70,0.35)', backgroundColor: 'rgba(187,242,70,0.08)' }]}> <View style={[styles.inputRow, { borderColor: 'rgba(187,242,70,0.35)', backgroundColor: 'rgba(187,242,70,0.08)' }]}>
<TouchableOpacity
accessibilityRole="button"
onPress={pickImages}
style={[styles.mediaBtn, { backgroundColor: 'rgba(187,242,70,0.16)' }]}
>
<Ionicons name="image-outline" size={18} color={'#192126'} />
</TouchableOpacity>
<TextInput <TextInput
placeholder="问我任何与普拉提相关的问题..." placeholder="问我任何与普拉提相关的问题..."
placeholderTextColor={theme.textMuted} placeholderTextColor={theme.textMuted}
@@ -485,11 +727,11 @@ export default function AICoachChatScreen() {
/> />
<TouchableOpacity <TouchableOpacity
accessibilityRole="button" accessibilityRole="button"
disabled={!input.trim() || isSending} disabled={(!input.trim() && selectedImages.length === 0) || isSending}
onPress={() => send(input)} onPress={() => send(input)}
style={[ style={[
styles.sendBtn, styles.sendBtn,
{ backgroundColor: theme.primary, opacity: input.trim() && !isSending ? 1 : 0.5 } { backgroundColor: theme.primary, opacity: (input.trim() || selectedImages.length > 0) && !isSending ? 1 : 0.5 }
]} ]}
> >
{isSending ? ( {isSending ? (
@@ -500,7 +742,6 @@ export default function AICoachChatScreen() {
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</BlurView> </BlurView>
</KeyboardAvoidingView>
{!isAtBottom && ( {!isAtBottom && (
<TouchableOpacity <TouchableOpacity
@@ -558,6 +799,15 @@ export default function AICoachChatScreen() {
</View> </View>
</TouchableOpacity> </TouchableOpacity>
</Modal> </Modal>
<Modal transparent visible={!!previewImageUri} animationType="fade" onRequestClose={() => setPreviewImageUri(null)}>
<TouchableOpacity activeOpacity={1} style={styles.previewBackdrop} onPress={() => setPreviewImageUri(null)}>
<View style={styles.previewBox}>
{previewImageUri ? (
<Image source={{ uri: previewImageUri }} style={styles.previewImage} resizeMode="contain" />
) : null}
</View>
</TouchableOpacity>
</Modal>
</View> </View>
); );
} }
@@ -613,6 +863,34 @@ const styles = StyleSheet.create({
fontSize: 15, fontSize: 15,
lineHeight: 22, lineHeight: 22,
}, },
weightRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
weightInput: {
flex: 1,
height: 36,
borderWidth: 1,
borderColor: 'rgba(0,0,0,0.08)',
borderRadius: 8,
paddingHorizontal: 10,
backgroundColor: 'rgba(255,255,255,0.9)',
color: '#192126',
},
weightUnit: {
color: '#192126',
fontWeight: '700',
},
weightSaveBtn: {
height: 36,
paddingHorizontal: 12,
borderRadius: 8,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(187,242,70,0.6)'
},
// markdown 基础样式承载容器的字体尺寸保持与气泡一致
composerWrap: { composerWrap: {
position: 'absolute', position: 'absolute',
left: 0, left: 0,
@@ -624,11 +902,13 @@ const styles = StyleSheet.create({
}, },
chipsRow: { chipsRow: {
flexDirection: 'row', flexDirection: 'row',
flexWrap: 'wrap',
gap: 8, gap: 8,
paddingHorizontal: 6, paddingHorizontal: 6,
marginBottom: 8, marginBottom: 8,
}, },
chipsRowScroll: {
marginBottom: 8,
},
chip: { chip: {
paddingHorizontal: 10, paddingHorizontal: 10,
height: 34, height: 34,
@@ -642,6 +922,47 @@ const styles = StyleSheet.create({
fontSize: 13, fontSize: 13,
fontWeight: '600', fontWeight: '600',
}, },
imagesRow: {
maxHeight: 92,
marginBottom: 8,
},
imageThumbWrap: {
width: 72,
height: 72,
borderRadius: 12,
overflow: 'hidden',
position: 'relative',
backgroundColor: 'rgba(0,0,0,0.06)'
},
imageThumb: {
width: '100%',
height: '100%'
},
imageRemoveBtn: {
position: 'absolute',
right: 4,
top: 4,
width: 20,
height: 20,
borderRadius: 10,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0,0,0,0.45)'
},
imageProgressOverlay: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.35)',
alignItems: 'center',
justifyContent: 'center',
},
imageProgressText: {
color: '#fff',
fontWeight: '700'
},
inputRow: { inputRow: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
@@ -650,6 +971,14 @@ const styles = StyleSheet.create({
borderRadius: 16, borderRadius: 16,
backgroundColor: 'rgba(0,0,0,0.04)' backgroundColor: 'rgba(0,0,0,0.04)'
}, },
mediaBtn: {
width: 40,
height: 40,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
marginRight: 6,
},
input: { input: {
flex: 1, flex: 1,
fontSize: 15, fontSize: 15,
@@ -748,6 +1077,66 @@ const styles = StyleSheet.create({
borderRadius: 10, borderRadius: 10,
backgroundColor: 'rgba(0,0,0,0.06)' backgroundColor: 'rgba(0,0,0,0.06)'
}, },
previewBackdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.85)',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
},
previewBox: {
width: '100%',
height: '80%',
borderRadius: 12,
overflow: 'hidden',
},
previewImage: {
width: '100%',
height: '100%',
},
}); });
const markdownStyles = {
body: {
color: '#192126',
fontSize: 15,
lineHeight: 22,
},
paragraph: {
marginTop: 2,
marginBottom: 2,
},
bullet_list: {
marginVertical: 4,
},
ordered_list: {
marginVertical: 4,
},
list_item: {
flexDirection: 'row',
},
code_inline: {
backgroundColor: 'rgba(0,0,0,0.06)',
borderRadius: 4,
paddingHorizontal: 4,
paddingVertical: 2,
},
code_block: {
backgroundColor: 'rgba(0,0,0,0.06)',
borderRadius: 8,
paddingHorizontal: 8,
paddingVertical: 6,
},
fence: {
backgroundColor: 'rgba(0,0,0,0.06)',
borderRadius: 8,
paddingHorizontal: 8,
paddingVertical: 6,
},
heading1: { fontSize: 20, fontWeight: '800', marginVertical: 6 },
heading2: { fontSize: 18, fontWeight: '800', marginVertical: 6 },
heading3: { fontSize: 16, fontWeight: '800', marginVertical: 6 },
link: { color: '#246BFD' },
} as const;

126
app/article/[id].tsx Normal file
View File

@@ -0,0 +1,126 @@
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
import { Article, getArticleById } from '@/services/articles';
import dayjs from 'dayjs';
import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useEffect, useState } from 'react';
import { ScrollView, StyleSheet, Text, View, useWindowDimensions } from 'react-native';
import RenderHTML from 'react-native-render-html';
export default function ArticleDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const [article, setArticle] = useState<Article | undefined>(undefined);
const { width } = useWindowDimensions();
const colorScheme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const theme = Colors[colorScheme];
useEffect(() => {
if (id) {
getArticleById(id).then((article) => {
console.log('article', article);
setArticle(article);
});
}
}, [id]);
if (!article) {
return (
<View style={{ flex: 1 }}>
<HeaderBar title="文章" onBack={() => router.back()} showBottomBorder />
<View style={{ padding: 24 }}>
</View>
</View>
);
}
const source = { html: wrapHtml(article.htmlContent) };
return (
<View style={{ flex: 1, backgroundColor: theme.surface }}>
<HeaderBar title="文章" onBack={() => router.back()} showBottomBorder />
<ScrollView contentContainerStyle={styles.contentContainer} showsVerticalScrollIndicator={false}>
<View style={styles.headerMeta}>
<Text style={[styles.title, { color: theme.text }]}>{article.title}</Text>
<View style={styles.row}>
<Text style={[styles.metaText, { color: theme.textMuted }]}>{dayjs(article.publishedAt).format('YYYY-MM-DD')}</Text>
<Text style={[styles.metaText, styles.dot]}>·</Text>
<Text style={[styles.metaText, { color: theme.textMuted }]}>{article.readCount} </Text>
</View>
</View>
<RenderHTML
contentWidth={width - 48}
source={source}
baseStyle={{ ...htmlBaseStyles, color: theme.text }}
tagsStyles={htmlTagStyles}
enableExperimentalMarginCollapsing={true}
/>
<View style={{ height: 36 }} />
</ScrollView>
</View>
);
}
function wrapHtml(inner: string) {
// 为了统一排版与图片自适应
return `
<div class="article">
${inner}
</div>
<style>
.article img { max-width: 100%; height: auto; border-radius: 12px; }
</style>
`;
}
const styles = StyleSheet.create({
contentContainer: {
paddingHorizontal: 24,
paddingTop: 12,
},
headerMeta: {
marginBottom: 12,
},
title: {
fontSize: 22,
fontWeight: '800',
color: '#192126',
},
row: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 8,
},
metaText: {
fontSize: 12,
color: '#8A8A8E',
},
dot: {
paddingHorizontal: 6,
},
});
const htmlBaseStyles = {
color: '#192126',
lineHeight: 24,
fontSize: 16,
} as const;
const htmlTagStyles = {
h1: { fontSize: 26, fontWeight: '800', marginBottom: 8 },
h2: { fontSize: 22, fontWeight: '800', marginTop: 8, marginBottom: 8 },
h3: { fontSize: 18, fontWeight: '700', marginTop: 12, marginBottom: 6 },
p: { marginBottom: 12 },
ol: { marginBottom: 12, paddingLeft: 18 },
ul: { marginBottom: 12, paddingLeft: 18 },
li: { marginBottom: 6 },
img: { marginTop: 8, marginBottom: 8, borderRadius: 12 },
em: { fontStyle: 'italic' },
strong: { fontWeight: '800' },
} as const;

View File

@@ -3,7 +3,7 @@ import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { DailyStatusItem, fetchDailyStatusRange } from '@/services/checkins'; import { DailyStatusItem, fetchDailyStatusRange } from '@/services/checkins';
import { getDailyCheckins, loadMonthCheckins, setCurrentDate } from '@/store/checkinSlice'; import { loadMonthCheckins } from '@/store/checkinSlice';
import { getMonthDaysZh } from '@/utils/date'; import { getMonthDaysZh } from '@/utils/date';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
@@ -76,9 +76,8 @@ export default function CheckinCalendarScreen() {
return ( return (
<TouchableOpacity <TouchableOpacity
onPress={async () => { onPress={async () => {
dispatch(setCurrentDate(dateStr)); // 通过路由参数传入日期,便于目标页初始化
await dispatch(getDailyCheckins(dateStr)); router.push({ pathname: '/checkin', params: { date: dateStr } });
router.push('/checkin');
}} }}
activeOpacity={0.8} activeOpacity={0.8}
style={[styles.dayCell, { backgroundColor: colorTokens.card }, hasAny && styles.dayCellCompleted, isToday && styles.dayCellToday]} style={[styles.dayCell, { backgroundColor: colorTokens.card }, hasAny && styles.dayCellCompleted, isToday && styles.dayCellToday]}

View File

@@ -3,12 +3,13 @@ import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import type { CheckinExercise } from '@/store/checkinSlice'; import type { CheckinExercise } from '@/store/checkinSlice';
import { getDailyCheckins, loadMonthCheckins, removeExercise, setCurrentDate, syncCheckin, toggleExerciseCompleted } from '@/store/checkinSlice'; import { getDailyCheckins, removeExercise, replaceExercises, setCurrentDate, syncCheckin, toggleExerciseCompleted } from '@/store/checkinSlice';
import { buildClassicalSession } from '@/utils/classicalSession';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useFocusEffect } from '@react-navigation/native'; import { useFocusEffect } from '@react-navigation/native';
import { useRouter } from 'expo-router'; import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useEffect, useMemo } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Alert, FlatList, SafeAreaView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { Alert, FlatList, Modal, SafeAreaView, StyleSheet, Switch, Text, TextInput, TouchableOpacity, View } from 'react-native';
function formatDate(d: Date) { function formatDate(d: Date) {
const y = d.getFullYear(); const y = d.getFullYear();
@@ -20,33 +21,65 @@ function formatDate(d: Date) {
export default function CheckinHome() { export default function CheckinHome() {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const router = useRouter(); const router = useRouter();
const params = useLocalSearchParams<{ date?: string }>();
const today = useMemo(() => formatDate(new Date()), []); const today = useMemo(() => formatDate(new Date()), []);
const checkin = useAppSelector((s) => (s as any).checkin); const checkin = useAppSelector((s) => (s as any).checkin);
const record = checkin?.byDate?.[today]; const routeDateParam = typeof params?.date === 'string' && params.date ? params.date : undefined;
const currentDate: string = routeDateParam || (checkin?.currentDate as string) || today;
const record = checkin?.byDate?.[currentDate] as (undefined | { items?: CheckinExercise[]; note?: string; raw?: any[] });
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme]; const colorTokens = Colors[theme];
console.log('CheckinHome render', {
currentDate,
routeDateParam,
itemsCount: record?.items?.length || 0,
rawCount: (record as any)?.raw?.length || 0,
});
const lastFetchedRef = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
dispatch(setCurrentDate(today)); // 初始化当前日期:路由参数优先,其次 store最后今天
// 进入页面立即从后端获取当天打卡列表,回填本地 if (currentDate && checkin?.currentDate !== currentDate) {
dispatch(getDailyCheckins(today)).unwrap().catch((err: any) => { dispatch(setCurrentDate(currentDate));
}
// 仅当切换日期时获取一次,避免重复请求
if (currentDate && lastFetchedRef.current !== currentDate) {
lastFetchedRef.current = currentDate;
dispatch(getDailyCheckins(currentDate)).unwrap().catch((err: any) => {
Alert.alert('获取打卡失败', err?.message || '请稍后重试'); Alert.alert('获取打卡失败', err?.message || '请稍后重试');
}); });
// 预取本月数据(用于日历视图点亮) }
const now = new Date(); }, [dispatch, currentDate]);
dispatch(loadMonthCheckins({ year: now.getFullYear(), month1Based: now.getMonth() + 1 }));
}, [dispatch, today]);
const lastSyncSigRef = useRef<string>('');
useFocusEffect( useFocusEffect(
React.useCallback(() => { React.useCallback(() => {
// 返回本页时确保与后端同步(若本地有内容则上报,后台 upsert // 仅当本地条目发生变更时才上报,避免反复刷写
if (record?.items && Array.isArray(record.items)) { const sig = JSON.stringify(record?.items || []);
dispatch(syncCheckin({ date: today, items: record.items as CheckinExercise[], note: record?.note })); if (record?.items && Array.isArray(record.items) && sig !== lastSyncSigRef.current) {
lastSyncSigRef.current = sig;
dispatch(syncCheckin({ date: currentDate, items: record.items as CheckinExercise[], note: record?.note }));
} }
return () => { }; return () => { };
}, [dispatch, today, record?.items]) }, [dispatch, currentDate, record?.items, record?.note])
); );
const [genVisible, setGenVisible] = useState(false);
const [genLevel, setGenLevel] = useState<'beginner' | 'intermediate' | 'advanced'>('beginner');
const [genWithRests, setGenWithRests] = useState(true);
const [genWithNotes, setGenWithNotes] = useState(true);
const [genRest, setGenRest] = useState('30');
const onGenerate = () => {
const restSec = Math.max(10, Math.min(120, parseInt(genRest || '30', 10)));
const { items, note } = buildClassicalSession({ withSectionRests: genWithRests, restSeconds: restSec, withNotes: genWithNotes, level: genLevel });
dispatch(replaceExercises({ date: currentDate, items, note }));
dispatch(syncCheckin({ date: currentDate, items, note }));
setGenVisible(false);
Alert.alert('排课已生成', '已为你生成经典普拉提序列,可继续调整。');
};
return ( return (
<SafeAreaView style={[styles.safeArea, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}> <SafeAreaView style={[styles.safeArea, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
<View style={[styles.container, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}> <View style={[styles.container, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
@@ -57,51 +90,128 @@ export default function CheckinHome() {
<HeaderBar title="每日打卡" onBack={() => router.back()} withSafeTop={false} transparent /> <HeaderBar title="每日打卡" onBack={() => router.back()} withSafeTop={false} transparent />
<View style={[styles.hero, { backgroundColor: colorTokens.heroSurfaceTint }]}> <View style={[styles.hero, { backgroundColor: colorTokens.heroSurfaceTint }]}>
<Text style={[styles.title, { color: colorTokens.text }]}>{today}</Text> <Text style={[styles.title, { color: colorTokens.text }]}>{currentDate}</Text>
<Text style={[styles.subtitle, { color: colorTokens.textMuted }]}></Text> <Text style={[styles.subtitle, { color: colorTokens.textMuted }]}></Text>
</View> </View>
<View style={styles.actionRow}> <View style={styles.actionRow}>
<TouchableOpacity style={[styles.primaryBtn, { backgroundColor: colorTokens.primary }]} onPress={() => router.push('/checkin/select')}> <TouchableOpacity
style={[styles.primaryBtn, { backgroundColor: colorTokens.primary }]}
onPress={() => router.push({ pathname: '/checkin/select', params: { date: currentDate } })}
>
<Text style={[styles.primaryBtnText, { color: colorTokens.onPrimary }]}></Text> <Text style={[styles.primaryBtnText, { color: colorTokens.onPrimary }]}></Text>
</TouchableOpacity> </TouchableOpacity>
<View style={{ height: 10 }} />
<TouchableOpacity
style={[styles.secondaryBtn, { borderColor: colorTokens.primary }]}
onPress={() => setGenVisible(true)}
>
<Text style={[styles.secondaryBtnText, { color: colorTokens.primary }]}></Text>
</TouchableOpacity>
</View> </View>
<FlatList <FlatList
data={record?.items || []} data={(record?.items && record.items.length > 0)
keyExtractor={(item) => item.key} ? record.items
: ((record as any)?.raw || [])}
keyExtractor={(item, index) => (item?.key || item?.id || `${currentDate}_${index}`)}
contentContainerStyle={{ paddingHorizontal: 20, paddingBottom: 20 }} contentContainerStyle={{ paddingHorizontal: 20, paddingBottom: 20 }}
ListEmptyComponent={ ListEmptyComponent={
<View style={[styles.emptyBox, { backgroundColor: colorTokens.card }]}> <View style={[styles.emptyBox, { backgroundColor: colorTokens.card }]}>
<Text style={[styles.emptyText, { color: colorTokens.textMuted }]}></Text> <Text style={[styles.emptyText, { color: colorTokens.textMuted }]}></Text>
</View> </View>
} }
renderItem={({ item }) => ( renderItem={({ item }) => {
// 若为后端原始项(无 key以标题/时间为卡片,禁用交互
const isRaw = !item?.key;
if (isRaw) {
const title = item?.title || '每日训练打卡';
const status = item?.status || '';
const startedAt = item?.startedAt ? new Date(item.startedAt).toLocaleString() : '';
return (
<View style={[styles.card, { backgroundColor: colorTokens.card }]}> <View style={[styles.card, { backgroundColor: colorTokens.card }]}>
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
<Text style={[styles.cardTitle, { color: colorTokens.text }]}>{item.name}</Text> <Text style={[styles.cardTitle, { color: colorTokens.text }]}>{title}</Text>
<Text style={[styles.cardMeta, { color: colorTokens.textMuted }]}>{item.category}</Text> {!!status && <Text style={[styles.cardMeta, { color: colorTokens.textMuted }]}>{status}</Text>}
<Text style={[styles.cardMeta, { color: colorTokens.textMuted }]}> {item.sets}{item.reps ? ` · 每组 ${item.reps}` : ''}{item.durationSec ? ` · 每组 ${item.durationSec}s` : ''}</Text> {!!startedAt && <Text style={[styles.cardMeta, { color: colorTokens.textMuted }]}>{startedAt}</Text>}
</View>
</View>
);
}
const exercise = item as CheckinExercise;
const type = exercise.itemType ?? 'exercise';
const isRest = type === 'rest';
const isNote = type === 'note';
const cardStyle = [styles.card, { backgroundColor: colorTokens.card }];
if (isRest || isNote) {
return (
<View style={styles.inlineRow}>
<Ionicons name={isRest ? 'time-outline' : 'information-circle-outline'} size={14} color={colorTokens.textMuted} />
<View style={[styles.inlineBadge, isRest ? styles.inlineBadgeRest : styles.inlineBadgeNote, { borderColor: colorTokens.border }]}>
<Text style={[isNote ? styles.inlineTextItalic : styles.inlineText, { color: colorTokens.textMuted }]}>
{isRest ? `间隔休息 ${exercise.restSec ?? 30}s` : (exercise.note || '提示')}
</Text>
</View> </View>
<TouchableOpacity
style={styles.inlineRemoveBtn}
onPress={() =>
Alert.alert('确认移除', '确定要移除该条目吗?', [
{ text: '取消', style: 'cancel' },
{
text: '移除',
style: 'destructive',
onPress: () => {
dispatch(removeExercise({ date: currentDate, key: exercise.key }));
const nextItems: CheckinExercise[] = (record?.items || []).filter((it: CheckinExercise) => it.key !== exercise.key);
dispatch(syncCheckin({ date: currentDate, items: nextItems, note: record?.note }));
},
},
])
}
hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
>
<Ionicons name="close-outline" size={16} color={colorTokens.textMuted} />
</TouchableOpacity>
</View>
);
}
return (
<View style={cardStyle as any}>
<View style={{ flex: 1 }}>
<Text style={[styles.cardTitle, { color: colorTokens.text }]}>{exercise.name}</Text>
<Text style={[styles.cardMeta, { color: colorTokens.textMuted }]}>{exercise.category}</Text>
{isNote && (
<Text style={[styles.cardMetaItalic, { color: colorTokens.textMuted }]}>{exercise.note || '提示'}</Text>
)}
{!isNote && (
<Text style={[styles.cardMeta, { color: colorTokens.textMuted }]}>
{isRest
? `建议休息 ${exercise.restSec ?? 30}s`
: `组数 ${exercise.sets}${exercise.reps ? ` · 每组 ${exercise.reps}` : ''}${exercise.durationSec ? ` · 每组 ${exercise.durationSec}s` : ''}`}
</Text>
)}
</View>
{type === 'exercise' && (
<TouchableOpacity <TouchableOpacity
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={item.completed ? '已完成,点击取消完成' : '未完成,点击标记完成'} accessibilityLabel={exercise.completed ? '已完成,点击取消完成' : '未完成,点击标记完成'}
style={styles.doneIconBtn} style={styles.doneIconBtn}
onPress={() => { onPress={() => {
dispatch(toggleExerciseCompleted({ date: today, key: item.key })); dispatch(toggleExerciseCompleted({ date: currentDate, key: exercise.key }));
const nextItems: CheckinExercise[] = (record?.items || []).map((it: CheckinExercise) => const nextItems: CheckinExercise[] = (record?.items || []).map((it: CheckinExercise) =>
it.key === item.key ? { ...it, completed: !it.completed } : it it.key === exercise.key ? { ...it, completed: !it.completed } : it
); );
dispatch(syncCheckin({ date: today, items: nextItems, note: record?.note })); dispatch(syncCheckin({ date: currentDate, items: nextItems, note: record?.note }));
}} }}
hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }} hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
> >
<Ionicons <Ionicons
name={item.completed ? 'checkmark-circle' : 'checkmark-circle-outline'} name={exercise.completed ? 'checkmark-circle' : 'checkmark-circle-outline'}
size={24} size={24}
color={item.completed ? colorTokens.primary : colorTokens.textMuted} color={exercise.completed ? colorTokens.primary : colorTokens.textMuted}
/> />
</TouchableOpacity> </TouchableOpacity>
)}
<TouchableOpacity <TouchableOpacity
style={[styles.removeBtn, { backgroundColor: colorTokens.border }]} style={[styles.removeBtn, { backgroundColor: colorTokens.border }]}
onPress={() => onPress={() =>
@@ -111,9 +221,9 @@ export default function CheckinHome() {
text: '移除', text: '移除',
style: 'destructive', style: 'destructive',
onPress: () => { onPress: () => {
dispatch(removeExercise({ date: today, key: item.key })); dispatch(removeExercise({ date: currentDate, key: exercise.key }));
const nextItems: CheckinExercise[] = (record?.items || []).filter((it: CheckinExercise) => it.key !== item.key); const nextItems: CheckinExercise[] = (record?.items || []).filter((it: CheckinExercise) => it.key !== exercise.key);
dispatch(syncCheckin({ date: today, items: nextItems, note: record?.note })); dispatch(syncCheckin({ date: currentDate, items: nextItems, note: record?.note }));
}, },
}, },
]) ])
@@ -122,8 +232,43 @@ export default function CheckinHome() {
<Text style={[styles.removeBtnText, { color: colorTokens.text }]}></Text> <Text style={[styles.removeBtnText, { color: colorTokens.text }]}></Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
)} );
}}
/> />
{/* 生成配置弹窗 */}
<Modal visible={genVisible} transparent animationType="fade" onRequestClose={() => setGenVisible(false)}>
<TouchableOpacity activeOpacity={1} style={styles.modalOverlay} onPress={() => setGenVisible(false)}>
<TouchableOpacity activeOpacity={1} style={[styles.modalSheet, { backgroundColor: colorTokens.card }]} onPress={(e) => e.stopPropagation() as any}>
<Text style={[styles.modalTitle, { color: colorTokens.text }]}></Text>
<Text style={[styles.modalLabel, { color: colorTokens.textMuted }]}></Text>
<View style={styles.segmentedRow}>
{(['beginner', 'intermediate', 'advanced'] as const).map((lv) => (
<TouchableOpacity key={lv} style={[styles.segment, genLevel === lv && { backgroundColor: colorTokens.primary }]} onPress={() => setGenLevel(lv)}>
<Text style={[styles.segmentText, genLevel === lv && { color: colorTokens.onPrimary }]}>
{lv === 'beginner' ? '入门' : lv === 'intermediate' ? '进阶' : '高级'}
</Text>
</TouchableOpacity>
))}
</View>
<View style={styles.switchRow}>
<Text style={[styles.switchLabel, { color: colorTokens.text }]}></Text>
<Switch value={genWithRests} onValueChange={setGenWithRests} />
</View>
<View style={styles.switchRow}>
<Text style={[styles.switchLabel, { color: colorTokens.text }]}></Text>
<Switch value={genWithNotes} onValueChange={setGenWithNotes} />
</View>
<View style={styles.inputRow}>
<Text style={[styles.switchLabel, { color: colorTokens.textMuted }]}></Text>
<TextInput value={genRest} onChangeText={setGenRest} keyboardType="number-pad" style={[styles.input, { borderColor: colorTokens.border, color: colorTokens.text }]} />
</View>
<View style={{ height: 8 }} />
<TouchableOpacity style={[styles.primaryBtn, { backgroundColor: colorTokens.primary }]} onPress={onGenerate}>
<Text style={[styles.primaryBtnText, { color: colorTokens.onPrimary }]}></Text>
</TouchableOpacity>
</TouchableOpacity>
</TouchableOpacity>
</Modal>
</View> </View>
</SafeAreaView> </SafeAreaView>
); );
@@ -145,14 +290,35 @@ const styles = StyleSheet.create({
actionRow: { paddingHorizontal: 20, marginTop: 8 }, actionRow: { paddingHorizontal: 20, marginTop: 8 },
primaryBtn: { backgroundColor: '#111827', paddingVertical: 10, borderRadius: 10, alignItems: 'center' }, primaryBtn: { backgroundColor: '#111827', paddingVertical: 10, borderRadius: 10, alignItems: 'center' },
primaryBtnText: { color: '#FFFFFF', fontWeight: '800' }, primaryBtnText: { color: '#FFFFFF', fontWeight: '800' },
secondaryBtn: { borderWidth: 2, paddingVertical: 10, borderRadius: 10, alignItems: 'center' },
secondaryBtnText: { fontWeight: '800' },
emptyBox: { marginTop: 16, backgroundColor: '#FFFFFF', borderRadius: 16, padding: 16, marginHorizontal: 0 }, emptyBox: { marginTop: 16, backgroundColor: '#FFFFFF', borderRadius: 16, padding: 16, marginHorizontal: 0 },
emptyText: { color: '#6B7280' }, emptyText: { color: '#6B7280' },
card: { marginTop: 12, marginHorizontal: 0, backgroundColor: '#FFFFFF', borderRadius: 16, padding: 16, flexDirection: 'row', alignItems: 'center', gap: 12, shadowColor: '#000', shadowOpacity: 0.06, shadowRadius: 12, shadowOffset: { width: 0, height: 6 }, elevation: 3 }, card: { marginTop: 12, marginHorizontal: 0, backgroundColor: '#FFFFFF', borderRadius: 16, padding: 16, flexDirection: 'row', alignItems: 'center', gap: 12, shadowColor: '#000', shadowOpacity: 0.06, shadowRadius: 12, shadowOffset: { width: 0, height: 6 }, elevation: 3 },
cardTitle: { fontSize: 16, fontWeight: '800', color: '#111827' }, cardTitle: { fontSize: 16, fontWeight: '800', color: '#111827' },
cardMeta: { marginTop: 4, fontSize: 12, color: '#6B7280' }, cardMeta: { marginTop: 4, fontSize: 12, color: '#6B7280' },
cardMetaItalic: { marginTop: 4, fontSize: 12, color: '#6B7280', fontStyle: 'italic' },
removeBtn: { backgroundColor: '#F3F4F6', paddingHorizontal: 10, paddingVertical: 6, borderRadius: 8 }, removeBtn: { backgroundColor: '#F3F4F6', paddingHorizontal: 10, paddingVertical: 6, borderRadius: 8 },
removeBtnText: { color: '#111827', fontWeight: '700' }, removeBtnText: { color: '#111827', fontWeight: '700' },
doneIconBtn: { paddingHorizontal: 4, paddingVertical: 4, borderRadius: 16, marginRight: 8 }, doneIconBtn: { paddingHorizontal: 4, paddingVertical: 4, borderRadius: 16, marginRight: 8 },
inlineRow: { marginTop: 10, marginHorizontal: 20, flexDirection: 'row', alignItems: 'center' },
inlineBadge: { marginLeft: 6, borderWidth: 1, borderRadius: 999, paddingVertical: 6, paddingHorizontal: 10 },
inlineBadgeRest: { backgroundColor: '#F8FAFC' },
inlineBadgeNote: { backgroundColor: '#F9FAFB' },
inlineText: { fontSize: 12, fontWeight: '700' },
inlineTextItalic: { fontSize: 12, fontStyle: 'italic' },
inlineRemoveBtn: { marginLeft: 6, padding: 4, borderRadius: 999 },
modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.35)', alignItems: 'center', justifyContent: 'flex-end' },
modalSheet: { width: '100%', borderTopLeftRadius: 16, borderTopRightRadius: 16, paddingHorizontal: 16, paddingTop: 14, paddingBottom: 24 },
modalTitle: { fontSize: 16, fontWeight: '800', marginBottom: 8 },
modalLabel: { fontSize: 12, marginBottom: 6 },
segmentedRow: { flexDirection: 'row', gap: 8, marginBottom: 8 },
segment: { flex: 1, borderRadius: 999, borderWidth: 1, borderColor: '#E5E7EB', paddingVertical: 8, alignItems: 'center' },
segmentText: { fontWeight: '700' },
switchRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginTop: 8 },
switchLabel: { fontWeight: '700' },
inputRow: { marginTop: 8 },
input: { height: 40, borderWidth: 1, borderRadius: 10, paddingHorizontal: 12 },
}); });

View File

@@ -6,7 +6,7 @@ import { addExercise, syncCheckin } from '@/store/checkinSlice';
import { EXERCISE_LIBRARY, getCategories, searchExercises } from '@/utils/exerciseLibrary'; import { EXERCISE_LIBRARY, getCategories, searchExercises } from '@/utils/exerciseLibrary';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import * as Haptics from 'expo-haptics'; import * as Haptics from 'expo-haptics';
import { useRouter } from 'expo-router'; import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useEffect, useMemo, useRef, useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Animated, FlatList, LayoutAnimation, Modal, Platform, SafeAreaView, StyleSheet, Text, TextInput, TouchableOpacity, UIManager, View } from 'react-native'; import { Animated, FlatList, LayoutAnimation, Modal, Platform, SafeAreaView, StyleSheet, Text, TextInput, TouchableOpacity, UIManager, View } from 'react-native';
@@ -20,7 +20,9 @@ function formatDate(d: Date) {
export default function SelectExerciseScreen() { export default function SelectExerciseScreen() {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const router = useRouter(); const router = useRouter();
const params = useLocalSearchParams<{ date?: string }>();
const today = useMemo(() => formatDate(new Date()), []); const today = useMemo(() => formatDate(new Date()), []);
const currentDate = (typeof params?.date === 'string' && params.date) ? params.date : today;
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme]; const colorTokens = Colors[theme];
@@ -70,7 +72,7 @@ export default function SelectExerciseScreen() {
const handleAdd = () => { const handleAdd = () => {
if (!selected) return; if (!selected) return;
dispatch(addExercise({ dispatch(addExercise({
date: today, date: currentDate,
item: { item: {
key: selected.key, key: selected.key,
name: selected.name, name: selected.name,
@@ -79,11 +81,11 @@ export default function SelectExerciseScreen() {
reps: reps && reps > 0 ? reps : undefined, reps: reps && reps > 0 ? reps : undefined,
}, },
})); }));
console.log('addExercise', today, selected.key, sets, reps); console.log('addExercise', currentDate, selected.key, sets, reps);
// 同步到后端(读取最新 store 需要在返回后由首页触发 load或此处直接上报 // 同步到后端(读取最新 store 需要在返回后由首页触发 load或此处直接上报
// 简单做法:直接上报新增项(其余项由后端合并/覆盖) // 简单做法:直接上报新增项(其余项由后端合并/覆盖)
dispatch(syncCheckin({ dispatch(syncCheckin({
date: today, date: currentDate,
items: [ items: [
{ {
key: selected.key, key: selected.key,

View File

@@ -218,7 +218,7 @@ export default function EditProfileScreen() {
allowsEditing: true, allowsEditing: true,
quality: 0.9, quality: 0.9,
aspect: [1, 1], aspect: [1, 1],
mediaTypes: ImagePicker.MediaTypeOptions.Images, mediaTypes: ['images'],
base64: false, base64: false,
}); });
if (!result.canceled) { if (!result.canceled) {

View File

@@ -0,0 +1,73 @@
import dayjs from 'dayjs';
import { useRouter } from 'expo-router';
import React from 'react';
import { Image, Pressable, StyleSheet, Text, View } from 'react-native';
type Props = {
id: string;
title: string;
coverImage: string;
publishedAt: string; // ISO
readCount: number;
};
export function ArticleCard({ id, title, coverImage, publishedAt, readCount }: Props) {
const router = useRouter();
return (
<Pressable onPress={() => router.push(`/article/${id}`)} style={styles.card}>
<Image source={{ uri: coverImage }} style={styles.cover} />
<View style={styles.meta}>
<Text style={styles.title} numberOfLines={2}>{title}</Text>
<View style={styles.row}>
<Text style={styles.metaText}>{dayjs(publishedAt).format('YYYY-MM-DD')}</Text>
<Text style={[styles.metaText, styles.dot]}>·</Text>
<Text style={styles.metaText}>{readCount} </Text>
</View>
</View>
</Pressable>
);
}
const styles = StyleSheet.create({
card: {
flexDirection: 'row',
backgroundColor: '#FFFFFF',
borderRadius: 20,
padding: 14,
marginBottom: 14,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.06,
shadowRadius: 10,
elevation: 2,
},
cover: {
width: 92,
height: 92,
borderRadius: 16,
},
meta: {
flex: 1,
paddingLeft: 12,
justifyContent: 'center',
},
title: {
fontSize: 16,
color: '#192126',
fontWeight: '800',
},
row: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 8,
},
metaText: {
fontSize: 12,
color: '#8A8A8E',
},
dot: {
paddingHorizontal: 6,
},
});

View File

@@ -1,5 +1,5 @@
export const COS_BUCKET: string = ''; export const COS_BUCKET: string = 'plates-1251306435';
export const COS_REGION: string = ''; export const COS_REGION: string = 'ap-guangzhou';
export const COS_PUBLIC_BASE: string = ''; export const COS_PUBLIC_BASE: string = '';
// 统一的对象键前缀(可按业务拆分) // 统一的对象键前缀(可按业务拆分)
@@ -19,8 +19,16 @@ export function buildCosKey(params: { prefix?: string; ext?: string; userId?: st
} }
export function buildPublicUrl(key: string): string { export function buildPublicUrl(key: string): string {
if (!COS_PUBLIC_BASE) return ''; const cleanedKey = key.replace(/^\//, '');
return `${COS_PUBLIC_BASE.replace(/\/$/, '')}/${key.replace(/^\//, '')}`; if (COS_PUBLIC_BASE && COS_PUBLIC_BASE.trim()) {
return `${COS_PUBLIC_BASE.replace(/\/$/, '')}/${cleanedKey}`;
}
// 回退:使用 COS 默认公网域名
// 例如: https://<bucket>.cos.<region>.myqcloud.com/<key>
if (COS_BUCKET && COS_REGION) {
return `https://${COS_BUCKET}.cos.${COS_REGION}.myqcloud.com/${cleanedKey}`;
}
return '';
} }

View File

@@ -1,6 +1,7 @@
import { buildCosKey, buildPublicUrl } from '@/constants/Cos'; import { buildCosKey, buildPublicUrl } from '@/constants/Cos';
import { uploadWithRetry } from '@/services/cos'; import { uploadWithRetry } from '@/services/cos';
import { useCallback, useMemo, useRef, useState } from 'react'; import { useCallback, useMemo, useRef, useState } from 'react';
import { Platform } from 'react-native';
export type UseCosUploadOptions = { export type UseCosUploadOptions = {
prefix?: string; prefix?: string;
@@ -31,37 +32,44 @@ export function useCosUpload(defaultOptions?: UseCosUploadOptions) {
setProgress(0); setProgress(0);
setUploading(true); setUploading(true);
try { try {
let res: any;
if (Platform.OS === 'web') {
// Web使用 Blob 走 cos-js-sdk-v5 分支
let body: any = null; let body: any = null;
// 1) 直接可用类型Blob 或 string
if (typeof file === 'string') { if (typeof file === 'string') {
// 允许直接传 Base64/DataURL 字符串
body = file; body = file;
} else if (typeof Blob !== 'undefined' && file instanceof Blob) { } else if ((file as any)?.blob) {
body = file;
} else if ((file as any)?.blob && (typeof Blob === 'undefined' || (file as any).blob instanceof Blob || (file as any).blob?._data)) {
// 2) 已提供 blob 字段
body = (file as any).blob; body = (file as any).blob;
} else if ((file as any)?.buffer) {
// 3) ArrayBuffer/TypedArray -> Blob
const buffer = (file as any).buffer;
body = new Blob([buffer], { type: (file as any)?.type || finalOptions.contentType || 'application/octet-stream' });
} else if ((file as any)?.uri) { } else if ((file as any)?.uri) {
// 4) Expo ImagePicker/文件:必须先转 Blob
const resp = await fetch((file as any).uri); const resp = await fetch((file as any).uri);
body = await resp.blob(); body = await resp.blob();
} else { } else if (typeof Blob !== 'undefined' && file instanceof Blob) {
// 兜底:尝试直接作为字符串,否则抛错
if (file && (typeof file === 'object')) {
throw new Error('无效的上传体:请提供 Blob/String或包含 uri 的对象');
}
body = file; body = file;
} else {
throw new Error('无效的文件:请提供 uri 或 Blob');
} }
const res = await uploadWithRetry({ res = await uploadWithRetry({
key, key,
body, body,
contentType: finalOptions.contentType || (file as any)?.type, contentType: finalOptions.contentType || (file as any)?.type,
signal: controller.signal, signal: controller.signal,
onProgress: ({ percent }) => setProgress(percent), onProgress: ({ percent }: { percent: number }) => setProgress(percent),
}); } as any);
} else {
// 原生:直接传本地路径
const srcUri = (file as any)?.uri || (typeof file === 'string' ? file : undefined);
if (!srcUri || typeof srcUri !== 'string') {
throw new Error('请提供包含 uri 的对象,或传入本地文件路径字符串');
}
res = await uploadWithRetry({
key,
srcUri,
contentType: finalOptions.contentType || (file as any)?.type,
signal: controller.signal,
onProgress: ({ percent }: { percent: number }) => setProgress(percent),
} as any);
}
const url = (res as any).publicUrl || buildPublicUrl(res.key); const url = (res as any).publicUrl || buildPublicUrl(res.key);
return { key: res.key, url }; return { key: res.key, url };
} finally { } finally {

View File

@@ -50,6 +50,14 @@ target 'digitalpilates' do
:ccache_enabled => podfile_properties['apple.ccacheEnabled'] == 'true', :ccache_enabled => podfile_properties['apple.ccacheEnabled'] == 'true',
) )
# Force all Pods to build simulator slices as arm64 (avoid mixed x86_64/arm64 issues)
installer.pods_project.targets.each do |target|
target.build_configurations.each do |config|
# Force pods to build arm64 simulator by excluding only x86_64
config.build_settings['EXCLUDED_ARCHS[sdk=iphonesimulator*]'] = 'x86_64'
end
end
# This is necessary for Xcode 14, because it signs resource bundles by default # This is necessary for Xcode 14, because it signs resource bundles by default
# when building for devices. # when building for devices.
installer.target_installation_results.pod_target_installation_results installer.target_installation_results.pod_target_installation_results

View File

@@ -113,6 +113,15 @@ PODS:
- libwebp/sharpyuv (1.5.0) - libwebp/sharpyuv (1.5.0)
- libwebp/webp (1.5.0): - libwebp/webp (1.5.0):
- libwebp/sharpyuv - libwebp/sharpyuv
- QCloudCore (6.5.1):
- QCloudCore/Default (= 6.5.1)
- QCloudCore/Default (6.5.1):
- QCloudTrack/Beacon (= 6.5.1)
- QCloudCOSXML (6.5.1):
- QCloudCOSXML/Default (= 6.5.1)
- QCloudCOSXML/Default (6.5.1):
- QCloudCore (= 6.5.1)
- QCloudTrack/Beacon (6.5.1)
- RCT-Folly (2024.11.18.00): - RCT-Folly (2024.11.18.00):
- boost - boost
- DoubleConversion - DoubleConversion
@@ -1367,78 +1376,13 @@ PODS:
- React-jsiexecutor - React-jsiexecutor
- React-RCTFBReactNativeSpec - React-RCTFBReactNativeSpec
- ReactCommon/turbomodule/core - ReactCommon/turbomodule/core
- react-native-cos-sdk (1.2.1):
- QCloudCOSXML (= 6.5.1)
- React-Core
- react-native-render-html (6.3.4):
- React-Core
- react-native-safe-area-context (5.4.0): - react-native-safe-area-context (5.4.0):
- DoubleConversion
- glog
- RCT-Folly (= 2024.11.18.00)
- RCTRequired
- RCTTypeSafety
- React-Core - React-Core
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-jsc
- React-jsi
- react-native-safe-area-context/common (= 5.4.0)
- react-native-safe-area-context/fabric (= 5.4.0)
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- react-native-safe-area-context/common (5.4.0):
- DoubleConversion
- glog
- RCT-Folly (= 2024.11.18.00)
- RCTRequired
- RCTTypeSafety
- React-Core
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-jsc
- React-jsi
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- react-native-safe-area-context/fabric (5.4.0):
- DoubleConversion
- glog
- RCT-Folly (= 2024.11.18.00)
- RCTRequired
- RCTTypeSafety
- React-Core
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-jsc
- React-jsi
- react-native-safe-area-context/common
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- react-native-webview (13.13.5): - react-native-webview (13.13.5):
- DoubleConversion - DoubleConversion
- glog - glog
@@ -1760,51 +1704,9 @@ PODS:
- RNAppleHealthKit (1.7.0): - RNAppleHealthKit (1.7.0):
- React - React
- RNCAsyncStorage (2.2.0): - RNCAsyncStorage (2.2.0):
- DoubleConversion
- glog
- RCT-Folly (= 2024.11.18.00)
- RCTRequired
- RCTTypeSafety
- React-Core - React-Core
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-jsc
- React-jsi
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- RNDateTimePicker (8.4.4): - RNDateTimePicker (8.4.4):
- DoubleConversion
- glog
- RCT-Folly (= 2024.11.18.00)
- RCTRequired
- RCTTypeSafety
- React-Core - React-Core
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-jsc
- React-jsi
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- RNGestureHandler (2.24.0): - RNGestureHandler (2.24.0):
- DoubleConversion - DoubleConversion
- glog - glog
@@ -1948,31 +1850,6 @@ PODS:
- ReactCommon/turbomodule/core - ReactCommon/turbomodule/core
- Yoga - Yoga
- RNScreens (4.11.1): - RNScreens (4.11.1):
- DoubleConversion
- glog
- RCT-Folly (= 2024.11.18.00)
- RCTRequired
- RCTTypeSafety
- React-Core
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-jsc
- React-jsi
- React-NativeModulesApple
- React-RCTFabric
- React-RCTImage
- React-renderercss
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- RNScreens/common (= 4.11.1)
- Yoga
- RNScreens/common (4.11.1):
- DoubleConversion - DoubleConversion
- glog - glog
- RCT-Folly (= 2024.11.18.00) - RCT-Folly (= 2024.11.18.00)
@@ -1997,52 +1874,7 @@ PODS:
- ReactCommon/turbomodule/core - ReactCommon/turbomodule/core
- Yoga - Yoga
- RNSVG (15.12.1): - RNSVG (15.12.1):
- DoubleConversion
- glog
- RCT-Folly (= 2024.11.18.00)
- RCTRequired
- RCTTypeSafety
- React-Core - React-Core
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-jsc
- React-jsi
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- RNSVG/common (= 15.12.1)
- Yoga
- RNSVG/common (15.12.1):
- DoubleConversion
- glog
- RCT-Folly (= 2024.11.18.00)
- RCTRequired
- RCTTypeSafety
- React-Core
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-jsc
- React-jsi
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- SDWebImage (5.21.1): - SDWebImage (5.21.1):
- SDWebImage/Core (= 5.21.1) - SDWebImage/Core (= 5.21.1)
- SDWebImage/Core (5.21.1) - SDWebImage/Core (5.21.1)
@@ -2107,7 +1939,6 @@ DEPENDENCIES:
- React-idlecallbacksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks`) - React-idlecallbacksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks`)
- React-ImageManager (from `../node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios`) - React-ImageManager (from `../node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios`)
- React-jsc (from `../node_modules/react-native/ReactCommon/jsc`) - React-jsc (from `../node_modules/react-native/ReactCommon/jsc`)
- React-jsc/Fabric (from `../node_modules/react-native/ReactCommon/jsc`)
- React-jserrorhandler (from `../node_modules/react-native/ReactCommon/jserrorhandler`) - React-jserrorhandler (from `../node_modules/react-native/ReactCommon/jserrorhandler`)
- React-jsi (from `../node_modules/react-native/ReactCommon/jsi`) - React-jsi (from `../node_modules/react-native/ReactCommon/jsi`)
- React-jsiexecutor (from `../node_modules/react-native/ReactCommon/jsiexecutor`) - React-jsiexecutor (from `../node_modules/react-native/ReactCommon/jsiexecutor`)
@@ -2118,6 +1949,8 @@ DEPENDENCIES:
- React-logger (from `../node_modules/react-native/ReactCommon/logger`) - React-logger (from `../node_modules/react-native/ReactCommon/logger`)
- React-Mapbuffer (from `../node_modules/react-native/ReactCommon`) - React-Mapbuffer (from `../node_modules/react-native/ReactCommon`)
- React-microtasksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`) - React-microtasksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`)
- react-native-cos-sdk (from `../node_modules/react-native-cos-sdk`)
- react-native-render-html (from `../node_modules/react-native-render-html`)
- react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`) - react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`)
- react-native-webview (from `../node_modules/react-native-webview`) - react-native-webview (from `../node_modules/react-native-webview`)
- React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`) - React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`)
@@ -2164,6 +1997,9 @@ SPEC REPOS:
- libavif - libavif
- libdav1d - libdav1d
- libwebp - libwebp
- QCloudCore
- QCloudCOSXML
- QCloudTrack
- SDWebImage - SDWebImage
- SDWebImageAVIFCoder - SDWebImageAVIFCoder
- SDWebImageSVGCoder - SDWebImageSVGCoder
@@ -2285,6 +2121,10 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native/ReactCommon" :path: "../node_modules/react-native/ReactCommon"
React-microtasksnativemodule: React-microtasksnativemodule:
:path: "../node_modules/react-native/ReactCommon/react/nativemodule/microtasks" :path: "../node_modules/react-native/ReactCommon/react/nativemodule/microtasks"
react-native-cos-sdk:
:path: "../node_modules/react-native-cos-sdk"
react-native-render-html:
:path: "../node_modules/react-native-render-html"
react-native-safe-area-context: react-native-safe-area-context:
:path: "../node_modules/react-native-safe-area-context" :path: "../node_modules/react-native-safe-area-context"
react-native-webview: react-native-webview:
@@ -2371,7 +2211,7 @@ SPEC CHECKSUMS:
DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb
EXConstants: 98bcf0f22b820f9b28f9fee55ff2daededadd2f8 EXConstants: 98bcf0f22b820f9b28f9fee55ff2daededadd2f8
EXImageLoader: 4d3d3284141f1a45006cc4d0844061c182daf7ee EXImageLoader: 4d3d3284141f1a45006cc4d0844061c182daf7ee
Expo: ad449665420b9fe5e907bd97e79aec2e47f98785 Expo: 8685113c16058e8b3eb101dd52d6c8bca260bbea
ExpoAppleAuthentication: 8a661b6f4936affafd830f983ac22463c936dad5 ExpoAppleAuthentication: 8a661b6f4936affafd830f983ac22463c936dad5
ExpoAsset: ef06e880126c375f580d4923fdd1cdf4ee6ee7d6 ExpoAsset: ef06e880126c375f580d4923fdd1cdf4ee6ee7d6
ExpoBlur: 3c8885b9bf9eef4309041ec87adec48b5f1986a9 ExpoBlur: 3c8885b9bf9eef4309041ec87adec48b5f1986a9
@@ -2384,8 +2224,8 @@ SPEC CHECKSUMS:
ExpoKeepAwake: bf0811570c8da182bfb879169437d4de298376e7 ExpoKeepAwake: bf0811570c8da182bfb879169437d4de298376e7
ExpoLinearGradient: 7734c8059972fcf691fb4330bcdf3390960a152d ExpoLinearGradient: 7734c8059972fcf691fb4330bcdf3390960a152d
ExpoLinking: d5c183998ca6ada66ff45e407e0f965b398a8902 ExpoLinking: d5c183998ca6ada66ff45e407e0f965b398a8902
ExpoModulesCore: 2c1a84ec154d32afb4f6569bc558f059ebbcdb8e ExpoModulesCore: 272bc6c06ddd9c4bee2048acc57891cab3700627
ExpoSplashScreen: 0ad5acac1b5d2953c6e00d4319f16d616f70d4dd ExpoSplashScreen: 1c22c5d37647106e42d4ae1582bb6d0dda3b2385
ExpoSymbols: c5612a90fb9179cdaebcd19bea9d8c69e5d3b859 ExpoSymbols: c5612a90fb9179cdaebcd19bea9d8c69e5d3b859
ExpoSystemUI: c2724f9d5af6b1bb74e013efadf9c6a8fae547a2 ExpoSystemUI: c2724f9d5af6b1bb74e013efadf9c6a8fae547a2
ExpoWebBrowser: dc39a88485f007e61a3dff05d6a75f22ab4a2e92 ExpoWebBrowser: dc39a88485f007e61a3dff05d6a75f22ab4a2e92
@@ -2396,6 +2236,9 @@ SPEC CHECKSUMS:
libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7 libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7
libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
QCloudCore: 6f8c67b96448472d2c6a92b9cfe1bdb5abbb1798
QCloudCOSXML: 92f50a787b4e8d9a7cb6ea8e626775256b4840a7
QCloudTrack: 20b79388365b4c8ed150019c82a56f1569f237f8
RCT-Folly: e78785aa9ba2ed998ea4151e314036f6c49e6d82 RCT-Folly: e78785aa9ba2ed998ea4151e314036f6c49e6d82
RCTDeprecation: 5f638f65935e273753b1f31a365db6a8d6dc53b5 RCTDeprecation: 5f638f65935e273753b1f31a365db6a8d6dc53b5
RCTRequired: 8b46a520ea9071e2bc47d474aa9ca31b4a935bd8 RCTRequired: 8b46a520ea9071e2bc47d474aa9ca31b4a935bd8
@@ -2427,22 +2270,24 @@ SPEC CHECKSUMS:
React-logger: 85fa3509931497c72ccd2547fcc91e7299d8591e React-logger: 85fa3509931497c72ccd2547fcc91e7299d8591e
React-Mapbuffer: 96a2f2a176268581733be182fa6eebab1c0193be React-Mapbuffer: 96a2f2a176268581733be182fa6eebab1c0193be
React-microtasksnativemodule: 11b292232f1626567a79d58136689f1b911c605f React-microtasksnativemodule: 11b292232f1626567a79d58136689f1b911c605f
react-native-safe-area-context: b0ee54c424896b916aab46212b884cb8794308d7 react-native-cos-sdk: a29ad87f60e2edb2adc46da634aa5b6e7cd14e35
react-native-webview: faccaeb84216940628d4422822d367ad03d15a81 react-native-render-html: 5afc4751f1a98621b3009432ef84c47019dcb2bd
react-native-safe-area-context: 9d72abf6d8473da73033b597090a80b709c0b2f1
react-native-webview: 3df1192782174d1bd23f6a0f5a4fec3cdcca9954
React-NativeModulesApple: 494c38599b82392ed14b2c0118fca162425bb618 React-NativeModulesApple: 494c38599b82392ed14b2c0118fca162425bb618
React-oscompat: 0592889a9fcf0eacb205532028e4a364e22907dd React-oscompat: 0592889a9fcf0eacb205532028e4a364e22907dd
React-perflogger: c584fa50e422a46f37404d083fad12eb289d5de4 React-perflogger: c584fa50e422a46f37404d083fad12eb289d5de4
React-performancetimeline: 8deae06fc819e6f7d1f834818e72ab5581540e45 React-performancetimeline: 8deae06fc819e6f7d1f834818e72ab5581540e45
React-RCTActionSheet: ce67bdc050cc1d9ef673c7a93e9799288a183f24 React-RCTActionSheet: ce67bdc050cc1d9ef673c7a93e9799288a183f24
React-RCTAnimation: 8bb813eb29c6de85be99c62640f3a999df76ba02 React-RCTAnimation: 8bb813eb29c6de85be99c62640f3a999df76ba02
React-RCTAppDelegate: 4de5b1b68d9bc435bb7949fdde274895f12428c6 React-RCTAppDelegate: 738515a4ab15cc17996887269e17444bf08dee85
React-RCTBlob: 4c6fa35aa8b2b4d46ff2e5fb80c2b26df9457e57 React-RCTBlob: 4c6fa35aa8b2b4d46ff2e5fb80c2b26df9457e57
React-RCTFabric: 05582e7dc62b2c393b054b39d1b4202e9dcbce68 React-RCTFabric: f53fbf29459c959ce9ccbea28edfe6dc9ca35e36
React-RCTFBReactNativeSpec: f5970e7ba0b15cf23c0552c82251aff9630a6acd React-RCTFBReactNativeSpec: 1a2f1bd84f03ea0d7e3228055c3b894fb56680dd
React-RCTImage: 8a4f6ce18e73a7e894b886dfb7625e9e9fbc90ef React-RCTImage: 8a4f6ce18e73a7e894b886dfb7625e9e9fbc90ef
React-RCTLinking: fa49c624cd63979e7a6295ae9b1351d23ac4395a React-RCTLinking: fa49c624cd63979e7a6295ae9b1351d23ac4395a
React-RCTNetwork: f236fd2897d18522bba24453e2995a4c83e01024 React-RCTNetwork: f236fd2897d18522bba24453e2995a4c83e01024
React-RCTRuntime: f46f5c9890b77bbb38a536157d317a7a04a8825e React-RCTRuntime: 596bd113c46f61d82ac5d6199023bafd9a390cf4
React-RCTSettings: 69e2f25a5a1bf6cb37eef2e5c3bd4bb7e848296b React-RCTSettings: 69e2f25a5a1bf6cb37eef2e5c3bd4bb7e848296b
React-RCTText: 515ce74ed79c31dbf509e6f12770420ebbf23755 React-RCTText: 515ce74ed79c31dbf509e6f12770420ebbf23755
React-RCTVibration: ef30ada606dfed859b2c71577f6f041d47f2cfbb React-RCTVibration: ef30ada606dfed859b2c71577f6f041d47f2cfbb
@@ -2460,12 +2305,12 @@ SPEC CHECKSUMS:
ReactCodegen: 272c9bc1a8a917bf557bd9d032a4b3e181c6abfe ReactCodegen: 272c9bc1a8a917bf557bd9d032a4b3e181c6abfe
ReactCommon: 7eb76fcd5133313d8c6a138a5c7dd89f80f189d5 ReactCommon: 7eb76fcd5133313d8c6a138a5c7dd89f80f189d5
RNAppleHealthKit: 86ef7ab70f762b802f5c5289372de360cca701f9 RNAppleHealthKit: 86ef7ab70f762b802f5c5289372de360cca701f9
RNCAsyncStorage: f4b48b7eb2ae9296be4df608ff60c1b12a469b7a RNCAsyncStorage: b44e8a4e798c3e1f56bffccd0f591f674fb9198f
RNDateTimePicker: 41af3f0749ea5555f15805b468bc8453e6fa9850 RNDateTimePicker: 7d93eacf4bdf56350e4b7efd5cfc47639185e10c
RNGestureHandler: 6bf8b210cbad95ced45f3f9b8df05924b3a97300 RNGestureHandler: 6e640921d207f070e4bbcf79f4e6d0eabf323389
RNReanimated: 79c239f5562adcf2406b681830f716f1e7d76081 RNReanimated: 34e90d19560aebd52a2ad583fdc2de2cf7651bbb
RNScreens: dd9a329b21412c5322a5447fc2c3ae6471cf6e5a RNScreens: 241cfe8fc82737f3e132dd45779f9512928075b8
RNSVG: 820687c168d70d90a47d96a0cd5e263905fc67d9 RNSVG: 3544def7b3ddc43c7ba69dade91bacf99f10ec46
SDWebImage: f29024626962457f3470184232766516dee8dfea SDWebImage: f29024626962457f3470184232766516dee8dfea
SDWebImageAVIFCoder: 00310d246aab3232ce77f1d8f0076f8c4b021d90 SDWebImageAVIFCoder: 00310d246aab3232ce77f1d8f0076f8c4b021d90
SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c
@@ -2473,6 +2318,6 @@ SPEC CHECKSUMS:
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
Yoga: adb397651e1c00672c12e9495babca70777e411e Yoga: adb397651e1c00672c12e9495babca70777e411e
PODFILE CHECKSUM: b384f735cddc85333f7f9842fb492a2893323ea2 PODFILE CHECKSUM: 8d79b726cf7814a1ef2e250b7a9ef91c07c77936
COCOAPODS: 1.16.2 COCOAPODS: 1.16.2

View File

@@ -1,5 +1,5 @@
{ {
"expo.jsEngine": "jsc", "expo.jsEngine": "jsc",
"EX_DEV_CLIENT_NETWORK_INSPECTOR": "true", "EX_DEV_CLIENT_NETWORK_INSPECTOR": "true",
"newArchEnabled": "true" "newArchEnabled": "false"
} }

View File

@@ -268,6 +268,7 @@
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoSystemUI/ExpoSystemUI_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/ExpoSystemUI/ExpoSystemUI_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/QCloudCOSXML/QCloudCOSXML.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RCT-Folly/RCT-Folly_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/RCT-Folly/RCT-Folly_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RNCAsyncStorage/RNCAsyncStorage_resources.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/RNCAsyncStorage/RNCAsyncStorage_resources.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RNSVG/RNSVGFilters.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/RNSVG/RNSVGFilters.bundle",
@@ -283,6 +284,7 @@
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoConstants_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoConstants_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoSystemUI_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoSystemUI_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/QCloudCOSXML.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RCT-Folly_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RCT-Folly_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNCAsyncStorage_resources.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNCAsyncStorage_resources.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNSVGFilters.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNSVGFilters.bundle",
@@ -322,6 +324,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 756WVXJ6MT; DEVELOPMENT_TEAM = 756WVXJ6MT;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64;
GCC_PREPROCESSOR_DEFINITIONS = ( GCC_PREPROCESSOR_DEFINITIONS = (
"$(inherited)", "$(inherited)",
"FB_SONARKIT_ENABLED=1", "FB_SONARKIT_ENABLED=1",
@@ -358,6 +361,7 @@
CODE_SIGN_ENTITLEMENTS = digitalpilates/digitalpilates.entitlements; CODE_SIGN_ENTITLEMENTS = digitalpilates/digitalpilates.entitlements;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 756WVXJ6MT; DEVELOPMENT_TEAM = 756WVXJ6MT;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64;
INFOPLIST_FILE = digitalpilates/Info.plist; INFOPLIST_FILE = digitalpilates/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1; IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (

432
package-lock.json generated
View File

@@ -36,11 +36,14 @@
"react": "19.0.0", "react": "19.0.0",
"react-dom": "19.0.0", "react-dom": "19.0.0",
"react-native": "0.79.5", "react-native": "0.79.5",
"react-native-cos-sdk": "^1.2.1",
"react-native-gesture-handler": "~2.24.0", "react-native-gesture-handler": "~2.24.0",
"react-native-health": "^1.19.0", "react-native-health": "^1.19.0",
"react-native-image-viewing": "^0.2.2", "react-native-image-viewing": "^0.2.2",
"react-native-markdown-display": "^7.0.2",
"react-native-modal-datetime-picker": "^18.0.0", "react-native-modal-datetime-picker": "^18.0.0",
"react-native-reanimated": "~3.17.4", "react-native-reanimated": "~3.17.4",
"react-native-render-html": "^6.3.4",
"react-native-safe-area-context": "5.4.0", "react-native-safe-area-context": "5.4.0",
"react-native-screens": "~4.11.1", "react-native-screens": "~4.11.1",
"react-native-svg": "^15.12.1", "react-native-svg": "^15.12.1",
@@ -2652,6 +2655,23 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@jsamr/counter-style": {
"version": "2.0.2",
"resolved": "https://mirrors.tencent.com/npm/@jsamr/counter-style/-/counter-style-2.0.2.tgz",
"integrity": "sha512-2mXudGVtSzVxWEA7B9jZLKjoXUeUFYDDtFrQoC0IFX9/Dszz4t1vZOmafi3JSw/FxD+udMQ+4TAFR8Qs0J3URQ==",
"license": "MIT"
},
"node_modules/@jsamr/react-native-li": {
"version": "2.3.1",
"resolved": "https://mirrors.tencent.com/npm/@jsamr/react-native-li/-/react-native-li-2.3.1.tgz",
"integrity": "sha512-Qbo4NEj48SQ4k8FZJHFE2fgZDKTWaUGmVxcIQh3msg5JezLdTMMHuRRDYctfdHI6L0FZGObmEv3haWbIvmol8w==",
"license": "MIT",
"peerDependencies": {
"@jsamr/counter-style": "^1.0.0 || ^2.0.0",
"react": "*",
"react-native": "*"
}
},
"node_modules/@napi-rs/wasm-runtime": { "node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12", "version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
@@ -2665,6 +2685,88 @@
"@tybys/wasm-util": "^0.10.0" "@tybys/wasm-util": "^0.10.0"
} }
}, },
"node_modules/@native-html/css-processor": {
"version": "1.11.0",
"resolved": "https://mirrors.tencent.com/npm/@native-html/css-processor/-/css-processor-1.11.0.tgz",
"integrity": "sha512-NnhBEbJX5M2gBGltPKOetiLlKhNf3OHdRafc8//e2ZQxXN8JaSW/Hy8cm94pnIckQxwaMKxrtaNT3x4ZcffoNQ==",
"license": "MIT",
"dependencies": {
"css-to-react-native": "^3.0.0",
"csstype": "^3.0.8"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-native": "*"
}
},
"node_modules/@native-html/transient-render-engine": {
"version": "11.2.3",
"resolved": "https://mirrors.tencent.com/npm/@native-html/transient-render-engine/-/transient-render-engine-11.2.3.tgz",
"integrity": "sha512-zXwgA3gPUEmFs3I3syfnvDvS6WiUHXEE6jY09OBzK+trq7wkweOSFWIoyXiGkbXrozGYG0KY90YgPyr8Tg8Uyg==",
"license": "MIT",
"dependencies": {
"@native-html/css-processor": "1.11.0",
"@types/ramda": "^0.27.44",
"csstype": "^3.0.9",
"domelementtype": "^2.2.0",
"domhandler": "^4.2.2",
"domutils": "^2.8.0",
"htmlparser2": "^7.1.2",
"ramda": "^0.27.2"
},
"peerDependencies": {
"@types/react-native": "*",
"react-native": "^*"
}
},
"node_modules/@native-html/transient-render-engine/node_modules/dom-serializer": {
"version": "1.4.1",
"resolved": "https://mirrors.tencent.com/npm/dom-serializer/-/dom-serializer-1.4.1.tgz",
"integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==",
"dependencies": {
"domelementtype": "^2.0.1",
"domhandler": "^4.2.0",
"entities": "^2.0.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/@native-html/transient-render-engine/node_modules/domhandler": {
"version": "4.3.1",
"resolved": "https://mirrors.tencent.com/npm/domhandler/-/domhandler-4.3.1.tgz",
"integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==",
"dependencies": {
"domelementtype": "^2.2.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/@native-html/transient-render-engine/node_modules/domutils": {
"version": "2.8.0",
"resolved": "https://mirrors.tencent.com/npm/domutils/-/domutils-2.8.0.tgz",
"integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==",
"dependencies": {
"dom-serializer": "^1.0.1",
"domelementtype": "^2.2.0",
"domhandler": "^4.2.0"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/@native-html/transient-render-engine/node_modules/entities": {
"version": "2.2.0",
"resolved": "https://mirrors.tencent.com/npm/entities/-/entities-2.2.0.tgz",
"integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==",
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/@nodelib/fs.scandir": { "node_modules/@nodelib/fs.scandir": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -3051,6 +3153,20 @@
"integrity": "sha512-nGXMNMclZgzLUxijQQ38Dm3IAEhgxuySAWQHnljFtfB0JdaMwpe0Ox9H7Tp2OgrEA+EMEv+Od9ElKlHwGKmmvQ==", "integrity": "sha512-nGXMNMclZgzLUxijQQ38Dm3IAEhgxuySAWQHnljFtfB0JdaMwpe0Ox9H7Tp2OgrEA+EMEv+Od9ElKlHwGKmmvQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@react-native/virtualized-lists": {
"version": "0.72.8",
"resolved": "https://mirrors.tencent.com/npm/@react-native/virtualized-lists/-/virtualized-lists-0.72.8.tgz",
"integrity": "sha512-J3Q4Bkuo99k7mu+jPS9gSUSgq+lLRSI/+ahXNwV92XgJ/8UgOTxu2LPwhJnBk/sQKxq7E8WkZBnBiozukQMqrw==",
"license": "MIT",
"peer": true,
"dependencies": {
"invariant": "^2.2.4",
"nullthrows": "^1.1.1"
},
"peerDependencies": {
"react-native": "*"
}
},
"node_modules/@react-navigation/bottom-tabs": { "node_modules/@react-navigation/bottom-tabs": {
"version": "7.4.5", "version": "7.4.5",
"resolved": "https://registry.npmjs.org/@react-navigation/bottom-tabs/-/bottom-tabs-7.4.5.tgz", "resolved": "https://registry.npmjs.org/@react-navigation/bottom-tabs/-/bottom-tabs-7.4.5.tgz",
@@ -3341,22 +3457,47 @@
"undici-types": "~7.10.0" "undici-types": "~7.10.0"
} }
}, },
"node_modules/@types/ramda": {
"version": "0.27.66",
"resolved": "https://mirrors.tencent.com/npm/@types/ramda/-/ramda-0.27.66.tgz",
"integrity": "sha512-i2YW+E2U6NfMt3dp0RxNcejox+bxJUNDjB7BpYuRuoHIzv5juPHkJkNgcUOu+YSQEmaWu8cnAo/8r63C0NnuVA==",
"license": "MIT",
"dependencies": {
"ts-toolbelt": "^6.15.1"
}
},
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "19.0.14", "version": "19.0.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.14.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.14.tgz",
"integrity": "sha512-ixLZ7zG7j1fM0DijL9hDArwhwcCb4vqmePgwtV0GfnkHRSCUEv4LvzarcTdhoqgyMznUx/EhoTUv31CKZzkQlw==", "integrity": "sha512-ixLZ7zG7j1fM0DijL9hDArwhwcCb4vqmePgwtV0GfnkHRSCUEv4LvzarcTdhoqgyMznUx/EhoTUv31CKZzkQlw==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.0.2" "csstype": "^3.0.2"
} }
}, },
"node_modules/@types/react-native": {
"version": "0.72.8",
"resolved": "https://mirrors.tencent.com/npm/@types/react-native/-/react-native-0.72.8.tgz",
"integrity": "sha512-St6xA7+EoHN5mEYfdWnfYt0e8u6k2FR0P9s2arYgakQGFgU1f9FlPrIEcj0X24pLCF5c5i3WVuLCUdiCYHmOoA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@react-native/virtualized-lists": "^0.72.4",
"@types/react": "*"
}
},
"node_modules/@types/stack-utils": { "node_modules/@types/stack-utils": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
"integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/urijs": {
"version": "1.19.25",
"resolved": "https://mirrors.tencent.com/npm/@types/urijs/-/urijs-1.19.25.tgz",
"integrity": "sha512-XOfUup9r3Y06nFAZh3WvO0rBU4OtlfPB/vgxpjg+NRdGU6CN6djdc6OEiH+PcqHCY6eFLo9Ista73uarf4gnBg==",
"license": "MIT"
},
"node_modules/@types/use-sync-external-store": { "node_modules/@types/use-sync-external-store": {
"version": "0.0.6", "version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
@@ -4848,6 +4989,14 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/camelize": {
"version": "1.0.1",
"resolved": "https://mirrors.tencent.com/npm/camelize/-/camelize-1.0.1.tgz",
"integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001733", "version": "1.0.30001733",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001733.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001733.tgz",
@@ -4884,6 +5033,26 @@
"url": "https://github.com/chalk/chalk?sponsor=1" "url": "https://github.com/chalk/chalk?sponsor=1"
} }
}, },
"node_modules/character-entities-html4": {
"version": "1.1.4",
"resolved": "https://mirrors.tencent.com/npm/character-entities-html4/-/character-entities-html4-1.1.4.tgz",
"integrity": "sha512-HRcDxZuZqMx3/a+qrzxdBKBPUpxWEq9xw2OPZ3a/174ihfrQKVsFhqtthBInFy1zZ9GgZyFXOatNujm8M+El3g==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/character-entities-legacy": {
"version": "1.1.4",
"resolved": "https://mirrors.tencent.com/npm/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz",
"integrity": "sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/chownr": { "node_modules/chownr": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
@@ -5284,6 +5453,15 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/css-color-keywords": {
"version": "1.0.0",
"resolved": "https://mirrors.tencent.com/npm/css-color-keywords/-/css-color-keywords-1.0.0.tgz",
"integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==",
"license": "ISC",
"engines": {
"node": ">=4"
}
},
"node_modules/css-in-js-utils": { "node_modules/css-in-js-utils": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-3.1.0.tgz", "resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-3.1.0.tgz",
@@ -5308,6 +5486,17 @@
"url": "https://github.com/sponsors/fb55" "url": "https://github.com/sponsors/fb55"
} }
}, },
"node_modules/css-to-react-native": {
"version": "3.2.0",
"resolved": "https://mirrors.tencent.com/npm/css-to-react-native/-/css-to-react-native-3.2.0.tgz",
"integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==",
"license": "MIT",
"dependencies": {
"camelize": "^1.0.0",
"css-color-keywords": "^1.0.0",
"postcss-value-parser": "^4.0.2"
}
},
"node_modules/css-tree": { "node_modules/css-tree": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://mirrors.tencent.com/npm/css-tree/-/css-tree-1.1.3.tgz", "resolved": "https://mirrors.tencent.com/npm/css-tree/-/css-tree-1.1.3.tgz",
@@ -5343,7 +5532,6 @@
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/data-view-buffer": { "node_modules/data-view-buffer": {
@@ -7458,6 +7646,83 @@
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/htmlparser2": {
"version": "7.2.0",
"resolved": "https://mirrors.tencent.com/npm/htmlparser2/-/htmlparser2-7.2.0.tgz",
"integrity": "sha512-H7MImA4MS6cw7nbyURtLPO1Tms7C5H602LRETv95z1MxO/7CP7rDVROehUYeYBUYEON94NXXDEPmZuq+hX4sog==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"dependencies": {
"domelementtype": "^2.0.1",
"domhandler": "^4.2.2",
"domutils": "^2.8.0",
"entities": "^3.0.1"
}
},
"node_modules/htmlparser2/node_modules/dom-serializer": {
"version": "1.4.1",
"resolved": "https://mirrors.tencent.com/npm/dom-serializer/-/dom-serializer-1.4.1.tgz",
"integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==",
"dependencies": {
"domelementtype": "^2.0.1",
"domhandler": "^4.2.0",
"entities": "^2.0.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/htmlparser2/node_modules/dom-serializer/node_modules/entities": {
"version": "2.2.0",
"resolved": "https://mirrors.tencent.com/npm/entities/-/entities-2.2.0.tgz",
"integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==",
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/htmlparser2/node_modules/domhandler": {
"version": "4.3.1",
"resolved": "https://mirrors.tencent.com/npm/domhandler/-/domhandler-4.3.1.tgz",
"integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==",
"dependencies": {
"domelementtype": "^2.2.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/htmlparser2/node_modules/domutils": {
"version": "2.8.0",
"resolved": "https://mirrors.tencent.com/npm/domutils/-/domutils-2.8.0.tgz",
"integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==",
"dependencies": {
"dom-serializer": "^1.0.1",
"domelementtype": "^2.2.0",
"domhandler": "^4.2.0"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/htmlparser2/node_modules/entities": {
"version": "3.0.1",
"resolved": "https://mirrors.tencent.com/npm/entities/-/entities-3.0.1.tgz",
"integrity": "sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q==",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/http-errors": { "node_modules/http-errors": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
@@ -8780,6 +9045,15 @@
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/linkify-it": {
"version": "2.2.0",
"resolved": "https://mirrors.tencent.com/npm/linkify-it/-/linkify-it-2.2.0.tgz",
"integrity": "sha512-GnAl/knGn+i1U/wjBz3akz2stz+HrHLsxMwHQGofCDfPvlf+gDKN58UtfmUquTY4/MXeE2x7k19KQmeoZi94Iw==",
"license": "MIT",
"dependencies": {
"uc.micro": "^1.0.1"
}
},
"node_modules/locate-path": { "node_modules/locate-path": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -8927,6 +9201,36 @@
"tmpl": "1.0.5" "tmpl": "1.0.5"
} }
}, },
"node_modules/markdown-it": {
"version": "10.0.0",
"resolved": "https://mirrors.tencent.com/npm/markdown-it/-/markdown-it-10.0.0.tgz",
"integrity": "sha512-YWOP1j7UbDNz+TumYP1kpwnP0aEa711cJjrAQrzd0UXlbJfc5aAq0F/PZHjiioqDC1NKgvIMX+o+9Bk7yuM2dg==",
"license": "MIT",
"dependencies": {
"argparse": "^1.0.7",
"entities": "~2.0.0",
"linkify-it": "^2.0.0",
"mdurl": "^1.0.1",
"uc.micro": "^1.0.5"
},
"bin": {
"markdown-it": "bin/markdown-it.js"
}
},
"node_modules/markdown-it/node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://mirrors.tencent.com/npm/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/markdown-it/node_modules/entities": {
"version": "2.0.3",
"resolved": "https://mirrors.tencent.com/npm/entities/-/entities-2.0.3.tgz",
"integrity": "sha512-MyoZ0jgnLvB2X3Lg5HqpFmn1kybDiIfEQmKzTb5apr51Rb+T3KdmMiqa70T+bhGnyv7bQ6WMj2QMHpGMmlrUYQ==",
"license": "BSD-2-Clause"
},
"node_modules/marky": { "node_modules/marky": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz", "resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz",
@@ -8949,6 +9253,11 @@
"integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==",
"license": "CC0-1.0" "license": "CC0-1.0"
}, },
"node_modules/mdurl": {
"version": "1.0.1",
"resolved": "https://mirrors.tencent.com/npm/mdurl/-/mdurl-1.0.1.tgz",
"integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g=="
},
"node_modules/memoize-one": { "node_modules/memoize-one": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
@@ -10361,6 +10670,12 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/ramda": {
"version": "0.27.2",
"resolved": "https://mirrors.tencent.com/npm/ramda/-/ramda-0.27.2.tgz",
"integrity": "sha512-SbiLPU40JuJniHexQSAgad32hfwd+DRUdwF2PlVuI5RZD0/vahUco7R8vD86J/tcEKKF9vZrUVwgtmGCqlCKyA==",
"license": "MIT"
},
"node_modules/range-parser": { "node_modules/range-parser": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -10529,6 +10844,22 @@
} }
} }
}, },
"node_modules/react-native-cos-sdk": {
"version": "1.2.1",
"resolved": "https://mirrors.tencent.com/npm/react-native-cos-sdk/-/react-native-cos-sdk-1.2.1.tgz",
"integrity": "sha512-mT75MIweoM2X3sWxe8/03RGtVsVsRWfV8ZkMAOyMsvPkhJ4k6a7D2G9rln24NKlOGONT2q/9f5rwjjYUTgFOBg==",
"license": "MIT",
"dependencies": {
"react-native-uuid": "^2.0.1"
},
"engines": {
"node": ">= 16.0.0"
},
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-edge-to-edge": { "node_modules/react-native-edge-to-edge": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/react-native-edge-to-edge/-/react-native-edge-to-edge-1.6.0.tgz", "resolved": "https://registry.npmjs.org/react-native-edge-to-edge/-/react-native-edge-to-edge-1.6.0.tgz",
@@ -10539,6 +10870,15 @@
"react-native": "*" "react-native": "*"
} }
}, },
"node_modules/react-native-fit-image": {
"version": "1.5.5",
"resolved": "https://mirrors.tencent.com/npm/react-native-fit-image/-/react-native-fit-image-1.5.5.tgz",
"integrity": "sha512-Wl3Vq2DQzxgsWKuW4USfck9zS7YzhvLNPpkwUUCF90bL32e1a0zOVQ3WsJILJOwzmPdHfzZmWasiiAUNBkhNkg==",
"license": "Beerware",
"dependencies": {
"prop-types": "^15.5.10"
}
},
"node_modules/react-native-gesture-handler": { "node_modules/react-native-gesture-handler": {
"version": "2.24.0", "version": "2.24.0",
"resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.24.0.tgz", "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.24.0.tgz",
@@ -10744,6 +11084,22 @@
"react-native": "*" "react-native": "*"
} }
}, },
"node_modules/react-native-markdown-display": {
"version": "7.0.2",
"resolved": "https://mirrors.tencent.com/npm/react-native-markdown-display/-/react-native-markdown-display-7.0.2.tgz",
"integrity": "sha512-Mn4wotMvMfLAwbX/huMLt202W5DsdpMO/kblk+6eUs55S57VVNni1gzZCh5qpznYLjIQELNh50VIozEfY6fvaQ==",
"license": "MIT",
"dependencies": {
"css-to-react-native": "^3.0.0",
"markdown-it": "^10.0.0",
"prop-types": "^15.7.2",
"react-native-fit-image": "^1.5.5"
},
"peerDependencies": {
"react": ">=16.2.0",
"react-native": ">=0.50.4"
}
},
"node_modules/react-native-modal-datetime-picker": { "node_modules/react-native-modal-datetime-picker": {
"version": "18.0.0", "version": "18.0.0",
"resolved": "https://mirrors.tencent.com/npm/react-native-modal-datetime-picker/-/react-native-modal-datetime-picker-18.0.0.tgz", "resolved": "https://mirrors.tencent.com/npm/react-native-modal-datetime-picker/-/react-native-modal-datetime-picker-18.0.0.tgz",
@@ -10792,6 +11148,27 @@
"react-native": "*" "react-native": "*"
} }
}, },
"node_modules/react-native-render-html": {
"version": "6.3.4",
"resolved": "https://mirrors.tencent.com/npm/react-native-render-html/-/react-native-render-html-6.3.4.tgz",
"integrity": "sha512-H2jSMzZjidE+Wo3qCWPUMU1nm98Vs2SGCvQCz/i6xf0P3Y9uVtG/b0sDbG/cYFir2mSYBYCIlS1Dv0WC1LjYig==",
"license": "BSD-2-Clause",
"dependencies": {
"@jsamr/counter-style": "^2.0.1",
"@jsamr/react-native-li": "^2.3.0",
"@native-html/transient-render-engine": "11.2.3",
"@types/ramda": "^0.27.40",
"@types/urijs": "^1.19.15",
"prop-types": "^15.5.7",
"ramda": "^0.27.2",
"stringify-entities": "^3.1.0",
"urijs": "^1.19.6"
},
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-safe-area-context": { "node_modules/react-native-safe-area-context": {
"version": "5.4.0", "version": "5.4.0",
"resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.4.0.tgz", "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.4.0.tgz",
@@ -10832,6 +11209,16 @@
"react-native": "*" "react-native": "*"
} }
}, },
"node_modules/react-native-uuid": {
"version": "2.0.3",
"resolved": "https://mirrors.tencent.com/npm/react-native-uuid/-/react-native-uuid-2.0.3.tgz",
"integrity": "sha512-f/YfIS2f5UB+gut7t/9BKGSCYbRA9/74A5R1MDp+FLYsuS+OSWoiM/D8Jko6OJB6Jcu3v6ONuddvZKHdIGpeiw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0",
"npm": ">=6.0.0"
}
},
"node_modules/react-native-web": { "node_modules/react-native-web": {
"version": "0.20.0", "version": "0.20.0",
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.20.0.tgz", "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.20.0.tgz",
@@ -12177,6 +12564,20 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/stringify-entities": {
"version": "3.1.0",
"resolved": "https://mirrors.tencent.com/npm/stringify-entities/-/stringify-entities-3.1.0.tgz",
"integrity": "sha512-3FP+jGMmMV/ffZs86MoghGqAoqXAdxLrJP4GUdrDN1aIScYih5tuIO3eF4To5AJZ79KDZ8Fpdy7QJnK8SsL1Vg==",
"dependencies": {
"character-entities-html4": "^1.0.0",
"character-entities-legacy": "^1.0.0",
"xtend": "^4.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/strip-ansi": { "node_modules/strip-ansi": {
"version": "7.1.0", "version": "7.1.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
@@ -12565,6 +12966,12 @@
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/ts-toolbelt": {
"version": "6.15.5",
"resolved": "https://mirrors.tencent.com/npm/ts-toolbelt/-/ts-toolbelt-6.15.5.tgz",
"integrity": "sha512-FZIXf1ksVyLcfr7M317jbB67XFJhOO1YqdTcuGaq9q5jLUoTikukZ+98TPjKiP2jC5CgmYdWWYs0s2nLSU0/1A==",
"license": "Apache-2.0"
},
"node_modules/tsconfig-paths": { "node_modules/tsconfig-paths": {
"version": "3.15.0", "version": "3.15.0",
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
@@ -12748,6 +13155,12 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/uc.micro": {
"version": "1.0.6",
"resolved": "https://mirrors.tencent.com/npm/uc.micro/-/uc.micro-1.0.6.tgz",
"integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==",
"license": "MIT"
},
"node_modules/unbox-primitive": { "node_modules/unbox-primitive": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
@@ -12918,6 +13331,12 @@
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
}, },
"node_modules/urijs": {
"version": "1.19.11",
"resolved": "https://mirrors.tencent.com/npm/urijs/-/urijs-1.19.11.tgz",
"integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==",
"license": "MIT"
},
"node_modules/use-latest-callback": { "node_modules/use-latest-callback": {
"version": "0.2.4", "version": "0.2.4",
"resolved": "https://registry.npmjs.org/use-latest-callback/-/use-latest-callback-0.2.4.tgz", "resolved": "https://registry.npmjs.org/use-latest-callback/-/use-latest-callback-0.2.4.tgz",
@@ -13356,6 +13775,15 @@
"node": ">=8.0" "node": ">=8.0"
} }
}, },
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://mirrors.tencent.com/npm/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
},
"node_modules/y18n": { "node_modules/y18n": {
"version": "5.0.8", "version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

View File

@@ -11,7 +11,6 @@
"lint": "expo lint" "lint": "expo lint"
}, },
"dependencies": { "dependencies": {
"cos-js-sdk-v5": "^1.6.0",
"@expo/vector-icons": "^14.1.0", "@expo/vector-icons": "^14.1.0",
"@react-native-async-storage/async-storage": "^2.2.0", "@react-native-async-storage/async-storage": "^2.2.0",
"@react-native-community/datetimepicker": "^8.4.4", "@react-native-community/datetimepicker": "^8.4.4",
@@ -19,6 +18,7 @@
"@react-navigation/elements": "^2.3.8", "@react-navigation/elements": "^2.3.8",
"@react-navigation/native": "^7.1.6", "@react-navigation/native": "^7.1.6",
"@reduxjs/toolkit": "^2.8.2", "@reduxjs/toolkit": "^2.8.2",
"cos-js-sdk-v5": "^1.6.0",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"expo": "~53.0.20", "expo": "~53.0.20",
"expo-apple-authentication": "6.4.2", "expo-apple-authentication": "6.4.2",
@@ -39,11 +39,14 @@
"react": "19.0.0", "react": "19.0.0",
"react-dom": "19.0.0", "react-dom": "19.0.0",
"react-native": "0.79.5", "react-native": "0.79.5",
"react-native-cos-sdk": "^1.2.1",
"react-native-gesture-handler": "~2.24.0", "react-native-gesture-handler": "~2.24.0",
"react-native-health": "^1.19.0", "react-native-health": "^1.19.0",
"react-native-image-viewing": "^0.2.2", "react-native-image-viewing": "^0.2.2",
"react-native-markdown-display": "^7.0.2",
"react-native-modal-datetime-picker": "^18.0.0", "react-native-modal-datetime-picker": "^18.0.0",
"react-native-reanimated": "~3.17.4", "react-native-reanimated": "~3.17.4",
"react-native-render-html": "^6.3.4",
"react-native-safe-area-context": "5.4.0", "react-native-safe-area-context": "5.4.0",
"react-native-screens": "~4.11.1", "react-native-screens": "~4.11.1",
"react-native-svg": "^15.12.1", "react-native-svg": "^15.12.1",

44
services/articles.ts Normal file
View File

@@ -0,0 +1,44 @@
import dayjs from 'dayjs';
import { api } from './api';
export type Article = {
id: string;
title: string;
coverImage: string;
htmlContent: string;
publishedAt: string; // ISO string
readCount: number;
};
const demoArticles: Article[] = [
{
id: 'intro-pilates-posture',
title: '新手入门:普拉提核心与体态的关系',
coverImage: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/imagedemo.jpeg',
publishedAt: dayjs().subtract(2, 'day').toISOString(),
readCount: 1268,
htmlContent: `
<h2>为什么核心很重要?</h2>
<p>核心是维持良好体态与动作稳定的关键。普拉提通过强调呼吸与深层肌群激活,帮助你在<em>日常站立、坐姿与训练</em>中保持更好的身体对齐。</p>
<h3>入门建议</h3>
<ol>
<li>从呼吸开始:尝试<strong>胸廓外扩</strong>而非耸肩。</li>
<li>慢而可控:注意动作过程中的连贯与专注。</li>
<li>记录变化:每周拍照或在应用中记录体态变化。</li>
</ol>
<p>更多实操可在本应用的「AI体态评估」中获取个性化建议。</p>
<img src="https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/ImageCheck.jpeg" alt="pilates-illustration" />
`,
},
];
export function listRecommendedArticles(): Article[] {
// 实际项目中可替换为 API 请求
return demoArticles;
}
export async function getArticleById(id: string): Promise<Article | undefined> {
return api.get<Article>(`/articles/${id}`);
}

View File

@@ -1,5 +1,6 @@
import { COS_BUCKET, COS_REGION, buildPublicUrl } from '@/constants/Cos'; import { COS_BUCKET, COS_REGION, buildPublicUrl } from '@/constants/Cos';
import { api } from '@/services/api'; import { api } from '@/services/api';
import Cos from 'react-native-cos-sdk';
type ServerCosToken = { type ServerCosToken = {
tmpSecretId: string; tmpSecretId: string;
@@ -29,41 +30,24 @@ type CosCredential = {
type UploadOptions = { type UploadOptions = {
key: string; key: string;
body: any; // React Native COS SDK 推荐使用本地文件路径file:// 或 content://
srcUri?: string;
// 为兼容旧实现Web/Blob。在 RN SDK 下会忽略 body
body?: any;
contentType?: string; contentType?: string;
onProgress?: (progress: { percent: number }) => void; onProgress?: (progress: { percent: number }) => void;
signal?: AbortSignal; signal?: AbortSignal;
}; };
let CosSdk: any | null = null; let rnTransferManager: any | null = null;
let rnInitialized = false;
async function ensureCosSdk(): Promise<any> {
if (CosSdk) return CosSdk;
// RN 兼容SDK 在初始化时会访问 navigator.userAgent
const g: any = globalThis as any;
if (!g.navigator) g.navigator = {};
if (!g.navigator.userAgent) g.navigator.userAgent = 'react-native';
// 动态导入避免影响首屏,并加入 require 回退,兼容打包差异
let mod: any = null;
try {
mod = await import('cos-js-sdk-v5');
} catch (_) {
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
mod = require('cos-js-sdk-v5');
} catch { }
}
const Candidate = mod?.COS || mod?.default || mod;
if (!Candidate) {
throw new Error('cos-js-sdk-v5 加载失败');
}
CosSdk = Candidate;
return CosSdk;
}
async function fetchCredential(): Promise<CosCredential> { async function fetchCredential(): Promise<CosCredential> {
// 后端返回 { code, message, data }api.get 会提取 data // 后端返回 { code, message, data }api.get 会提取 data
const data = await api.get<ServerCosToken>('/api/users/cos/upload-token'); const data = await api.get<ServerCosToken>('/api/users/cos/upload-token');
console.log('fetchCredential', data);
return { return {
credentials: { credentials: {
tmpSecretId: data.tmpSecretId, tmpSecretId: data.tmpSecretId,
@@ -80,8 +64,8 @@ async function fetchCredential(): Promise<CosCredential> {
} }
export async function uploadToCos(options: UploadOptions): Promise<{ key: string; etag?: string; headers?: Record<string, string>; publicUrl?: string }> { export async function uploadToCos(options: UploadOptions): Promise<{ key: string; etag?: string; headers?: Record<string, string>; publicUrl?: string }> {
const { key, body, contentType, onProgress, signal } = options; const { key, srcUri, contentType, onProgress, signal } = options;
const COS = await ensureCosSdk();
const cred = await fetchCredential(); const cred = await fetchCredential();
const bucket = COS_BUCKET || cred.bucket; const bucket = COS_BUCKET || cred.bucket;
const region = COS_REGION || cred.region; const region = COS_REGION || cred.region;
@@ -89,14 +73,48 @@ export async function uploadToCos(options: UploadOptions): Promise<{ key: string
throw new Error('未配置 COS_BUCKET / COS_REGION且服务端未返回 bucket/region'); throw new Error('未配置 COS_BUCKET / COS_REGION且服务端未返回 bucket/region');
} }
// 确保对象键以服务端授权的前缀开头 // 确保对象键以服务端授权的前缀开头(去除通配符,折叠斜杠,避免把 * 拼进 Key
const finalKey = ((): string => { const finalKey = ((): string => {
const prefix = (cred.prefix || '').replace(/^\/+|\/+$/g, ''); const rawPrefix = String(cred.prefix || '');
if (!prefix) return key.replace(/^\//, ''); // 1) 去掉 * 与多余斜杠,再去掉首尾斜杠
const safePrefix = rawPrefix
.replace(/\*/g, '')
.replace(/\/{2,}/g, '/')
.replace(/^\/+|\/+$/g, '');
const normalizedKey = key.replace(/^\//, ''); const normalizedKey = key.replace(/^\//, '');
return normalizedKey.startsWith(prefix + '/') ? normalizedKey : `${prefix}/${normalizedKey}`; if (!safePrefix) return normalizedKey;
if (normalizedKey.startsWith(safePrefix + '/')) return normalizedKey;
return `${safePrefix}/${normalizedKey}`.replace(/\/{2,}/g, '/');
})(); })();
// 初始化 react-native-cos-sdk一次
if (!rnInitialized) {
await Cos.initWithSessionCredentialCallback(async () => {
// SDK 会在需要时调用该回调,我们返回当前的临时密钥
return {
tmpSecretId: cred.credentials.tmpSecretId,
tmpSecretKey: cred.credentials.tmpSecretKey,
sessionToken: cred.credentials.sessionToken,
startTime: cred.startTime,
expiredTime: cred.expiredTime,
} as any;
});
const serviceConfig = { region, isDebuggable: true, isHttps: true } as any;
await Cos.registerDefaultService(serviceConfig);
const transferConfig = {
forceSimpleUpload: false,
enableVerification: true,
divisionForUpload: 2 * 1024 * 1024,
sliceSizeForUpload: 1 * 1024 * 1024,
} as any;
rnTransferManager = await Cos.registerDefaultTransferManger(serviceConfig, transferConfig);
rnInitialized = true;
}
if (!srcUri || typeof srcUri !== 'string') {
throw new Error('请提供本地文件路径 srcUri形如 file:/// 或 content://');
}
const controller = new AbortController(); const controller = new AbortController();
if (signal) { if (signal) {
if (signal.aborted) controller.abort(); if (signal.aborted) controller.abort();
@@ -104,43 +122,46 @@ export async function uploadToCos(options: UploadOptions): Promise<{ key: string
} }
return await new Promise((resolve, reject) => { return await new Promise((resolve, reject) => {
const cos = new COS({ let cancelled = false;
getAuthorization: (_opts: any, cb: any) => { let taskRef: any = null;
cb({ (async () => {
TmpSecretId: cred.credentials.tmpSecretId, try {
TmpSecretKey: cred.credentials.tmpSecretKey, taskRef = await rnTransferManager.upload(
SecurityToken: cred.credentials.sessionToken, bucket,
StartTime: cred.startTime, finalKey,
ExpiredTime: cred.expiredTime, srcUri,
});
},
});
const task = cos.putObject(
{ {
Bucket: bucket, resultListener: {
Region: region, successCallBack: (header: any) => {
Key: finalKey, if (cancelled) return;
Body: body, const publicUrl = buildPublicUrl(finalKey);
ContentType: contentType, const etag = header?.ETag || header?.headers?.ETag || header?.headers?.etag;
onProgress: (progressData: any) => { resolve({ key: finalKey, etag, headers: header, publicUrl });
},
failCallBack: (clientError: any, serviceError: any) => {
if (cancelled) return;
console.log('uploadToCos', { clientError, serviceError });
const err = clientError || serviceError || new Error('COS 上传失败');
reject(err);
},
},
progressCallback: (complete: number, target: number) => {
if (onProgress) { if (onProgress) {
const percent = progressData && progressData.percent ? progressData.percent : 0; const percent = target > 0 ? complete / target : 0;
onProgress({ percent }); onProgress({ percent });
} }
}, },
}, contentType,
(err: any, data: any) => {
if (err) return reject(err);
const publicUrl = cred.cdnDomain
? `${String(cred.cdnDomain).replace(/\/$/, '')}/${finalKey.replace(/^\//, '')}`
: buildPublicUrl(finalKey);
resolve({ key: finalKey, etag: data && data.ETag, headers: data && data.headers, publicUrl });
} }
); );
} catch (e) {
if (!cancelled) reject(e);
}
})();
controller.signal.addEventListener('abort', () => { controller.signal.addEventListener('abort', () => {
try { task && task.cancel && task.cancel(); } catch { } cancelled = true;
try { taskRef?.cancel?.(); } catch { }
reject(new DOMException('Aborted', 'AbortError')); reject(new DOMException('Aborted', 'AbortError'));
}); });
}); });

View File

@@ -0,0 +1,23 @@
import { api } from '@/services/api';
export enum RecommendationType {
Article = 'article',
Checkin = 'checkin',
}
export type RecommendationCard = {
id: string;
type: RecommendationType;
title?: string;
coverUrl: string;
articleId?: string;
subtitle?: string;
extra?: Record<string, any>;
};
export async function fetchRecommendations(): Promise<RecommendationCard[]> {
// 后端返回 BaseResponseDto<data>services/api 会自动解出 data 字段
return api.get<RecommendationCard[]>(`/recommendations/list`);
}

View File

@@ -5,9 +5,18 @@ export type CheckinExercise = {
key: string; key: string;
name: string; name: string;
category: string; category: string;
sets: number; // 组数 /**
* itemType
* - exercise: 正常训练动作(默认)
* - rest: 组间/动作间休息(仅展示,不可勾选完成)
* - note: 备注/口令提示(仅展示)
*/
itemType?: 'exercise' | 'rest' | 'note';
sets: number; // 组数rest/note 可为 0
reps?: number; // 每组重复(计次型) reps?: number; // 每组重复(计次型)
durationSec?: number; // 每组时长(计时型) durationSec?: number; // 每组时长(计时型)
restSec?: number; // 休息时长(当 itemType=rest 时使用)
note?: string; // 备注内容(当 itemType=note 时使用)
completed?: boolean; // 是否已完成该动作 completed?: boolean; // 是否已完成该动作
}; };
@@ -16,6 +25,8 @@ export type CheckinRecord = {
date: string; // YYYY-MM-DD date: string; // YYYY-MM-DD
items: CheckinExercise[]; items: CheckinExercise[];
note?: string; note?: string;
// 保留后端原始返回,便于当 metrics.items 为空时做回退展示
raw?: any[];
}; };
export type CheckinState = { export type CheckinState = {
@@ -60,10 +71,17 @@ const checkinSlice = createSlice({
const rec = ensureRecord(state, action.payload.date); const rec = ensureRecord(state, action.payload.date);
rec.items = rec.items.filter((it) => it.key !== action.payload.key); rec.items = rec.items.filter((it) => it.key !== action.payload.key);
}, },
replaceExercises(state, action: PayloadAction<{ date: string; items: CheckinExercise[]; note?: string }>) {
const rec = ensureRecord(state, action.payload.date);
rec.items = (action.payload.items || []).map((it) => ({ ...it, completed: false }));
if (typeof action.payload.note === 'string') rec.note = action.payload.note;
},
toggleExerciseCompleted(state, action: PayloadAction<{ date: string; key: string }>) { toggleExerciseCompleted(state, action: PayloadAction<{ date: string; key: string }>) {
const rec = ensureRecord(state, action.payload.date); const rec = ensureRecord(state, action.payload.date);
const idx = rec.items.findIndex((it) => it.key === action.payload.key); const idx = rec.items.findIndex((it) => it.key === action.payload.key);
if (idx >= 0) rec.items[idx].completed = !rec.items[idx].completed; if (idx >= 0 && (rec.items[idx].itemType ?? 'exercise') === 'exercise') {
rec.items[idx].completed = !rec.items[idx].completed;
}
}, },
setNote(state, action: PayloadAction<{ date: string; note: string }>) { setNote(state, action: PayloadAction<{ date: string; note: string }>) {
const rec = ensureRecord(state, action.payload.date); const rec = ensureRecord(state, action.payload.date);
@@ -106,20 +124,27 @@ const checkinSlice = createSlice({
date, date,
items: mergedItems, items: mergedItems,
note, note,
raw: list,
}; };
}) })
.addCase(loadMonthCheckins.fulfilled, (state, action) => { .addCase(loadMonthCheckins.fulfilled, (state, action) => {
const monthKey = action.payload.monthKey; const monthKey = action.payload.monthKey;
const merged = action.payload.byDate; const merged = action.payload.byDate;
for (const d of Object.keys(merged)) { for (const d of Object.keys(merged)) {
state.byDate[d] = merged[d]; const prev = state.byDate[d];
const next = merged[d];
const items = (next.items && next.items.length > 0) ? next.items : (prev?.items ?? []);
const note = (typeof next.note === 'string') ? next.note : prev?.note;
const id = next.id || prev?.id || `rec_${d}`;
const raw = prev?.raw ?? (next as any)?.raw;
state.byDate[d] = { id, date: d, items, note, raw } as CheckinRecord;
} }
state.monthLoaded[monthKey] = true; state.monthLoaded[monthKey] = true;
}); });
}, },
}); });
export const { setCurrentDate, addExercise, removeExercise, toggleExerciseCompleted, setNote, resetDate } = checkinSlice.actions; export const { setCurrentDate, addExercise, removeExercise, replaceExercises, toggleExerciseCompleted, setNote, resetDate } = checkinSlice.actions;
export default checkinSlice.reducer; export default checkinSlice.reducer;
// Thunks // Thunks
@@ -145,9 +170,10 @@ export const syncCheckin = createAsyncThunk('checkin/sync', async (record: { dat
// 获取当天打卡列表(用于进入页面时拉取最新云端数据) // 获取当天打卡列表(用于进入页面时拉取最新云端数据)
export const getDailyCheckins = createAsyncThunk('checkin/getDaily', async (date?: string) => { export const getDailyCheckins = createAsyncThunk('checkin/getDaily', async (date?: string) => {
const list = await fetchDailyCheckins(date); const dateParam = date ?? new Date().toISOString().slice(0, 10);
const list = await fetchDailyCheckins(dateParam);
return { date, list } as { date?: string; list: any[] }; try { console.log('getDailyCheckins', { date: dateParam, count: Array.isArray(list) ? list.length : -1 }); } catch { }
return { date: dateParam, list } as { date?: string; list: any[] };
}); });
// 按月加载:优先使用区间接口,失败则逐日回退 // 按月加载:优先使用区间接口,失败则逐日回退

34
types/react-native-cos-sdk.d.ts vendored Normal file
View File

@@ -0,0 +1,34 @@
declare module 'react-native-cos-sdk' {
export type SessionCredential = {
tmpSecretId: string;
tmpSecretKey: string;
sessionToken: string;
startTime?: number;
expiredTime?: number;
};
export function initWithSessionCredentialCallback(cb: () => Promise<SessionCredential> | SessionCredential): Promise<void> | void;
export function registerDefaultService(config: { region: string; isHttps?: boolean; isDebuggable?: boolean }): Promise<any>;
export function registerDefaultTransferManger(
serviceConfig: { region: string; isHttps?: boolean; isDebuggable?: boolean },
transferConfig: {
forceSimpleUpload?: boolean;
enableVerification?: boolean;
divisionForUpload?: number;
sliceSizeForUpload?: number;
}
): Promise<any>;
export function getDefaultTransferManger(): any;
export default {
initWithSessionCredentialCallback,
registerDefaultService,
registerDefaultTransferManger,
getDefaultTransferManger,
};
}

112
utils/classicalSession.ts Normal file
View File

@@ -0,0 +1,112 @@
import type { CheckinExercise } from '@/store/checkinSlice';
export type ClassicalLevel = 'beginner' | 'intermediate' | 'advanced';
export type BuildOptions = {
level?: ClassicalLevel;
withSectionRests?: boolean; // 大段之间插入休息项
restSeconds?: number; // 休息秒数
withNotes?: boolean; // 插入提示/备注项
};
function restItem(idx: number, sec: number): CheckinExercise {
return {
key: `rest_${idx}`,
name: `间隔休息 ${sec}s`,
category: '休息',
itemType: 'rest',
sets: 0,
restSec: sec,
};
}
function noteItem(idx: number, text: string): CheckinExercise {
return {
key: `note_${idx}`,
name: '提示',
category: '备注',
itemType: 'note',
sets: 0,
note: text,
};
}
// 将图片中的“经典排课思路”转为结构化的动作清单(偏改革床序列,但以通用名称表示)
export function buildClassicalSession(options: BuildOptions = {}): { items: CheckinExercise[]; note: string } {
const level = options.level ?? 'beginner';
const withRests = options.withSectionRests ?? true;
const restSec = Math.max(10, Math.min(120, options.restSeconds ?? 30));
const withNotes = options.withNotes ?? true;
const items: CheckinExercise[] = [];
let noteText = '经典普拉提排课(根据学员情况可删减与调序)';
const pushSectionRest = () => { if (withRests) items.push(restItem(items.length, restSec)); };
const pushNote = (text: string) => { if (withNotes) items.push(noteItem(items.length, text)); };
// 1) 垫上热身/呼吸
pushNote('垫上热身:在仰卧位找到中立位,呼吸练习配合上举落下手臂');
items.push({ key: 'breathing', name: '呼吸练习', category: '热身', itemType: 'exercise', sets: 1, durationSec: 60 });
pushSectionRest();
// 2) 正式核心床练习(节选/映射)
// Footwork 系列每项10次
pushNote('Footwork脚趾、脚跟、V型脚、宽距各10次');
const footworkReps = 10;
items.push({ key: 'footwork_toes', name: 'Footwork - 脚趾', category: '下肢与核心', sets: 1, reps: footworkReps });
items.push({ key: 'footwork_heels', name: 'Footwork - 脚跟', category: '下肢与核心', sets: 1, reps: footworkReps });
items.push({ key: 'footwork_v', name: 'Footwork - V型脚', category: '下肢与核心', sets: 1, reps: footworkReps });
items.push({ key: 'footwork_wide', name: 'Footwork - 宽距', category: '下肢与核心', sets: 1, reps: footworkReps });
items.push({ key: 'hundred', name: '百次拍击 (Hundred)', category: '核心', sets: 1, reps: 100 });
items.push({ key: 'coordination', name: '协调 (Coordination)', category: '核心', sets: 1, reps: 6 });
items.push({ key: 'short_spine', name: '短脊柱按摩 (Short Spine Massage)', category: '脊柱与柔韧', sets: 1, reps: 6 });
items.push({ key: 'hug_a_tree', name: '抱树 (Hug a Tree)', category: '肩带与核心', sets: 1, reps: 10 });
// Stomach Massage
items.push({ key: 'stomach_round', name: '腹部按摩 - 背部弯曲', category: '核心与髋', sets: 1, reps: 6 });
items.push({ key: 'stomach_chest_up', name: '腹部按摩 - 挺胸', category: '核心与髋', sets: 1, reps: 6 });
// Short Box Abdominals 变式每个3-4次
const sbaReps = level === 'advanced' ? 4 : 3;
items.push({ key: 'short_box_round', name: '短箱腹肌 - 背部弯曲', category: '核心', sets: 1, reps: sbaReps });
items.push({ key: 'short_box_flat', name: '短箱腹肌 - 背部平直', category: '核心', sets: 1, reps: sbaReps });
items.push({ key: 'short_box_oblique', name: '短箱腹肌 - 斜向', category: '核心', sets: 1, reps: sbaReps });
items.push({ key: 'short_box_tree', name: '短箱腹肌 - 爬树', category: '核心', sets: 1, reps: sbaReps });
pushSectionRest();
// 3) Long Stretch Series每组4次Elephant 6次
const lssReps = 4;
items.push({ key: 'long_stretch', name: 'Long Stretch', category: '平衡与支撑', sets: 1, reps: lssReps });
items.push({ key: 'up_stretch', name: 'Up Stretch', category: '平衡与支撑', sets: 1, reps: lssReps });
items.push({ key: 'down_stretch', name: 'Down Stretch', category: '平衡与支撑', sets: 1, reps: lssReps });
items.push({ key: 'elephant', name: 'Elephant', category: '后链与拉伸', sets: 1, reps: 6 });
pushSectionRest();
// 4) 半圆、长脊柱按摩
items.push({ key: 'semi_circle', name: '半圆 (Semi-Circle) 每个方向', category: '脊柱与胸椎', sets: 1, reps: 6 });
items.push({ key: 'long_spine', name: '长脊柱按摩 (Long Spine Massage) 每个方向', category: '脊柱与柔韧', sets: 1, reps: 3 });
pushSectionRest();
// 5) 膝跪伸展Round/Flat/Knees Off
items.push({ key: 'knee_stretch_round', name: '膝跪伸展 - 弯背', category: '核心与髋', sets: 1, reps: 10 });
items.push({ key: 'knee_stretch_flat', name: '膝跪伸展 - 平背', category: '核心与髋', sets: 1, reps: 15 });
items.push({ key: 'knee_stretch_knees_off', name: '膝跪伸展 - 双膝离地', category: '核心与髋', sets: 1, reps: level === 'advanced' ? 8 : 6 });
pushSectionRest();
// 6) 跑步、骨盆抬起
items.push({ key: 'running', name: '原地跑步 (Running in Place)', category: '下肢与心肺', sets: 1, reps: 25 });
items.push({ key: 'pelvic_lift', name: '骨盆抬起 (Pelvic Lift)', category: '后链', sets: 1, reps: 10 });
pushSectionRest();
// 7) 站姿与划船、Mermaid
items.push({ key: 'standing_knees_straight', name: '站姿 - 双膝伸直', category: '平衡与体态', sets: 1, reps: 6 });
items.push({ key: 'standing_knees_bent', name: '站姿 - 双膝弯曲', category: '平衡与体态', sets: 1, reps: 6 });
items.push({ key: 'rowing_front_1', name: '划船 正面1', category: '肩带与核心', sets: 1, reps: 4 });
items.push({ key: 'rowing_front_2', name: '划船 正面2', category: '肩带与核心', sets: 1, reps: 4 });
items.push({ key: 'mermaid', name: '美人鱼 (Mermaid)', category: '侧链与拉伸', sets: 1, reps: 3 });
// 结尾提示
pushNote('完成后侧坐于床侧边上,向前倾伸展背部,向后细伸脊椎并站起');
return { items, note: noteText };
}