feat: 优化 AI 教练聊天界面和个人信息页面

- 在 AI 教练聊天界面中添加消息自动滚动功能,提升用户体验
- 更新消息发送逻辑,确保新消息渲染后再滚动
- 在个人信息页面中集成用户资料拉取功能,支持每次聚焦时更新用户信息
- 修改登录页面,增加身份令牌验证,确保安全性
- 更新样式以适应新功能的展示和交互
This commit is contained in:
richarjiang
2025-08-13 16:51:51 +08:00
parent 321947db98
commit ebc74eb1c8
6 changed files with 207 additions and 31 deletions

View File

@@ -1,7 +1,7 @@
import { Ionicons } from '@expo/vector-icons';
import { BlurView } from 'expo-blur';
import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useMemo, useRef, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
ActivityIndicator,
FlatList,
@@ -49,6 +49,10 @@ export default function AICoachChatScreen() {
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);
@@ -59,11 +63,35 @@ export default function AICoachChatScreen() {
{ key: 'analyze', label: '分析运动记录', action: () => handleAnalyzeRecords() },
], [router, planDraft, checkin]);
function scrollToEnd() {
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逐字输出可替换为真实后端流式接口
@@ -78,18 +106,22 @@ export default function AICoachChatScreen() {
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);
scrollToEnd();
// 立即滚动改为延后到 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]);
scrollToEnd();
} catch (e) {
const aiMsg: ChatMessage = { id: `a_${Date.now()}`, role: 'assistant', content: '抱歉,请求失败,请稍后再试。' };
shouldAutoScrollRef.current = isAtBottom;
setMessages((m) => [...m, aiMsg]);
} finally {
setIsSending(false);
@@ -195,13 +227,46 @@ export default function AICoachChatScreen() {
data={messages}
keyExtractor={(m) => m.id}
renderItem={renderItem}
contentContainerStyle={{ paddingHorizontal: 14, paddingTop: 8, paddingBottom: insets.bottom + 140 }}
onContentSizeChange={scrollToEnd}
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' : undefined} keyboardVerticalOffset={80}>
<BlurView intensity={18} tint={'light'} style={[styles.composerWrap, { paddingBottom: insets.bottom + 10 }]}>
<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}>
@@ -239,6 +304,16 @@ export default function AICoachChatScreen() {
</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>
);
}
@@ -347,6 +422,20 @@ const styles = StyleSheet.create({
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,
},
});