diff --git a/app/(tabs)/explore.tsx b/app/(tabs)/explore.tsx index 9dcba7a..fdb9ce6 100644 --- a/app/(tabs)/explore.tsx +++ b/app/(tabs)/explore.tsx @@ -6,7 +6,7 @@ import { getTabBarBottomPadding } from '@/constants/TabBar'; import { useAppSelector } from '@/hooks/redux'; import { useColorScheme } from '@/hooks/useColorScheme'; import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date'; -import { ensureHealthPermissions, fetchHealthDataForDate, fetchTodayHealthData } from '@/utils/health'; +import { ensureHealthPermissions, fetchHealthDataForDate } from '@/utils/health'; import { Ionicons } from '@expo/vector-icons'; import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; import { useFocusEffect } from '@react-navigation/native'; @@ -65,6 +65,11 @@ export default function ExploreScreen() { const [animToken, setAnimToken] = useState(0); const [trainingProgress, setTrainingProgress] = useState(0.8); // 暂定静态80% + // 记录最近一次请求的“日期键”,避免旧请求覆盖新结果 + const latestRequestKeyRef = useRef(null); + + const getDateKey = (d: Date) => `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`; + const loadHealthData = async (targetDate?: Date) => { try { console.log('=== 开始HealthKit初始化流程 ==='); @@ -77,13 +82,23 @@ export default function ExploreScreen() { return; } - console.log('权限获取成功,开始获取健康数据...'); - const data = targetDate ? await fetchHealthDataForDate(targetDate) : await fetchTodayHealthData(); + // 若未显式传入日期,按当前选中索引推导日期 + const derivedDate = targetDate ?? days[selectedIndex]?.date?.toDate() ?? new Date(); + const requestKey = getDateKey(derivedDate); + latestRequestKeyRef.current = requestKey; + + console.log('权限获取成功,开始获取健康数据...', derivedDate); + const data = await fetchHealthDataForDate(derivedDate); console.log('设置UI状态:', data); - setStepCount(data.steps); - setActiveCalories(Math.round(data.activeEnergyBurned)); - setAnimToken((t) => t + 1); + // 仅当该请求仍是最新时,才应用结果 + if (latestRequestKeyRef.current === requestKey) { + setStepCount(data.steps); + setActiveCalories(Math.round(data.activeEnergyBurned)); + setAnimToken((t) => t + 1); + } else { + console.log('忽略过期健康数据请求结果,key=', requestKey, '最新key=', latestRequestKeyRef.current); + } console.log('=== HealthKit数据获取完成 ==='); } catch (error) { @@ -95,8 +110,9 @@ export default function ExploreScreen() { useFocusEffect( React.useCallback(() => { + // 聚焦时按当前选中的日期加载,避免与用户手动选择的日期不一致 loadHealthData(); - }, []) + }, [selectedIndex]) ); // 日期点击时,加载对应日期数据 @@ -147,7 +163,7 @@ export default function ExploreScreen() { {/* 打卡入口 */} - 今日报告 + 每日报告 router.push('/checkin/calendar')} accessibilityRole="button"> 查看打卡日历 diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 1055551..7f77c27 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,9 +1,12 @@ +import { ArticleCard } from '@/components/ArticleCard'; import { PlanCard } from '@/components/PlanCard'; import { SearchBox } from '@/components/SearchBox'; import { ThemedText } from '@/components/ThemedText'; import { ThemedView } from '@/components/ThemedView'; import { Colors } from '@/constants/Colors'; import { useColorScheme } from '@/hooks/useColorScheme'; +import { listRecommendedArticles } from '@/services/articles'; +import { fetchRecommendations, RecommendationType } from '@/services/recommendations'; // Removed WorkoutCard import since we no longer use the horizontal carousel import { useAuthGuard } from '@/hooks/useAuthGuard'; import { getChineseGreeting } from '@/utils/date'; @@ -16,7 +19,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; export default function HomeScreen() { const router = useRouter(); - const { pushIfAuthedElseLogin } = useAuthGuard(); + const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard(); const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const colorTokens = Colors[theme]; const insets = useSafeAreaInsets(); @@ -72,6 +75,107 @@ export default function HomeScreen() { }); }, }), [coachSize.height, coachSize.width, insets.bottom, insets.top, pan, windowHeight, windowWidth, router]); + // 推荐项类型(本地 UI 使用) + type RecommendItem = + | { + type: 'plan'; + key: string; + image: string; + title: string; + subtitle: string; + level?: '初学者' | '中级' | '高级'; + progress: number; + onPress?: () => void; + } + | { + type: 'article'; + key: string; + id: string; + title: string; + coverImage: string; + publishedAt: string; + readCount: number; + }; + + // 打底数据(接口不可用时) + const getFallbackItems = React.useCallback((): RecommendItem[] => { + return [ + { + type: 'plan', + key: 'assess', + image: + 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/imagedemo.jpeg', + title: '体态评估', + subtitle: '评估你的体态,制定训练计划', + level: '初学者', + progress: 0, + onPress: () => router.push('/ai-posture-assessment'), + }, + ...listRecommendedArticles().map((a) => ({ + type: 'article' as const, + key: `article-${a.id}`, + id: a.id, + title: a.title, + coverImage: a.coverImage, + publishedAt: a.publishedAt, + readCount: a.readCount, + })), + ]; + }, [router]); + + const [items, setItems] = React.useState(() => 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 ( @@ -124,7 +228,7 @@ export default function HomeScreen() { {/* Header Section */} - {getChineseGreeting()} 🔥 + {getChineseGreeting()} 新学员,欢迎你 @@ -142,9 +246,6 @@ export default function HomeScreen() { > AI体态评估 3分钟获取体态报告 - - 开始评估 - 在线教练 认证教练 · 1对1即时解答 - - 立即咨询 - + + + pushIfAuthedElseLogin('/checkin')} + > + 每日打卡 + 自选动作 · 记录完成 + + + pushIfAuthedElseLogin('/training-plan')} + > + 训练计划制定 + 按周安排 · 个性化目标 @@ -165,40 +279,36 @@ export default function HomeScreen() { 为你推荐 - - {/* 原“每周打卡”改为进入打卡日历 */} - router.push('/checkin/calendar')}> - - - pushIfAuthedElseLogin('/training-plan')}> - - - pushIfAuthedElseLogin('/checkin')}> - - + {items.map((item) => { + if (item.type === 'article') { + return ( + + ); + } + const card = ( + + ); + return item.onPress ? ( + + {card} + + ) : ( + {card} + ); + })} @@ -299,19 +409,20 @@ const styles = StyleSheet.create({ paddingHorizontal: 24, flexDirection: 'row', justifyContent: 'space-between', + flexWrap: 'wrap', }, featureCard: { width: '48%', - borderRadius: 16, - padding: 16, + borderRadius: 12, + padding: 12, backgroundColor: '#FFFFFF', - // iOS shadow + marginBottom: 12, + // 轻量阴影,减少臃肿感 shadowColor: '#000', - shadowOpacity: 0.08, - shadowRadius: 12, - shadowOffset: { width: 0, height: 6 }, - // Android shadow - elevation: 4, + shadowOpacity: 0.04, + shadowRadius: 8, + shadowOffset: { width: 0, height: 4 }, + elevation: 2, }, featureCardPrimary: { backgroundColor: '#EEF2FF', // 柔和的靛蓝背景 @@ -319,33 +430,26 @@ const styles = StyleSheet.create({ featureCardSecondary: { backgroundColor: '#F0FDFA', // 柔和的青绿背景 }, + featureCardTertiary: { + backgroundColor: '#FFF7ED', // 柔和的橙色背景 + }, + featureCardQuaternary: { + backgroundColor: '#F5F3FF', // 柔和的紫色背景 + }, featureIcon: { fontSize: 28, marginBottom: 8, }, featureTitle: { - fontSize: 18, + fontSize: 16, fontWeight: '700', color: '#0F172A', - marginBottom: 6, + marginBottom: 4, }, featureSubtitle: { - fontSize: 12, + fontSize: 11, color: '#6B7280', - lineHeight: 16, - marginBottom: 12, - }, - featureCta: { - alignSelf: 'flex-start', - backgroundColor: '#0F172A', - paddingHorizontal: 10, - paddingVertical: 6, - borderRadius: 999, - }, - featureCtaText: { - color: '#FFFFFF', - fontSize: 12, - fontWeight: '600', + lineHeight: 15, }, planList: { paddingHorizontal: 24, diff --git a/app/_layout.tsx b/app/_layout.tsx index 587dd5c..61ec9bb 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -49,6 +49,7 @@ export default function RootLayout() { + diff --git a/app/ai-coach-chat.tsx b/app/ai-coach-chat.tsx index fd7320d..3d25b2c 100644 --- a/app/ai-coach-chat.tsx +++ b/app/ai-coach-chat.tsx @@ -1,5 +1,6 @@ import { Ionicons } from '@expo/vector-icons'; import { BlurView } from 'expo-blur'; +import * as ImagePicker from 'expo-image-picker'; import { useLocalSearchParams, useRouter } from 'expo-router'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { @@ -7,7 +8,7 @@ import { Alert, FlatList, Image, - KeyboardAvoidingView, + Keyboard, Modal, Platform, ScrollView, @@ -17,17 +18,22 @@ import { TouchableOpacity, View, } from 'react-native'; +import Markdown from 'react-native-markdown-display'; import Animated, { FadeInDown, FadeInUp, Layout } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { HeaderBar } from '@/components/ui/HeaderBar'; 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 { deleteConversation, getConversationDetail, listConversations, type AiConversationListItem } from '@/services/aiCoach'; import { loadAiCoachSessionCache, saveAiCoachSessionCache } from '@/services/aiCoachSession'; 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 { fetchMyProfile, updateProfile } from '@/store/userSlice'; import dayjs from 'dayjs'; type Role = 'user' | 'assistant'; @@ -67,14 +73,30 @@ export default function AICoachChatScreen() { const didInitialScrollRef = useRef(false); const [composerHeight, setComposerHeight] = useState(80); const shouldAutoScrollRef = useRef(false); + const [keyboardOffset, setKeyboardOffset] = useState(0); + const pendingAssistantIdRef = useRef(null); + const [selectedImages, setSelectedImages] = useState>([]); + const [previewImageUri, setPreviewImageUri] = useState(null); const planDraft = useAppSelector((s) => s.trainingPlan?.draft); const checkin = useAppSelector((s) => (s as any).checkin); + const dispatch = useAppDispatch(); + const userProfile = useAppSelector((s) => (s as any)?.user?.profile); const chips = useMemo(() => [ { key: 'posture', label: '体态评估', action: () => router.push('/ai-posture-assessment') }, { key: 'plan', label: 'AI制定训练计划', action: () => handleQuickPlan() }, { key: 'analyze', label: '分析运动记录', action: () => handleAnalyzeRecords() }, + { key: 'weight', label: '记体重', action: () => insertWeightInputCard() }, ], [router, planDraft, checkin]); const scrollToEnd = useCallback(() => { @@ -132,6 +154,29 @@ export default function AICoachChatScreen() { } }, [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); useEffect(() => { @@ -246,6 +291,7 @@ export default function AICoachChatScreen() { const userMsg: ChatMessage = { id: userMsgId, role: 'user', content: text }; shouldAutoScrollRef.current = isAtBottom; setMessages((m) => [...m, userMsg, { id: assistantId, role: 'assistant', content: '' }]); + pendingAssistantIdRef.current = assistantId; setIsSending(true); setIsStreaming(true); @@ -281,6 +327,7 @@ export default function AICoachChatScreen() { setIsStreaming(false); streamAbortRef.current = null; if (cidFromHeader && !conversationId) setConversationId(cidFromHeader); + pendingAssistantIdRef.current = null; try { console.log('[AI_CHAT][api] end', { cidFromHeader, hadChunks: receivedAnyChunk }); } catch { } }; @@ -289,6 +336,7 @@ export default function AICoachChatScreen() { setIsSending(false); setIsStreaming(false); streamAbortRef.current = null; + pendingAssistantIdRef.current = null; // 流式失败时的降级:尝试一次性非流式 try { const bodyNoStream = { ...body, stream: false }; @@ -314,10 +362,59 @@ export default function AICoachChatScreen() { } async function send(text: string) { - if (!text.trim() || isSending) return; + if (isSending) return; const trimmed = text.trim(); - setInput(''); - await sendStream(trimmed); + if (!trimmed && selectedImages.length === 0) return; + + async function ensureImagesUploaded(): Promise { + 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(''); + setSelectedImages([]); + await sendStream(composed); + } catch (e: any) { + Alert.alert('上传失败', e?.message || '图片上传失败,请稍后重试'); + } } function handleQuickPlan() { @@ -378,6 +475,37 @@ export default function AICoachChatScreen() { 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 }) { const isUser = item.role === 'user'; return ( @@ -399,12 +527,88 @@ export default function AICoachChatScreen() { }, ]} > - {item.content} + {renderBubbleContent(item)} + {false} ); } + function renderBubbleContent(item: ChatMessage) { + if (!item.content?.trim() && isStreaming && pendingAssistantIdRef.current === item.id) { + return 正在思考…; + } + 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 ( + + 记录今日体重 + + handleSubmitWeight(e.nativeEvent.text)} + returnKeyType="done" + blurOnSubmit + /> + kg + handleSubmitWeight((preset || '').toString())}> + 保存 + + + 按回车或点击保存,即可将该体重同步到账户并发送到对话。 + + ); + } + return ( + + {item.content} + + ); + } + + 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 ( ( - + )} onContentSizeChange={() => { // 首次内容变化强制滚底,其余仅在接近底部时滚动 @@ -454,53 +658,90 @@ export default function AICoachChatScreen() { showsVerticalScrollIndicator={false} /> - - { - const h = e.nativeEvent.layout.height; - if (h && Math.abs(h - composerHeight) > 0.5) setComposerHeight(h); - }} + { + const h = e.nativeEvent.layout.height; + if (h && Math.abs(h - composerHeight) > 0.5) setComposerHeight(h); + }} + > + - - {chips.map((c) => ( - - {c.label} - - ))} - - - - send(input)} - blurOnSubmit={false} - /> - send(input)} - style={[ - styles.sendBtn, - { backgroundColor: theme.primary, opacity: input.trim() && !isSending ? 1 : 0.5 } - ]} - > - {isSending ? ( - - ) : ( - - )} + {chips.map((c) => ( + + {c.label} - - - + ))} + + + {!!selectedImages.length && ( + + {selectedImages.map((img) => ( + + setPreviewImageUri(img.uploadedUrl || img.localUri)}> + + + {!!(img.progress > 0 && img.progress < 1) && ( + + {Math.round((img.progress || 0) * 100)}% + + )} + removeSelectedImage(img.id)} style={styles.imageRemoveBtn}> + + + + ))} + + )} + + + + + + send(input)} + blurOnSubmit={false} + /> + send(input)} + style={[ + styles.sendBtn, + { backgroundColor: theme.primary, opacity: (input.trim() || selectedImages.length > 0) && !isSending ? 1 : 0.5 } + ]} + > + {isSending ? ( + + ) : ( + + )} + + + {!isAtBottom && ( + setPreviewImageUri(null)}> + setPreviewImageUri(null)}> + + {previewImageUri ? ( + + ) : null} + + + ); } @@ -613,6 +863,34 @@ const styles = StyleSheet.create({ fontSize: 15, 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: { position: 'absolute', left: 0, @@ -624,11 +902,13 @@ const styles = StyleSheet.create({ }, chipsRow: { flexDirection: 'row', - flexWrap: 'wrap', gap: 8, paddingHorizontal: 6, marginBottom: 8, }, + chipsRowScroll: { + marginBottom: 8, + }, chip: { paddingHorizontal: 10, height: 34, @@ -642,6 +922,47 @@ const styles = StyleSheet.create({ fontSize: 13, 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: { flexDirection: 'row', alignItems: 'center', @@ -650,6 +971,14 @@ const styles = StyleSheet.create({ borderRadius: 16, backgroundColor: 'rgba(0,0,0,0.04)' }, + mediaBtn: { + width: 40, + height: 40, + borderRadius: 12, + alignItems: 'center', + justifyContent: 'center', + marginRight: 6, + }, input: { flex: 1, fontSize: 15, @@ -748,6 +1077,66 @@ const styles = StyleSheet.create({ borderRadius: 10, 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; + diff --git a/app/article/[id].tsx b/app/article/[id].tsx new file mode 100644 index 0000000..6791907 --- /dev/null +++ b/app/article/[id].tsx @@ -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
(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 ( + + router.back()} showBottomBorder /> + + + + ); + } + + const source = { html: wrapHtml(article.htmlContent) }; + + + + + return ( + + router.back()} showBottomBorder /> + + + {article.title} + + {dayjs(article.publishedAt).format('YYYY-MM-DD')} + · + {article.readCount} 阅读 + + + + + + + ); +} + +function wrapHtml(inner: string) { + // 为了统一排版与图片自适应 + return ` +
+ ${inner} +
+ + `; +} + +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; + + diff --git a/app/checkin/calendar.tsx b/app/checkin/calendar.tsx index 3a4f5bf..670641f 100644 --- a/app/checkin/calendar.tsx +++ b/app/checkin/calendar.tsx @@ -3,7 +3,7 @@ import { Colors } from '@/constants/Colors'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useColorScheme } from '@/hooks/useColorScheme'; import { DailyStatusItem, fetchDailyStatusRange } from '@/services/checkins'; -import { getDailyCheckins, loadMonthCheckins, setCurrentDate } from '@/store/checkinSlice'; +import { loadMonthCheckins } from '@/store/checkinSlice'; import { getMonthDaysZh } from '@/utils/date'; import dayjs from 'dayjs'; import { useRouter } from 'expo-router'; @@ -76,9 +76,8 @@ export default function CheckinCalendarScreen() { return ( { - dispatch(setCurrentDate(dateStr)); - await dispatch(getDailyCheckins(dateStr)); - router.push('/checkin'); + // 通过路由参数传入日期,便于目标页初始化 + router.push({ pathname: '/checkin', params: { date: dateStr } }); }} activeOpacity={0.8} style={[styles.dayCell, { backgroundColor: colorTokens.card }, hasAny && styles.dayCellCompleted, isToday && styles.dayCellToday]} diff --git a/app/checkin/index.tsx b/app/checkin/index.tsx index a41cba4..395fc6a 100644 --- a/app/checkin/index.tsx +++ b/app/checkin/index.tsx @@ -3,12 +3,13 @@ import { Colors } from '@/constants/Colors'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useColorScheme } from '@/hooks/useColorScheme'; 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 { useFocusEffect } from '@react-navigation/native'; -import { useRouter } from 'expo-router'; -import React, { useEffect, useMemo } from 'react'; -import { Alert, FlatList, SafeAreaView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { Alert, FlatList, Modal, SafeAreaView, StyleSheet, Switch, Text, TextInput, TouchableOpacity, View } from 'react-native'; function formatDate(d: Date) { const y = d.getFullYear(); @@ -20,33 +21,65 @@ function formatDate(d: Date) { export default function CheckinHome() { const dispatch = useAppDispatch(); const router = useRouter(); + const params = useLocalSearchParams<{ date?: string }>(); const today = useMemo(() => formatDate(new Date()), []); 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 colorTokens = Colors[theme]; - useEffect(() => { - dispatch(setCurrentDate(today)); - // 进入页面立即从后端获取当天打卡列表,回填本地 - dispatch(getDailyCheckins(today)).unwrap().catch((err: any) => { - Alert.alert('获取打卡失败', err?.message || '请稍后重试'); - }); - // 预取本月数据(用于日历视图点亮) - const now = new Date(); - dispatch(loadMonthCheckins({ year: now.getFullYear(), month1Based: now.getMonth() + 1 })); - }, [dispatch, today]); + console.log('CheckinHome render', { + currentDate, + routeDateParam, + itemsCount: record?.items?.length || 0, + rawCount: (record as any)?.raw?.length || 0, + }); + const lastFetchedRef = useRef(null); + useEffect(() => { + // 初始化当前日期:路由参数优先,其次 store,最后今天 + if (currentDate && checkin?.currentDate !== currentDate) { + dispatch(setCurrentDate(currentDate)); + } + // 仅当切换日期时获取一次,避免重复请求 + if (currentDate && lastFetchedRef.current !== currentDate) { + lastFetchedRef.current = currentDate; + dispatch(getDailyCheckins(currentDate)).unwrap().catch((err: any) => { + Alert.alert('获取打卡失败', err?.message || '请稍后重试'); + }); + } + }, [dispatch, currentDate]); + + const lastSyncSigRef = useRef(''); useFocusEffect( React.useCallback(() => { - // 返回本页时确保与后端同步(若本地有内容则上报,后台 upsert) - if (record?.items && Array.isArray(record.items)) { - dispatch(syncCheckin({ date: today, items: record.items as CheckinExercise[], note: record?.note })); + // 仅当本地条目发生变更时才上报,避免反复刷写 + const sig = JSON.stringify(record?.items || []); + 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 () => { }; - }, [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 ( @@ -57,73 +90,185 @@ export default function CheckinHome() { router.back()} withSafeTop={false} transparent /> - {today} + {currentDate} 请选择动作并记录完成情况 - router.push('/checkin/select')}> + router.push({ pathname: '/checkin/select', params: { date: currentDate } })} + > 新增动作 + + setGenVisible(true)} + > + 一键排课(经典序列) + item.key} + data={(record?.items && record.items.length > 0) + ? record.items + : ((record as any)?.raw || [])} + keyExtractor={(item, index) => (item?.key || item?.id || `${currentDate}_${index}`)} contentContainerStyle={{ paddingHorizontal: 20, paddingBottom: 20 }} ListEmptyComponent={ 还没有选择任何动作,点击“新增动作”开始吧。 } - renderItem={({ item }) => ( - - - {item.name} - {item.category} - 组数 {item.sets}{item.reps ? ` · 每组 ${item.reps} 次` : ''}{item.durationSec ? ` · 每组 ${item.durationSec}s` : ''} - - { - dispatch(toggleExerciseCompleted({ date: today, key: item.key })); - const nextItems: CheckinExercise[] = (record?.items || []).map((it: CheckinExercise) => - it.key === item.key ? { ...it, completed: !it.completed } : it - ); - dispatch(syncCheckin({ date: today, items: nextItems, note: record?.note })); - }} - hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }} - > - - - - Alert.alert('确认移除', '确定要移除该动作吗?', [ - { text: '取消', style: 'cancel' }, - { - text: '移除', - style: 'destructive', - onPress: () => { - dispatch(removeExercise({ date: today, key: item.key })); - const nextItems: CheckinExercise[] = (record?.items || []).filter((it: CheckinExercise) => it.key !== item.key); - dispatch(syncCheckin({ date: today, items: nextItems, note: record?.note })); + 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 ( + + + {title} + {!!status && {status}} + {!!startedAt && {startedAt}} + + + ); + } + 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 ( + + + + + {isRest ? `间隔休息 ${exercise.restSec ?? 30}s` : (exercise.note || '提示')} + + + + 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 }} + > + + + + ); + } + return ( + + + {exercise.name} + {exercise.category} + {isNote && ( + {exercise.note || '提示'} + )} + {!isNote && ( + + {isRest + ? `建议休息 ${exercise.restSec ?? 30}s` + : `组数 ${exercise.sets}${exercise.reps ? ` · 每组 ${exercise.reps} 次` : ''}${exercise.durationSec ? ` · 每组 ${exercise.durationSec}s` : ''}`} + + )} + + {type === 'exercise' && ( + { + dispatch(toggleExerciseCompleted({ date: currentDate, key: exercise.key })); + const nextItems: CheckinExercise[] = (record?.items || []).map((it: CheckinExercise) => + it.key === exercise.key ? { ...it, completed: !it.completed } : it + ); + dispatch(syncCheckin({ date: currentDate, items: nextItems, note: record?.note })); + }} + hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }} + > + + + )} + + 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 })); + }, }, - }, - ]) - } - > - 移除 - - - )} + ]) + } + > + 移除 + + + ); + }} /> + {/* 生成配置弹窗 */} + setGenVisible(false)}> + setGenVisible(false)}> + e.stopPropagation() as any}> + 经典排课配置 + 强度水平 + + {(['beginner', 'intermediate', 'advanced'] as const).map((lv) => ( + setGenLevel(lv)}> + + {lv === 'beginner' ? '入门' : lv === 'intermediate' ? '进阶' : '高级'} + + + ))} + + + 段间休息 + + + + 插入操作提示 + + + + 休息秒数 + + + + + 生成今日计划 + + + + ); @@ -145,14 +290,35 @@ const styles = StyleSheet.create({ actionRow: { paddingHorizontal: 20, marginTop: 8 }, primaryBtn: { backgroundColor: '#111827', paddingVertical: 10, borderRadius: 10, alignItems: 'center' }, 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 }, 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 }, cardTitle: { fontSize: 16, fontWeight: '800', color: '#111827' }, 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 }, removeBtnText: { color: '#111827', fontWeight: '700' }, 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 }, }); diff --git a/app/checkin/select.tsx b/app/checkin/select.tsx index 71546ce..a9c5fb5 100644 --- a/app/checkin/select.tsx +++ b/app/checkin/select.tsx @@ -6,7 +6,7 @@ import { addExercise, syncCheckin } from '@/store/checkinSlice'; import { EXERCISE_LIBRARY, getCategories, searchExercises } from '@/utils/exerciseLibrary'; import { Ionicons } from '@expo/vector-icons'; 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 { 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() { const dispatch = useAppDispatch(); const router = useRouter(); + const params = useLocalSearchParams<{ date?: string }>(); 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 colorTokens = Colors[theme]; @@ -70,7 +72,7 @@ export default function SelectExerciseScreen() { const handleAdd = () => { if (!selected) return; dispatch(addExercise({ - date: today, + date: currentDate, item: { key: selected.key, name: selected.name, @@ -79,11 +81,11 @@ export default function SelectExerciseScreen() { reps: reps && reps > 0 ? reps : undefined, }, })); - console.log('addExercise', today, selected.key, sets, reps); + console.log('addExercise', currentDate, selected.key, sets, reps); // 同步到后端(读取最新 store 需要在返回后由首页触发 load,或此处直接上报) // 简单做法:直接上报新增项(其余项由后端合并/覆盖) dispatch(syncCheckin({ - date: today, + date: currentDate, items: [ { key: selected.key, diff --git a/app/profile/edit.tsx b/app/profile/edit.tsx index a211806..1731334 100644 --- a/app/profile/edit.tsx +++ b/app/profile/edit.tsx @@ -218,7 +218,7 @@ export default function EditProfileScreen() { allowsEditing: true, quality: 0.9, aspect: [1, 1], - mediaTypes: ImagePicker.MediaTypeOptions.Images, + mediaTypes: ['images'], base64: false, }); if (!result.canceled) { diff --git a/components/ArticleCard.tsx b/components/ArticleCard.tsx new file mode 100644 index 0000000..5fbbdd5 --- /dev/null +++ b/components/ArticleCard.tsx @@ -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 ( + router.push(`/article/${id}`)} style={styles.card}> + + + {title} + + {dayjs(publishedAt).format('YYYY-MM-DD')} + · + {readCount} 阅读 + + + + ); +} + +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, + }, +}); + + diff --git a/constants/Cos.ts b/constants/Cos.ts index 7e56eb4..8af1335 100644 --- a/constants/Cos.ts +++ b/constants/Cos.ts @@ -1,5 +1,5 @@ -export const COS_BUCKET: string = ''; -export const COS_REGION: string = ''; +export const COS_BUCKET: string = 'plates-1251306435'; +export const COS_REGION: string = 'ap-guangzhou'; 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 { - if (!COS_PUBLIC_BASE) return ''; - return `${COS_PUBLIC_BASE.replace(/\/$/, '')}/${key.replace(/^\//, '')}`; + const cleanedKey = key.replace(/^\//, ''); + if (COS_PUBLIC_BASE && COS_PUBLIC_BASE.trim()) { + return `${COS_PUBLIC_BASE.replace(/\/$/, '')}/${cleanedKey}`; + } + // 回退:使用 COS 默认公网域名 + // 例如: https://.cos..myqcloud.com/ + if (COS_BUCKET && COS_REGION) { + return `https://${COS_BUCKET}.cos.${COS_REGION}.myqcloud.com/${cleanedKey}`; + } + return ''; } diff --git a/hooks/useCosUpload.ts b/hooks/useCosUpload.ts index 53d9f00..07f1078 100644 --- a/hooks/useCosUpload.ts +++ b/hooks/useCosUpload.ts @@ -1,6 +1,7 @@ import { buildCosKey, buildPublicUrl } from '@/constants/Cos'; import { uploadWithRetry } from '@/services/cos'; import { useCallback, useMemo, useRef, useState } from 'react'; +import { Platform } from 'react-native'; export type UseCosUploadOptions = { prefix?: string; @@ -31,37 +32,44 @@ export function useCosUpload(defaultOptions?: UseCosUploadOptions) { setProgress(0); setUploading(true); try { - let body: any = null; - // 1) 直接可用类型:Blob 或 string - if (typeof file === 'string') { - body = file; - } else if (typeof Blob !== 'undefined' && file instanceof 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; - } 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) { - // 4) Expo ImagePicker/文件:必须先转 Blob - const resp = await fetch((file as any).uri); - body = await resp.blob(); - } else { - // 兜底:尝试直接作为字符串,否则抛错 - if (file && (typeof file === 'object')) { - throw new Error('无效的上传体:请提供 Blob/String,或包含 uri 的对象'); + let res: any; + if (Platform.OS === 'web') { + // Web:使用 Blob 走 cos-js-sdk-v5 分支 + let body: any = null; + if (typeof file === 'string') { + // 允许直接传 Base64/DataURL 字符串 + body = file; + } else if ((file as any)?.blob) { + body = (file as any).blob; + } else if ((file as any)?.uri) { + const resp = await fetch((file as any).uri); + body = await resp.blob(); + } else if (typeof Blob !== 'undefined' && file instanceof Blob) { + body = file; + } else { + throw new Error('无效的文件:请提供 uri 或 Blob'); } - body = file; + res = await uploadWithRetry({ + key, + body, + contentType: finalOptions.contentType || (file as any)?.type, + signal: controller.signal, + 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 res = await uploadWithRetry({ - key, - body, - contentType: finalOptions.contentType || (file as any)?.type, - signal: controller.signal, - onProgress: ({ percent }) => setProgress(percent), - }); const url = (res as any).publicUrl || buildPublicUrl(res.key); return { key: res.key, url }; } finally { diff --git a/ios/Podfile b/ios/Podfile index f60a7bb..6a64ccb 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -50,6 +50,14 @@ target 'digitalpilates' do :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 # when building for devices. installer.target_installation_results.pod_target_installation_results diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 7b6dd49..20234f2 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -113,6 +113,15 @@ PODS: - libwebp/sharpyuv (1.5.0) - libwebp/webp (1.5.0): - 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): - boost - DoubleConversion @@ -1367,78 +1376,13 @@ PODS: - React-jsiexecutor - React-RCTFBReactNativeSpec - 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): - - 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 (= 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): - DoubleConversion - glog @@ -1760,51 +1704,9 @@ PODS: - RNAppleHealthKit (1.7.0): - React - RNCAsyncStorage (2.2.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 - RNDateTimePicker (8.4.4): - - 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 - RNGestureHandler (2.24.0): - DoubleConversion - glog @@ -1948,31 +1850,6 @@ PODS: - ReactCommon/turbomodule/core - Yoga - 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 - glog - RCT-Folly (= 2024.11.18.00) @@ -1997,52 +1874,7 @@ PODS: - ReactCommon/turbomodule/core - Yoga - RNSVG (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 - - 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/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-ImageManager (from `../node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios`) - 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-jsi (from `../node_modules/react-native/ReactCommon/jsi`) - React-jsiexecutor (from `../node_modules/react-native/ReactCommon/jsiexecutor`) @@ -2118,6 +1949,8 @@ DEPENDENCIES: - React-logger (from `../node_modules/react-native/ReactCommon/logger`) - React-Mapbuffer (from `../node_modules/react-native/ReactCommon`) - 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-webview (from `../node_modules/react-native-webview`) - React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`) @@ -2164,6 +1997,9 @@ SPEC REPOS: - libavif - libdav1d - libwebp + - QCloudCore + - QCloudCOSXML + - QCloudTrack - SDWebImage - SDWebImageAVIFCoder - SDWebImageSVGCoder @@ -2285,6 +2121,10 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon" React-microtasksnativemodule: :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: :path: "../node_modules/react-native-safe-area-context" react-native-webview: @@ -2371,7 +2211,7 @@ SPEC CHECKSUMS: DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb EXConstants: 98bcf0f22b820f9b28f9fee55ff2daededadd2f8 EXImageLoader: 4d3d3284141f1a45006cc4d0844061c182daf7ee - Expo: ad449665420b9fe5e907bd97e79aec2e47f98785 + Expo: 8685113c16058e8b3eb101dd52d6c8bca260bbea ExpoAppleAuthentication: 8a661b6f4936affafd830f983ac22463c936dad5 ExpoAsset: ef06e880126c375f580d4923fdd1cdf4ee6ee7d6 ExpoBlur: 3c8885b9bf9eef4309041ec87adec48b5f1986a9 @@ -2384,8 +2224,8 @@ SPEC CHECKSUMS: ExpoKeepAwake: bf0811570c8da182bfb879169437d4de298376e7 ExpoLinearGradient: 7734c8059972fcf691fb4330bcdf3390960a152d ExpoLinking: d5c183998ca6ada66ff45e407e0f965b398a8902 - ExpoModulesCore: 2c1a84ec154d32afb4f6569bc558f059ebbcdb8e - ExpoSplashScreen: 0ad5acac1b5d2953c6e00d4319f16d616f70d4dd + ExpoModulesCore: 272bc6c06ddd9c4bee2048acc57891cab3700627 + ExpoSplashScreen: 1c22c5d37647106e42d4ae1582bb6d0dda3b2385 ExpoSymbols: c5612a90fb9179cdaebcd19bea9d8c69e5d3b859 ExpoSystemUI: c2724f9d5af6b1bb74e013efadf9c6a8fae547a2 ExpoWebBrowser: dc39a88485f007e61a3dff05d6a75f22ab4a2e92 @@ -2396,6 +2236,9 @@ SPEC CHECKSUMS: libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7 libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 + QCloudCore: 6f8c67b96448472d2c6a92b9cfe1bdb5abbb1798 + QCloudCOSXML: 92f50a787b4e8d9a7cb6ea8e626775256b4840a7 + QCloudTrack: 20b79388365b4c8ed150019c82a56f1569f237f8 RCT-Folly: e78785aa9ba2ed998ea4151e314036f6c49e6d82 RCTDeprecation: 5f638f65935e273753b1f31a365db6a8d6dc53b5 RCTRequired: 8b46a520ea9071e2bc47d474aa9ca31b4a935bd8 @@ -2427,22 +2270,24 @@ SPEC CHECKSUMS: React-logger: 85fa3509931497c72ccd2547fcc91e7299d8591e React-Mapbuffer: 96a2f2a176268581733be182fa6eebab1c0193be React-microtasksnativemodule: 11b292232f1626567a79d58136689f1b911c605f - react-native-safe-area-context: b0ee54c424896b916aab46212b884cb8794308d7 - react-native-webview: faccaeb84216940628d4422822d367ad03d15a81 + react-native-cos-sdk: a29ad87f60e2edb2adc46da634aa5b6e7cd14e35 + react-native-render-html: 5afc4751f1a98621b3009432ef84c47019dcb2bd + react-native-safe-area-context: 9d72abf6d8473da73033b597090a80b709c0b2f1 + react-native-webview: 3df1192782174d1bd23f6a0f5a4fec3cdcca9954 React-NativeModulesApple: 494c38599b82392ed14b2c0118fca162425bb618 React-oscompat: 0592889a9fcf0eacb205532028e4a364e22907dd React-perflogger: c584fa50e422a46f37404d083fad12eb289d5de4 React-performancetimeline: 8deae06fc819e6f7d1f834818e72ab5581540e45 React-RCTActionSheet: ce67bdc050cc1d9ef673c7a93e9799288a183f24 React-RCTAnimation: 8bb813eb29c6de85be99c62640f3a999df76ba02 - React-RCTAppDelegate: 4de5b1b68d9bc435bb7949fdde274895f12428c6 + React-RCTAppDelegate: 738515a4ab15cc17996887269e17444bf08dee85 React-RCTBlob: 4c6fa35aa8b2b4d46ff2e5fb80c2b26df9457e57 - React-RCTFabric: 05582e7dc62b2c393b054b39d1b4202e9dcbce68 - React-RCTFBReactNativeSpec: f5970e7ba0b15cf23c0552c82251aff9630a6acd + React-RCTFabric: f53fbf29459c959ce9ccbea28edfe6dc9ca35e36 + React-RCTFBReactNativeSpec: 1a2f1bd84f03ea0d7e3228055c3b894fb56680dd React-RCTImage: 8a4f6ce18e73a7e894b886dfb7625e9e9fbc90ef React-RCTLinking: fa49c624cd63979e7a6295ae9b1351d23ac4395a React-RCTNetwork: f236fd2897d18522bba24453e2995a4c83e01024 - React-RCTRuntime: f46f5c9890b77bbb38a536157d317a7a04a8825e + React-RCTRuntime: 596bd113c46f61d82ac5d6199023bafd9a390cf4 React-RCTSettings: 69e2f25a5a1bf6cb37eef2e5c3bd4bb7e848296b React-RCTText: 515ce74ed79c31dbf509e6f12770420ebbf23755 React-RCTVibration: ef30ada606dfed859b2c71577f6f041d47f2cfbb @@ -2460,12 +2305,12 @@ SPEC CHECKSUMS: ReactCodegen: 272c9bc1a8a917bf557bd9d032a4b3e181c6abfe ReactCommon: 7eb76fcd5133313d8c6a138a5c7dd89f80f189d5 RNAppleHealthKit: 86ef7ab70f762b802f5c5289372de360cca701f9 - RNCAsyncStorage: f4b48b7eb2ae9296be4df608ff60c1b12a469b7a - RNDateTimePicker: 41af3f0749ea5555f15805b468bc8453e6fa9850 - RNGestureHandler: 6bf8b210cbad95ced45f3f9b8df05924b3a97300 - RNReanimated: 79c239f5562adcf2406b681830f716f1e7d76081 - RNScreens: dd9a329b21412c5322a5447fc2c3ae6471cf6e5a - RNSVG: 820687c168d70d90a47d96a0cd5e263905fc67d9 + RNCAsyncStorage: b44e8a4e798c3e1f56bffccd0f591f674fb9198f + RNDateTimePicker: 7d93eacf4bdf56350e4b7efd5cfc47639185e10c + RNGestureHandler: 6e640921d207f070e4bbcf79f4e6d0eabf323389 + RNReanimated: 34e90d19560aebd52a2ad583fdc2de2cf7651bbb + RNScreens: 241cfe8fc82737f3e132dd45779f9512928075b8 + RNSVG: 3544def7b3ddc43c7ba69dade91bacf99f10ec46 SDWebImage: f29024626962457f3470184232766516dee8dfea SDWebImageAVIFCoder: 00310d246aab3232ce77f1d8f0076f8c4b021d90 SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c @@ -2473,6 +2318,6 @@ SPEC CHECKSUMS: SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 Yoga: adb397651e1c00672c12e9495babca70777e411e -PODFILE CHECKSUM: b384f735cddc85333f7f9842fb492a2893323ea2 +PODFILE CHECKSUM: 8d79b726cf7814a1ef2e250b7a9ef91c07c77936 COCOAPODS: 1.16.2 diff --git a/ios/Podfile.properties.json b/ios/Podfile.properties.json index 282473a..ddd0386 100644 --- a/ios/Podfile.properties.json +++ b/ios/Podfile.properties.json @@ -1,5 +1,5 @@ { "expo.jsEngine": "jsc", "EX_DEV_CLIENT_NETWORK_INSPECTOR": "true", - "newArchEnabled": "true" + "newArchEnabled": "false" } \ No newline at end of file diff --git a/ios/digitalpilates.xcodeproj/project.pbxproj b/ios/digitalpilates.xcodeproj/project.pbxproj index 745d981..b18038b 100644 --- a/ios/digitalpilates.xcodeproj/project.pbxproj +++ b/ios/digitalpilates.xcodeproj/project.pbxproj @@ -268,6 +268,7 @@ "${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_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}/RNCAsyncStorage/RNCAsyncStorage_resources.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}/ExpoFileSystem_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}/RNCAsyncStorage_resources.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNSVGFilters.bundle", @@ -322,6 +324,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 756WVXJ6MT; ENABLE_BITCODE = NO; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64; GCC_PREPROCESSOR_DEFINITIONS = ( "$(inherited)", "FB_SONARKIT_ENABLED=1", @@ -358,6 +361,7 @@ CODE_SIGN_ENTITLEMENTS = digitalpilates/digitalpilates.entitlements; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 756WVXJ6MT; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64; INFOPLIST_FILE = digitalpilates/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; LD_RUNPATH_SEARCH_PATHS = ( diff --git a/package-lock.json b/package-lock.json index 8de7cdc..83756a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,11 +36,14 @@ "react": "19.0.0", "react-dom": "19.0.0", "react-native": "0.79.5", + "react-native-cos-sdk": "^1.2.1", "react-native-gesture-handler": "~2.24.0", "react-native-health": "^1.19.0", "react-native-image-viewing": "^0.2.2", + "react-native-markdown-display": "^7.0.2", "react-native-modal-datetime-picker": "^18.0.0", "react-native-reanimated": "~3.17.4", + "react-native-render-html": "^6.3.4", "react-native-safe-area-context": "5.4.0", "react-native-screens": "~4.11.1", "react-native-svg": "^15.12.1", @@ -2652,6 +2655,23 @@ "@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": { "version": "0.2.12", "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" } }, + "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": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3051,6 +3153,20 @@ "integrity": "sha512-nGXMNMclZgzLUxijQQ38Dm3IAEhgxuySAWQHnljFtfB0JdaMwpe0Ox9H7Tp2OgrEA+EMEv+Od9ElKlHwGKmmvQ==", "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": { "version": "7.4.5", "resolved": "https://registry.npmjs.org/@react-navigation/bottom-tabs/-/bottom-tabs-7.4.5.tgz", @@ -3341,22 +3457,47 @@ "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": { "version": "19.0.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.14.tgz", "integrity": "sha512-ixLZ7zG7j1fM0DijL9hDArwhwcCb4vqmePgwtV0GfnkHRSCUEv4LvzarcTdhoqgyMznUx/EhoTUv31CKZzkQlw==", - "devOptional": true, "license": "MIT", "dependencies": { "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": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", "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": { "version": "0.0.6", "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_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": { "version": "1.0.30001733", "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" } }, + "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": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -5284,6 +5453,15 @@ "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": { "version": "3.1.0", "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" } }, + "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": { "version": "1.1.3", "resolved": "https://mirrors.tencent.com/npm/css-tree/-/css-tree-1.1.3.tgz", @@ -5343,7 +5532,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "devOptional": true, "license": "MIT" }, "node_modules/data-view-buffer": { @@ -7458,6 +7646,83 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "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": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -8780,6 +9045,15 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "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": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -8927,6 +9201,36 @@ "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": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz", @@ -8949,6 +9253,11 @@ "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", "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": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", @@ -10361,6 +10670,12 @@ ], "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": { "version": "1.2.1", "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": { "version": "1.6.0", "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": "*" } }, + "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": { "version": "2.24.0", "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.24.0.tgz", @@ -10744,6 +11084,22 @@ "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": { "version": "18.0.0", "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": "*" } }, + "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": { "version": "5.4.0", "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": "*" } }, + "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": { "version": "0.20.0", "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" } }, + "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": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", @@ -12565,6 +12966,12 @@ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", "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": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -12748,6 +13155,12 @@ "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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -12918,6 +13331,12 @@ "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": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/use-latest-callback/-/use-latest-callback-0.2.4.tgz", @@ -13356,6 +13775,15 @@ "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": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index a9e90a4..5ea52c7 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,6 @@ "lint": "expo lint" }, "dependencies": { - "cos-js-sdk-v5": "^1.6.0", "@expo/vector-icons": "^14.1.0", "@react-native-async-storage/async-storage": "^2.2.0", "@react-native-community/datetimepicker": "^8.4.4", @@ -19,6 +18,7 @@ "@react-navigation/elements": "^2.3.8", "@react-navigation/native": "^7.1.6", "@reduxjs/toolkit": "^2.8.2", + "cos-js-sdk-v5": "^1.6.0", "dayjs": "^1.11.13", "expo": "~53.0.20", "expo-apple-authentication": "6.4.2", @@ -39,11 +39,14 @@ "react": "19.0.0", "react-dom": "19.0.0", "react-native": "0.79.5", + "react-native-cos-sdk": "^1.2.1", "react-native-gesture-handler": "~2.24.0", "react-native-health": "^1.19.0", "react-native-image-viewing": "^0.2.2", + "react-native-markdown-display": "^7.0.2", "react-native-modal-datetime-picker": "^18.0.0", "react-native-reanimated": "~3.17.4", + "react-native-render-html": "^6.3.4", "react-native-safe-area-context": "5.4.0", "react-native-screens": "~4.11.1", "react-native-svg": "^15.12.1", diff --git a/services/articles.ts b/services/articles.ts new file mode 100644 index 0000000..cb2a889 --- /dev/null +++ b/services/articles.ts @@ -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: ` +

为什么核心很重要?

+

核心是维持良好体态与动作稳定的关键。普拉提通过强调呼吸与深层肌群激活,帮助你在日常站立、坐姿与训练中保持更好的身体对齐。

+

入门建议

+
    +
  1. 从呼吸开始:尝试胸廓外扩而非耸肩。
  2. +
  3. 慢而可控:注意动作过程中的连贯与专注。
  4. +
  5. 记录变化:每周拍照或在应用中记录体态变化。
  6. +
+

更多实操可在本应用的「AI体态评估」中获取个性化建议。

+ pilates-illustration + `, + }, +]; + +export function listRecommendedArticles(): Article[] { + // 实际项目中可替换为 API 请求 + return demoArticles; +} + +export async function getArticleById(id: string): Promise
{ + return api.get
(`/articles/${id}`); +} + + diff --git a/services/cos.ts b/services/cos.ts index 13c2ccf..cd77844 100644 --- a/services/cos.ts +++ b/services/cos.ts @@ -1,5 +1,6 @@ import { COS_BUCKET, COS_REGION, buildPublicUrl } from '@/constants/Cos'; import { api } from '@/services/api'; +import Cos from 'react-native-cos-sdk'; type ServerCosToken = { tmpSecretId: string; @@ -29,41 +30,24 @@ type CosCredential = { type UploadOptions = { key: string; - body: any; + // React Native COS SDK 推荐使用本地文件路径(file:// 或 content://) + srcUri?: string; + // 为兼容旧实现(Web/Blob)。在 RN SDK 下会忽略 body + body?: any; contentType?: string; onProgress?: (progress: { percent: number }) => void; signal?: AbortSignal; }; -let CosSdk: any | null = null; +let rnTransferManager: any | null = null; +let rnInitialized = false; -async function ensureCosSdk(): Promise { - 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 { // 后端返回 { code, message, data },api.get 会提取 data const data = await api.get('/api/users/cos/upload-token'); + + console.log('fetchCredential', data); return { credentials: { tmpSecretId: data.tmpSecretId, @@ -80,8 +64,8 @@ async function fetchCredential(): Promise { } export async function uploadToCos(options: UploadOptions): Promise<{ key: string; etag?: string; headers?: Record; publicUrl?: string }> { - const { key, body, contentType, onProgress, signal } = options; - const COS = await ensureCosSdk(); + const { key, srcUri, contentType, onProgress, signal } = options; + const cred = await fetchCredential(); const bucket = COS_BUCKET || cred.bucket; 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'); } - // 确保对象键以服务端授权的前缀开头 + // 确保对象键以服务端授权的前缀开头(去除通配符,折叠斜杠,避免把 * 拼进 Key) const finalKey = ((): string => { - const prefix = (cred.prefix || '').replace(/^\/+|\/+$/g, ''); - if (!prefix) return key.replace(/^\//, ''); + const rawPrefix = String(cred.prefix || ''); + // 1) 去掉 * 与多余斜杠,再去掉首尾斜杠 + const safePrefix = rawPrefix + .replace(/\*/g, '') + .replace(/\/{2,}/g, '/') + .replace(/^\/+|\/+$/g, ''); 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(); if (signal) { if (signal.aborted) controller.abort(); @@ -104,43 +122,46 @@ export async function uploadToCos(options: UploadOptions): Promise<{ key: string } return await new Promise((resolve, reject) => { - const cos = new COS({ - getAuthorization: (_opts: any, cb: any) => { - cb({ - TmpSecretId: cred.credentials.tmpSecretId, - TmpSecretKey: cred.credentials.tmpSecretKey, - SecurityToken: cred.credentials.sessionToken, - StartTime: cred.startTime, - ExpiredTime: cred.expiredTime, - }); - }, - }); - - const task = cos.putObject( - { - Bucket: bucket, - Region: region, - Key: finalKey, - Body: body, - ContentType: contentType, - onProgress: (progressData: any) => { - if (onProgress) { - const percent = progressData && progressData.percent ? progressData.percent : 0; - onProgress({ percent }); + let cancelled = false; + let taskRef: any = null; + (async () => { + try { + taskRef = await rnTransferManager.upload( + bucket, + finalKey, + srcUri, + { + resultListener: { + successCallBack: (header: any) => { + if (cancelled) return; + const publicUrl = buildPublicUrl(finalKey); + const etag = header?.ETag || header?.headers?.ETag || header?.headers?.etag; + 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) { + const percent = target > 0 ? complete / target : 0; + 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', () => { - try { task && task.cancel && task.cancel(); } catch { } + cancelled = true; + try { taskRef?.cancel?.(); } catch { } reject(new DOMException('Aborted', 'AbortError')); }); }); diff --git a/services/recommendations.ts b/services/recommendations.ts new file mode 100644 index 0000000..802536b --- /dev/null +++ b/services/recommendations.ts @@ -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; +}; + +export async function fetchRecommendations(): Promise { + // 后端返回 BaseResponseDto,services/api 会自动解出 data 字段 + return api.get(`/recommendations/list`); +} + + diff --git a/store/checkinSlice.ts b/store/checkinSlice.ts index b8219ad..2d99344 100644 --- a/store/checkinSlice.ts +++ b/store/checkinSlice.ts @@ -5,9 +5,18 @@ export type CheckinExercise = { key: string; name: string; category: string; - sets: number; // 组数 + /** + * itemType + * - exercise: 正常训练动作(默认) + * - rest: 组间/动作间休息(仅展示,不可勾选完成) + * - note: 备注/口令提示(仅展示) + */ + itemType?: 'exercise' | 'rest' | 'note'; + sets: number; // 组数(rest/note 可为 0) reps?: number; // 每组重复(计次型) durationSec?: number; // 每组时长(计时型) + restSec?: number; // 休息时长(当 itemType=rest 时使用) + note?: string; // 备注内容(当 itemType=note 时使用) completed?: boolean; // 是否已完成该动作 }; @@ -16,6 +25,8 @@ export type CheckinRecord = { date: string; // YYYY-MM-DD items: CheckinExercise[]; note?: string; + // 保留后端原始返回,便于当 metrics.items 为空时做回退展示 + raw?: any[]; }; export type CheckinState = { @@ -60,10 +71,17 @@ const checkinSlice = createSlice({ const rec = ensureRecord(state, action.payload.date); 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 }>) { const rec = ensureRecord(state, action.payload.date); 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 }>) { const rec = ensureRecord(state, action.payload.date); @@ -106,20 +124,27 @@ const checkinSlice = createSlice({ date, items: mergedItems, note, + raw: list, }; }) .addCase(loadMonthCheckins.fulfilled, (state, action) => { const monthKey = action.payload.monthKey; const merged = action.payload.byDate; 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; }); }, }); -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; // Thunks @@ -145,9 +170,10 @@ export const syncCheckin = createAsyncThunk('checkin/sync', async (record: { dat // 获取当天打卡列表(用于进入页面时拉取最新云端数据) export const getDailyCheckins = createAsyncThunk('checkin/getDaily', async (date?: string) => { - const list = await fetchDailyCheckins(date); - - return { date, list } as { date?: string; list: any[] }; + const dateParam = date ?? new Date().toISOString().slice(0, 10); + const list = await fetchDailyCheckins(dateParam); + try { console.log('getDailyCheckins', { date: dateParam, count: Array.isArray(list) ? list.length : -1 }); } catch { } + return { date: dateParam, list } as { date?: string; list: any[] }; }); // 按月加载:优先使用区间接口,失败则逐日回退 diff --git a/types/react-native-cos-sdk.d.ts b/types/react-native-cos-sdk.d.ts new file mode 100644 index 0000000..a7aa203 --- /dev/null +++ b/types/react-native-cos-sdk.d.ts @@ -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): Promise | void; + + export function registerDefaultService(config: { region: string; isHttps?: boolean; isDebuggable?: boolean }): Promise; + + export function registerDefaultTransferManger( + serviceConfig: { region: string; isHttps?: boolean; isDebuggable?: boolean }, + transferConfig: { + forceSimpleUpload?: boolean; + enableVerification?: boolean; + divisionForUpload?: number; + sliceSizeForUpload?: number; + } + ): Promise; + + export function getDefaultTransferManger(): any; + + export default { + initWithSessionCredentialCallback, + registerDefaultService, + registerDefaultTransferManger, + getDefaultTransferManger, + }; +} + + diff --git a/utils/classicalSession.ts b/utils/classicalSession.ts new file mode 100644 index 0000000..d939c48 --- /dev/null +++ b/utils/classicalSession.ts @@ -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 }; +} + +