- 在 AI 教练聊天界面中添加消息自动滚动功能,提升用户体验 - 更新消息发送逻辑,确保新消息渲染后再滚动 - 在个人信息页面中集成用户资料拉取功能,支持每次聚焦时更新用户信息 - 修改登录页面,增加身份令牌验证,确保安全性 - 更新样式以适应新功能的展示和交互
442 lines
15 KiB
TypeScript
442 lines
15 KiB
TypeScript
import { Ionicons } from '@expo/vector-icons';
|
||
import { BlurView } from 'expo-blur';
|
||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||
import {
|
||
ActivityIndicator,
|
||
FlatList,
|
||
Image,
|
||
KeyboardAvoidingView,
|
||
Platform,
|
||
StyleSheet,
|
||
Text,
|
||
TextInput,
|
||
TouchableOpacity,
|
||
View,
|
||
} from 'react-native';
|
||
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 { useColorScheme } from '@/hooks/useColorScheme';
|
||
import type { CheckinRecord } from '@/store/checkinSlice';
|
||
|
||
type Role = 'user' | 'assistant';
|
||
|
||
type ChatMessage = {
|
||
id: string;
|
||
role: Role;
|
||
content: string;
|
||
};
|
||
|
||
const COACH_AVATAR = 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/avatar/imageCoach01.jpeg';
|
||
|
||
export default function AICoachChatScreen() {
|
||
const router = useRouter();
|
||
const params = useLocalSearchParams<{ name?: string }>();
|
||
const insets = useSafeAreaInsets();
|
||
const colorScheme = useColorScheme() ?? 'light';
|
||
// 为了让页面更贴近品牌主题与更亮的观感,这里使用亮色系配色
|
||
const theme = Colors.light;
|
||
const coachName = (params?.name || 'Sarah').toString();
|
||
const [input, setInput] = useState('');
|
||
const [isSending, setIsSending] = useState(false);
|
||
const [messages, setMessages] = useState<ChatMessage[]>([{
|
||
id: 'm_welcome',
|
||
role: 'assistant',
|
||
content: `你好,我是你的普拉提教练 ${coachName}。可以向我咨询训练、体态、康复、柔韧等问题~`,
|
||
}]);
|
||
const listRef = useRef<FlatList<ChatMessage>>(null);
|
||
const [isAtBottom, setIsAtBottom] = useState(true);
|
||
const didInitialScrollRef = useRef(false);
|
||
const [composerHeight, setComposerHeight] = useState<number>(80);
|
||
const shouldAutoScrollRef = useRef(false);
|
||
|
||
const planDraft = useAppSelector((s) => s.trainingPlan?.draft);
|
||
const checkin = useAppSelector((s) => (s as any).checkin);
|
||
|
||
const chips = useMemo(() => [
|
||
{ key: 'posture', label: '体态评估', action: () => router.push('/ai-posture-assessment') },
|
||
{ key: 'plan', label: 'AI制定训练计划', action: () => handleQuickPlan() },
|
||
{ key: 'analyze', label: '分析运动记录', action: () => handleAnalyzeRecords() },
|
||
], [router, planDraft, checkin]);
|
||
|
||
const scrollToEnd = useCallback(() => {
|
||
requestAnimationFrame(() => {
|
||
listRef.current?.scrollToEnd({ animated: true });
|
||
});
|
||
}, []);
|
||
|
||
const handleScroll = useCallback((e: any) => {
|
||
try {
|
||
const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent || {};
|
||
const paddingToBottom = 60;
|
||
const distanceFromBottom = (contentSize?.height || 0) - ((layoutMeasurement?.height || 0) + (contentOffset?.y || 0));
|
||
setIsAtBottom(distanceFromBottom <= paddingToBottom);
|
||
} catch { }
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
// 初次进入或恢复时,保持最新消息可见
|
||
scrollToEnd();
|
||
}, [scrollToEnd]);
|
||
|
||
// 取消对 messages.length 的全局监听滚动,改为在“消息实际追加完成”后再判断与滚动,避免突兀与多次触发
|
||
|
||
useEffect(() => {
|
||
// 输入区高度变化时,若用户在底部则轻柔跟随一次
|
||
if (isAtBottom) {
|
||
const id = setTimeout(scrollToEnd, 0);
|
||
return () => clearTimeout(id);
|
||
}
|
||
}, [composerHeight, isAtBottom, scrollToEnd]);
|
||
|
||
async function fakeStreamResponse(prompt: string): Promise<string> {
|
||
// 占位实现:模拟AI逐字输出(可替换为真实后端流式接口)
|
||
const canned =
|
||
prompt.includes('训练计划') || prompt.includes('制定')
|
||
? '好的,我将基于你的目标与时间安排制定一周普拉提计划:\n\n- 周一:核心激活与呼吸(20-25分钟)\n- 周三:下肢稳定与髋部灵活(25-30分钟)\n- 周五:全身整合与平衡(30分钟)\n\n每次训练前后各进行5分钟呼吸与拉伸。若有不适请降低强度或暂停。'
|
||
: '已收到,我会根据你的问题给出建议:保持规律练习与充分恢复,注意呼吸控制与动作节奏。若感到疼痛请及时调整或咨询专业教练。';
|
||
await new Promise((r) => setTimeout(r, 500));
|
||
return canned;
|
||
}
|
||
|
||
async function send(text: string) {
|
||
if (!text.trim() || isSending) return;
|
||
const userMsg: ChatMessage = { id: `u_${Date.now()}`, role: 'user', content: text.trim() };
|
||
// 标记:这次是新增消息,等内容真正渲染并触发 onContentSizeChange 时再滚动
|
||
shouldAutoScrollRef.current = isAtBottom;
|
||
setMessages((m) => [...m, userMsg]);
|
||
setInput('');
|
||
setIsSending(true);
|
||
// 立即滚动改为延后到 onContentSizeChange,避免突兀
|
||
|
||
try {
|
||
const replyText = await fakeStreamResponse(text.trim());
|
||
const aiMsg: ChatMessage = { id: `a_${Date.now()}`, role: 'assistant', content: replyText };
|
||
// 同理:AI 消息到达时,与内容变化同步滚动
|
||
shouldAutoScrollRef.current = isAtBottom;
|
||
setMessages((m) => [...m, aiMsg]);
|
||
} catch (e) {
|
||
const aiMsg: ChatMessage = { id: `a_${Date.now()}`, role: 'assistant', content: '抱歉,请求失败,请稍后再试。' };
|
||
shouldAutoScrollRef.current = isAtBottom;
|
||
setMessages((m) => [...m, aiMsg]);
|
||
} finally {
|
||
setIsSending(false);
|
||
}
|
||
}
|
||
|
||
function handleQuickPlan() {
|
||
const goalMap: Record<string, string> = {
|
||
postpartum_recovery: '产后恢复',
|
||
fat_loss: '减脂塑形',
|
||
posture_correction: '体态矫正',
|
||
core_strength: '核心力量',
|
||
flexibility: '柔韧灵活',
|
||
rehab: '康复保健',
|
||
stress_relief: '释压放松',
|
||
};
|
||
const goalText = planDraft?.goal ? goalMap[planDraft.goal] : '整体提升';
|
||
const freq = planDraft?.mode === 'sessionsPerWeek'
|
||
? `${planDraft?.sessionsPerWeek ?? 3}次/周`
|
||
: (planDraft?.daysOfWeek?.length ? `${planDraft.daysOfWeek.length}次/周` : '3次/周');
|
||
const prefer = planDraft?.preferredTimeOfDay ? `偏好${planDraft.preferredTimeOfDay}` : '时间灵活';
|
||
const prompt = `请根据我的目标“${goalText}”、频率“${freq}”、${prefer},制定1周的普拉提训练计划,包含每次训练主题、时长、主要动作与注意事项,并给出恢复建议。`;
|
||
send(prompt);
|
||
}
|
||
|
||
function buildTrainingSummary(): string {
|
||
const entries = Object.values(checkin?.byDate || {}) as CheckinRecord[];
|
||
if (!entries.length) return '';
|
||
const recent = entries.sort((a: any, b: any) => String(b.date).localeCompare(String(a.date))).slice(0, 14);
|
||
let totalSessions = 0;
|
||
let totalExercises = 0;
|
||
let totalCompleted = 0;
|
||
const categoryCount: Record<string, number> = {};
|
||
const exerciseCount: Record<string, number> = {};
|
||
for (const rec of recent) {
|
||
if (!rec?.items?.length) continue;
|
||
totalSessions += 1;
|
||
for (const it of rec.items) {
|
||
totalExercises += 1;
|
||
if (it.completed) totalCompleted += 1;
|
||
categoryCount[it.category] = (categoryCount[it.category] || 0) + 1;
|
||
exerciseCount[it.name] = (exerciseCount[it.name] || 0) + 1;
|
||
}
|
||
}
|
||
const topCategories = Object.entries(categoryCount).sort((a, b) => b[1] - a[1]).slice(0, 3).map(([k, v]) => `${k}×${v}`);
|
||
const topExercises = Object.entries(exerciseCount).sort((a, b) => b[1] - a[1]).slice(0, 5).map(([k, v]) => `${k}×${v}`);
|
||
return [
|
||
`统计周期:最近${recent.length}天(按有记录日计 ${totalSessions} 天)`,
|
||
`记录条目:${totalExercises},完成标记:${totalCompleted}`,
|
||
topCategories.length ? `高频类别:${topCategories.join(',')}` : '',
|
||
topExercises.length ? `高频动作:${topExercises.join(',')}` : '',
|
||
].filter(Boolean).join('\n');
|
||
}
|
||
|
||
function handleAnalyzeRecords() {
|
||
const summary = buildTrainingSummary();
|
||
if (!summary) {
|
||
send('我还没有可分析的打卡记录,请先在“每日打卡”添加并完成一些训练记录,然后帮我分析近期训练表现与改进建议。');
|
||
return;
|
||
}
|
||
const prompt = `请基于以下我的近期训练记录进行分析,输出:1)整体训练负荷与节奏;2)动作与肌群的均衡性(指出偏多/偏少);3)容易忽视的恢复与热身建议;4)后续一周的优化建议(频次/时长/动作方向)。\n\n${summary}`;
|
||
send(prompt);
|
||
}
|
||
|
||
function renderItem({ item }: { item: ChatMessage }) {
|
||
const isUser = item.role === 'user';
|
||
return (
|
||
<Animated.View
|
||
entering={isUser ? FadeInUp.springify().damping(18) : FadeInDown.springify().damping(18)}
|
||
layout={Layout.springify().damping(18)}
|
||
style={[styles.row, { justifyContent: isUser ? 'flex-end' : 'flex-start' }]}
|
||
>
|
||
{!isUser && (
|
||
<Image source={{ uri: COACH_AVATAR }} style={styles.avatar} />
|
||
)}
|
||
<View
|
||
style={[
|
||
styles.bubble,
|
||
{
|
||
backgroundColor: isUser ? theme.primary : 'rgba(187,242,70,0.16)',
|
||
borderTopLeftRadius: isUser ? 16 : 6,
|
||
borderTopRightRadius: isUser ? 6 : 16,
|
||
},
|
||
]}
|
||
>
|
||
<Text style={[styles.bubbleText, { color: isUser ? theme.onPrimary : '#192126' }]}>{item.content}</Text>
|
||
</View>
|
||
</Animated.View>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<View style={[styles.screen, { backgroundColor: theme.background }]}>
|
||
<HeaderBar
|
||
title={`教练 ${coachName}`}
|
||
onBack={() => router.back()}
|
||
tone="light"
|
||
transparent
|
||
/>
|
||
|
||
<FlatList
|
||
ref={listRef}
|
||
data={messages}
|
||
keyExtractor={(m) => m.id}
|
||
renderItem={renderItem}
|
||
onLayout={() => {
|
||
// 确保首屏布局后也尝试滚动
|
||
if (!didInitialScrollRef.current) {
|
||
didInitialScrollRef.current = true;
|
||
setTimeout(scrollToEnd, 0);
|
||
requestAnimationFrame(scrollToEnd);
|
||
}
|
||
}}
|
||
contentContainerStyle={{ paddingHorizontal: 14, paddingTop: 8 }}
|
||
ListFooterComponent={() => (
|
||
<View style={{ height: insets.bottom + composerHeight + (isAtBottom ? 0 : 56) + 16 }} />
|
||
)}
|
||
onContentSizeChange={() => {
|
||
// 首次内容变化强制滚底,其余仅在接近底部时滚动
|
||
if (!didInitialScrollRef.current) {
|
||
didInitialScrollRef.current = true;
|
||
setTimeout(scrollToEnd, 0);
|
||
requestAnimationFrame(scrollToEnd);
|
||
return;
|
||
}
|
||
if (shouldAutoScrollRef.current) {
|
||
shouldAutoScrollRef.current = false;
|
||
setTimeout(scrollToEnd, 0);
|
||
}
|
||
}}
|
||
onScroll={handleScroll}
|
||
scrollEventThrottle={16}
|
||
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);
|
||
}}
|
||
>
|
||
<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} />
|
||
)}
|
||
</TouchableOpacity>
|
||
</View>
|
||
</BlurView>
|
||
</KeyboardAvoidingView>
|
||
|
||
{!isAtBottom && (
|
||
<TouchableOpacity
|
||
accessibilityRole="button"
|
||
onPress={scrollToEnd}
|
||
style={[styles.scrollToBottomFab, { bottom: insets.bottom + composerHeight + 16, backgroundColor: theme.primary }]}
|
||
>
|
||
<Ionicons name="chevron-down" size={18} color={theme.onPrimary} />
|
||
</TouchableOpacity>
|
||
)}
|
||
</View>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
screen: {
|
||
flex: 1,
|
||
},
|
||
header: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
paddingHorizontal: 16,
|
||
paddingBottom: 10,
|
||
},
|
||
backButton: {
|
||
width: 32,
|
||
height: 32,
|
||
borderRadius: 16,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
backgroundColor: 'rgba(255,255,255,0.06)'
|
||
},
|
||
headerTitle: {
|
||
fontSize: 20,
|
||
fontWeight: '800',
|
||
},
|
||
row: {
|
||
flexDirection: 'row',
|
||
alignItems: 'flex-end',
|
||
gap: 8,
|
||
marginVertical: 6,
|
||
},
|
||
avatar: {
|
||
width: 28,
|
||
height: 28,
|
||
borderRadius: 14,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
avatarText: {
|
||
color: '#192126',
|
||
fontSize: 12,
|
||
fontWeight: '800',
|
||
},
|
||
bubble: {
|
||
maxWidth: '82%',
|
||
paddingHorizontal: 12,
|
||
paddingVertical: 10,
|
||
borderRadius: 16,
|
||
},
|
||
bubbleText: {
|
||
fontSize: 15,
|
||
lineHeight: 22,
|
||
},
|
||
composerWrap: {
|
||
position: 'absolute',
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
paddingTop: 8,
|
||
paddingHorizontal: 10,
|
||
borderTopWidth: 0,
|
||
},
|
||
chipsRow: {
|
||
flexDirection: 'row',
|
||
flexWrap: 'wrap',
|
||
gap: 8,
|
||
paddingHorizontal: 6,
|
||
marginBottom: 8,
|
||
},
|
||
chip: {
|
||
paddingHorizontal: 10,
|
||
height: 34,
|
||
borderRadius: 18,
|
||
borderWidth: 1,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
backgroundColor: 'transparent',
|
||
},
|
||
chipText: {
|
||
fontSize: 13,
|
||
fontWeight: '600',
|
||
},
|
||
inputRow: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
padding: 8,
|
||
borderWidth: 1,
|
||
borderRadius: 16,
|
||
backgroundColor: 'rgba(0,0,0,0.04)'
|
||
},
|
||
input: {
|
||
flex: 1,
|
||
fontSize: 15,
|
||
maxHeight: 120,
|
||
minHeight: 40,
|
||
paddingHorizontal: 8,
|
||
paddingVertical: 6,
|
||
textAlignVertical: 'center',
|
||
},
|
||
sendBtn: {
|
||
width: 40,
|
||
height: 40,
|
||
borderRadius: 20,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
scrollToBottomFab: {
|
||
position: 'absolute',
|
||
right: 16,
|
||
width: 40,
|
||
height: 40,
|
||
borderRadius: 20,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
shadowColor: '#000',
|
||
shadowOffset: { width: 0, height: 2 },
|
||
shadowOpacity: 0.15,
|
||
shadowRadius: 4,
|
||
elevation: 2,
|
||
},
|
||
});
|
||
|
||
|