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

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

View File

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

View File

@@ -1,9 +1,12 @@
import { ArticleCard } from '@/components/ArticleCard';
import { PlanCard } from '@/components/PlanCard';
import { SearchBox } from '@/components/SearchBox';
import { ThemedText } from '@/components/ThemedText';
import { ThemedView } from '@/components/ThemedView';
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
import { listRecommendedArticles } from '@/services/articles';
import { fetchRecommendations, RecommendationType } from '@/services/recommendations';
// Removed WorkoutCard import since we no longer use the horizontal carousel
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { getChineseGreeting } from '@/utils/date';
@@ -16,7 +19,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
export default function HomeScreen() {
const router = useRouter();
const { pushIfAuthedElseLogin } = useAuthGuard();
const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const insets = useSafeAreaInsets();
@@ -72,6 +75,107 @@ export default function HomeScreen() {
});
},
}), [coachSize.height, coachSize.width, insets.bottom, insets.top, pan, windowHeight, windowWidth, router]);
// 推荐项类型(本地 UI 使用)
type RecommendItem =
| {
type: 'plan';
key: string;
image: string;
title: string;
subtitle: string;
level?: '初学者' | '中级' | '高级';
progress: number;
onPress?: () => void;
}
| {
type: 'article';
key: string;
id: string;
title: string;
coverImage: string;
publishedAt: string;
readCount: number;
};
// 打底数据(接口不可用时)
const getFallbackItems = React.useCallback((): RecommendItem[] => {
return [
{
type: 'plan',
key: 'assess',
image:
'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/imagedemo.jpeg',
title: '体态评估',
subtitle: '评估你的体态,制定训练计划',
level: '初学者',
progress: 0,
onPress: () => router.push('/ai-posture-assessment'),
},
...listRecommendedArticles().map((a) => ({
type: 'article' as const,
key: `article-${a.id}`,
id: a.id,
title: a.title,
coverImage: a.coverImage,
publishedAt: a.publishedAt,
readCount: a.readCount,
})),
];
}, [router]);
const [items, setItems] = React.useState<RecommendItem[]>(() => getFallbackItems());
// 拉取推荐接口(已登录时)
React.useEffect(() => {
let canceled = false;
async function load() {
if (!isLoggedIn) {
console.log('fetchRecommendations not logged in');
setItems(getFallbackItems());
return;
}
try {
const cards = await fetchRecommendations();
console.log('fetchRecommendations', cards);
if (canceled) return;
const mapped: RecommendItem[] = [];
for (const c of cards || []) {
if (c.type === RecommendationType.Article) {
const publishedAt = (c.extra && (c.extra.publishedDate || c.extra.published_at)) || new Date().toISOString();
const readCount = (c.extra && (c.extra.readCount ?? c.extra.read_count)) || 0;
mapped.push({
type: 'article',
key: c.id,
id: c.articleId || c.id,
title: c.title || '',
coverImage: c.coverUrl,
publishedAt,
readCount,
});
} else if (c.type === RecommendationType.Checkin) {
mapped.push({
type: 'plan',
key: c.id || 'checkin',
image: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/imagedemo.jpeg',
title: c.title || '今日打卡',
subtitle: c.subtitle || '完成一次普拉提训练,记录你的坚持',
progress: 0,
onPress: () => pushIfAuthedElseLogin('/checkin'),
});
}
}
// 若接口返回空,也回退到打底
setItems(mapped.length > 0 ? mapped : getFallbackItems());
} catch (e) {
console.error('fetchRecommendations error', e);
setItems(getFallbackItems());
}
}
load();
return () => { canceled = true; };
}, [isLoggedIn, pushIfAuthedElseLogin, getFallbackItems]);
return (
<SafeAreaView style={[styles.safeArea, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
<ThemedView style={[styles.container, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
@@ -124,7 +228,7 @@ export default function HomeScreen() {
<ScrollView showsVerticalScrollIndicator={false}>
{/* Header Section */}
<View style={styles.header}>
<ThemedText style={styles.greeting}>{getChineseGreeting()} 🔥</ThemedText>
<ThemedText style={styles.greeting}>{getChineseGreeting()}</ThemedText>
<ThemedText style={styles.userName}></ThemedText>
</View>
@@ -142,9 +246,6 @@ export default function HomeScreen() {
>
<ThemedText style={styles.featureTitle}>AI体态评估</ThemedText>
<ThemedText style={styles.featureSubtitle}>3</ThemedText>
<View style={styles.featureCta}>
<ThemedText style={styles.featureCtaText}></ThemedText>
</View>
</Pressable>
<Pressable
@@ -153,9 +254,22 @@ export default function HomeScreen() {
>
<ThemedText style={styles.featureTitle}>线</ThemedText>
<ThemedText style={styles.featureSubtitle}> · 11</ThemedText>
<View style={styles.featureCta}>
<ThemedText style={styles.featureCtaText}></ThemedText>
</View>
</Pressable>
<Pressable
style={[styles.featureCard, styles.featureCardTertiary]}
onPress={() => pushIfAuthedElseLogin('/checkin')}
>
<ThemedText style={styles.featureTitle}></ThemedText>
<ThemedText style={styles.featureSubtitle}> · </ThemedText>
</Pressable>
<Pressable
style={[styles.featureCard, styles.featureCardQuaternary]}
onPress={() => pushIfAuthedElseLogin('/training-plan')}
>
<ThemedText style={styles.featureTitle}></ThemedText>
<ThemedText style={styles.featureSubtitle}> · </ThemedText>
</Pressable>
</View>
</View>
@@ -165,40 +279,36 @@ export default function HomeScreen() {
<ThemedText style={styles.sectionTitle}></ThemedText>
<View style={styles.planList}>
<PlanCard
image={'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/imagedemo.jpeg'}
title="体态评估"
subtitle="评估你的体态,制定训练计划"
level="初学者"
progress={0}
/>
{/* 原“每周打卡”改为进入打卡日历 */}
<Pressable onPress={() => router.push('/checkin/calendar')}>
<PlanCard
image={'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/Image30play@2x.png'}
title="打卡日历"
subtitle="查看每日打卡记录(点亮日期)"
progress={0.75}
/>
</Pressable>
<Pressable onPress={() => pushIfAuthedElseLogin('/training-plan')}>
<PlanCard
image={'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/Image30play@2x.png'}
title="训练计划制定"
subtitle="按周安排/次数·常见目标·个性化选项"
level="初学者"
progress={0}
/>
</Pressable>
<Pressable onPress={() => pushIfAuthedElseLogin('/checkin')}>
<PlanCard
image={'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/ImageCheck.jpeg'}
title="每日打卡(自选动作)"
subtitle="选择动作,设置组数/次数,记录完成"
level="初学者"
progress={0}
/>
</Pressable>
{items.map((item) => {
if (item.type === 'article') {
return (
<ArticleCard
key={item.key}
id={item.id}
title={item.title}
coverImage={item.coverImage}
publishedAt={item.publishedAt}
readCount={item.readCount}
/>
);
}
const card = (
<PlanCard
image={item.image}
title={item.title}
subtitle={item.subtitle}
level={item.level}
progress={item.progress}
/>
);
return item.onPress ? (
<Pressable key={item.key} onPress={item.onPress}>
{card}
</Pressable>
) : (
<View key={item.key}>{card}</View>
);
})}
</View>
</View>
@@ -299,19 +409,20 @@ const styles = StyleSheet.create({
paddingHorizontal: 24,
flexDirection: 'row',
justifyContent: 'space-between',
flexWrap: 'wrap',
},
featureCard: {
width: '48%',
borderRadius: 16,
padding: 16,
borderRadius: 12,
padding: 12,
backgroundColor: '#FFFFFF',
// iOS shadow
marginBottom: 12,
// 轻量阴影,减少臃肿感
shadowColor: '#000',
shadowOpacity: 0.08,
shadowRadius: 12,
shadowOffset: { width: 0, height: 6 },
// Android shadow
elevation: 4,
shadowOpacity: 0.04,
shadowRadius: 8,
shadowOffset: { width: 0, height: 4 },
elevation: 2,
},
featureCardPrimary: {
backgroundColor: '#EEF2FF', // 柔和的靛蓝背景
@@ -319,33 +430,26 @@ const styles = StyleSheet.create({
featureCardSecondary: {
backgroundColor: '#F0FDFA', // 柔和的青绿背景
},
featureCardTertiary: {
backgroundColor: '#FFF7ED', // 柔和的橙色背景
},
featureCardQuaternary: {
backgroundColor: '#F5F3FF', // 柔和的紫色背景
},
featureIcon: {
fontSize: 28,
marginBottom: 8,
},
featureTitle: {
fontSize: 18,
fontSize: 16,
fontWeight: '700',
color: '#0F172A',
marginBottom: 6,
marginBottom: 4,
},
featureSubtitle: {
fontSize: 12,
fontSize: 11,
color: '#6B7280',
lineHeight: 16,
marginBottom: 12,
},
featureCta: {
alignSelf: 'flex-start',
backgroundColor: '#0F172A',
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 999,
},
featureCtaText: {
color: '#FFFFFF',
fontSize: 12,
fontWeight: '600',
lineHeight: 15,
},
planList: {
paddingHorizontal: 24,

View File

@@ -49,6 +49,7 @@ export default function RootLayout() {
<Stack.Screen name="auth/login" options={{ headerShown: false }} />
<Stack.Screen name="legal/user-agreement" options={{ headerShown: true, title: '用户协议' }} />
<Stack.Screen name="legal/privacy-policy" options={{ headerShown: true, title: '隐私政策' }} />
<Stack.Screen name="article/[id]" options={{ headerShown: false }} />
<Stack.Screen name="+not-found" />
</Stack>
<StatusBar style="auto" />

View File

@@ -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<number>(80);
const shouldAutoScrollRef = useRef(false);
const [keyboardOffset, setKeyboardOffset] = useState(0);
const pendingAssistantIdRef = useRef<string | null>(null);
const [selectedImages, setSelectedImages] = useState<Array<{
id: string;
localUri: string;
width?: number;
height?: number;
progress: number;
uploadedKey?: string;
uploadedUrl?: string;
error?: string;
}>>([]);
const [previewImageUri, setPreviewImageUri] = useState<string | null>(null);
const planDraft = useAppSelector((s) => s.trainingPlan?.draft);
const 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<string[]> {
const urls: string[] = [];
for (const img of selectedImages) {
if (img.uploadedUrl) {
urls.push(img.uploadedUrl);
continue;
}
try {
const resp = await fetch(img.localUri);
const blob = await resp.blob();
const ext = (() => {
const t = (blob.type || '').toLowerCase();
if (t.includes('png')) return 'png';
if (t.includes('webp')) return 'webp';
if (t.includes('heic')) return 'heic';
if (t.includes('heif')) return 'heif';
return 'jpg';
})();
const key = buildCosKey({ prefix: 'images/chat', ext });
const res = await uploadWithRetry({
key,
body: blob,
contentType: blob.type || 'image/jpeg',
onProgress: ({ percent }: { percent?: number }) => {
const p = typeof percent === 'number' ? percent : 0;
setSelectedImages((prev) => prev.map((it) => it.id === img.id ? { ...it, progress: p } : it));
},
} as any);
const url = buildPublicUrl(res.key);
urls.push(url);
setSelectedImages((prev) => prev.map((it) => it.id === img.id ? { ...it, uploadedKey: res.key, uploadedUrl: url, progress: 1 } : it));
} catch (e: any) {
setSelectedImages((prev) => prev.map((it) => it.id === img.id ? { ...it, error: e?.message || '上传失败' } : it));
throw e;
}
}
return urls;
}
try {
const urls = await ensureImagesUploaded();
const mdImages = urls.map((u) => `![image](${u})`).join('\n\n');
const composed = [trimmed, mdImages].filter(Boolean).join('\n\n');
setInput('');
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() {
},
]}
>
<Text style={[styles.bubbleText, { color: isUser ? theme.onPrimary : '#192126' }]}>{item.content}</Text>
{renderBubbleContent(item)}
</View>
{false}
</Animated.View>
);
}
function renderBubbleContent(item: ChatMessage) {
if (!item.content?.trim() && isStreaming && pendingAssistantIdRef.current === item.id) {
return <Text style={[styles.bubbleText, { color: '#687076' }]}></Text>;
}
if (item.content?.startsWith('__WEIGHT_INPUT_CARD__')) {
const preset = (() => {
const m = item.content.split('\n')?.[1];
const v = parseFloat(m || '');
return isNaN(v) ? '' : String(v);
})();
return (
<View style={{ gap: 8 }}>
<Text style={[styles.bubbleText, { color: '#192126', fontWeight: '700' }]}></Text>
<View style={styles.weightRow}>
<TextInput
placeholder="例如 60.5"
keyboardType="decimal-pad"
defaultValue={preset}
placeholderTextColor={'#687076'}
style={styles.weightInput}
onSubmitEditing={(e) => handleSubmitWeight(e.nativeEvent.text)}
returnKeyType="done"
blurOnSubmit
/>
<Text style={styles.weightUnit}>kg</Text>
<TouchableOpacity accessibilityRole="button" style={styles.weightSaveBtn} onPress={() => handleSubmitWeight((preset || '').toString())}>
<Text style={{ color: '#192126', fontWeight: '700' }}></Text>
</TouchableOpacity>
</View>
<Text style={{ color: '#687076', fontSize: 12 }}></Text>
</View>
);
}
return (
<Markdown style={markdownStyles} mergeStyle>
{item.content}
</Markdown>
);
}
function insertWeightInputCard() {
const id = `wcard_${Date.now()}`;
const preset = userProfile?.weight ? Number(userProfile.weight) : undefined;
const payload = `__WEIGHT_INPUT_CARD__\n${preset ?? ''}`;
setMessages((prev) => [...prev, { id, role: 'assistant', content: payload }]);
setTimeout(scrollToEnd, 0);
}
async function handleSubmitWeight(text?: string) {
const val = parseFloat(String(text ?? '').trim());
if (isNaN(val) || val <= 0 || val > 500) {
Alert.alert('请输入有效体重', '请填写合理的公斤数,例如 60.5');
return;
}
try {
// 本地更新
dispatch(updateProfile({ weight: val }));
// 后端同步(若有 userId 则更稳妥;后端实现容错)
try {
const userId = (userProfile as any)?.userId || (userProfile as any)?.id || (userProfile as any)?._id;
if (userId) {
await updateUserApi({ userId, weight: val });
await dispatch(fetchMyProfile() as any);
}
} catch (e) {
// 不阻断对话体验
}
// 在对话中插入“确认消息”并发送给教练
const textMsg = `我记录了今日体重:${val} kg。请基于这一变化给出训练/营养建议。`;
await send(textMsg);
} catch (e: any) {
Alert.alert('保存失败', e?.message || '请稍后重试');
}
}
return (
<View style={[styles.screen, { backgroundColor: theme.background }]}>
<HeaderBar
@@ -434,7 +638,7 @@ export default function AICoachChatScreen() {
}}
contentContainerStyle={{ paddingHorizontal: 14, paddingTop: 8 }}
ListFooterComponent={() => (
<View style={{ height: insets.bottom + composerHeight + (isAtBottom ? 0 : 56) + 16 }} />
<View style={{ height: insets.bottom + keyboardOffset + composerHeight + (isAtBottom ? 0 : 56) + 16 }} />
)}
onContentSizeChange={() => {
// 首次内容变化强制滚底,其余仅在接近底部时滚动
@@ -454,53 +658,90 @@ export default function AICoachChatScreen() {
showsVerticalScrollIndicator={false}
/>
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'} keyboardVerticalOffset={insets.top}>
<BlurView
intensity={18}
tint={'light'}
style={[styles.composerWrap, { paddingBottom: insets.bottom + 10 }]}
onLayout={(e) => {
const h = e.nativeEvent.layout.height;
if (h && Math.abs(h - composerHeight) > 0.5) setComposerHeight(h);
}}
<BlurView
intensity={18}
tint={'light'}
style={[styles.composerWrap, { paddingBottom: insets.bottom + 10, bottom: keyboardOffset }]}
onLayout={(e) => {
const h = e.nativeEvent.layout.height;
if (h && Math.abs(h - composerHeight) > 0.5) setComposerHeight(h);
}}
>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
decelerationRate="fast"
snapToAlignment="start"
style={styles.chipsRowScroll}
contentContainerStyle={{ paddingHorizontal: 6, gap: 8 }}
>
<View style={styles.chipsRow}>
{chips.map((c) => (
<TouchableOpacity key={c.key} style={[styles.chip, { borderColor: 'rgba(187,242,70,0.35)', backgroundColor: 'rgba(187,242,70,0.12)' }]} onPress={c.action}>
<Text style={[styles.chipText, { color: '#192126' }]}>{c.label}</Text>
</TouchableOpacity>
))}
</View>
<View style={[styles.inputRow, { borderColor: 'rgba(187,242,70,0.35)', backgroundColor: 'rgba(187,242,70,0.08)' }]}>
<TextInput
placeholder="问我任何与普拉提相关的问题..."
placeholderTextColor={theme.textMuted}
style={[styles.input, { color: '#192126' }]}
value={input}
onChangeText={setInput}
multiline
onSubmitEditing={() => send(input)}
blurOnSubmit={false}
/>
<TouchableOpacity
accessibilityRole="button"
disabled={!input.trim() || isSending}
onPress={() => send(input)}
style={[
styles.sendBtn,
{ backgroundColor: theme.primary, opacity: input.trim() && !isSending ? 1 : 0.5 }
]}
>
{isSending ? (
<ActivityIndicator color={theme.onPrimary} />
) : (
<Ionicons name="arrow-up" size={18} color={theme.onPrimary} />
)}
{chips.map((c) => (
<TouchableOpacity key={c.key} style={[styles.chip, { borderColor: 'rgba(187,242,70,0.35)', backgroundColor: 'rgba(187,242,70,0.12)' }]} onPress={c.action}>
<Text style={[styles.chipText, { color: '#192126' }]}>{c.label}</Text>
</TouchableOpacity>
</View>
</BlurView>
</KeyboardAvoidingView>
))}
</ScrollView>
{!!selectedImages.length && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.imagesRow}
contentContainerStyle={{ paddingHorizontal: 6, gap: 8 }}
>
{selectedImages.map((img) => (
<View key={img.id} style={styles.imageThumbWrap}>
<TouchableOpacity accessibilityRole="imagebutton" onPress={() => setPreviewImageUri(img.uploadedUrl || img.localUri)}>
<Image source={{ uri: img.uploadedUrl || img.localUri }} style={styles.imageThumb} />
</TouchableOpacity>
{!!(img.progress > 0 && img.progress < 1) && (
<View style={styles.imageProgressOverlay}>
<Text style={styles.imageProgressText}>{Math.round((img.progress || 0) * 100)}%</Text>
</View>
)}
<TouchableOpacity accessibilityRole="button" onPress={() => removeSelectedImage(img.id)} style={styles.imageRemoveBtn}>
<Ionicons name="close" size={12} color="#fff" />
</TouchableOpacity>
</View>
))}
</ScrollView>
)}
<View style={[styles.inputRow, { borderColor: 'rgba(187,242,70,0.35)', backgroundColor: 'rgba(187,242,70,0.08)' }]}>
<TouchableOpacity
accessibilityRole="button"
onPress={pickImages}
style={[styles.mediaBtn, { backgroundColor: 'rgba(187,242,70,0.16)' }]}
>
<Ionicons name="image-outline" size={18} color={'#192126'} />
</TouchableOpacity>
<TextInput
placeholder="问我任何与普拉提相关的问题..."
placeholderTextColor={theme.textMuted}
style={[styles.input, { color: '#192126' }]}
value={input}
onChangeText={setInput}
multiline
onSubmitEditing={() => send(input)}
blurOnSubmit={false}
/>
<TouchableOpacity
accessibilityRole="button"
disabled={(!input.trim() && selectedImages.length === 0) || isSending}
onPress={() => send(input)}
style={[
styles.sendBtn,
{ backgroundColor: theme.primary, opacity: (input.trim() || selectedImages.length > 0) && !isSending ? 1 : 0.5 }
]}
>
{isSending ? (
<ActivityIndicator color={theme.onPrimary} />
) : (
<Ionicons name="arrow-up" size={18} color={theme.onPrimary} />
)}
</TouchableOpacity>
</View>
</BlurView>
{!isAtBottom && (
<TouchableOpacity
@@ -558,6 +799,15 @@ export default function AICoachChatScreen() {
</View>
</TouchableOpacity>
</Modal>
<Modal transparent visible={!!previewImageUri} animationType="fade" onRequestClose={() => setPreviewImageUri(null)}>
<TouchableOpacity activeOpacity={1} style={styles.previewBackdrop} onPress={() => setPreviewImageUri(null)}>
<View style={styles.previewBox}>
{previewImageUri ? (
<Image source={{ uri: previewImageUri }} style={styles.previewImage} resizeMode="contain" />
) : null}
</View>
</TouchableOpacity>
</Modal>
</View>
);
}
@@ -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;

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

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

View File

@@ -3,7 +3,7 @@ import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { 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 (
<TouchableOpacity
onPress={async () => {
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]}

View File

@@ -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<string | null>(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<string>('');
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 (
<SafeAreaView style={[styles.safeArea, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
<View style={[styles.container, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
@@ -57,73 +90,185 @@ export default function CheckinHome() {
<HeaderBar title="每日打卡" onBack={() => router.back()} withSafeTop={false} transparent />
<View style={[styles.hero, { backgroundColor: colorTokens.heroSurfaceTint }]}>
<Text style={[styles.title, { color: colorTokens.text }]}>{today}</Text>
<Text style={[styles.title, { color: colorTokens.text }]}>{currentDate}</Text>
<Text style={[styles.subtitle, { color: colorTokens.textMuted }]}></Text>
</View>
<View style={styles.actionRow}>
<TouchableOpacity style={[styles.primaryBtn, { backgroundColor: colorTokens.primary }]} onPress={() => router.push('/checkin/select')}>
<TouchableOpacity
style={[styles.primaryBtn, { backgroundColor: colorTokens.primary }]}
onPress={() => router.push({ pathname: '/checkin/select', params: { date: currentDate } })}
>
<Text style={[styles.primaryBtnText, { color: colorTokens.onPrimary }]}></Text>
</TouchableOpacity>
<View style={{ height: 10 }} />
<TouchableOpacity
style={[styles.secondaryBtn, { borderColor: colorTokens.primary }]}
onPress={() => setGenVisible(true)}
>
<Text style={[styles.secondaryBtnText, { color: colorTokens.primary }]}></Text>
</TouchableOpacity>
</View>
<FlatList
data={record?.items || []}
keyExtractor={(item) => 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={
<View style={[styles.emptyBox, { backgroundColor: colorTokens.card }]}>
<Text style={[styles.emptyText, { color: colorTokens.textMuted }]}></Text>
</View>
}
renderItem={({ item }) => (
<View style={[styles.card, { backgroundColor: colorTokens.card }]}>
<View style={{ flex: 1 }}>
<Text style={[styles.cardTitle, { color: colorTokens.text }]}>{item.name}</Text>
<Text style={[styles.cardMeta, { color: colorTokens.textMuted }]}>{item.category}</Text>
<Text style={[styles.cardMeta, { color: colorTokens.textMuted }]}> {item.sets}{item.reps ? ` · 每组 ${item.reps}` : ''}{item.durationSec ? ` · 每组 ${item.durationSec}s` : ''}</Text>
</View>
<TouchableOpacity
accessibilityRole="button"
accessibilityLabel={item.completed ? '已完成,点击取消完成' : '未完成,点击标记完成'}
style={styles.doneIconBtn}
onPress={() => {
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 }}
>
<Ionicons
name={item.completed ? 'checkmark-circle' : 'checkmark-circle-outline'}
size={24}
color={item.completed ? colorTokens.primary : colorTokens.textMuted}
/>
</TouchableOpacity>
<TouchableOpacity
style={[styles.removeBtn, { backgroundColor: colorTokens.border }]}
onPress={() =>
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 (
<View style={[styles.card, { backgroundColor: colorTokens.card }]}>
<View style={{ flex: 1 }}>
<Text style={[styles.cardTitle, { color: colorTokens.text }]}>{title}</Text>
{!!status && <Text style={[styles.cardMeta, { color: colorTokens.textMuted }]}>{status}</Text>}
{!!startedAt && <Text style={[styles.cardMeta, { color: colorTokens.textMuted }]}>{startedAt}</Text>}
</View>
</View>
);
}
const exercise = item as CheckinExercise;
const type = exercise.itemType ?? 'exercise';
const isRest = type === 'rest';
const isNote = type === 'note';
const cardStyle = [styles.card, { backgroundColor: colorTokens.card }];
if (isRest || isNote) {
return (
<View style={styles.inlineRow}>
<Ionicons name={isRest ? 'time-outline' : 'information-circle-outline'} size={14} color={colorTokens.textMuted} />
<View style={[styles.inlineBadge, isRest ? styles.inlineBadgeRest : styles.inlineBadgeNote, { borderColor: colorTokens.border }]}>
<Text style={[isNote ? styles.inlineTextItalic : styles.inlineText, { color: colorTokens.textMuted }]}>
{isRest ? `间隔休息 ${exercise.restSec ?? 30}s` : (exercise.note || '提示')}
</Text>
</View>
<TouchableOpacity
style={styles.inlineRemoveBtn}
onPress={() =>
Alert.alert('确认移除', '确定要移除该条目吗?', [
{ text: '取消', style: 'cancel' },
{
text: '移除',
style: 'destructive',
onPress: () => {
dispatch(removeExercise({ date: currentDate, key: exercise.key }));
const nextItems: CheckinExercise[] = (record?.items || []).filter((it: CheckinExercise) => it.key !== exercise.key);
dispatch(syncCheckin({ date: currentDate, items: nextItems, note: record?.note }));
},
},
])
}
hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
>
<Ionicons name="close-outline" size={16} color={colorTokens.textMuted} />
</TouchableOpacity>
</View>
);
}
return (
<View style={cardStyle as any}>
<View style={{ flex: 1 }}>
<Text style={[styles.cardTitle, { color: colorTokens.text }]}>{exercise.name}</Text>
<Text style={[styles.cardMeta, { color: colorTokens.textMuted }]}>{exercise.category}</Text>
{isNote && (
<Text style={[styles.cardMetaItalic, { color: colorTokens.textMuted }]}>{exercise.note || '提示'}</Text>
)}
{!isNote && (
<Text style={[styles.cardMeta, { color: colorTokens.textMuted }]}>
{isRest
? `建议休息 ${exercise.restSec ?? 30}s`
: `组数 ${exercise.sets}${exercise.reps ? ` · 每组 ${exercise.reps}` : ''}${exercise.durationSec ? ` · 每组 ${exercise.durationSec}s` : ''}`}
</Text>
)}
</View>
{type === 'exercise' && (
<TouchableOpacity
accessibilityRole="button"
accessibilityLabel={exercise.completed ? '已完成,点击取消完成' : '未完成,点击标记完成'}
style={styles.doneIconBtn}
onPress={() => {
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 }}
>
<Ionicons
name={exercise.completed ? 'checkmark-circle' : 'checkmark-circle-outline'}
size={24}
color={exercise.completed ? colorTokens.primary : colorTokens.textMuted}
/>
</TouchableOpacity>
)}
<TouchableOpacity
style={[styles.removeBtn, { backgroundColor: colorTokens.border }]}
onPress={() =>
Alert.alert('确认移除', '确定要移除该动作吗?', [
{ text: '取消', style: 'cancel' },
{
text: '移除',
style: 'destructive',
onPress: () => {
dispatch(removeExercise({ date: currentDate, key: exercise.key }));
const nextItems: CheckinExercise[] = (record?.items || []).filter((it: CheckinExercise) => it.key !== exercise.key);
dispatch(syncCheckin({ date: currentDate, items: nextItems, note: record?.note }));
},
},
},
])
}
>
<Text style={[styles.removeBtnText, { color: colorTokens.text }]}></Text>
</TouchableOpacity>
</View>
)}
])
}
>
<Text style={[styles.removeBtnText, { color: colorTokens.text }]}></Text>
</TouchableOpacity>
</View>
);
}}
/>
{/* 生成配置弹窗 */}
<Modal visible={genVisible} transparent animationType="fade" onRequestClose={() => setGenVisible(false)}>
<TouchableOpacity activeOpacity={1} style={styles.modalOverlay} onPress={() => setGenVisible(false)}>
<TouchableOpacity activeOpacity={1} style={[styles.modalSheet, { backgroundColor: colorTokens.card }]} onPress={(e) => e.stopPropagation() as any}>
<Text style={[styles.modalTitle, { color: colorTokens.text }]}></Text>
<Text style={[styles.modalLabel, { color: colorTokens.textMuted }]}></Text>
<View style={styles.segmentedRow}>
{(['beginner', 'intermediate', 'advanced'] as const).map((lv) => (
<TouchableOpacity key={lv} style={[styles.segment, genLevel === lv && { backgroundColor: colorTokens.primary }]} onPress={() => setGenLevel(lv)}>
<Text style={[styles.segmentText, genLevel === lv && { color: colorTokens.onPrimary }]}>
{lv === 'beginner' ? '入门' : lv === 'intermediate' ? '进阶' : '高级'}
</Text>
</TouchableOpacity>
))}
</View>
<View style={styles.switchRow}>
<Text style={[styles.switchLabel, { color: colorTokens.text }]}></Text>
<Switch value={genWithRests} onValueChange={setGenWithRests} />
</View>
<View style={styles.switchRow}>
<Text style={[styles.switchLabel, { color: colorTokens.text }]}></Text>
<Switch value={genWithNotes} onValueChange={setGenWithNotes} />
</View>
<View style={styles.inputRow}>
<Text style={[styles.switchLabel, { color: colorTokens.textMuted }]}></Text>
<TextInput value={genRest} onChangeText={setGenRest} keyboardType="number-pad" style={[styles.input, { borderColor: colorTokens.border, color: colorTokens.text }]} />
</View>
<View style={{ height: 8 }} />
<TouchableOpacity style={[styles.primaryBtn, { backgroundColor: colorTokens.primary }]} onPress={onGenerate}>
<Text style={[styles.primaryBtnText, { color: colorTokens.onPrimary }]}></Text>
</TouchableOpacity>
</TouchableOpacity>
</TouchableOpacity>
</Modal>
</View>
</SafeAreaView>
);
@@ -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 },
});

View File

@@ -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,

View File

@@ -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) {