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