feat: 引入路由常量并更新相关页面导航

- 新增 ROUTES 常量文件,集中管理应用路由
- 更新多个页面的导航逻辑,使用 ROUTES 常量替代硬编码路径
- 修改教练页面和今日训练页面的路由,提升代码可维护性
- 优化标签页和登录页面的导航,确保一致性和易用性
This commit is contained in:
richarjiang
2025-08-18 10:05:22 +08:00
parent 93918366a9
commit 849447c5da
9 changed files with 289 additions and 253 deletions

View File

@@ -24,16 +24,14 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Colors } from '@/constants/Colors';
import { getTabBarBottomPadding } from '@/constants/TabBar';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useCosUpload } from '@/hooks/useCosUpload';
import { deleteConversation, getConversationDetail, listConversations, type AiConversationListItem } from '@/services/aiCoach';
import { loadAiCoachSessionCache, saveAiCoachSessionCache } from '@/services/aiCoachSession';
import { api, getAuthToken, postTextStream } from '@/services/api';
import { updateUser as updateUserApi } from '@/services/users';
import type { CheckinRecord } from '@/store/checkinSlice';
import { fetchMyProfile, fetchWeightHistory, updateProfile } from '@/store/userSlice';
import dayjs from 'dayjs';
import { ActionSheet } from '../../components/ui/ActionSheet';
type Role = 'user' | 'assistant';
@@ -43,6 +41,15 @@ type ChatMessage = {
content: string;
};
// 卡片类型常量定义
const CardType = {
WEIGHT_INPUT: '__WEIGHT_INPUT_CARD__',
DIET_INPUT: '__DIET_INPUT_CARD__',
DIET_TEXT_INPUT: '__DIET_TEXT_INPUT__',
} as const;
type CardType = typeof CardType[keyof typeof CardType];
const COACH_AVATAR = require('@/assets/images/logo.png');
export default function CoachScreen() {
@@ -84,10 +91,13 @@ export default function CoachScreen() {
}>>([]);
const [previewImageUri, setPreviewImageUri] = useState<string | null>(null);
const [dietTextInputs, setDietTextInputs] = useState<Record<string, string>>({});
const [weightInputs, setWeightInputs] = useState<Record<string, string>>({});
const [showDietPhotoActionSheet, setShowDietPhotoActionSheet] = useState(false);
const [currentCardId, setCurrentCardId] = useState<string | null>(null);
const planDraft = useAppSelector((s) => s.trainingPlan?.draft);
const checkin = useAppSelector((s) => s.checkin || {});
const dispatch = useAppDispatch();
const userProfile = useAppSelector((s) => s.user?.profile);
const { upload } = useCosUpload();
@@ -96,7 +106,7 @@ export default function CoachScreen() {
const hour = new Date().getHours();
const name = userProfile?.name || '朋友';
const botName = (params?.name || 'Health Bot').toString();
// 时段问候
let timeGreeting = '';
if (hour >= 5 && hour < 9) {
@@ -199,8 +209,8 @@ export default function CoachScreen() {
// { 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() },
{ key: 'diet', label: '记饮食', action: () => insertDietInputCard() },
{ key: 'weight', label: '#记体重', action: () => insertWeightInputCard() },
{ key: 'diet', label: '#记饮食', action: () => insertDietInputCard() },
], [router, planDraft, checkin]);
const scrollToEnd = useCallback(() => {
@@ -213,7 +223,7 @@ export default function CoachScreen() {
try {
const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent || {};
if (!contentOffset || !contentSize || !layoutMeasurement) return;
const paddingToBottom = 60;
const distanceFromBottom = (contentSize.height || 0) - ((layoutMeasurement.height || 0) + (contentOffset.y || 0));
setIsAtBottom(distanceFromBottom <= paddingToBottom);
@@ -240,7 +250,7 @@ export default function CoachScreen() {
// 启动页面时尝试恢复当次应用会话缓存
useEffect(() => {
let isMounted = true;
(async () => {
try {
const cached = await loadAiCoachSessionCache();
@@ -264,7 +274,7 @@ export default function CoachScreen() {
}
}
})();
return () => {
isMounted = false;
};
@@ -276,21 +286,21 @@ export default function CoachScreen() {
if (saveCacheTimerRef.current) {
clearTimeout(saveCacheTimerRef.current);
}
// 只有在有实际消息内容时才保存缓存
if (messages.length > 1 || (messages.length === 1 && messages[0].id !== 'm_welcome')) {
saveCacheTimerRef.current = setTimeout(() => {
const validMessages = messages.filter(msg => msg && msg.role && msg.content);
saveAiCoachSessionCache({
conversationId,
messages: validMessages,
updatedAt: Date.now()
saveAiCoachSessionCache({
conversationId,
messages: validMessages,
updatedAt: Date.now()
}).catch((error) => {
console.warn('[AI_CHAT] Failed to save session cache:', error);
});
}, 300);
}
return () => {
if (saveCacheTimerRef.current) {
clearTimeout(saveCacheTimerRef.current);
@@ -314,7 +324,7 @@ export default function CoachScreen() {
useEffect(() => {
let showSub: any = null;
let hideSub: any = null;
if (Platform.OS === 'ios') {
showSub = Keyboard.addListener('keyboardWillChangeFrame', (e: any) => {
try {
@@ -345,7 +355,7 @@ export default function CoachScreen() {
setKeyboardOffset(0);
});
}
return () => {
try {
showSub?.remove?.();
@@ -370,7 +380,7 @@ export default function CoachScreen() {
} catch (error) {
console.warn('[AI_CHAT] Error aborting stream on unmount:', error);
}
if (saveCacheTimerRef.current) {
clearTimeout(saveCacheTimerRef.current);
saveCacheTimerRef.current = null;
@@ -419,24 +429,25 @@ export default function CoachScreen() {
if (isStreaming) {
try { streamAbortRef.current?.abort(); } catch { }
}
// 清理当前会话状态
setConversationId(undefined);
setSelectedImages([]);
setDietTextInputs({});
setWeightInputs({});
// 创建新的欢迎消息
initializeWelcomeMessage();
// 清理本地缓存
saveAiCoachSessionCache({
conversationId: undefined,
messages: [],
updatedAt: Date.now()
saveAiCoachSessionCache({
conversationId: undefined,
messages: [],
updatedAt: Date.now()
}).catch((error) => {
console.warn('[AI_CHAT] Failed to clear session cache:', error);
});
setTimeout(scrollToEnd, 100);
}
@@ -614,103 +625,21 @@ export default function CoachScreen() {
}
}
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 {
try {
const entries = Object.values(checkin?.byDate || {}) as CheckinRecord[];
if (!entries.length) return '';
const recent = entries
.filter(entry => entry && entry.date) // 过滤无效数据
.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) {
if (!it || typeof it !== 'object') continue;
totalExercises += 1;
if (it.completed) totalCompleted += 1;
if (it.category) {
categoryCount[it.category] = (categoryCount[it.category] || 0) + 1;
}
if (it.name) {
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');
} catch (error) {
console.warn('[AI_CHAT] Error building training summary:', error);
return '';
}
}
function handleAnalyzeRecords() {
const summary = buildTrainingSummary();
if (!summary) {
send('我还没有可分析的打卡记录,请先在"每日打卡"添加并完成一些训练记录,然后帮我分析近期训练表现与改进建议。');
return;
}
const prompt = `请基于以下我的近期训练记录进行分析输出1整体训练负荷与节奏2动作与肌群的均衡性指出偏多/偏少3容易忽视的恢复与热身建议4后续一周的优化建议频次/时长/动作方向)。\n\n${summary}`;
send(prompt);
}
const uploadImage = useCallback(async (img: any) => {
if (!img?.localUri || !img?.id) {
console.warn('[AI_CHAT] Invalid image data for upload:', img);
return;
}
try {
const { url } = await upload(
{ uri: img.localUri, name: img.id, type: 'image/jpeg' },
{ prefix: 'images/chat' }
);
if (url) {
setSelectedImages((prev) => prev.map((it) =>
setSelectedImages((prev) => prev.map((it) =>
it.id === img.id ? { ...it, uploadedUrl: url, progress: 1, error: undefined } : it
));
} else {
@@ -718,7 +647,7 @@ export default function CoachScreen() {
}
} catch (e: any) {
console.error('[AI_CHAT] Image upload failed:', e);
setSelectedImages((prev) => prev.map((it) =>
setSelectedImages((prev) => prev.map((it) =>
it.id === img.id ? { ...it, error: e?.message || '上传失败', progress: 0 } : it
));
}
@@ -732,15 +661,15 @@ export default function CoachScreen() {
selectionLimit: 4,
quality: 0.9,
} as any);
if ((result as any).canceled) return;
const assets = (result as any).assets || [];
if (!Array.isArray(assets) || assets.length === 0) {
console.warn('[AI_CHAT] No valid assets returned from image picker');
return;
}
const next = assets
.filter(a => a && a.uri) // 过滤无效的资源
.map((a: any) => ({
@@ -750,19 +679,19 @@ export default function CoachScreen() {
height: a.height,
progress: 0,
}));
if (next.length === 0) {
Alert.alert('错误', '未选择有效的图片');
return;
}
setSelectedImages((prev) => {
const merged = [...prev, ...next];
return merged.slice(0, 4);
});
setTimeout(scrollToEnd, 100);
// 立即开始上传新选择的图片
for (const img of next) {
uploadImage(img);
@@ -785,9 +714,6 @@ export default function CoachScreen() {
layout={Layout.springify().damping(18)}
style={[styles.row, { justifyContent: isUser ? 'flex-end' : 'flex-start' }]}
>
{!isUser && (
<Image source={COACH_AVATAR} style={styles.avatar} />
)}
<View
style={[
styles.bubble,
@@ -810,8 +736,9 @@ export default function CoachScreen() {
if (!item.content?.trim() && isStreaming && pendingAssistantIdRef.current === item.id) {
return <Text style={[styles.bubbleText, { color: '#687076' }]}></Text>;
}
if (item.content?.startsWith('__WEIGHT_INPUT_CARD__')) {
if (item.content?.startsWith(CardType.WEIGHT_INPUT)) {
const cardId = item.id;
const preset = (() => {
try {
const m = item.content.split('\n')?.[1];
@@ -821,7 +748,10 @@ export default function CoachScreen() {
return '';
}
})();
// 初始化输入值(如果还没有的话)
const currentValue = weightInputs[cardId] ?? preset;
return (
<View style={{ gap: 8 }}>
<Text style={[styles.bubbleText, { color: '#192126', fontWeight: '700' }]}></Text>
@@ -829,36 +759,37 @@ export default function CoachScreen() {
<TextInput
placeholder="例如 60.5"
keyboardType="decimal-pad"
defaultValue={preset}
value={currentValue}
placeholderTextColor={'#687076'}
style={styles.weightInput}
onSubmitEditing={(e) => handleSubmitWeight(e.nativeEvent.text)}
onChangeText={(text) => setWeightInputs(prev => ({ ...prev, [cardId]: text }))}
onSubmitEditing={(e) => handleSubmitWeight(e.nativeEvent.text, cardId)}
returnKeyType="done"
submitBehavior="blurAndSubmit"
/>
<Text style={styles.weightUnit}>kg</Text>
<TouchableOpacity
accessibilityRole="button"
style={styles.weightSaveBtn}
onPress={() => handleSubmitWeight(preset || '')}
<TouchableOpacity
accessibilityRole="button"
style={styles.weightSaveBtn}
onPress={() => handleSubmitWeight(currentValue, cardId)}
>
<Text style={{ color: '#192126', fontWeight: '700' }}></Text>
<Text style={{ color: '#192126', fontWeight: '700' }}></Text>
</TouchableOpacity>
</View>
<Text style={{ color: '#687076', fontSize: 12 }}></Text>
</View>
);
}
if (item.content?.startsWith('__DIET_INPUT_CARD__')) {
if (item.content?.startsWith(CardType.DIET_INPUT)) {
return (
<View style={{ gap: 12 }}>
<Text style={[styles.bubbleText, { color: '#192126', fontWeight: '700' }]}></Text>
<Text style={{ color: '#687076', fontSize: 14 }}></Text>
<View style={styles.dietOptionsContainer}>
<TouchableOpacity
accessibilityRole="button"
<TouchableOpacity
accessibilityRole="button"
style={styles.dietOptionBtn}
onPress={() => handleDietTextInput(item.id)}
>
@@ -870,9 +801,9 @@ export default function CoachScreen() {
<Text style={styles.dietOptionDesc}></Text>
</View>
</TouchableOpacity>
<TouchableOpacity
accessibilityRole="button"
<TouchableOpacity
accessibilityRole="button"
style={styles.dietOptionBtn}
onPress={() => handleDietPhotoInput(item.id)}
>
@@ -885,29 +816,29 @@ export default function CoachScreen() {
</View>
</TouchableOpacity>
</View>
<Text style={{ color: '#687076', fontSize: 12 }}>Health Bot会根据您的饮食情况给出专业的营养建议</Text>
</View>
);
}
if (item.content?.startsWith('__DIET_TEXT_INPUT__')) {
if (item.content?.startsWith(CardType.DIET_TEXT_INPUT)) {
const cardId = item.content.split('\n')?.[1] || '';
const currentText = dietTextInputs[cardId] || '';
return (
<View style={{ gap: 8 }}>
<View style={styles.dietInputHeader}>
<Text style={[styles.bubbleText, { color: '#192126', fontWeight: '700' }]}></Text>
<TouchableOpacity
accessibilityRole="button"
<TouchableOpacity
accessibilityRole="button"
onPress={() => handleBackToDietOptions(cardId)}
style={styles.dietBackBtn}
>
<Ionicons name="arrow-back" size={16} color="#687076" />
</TouchableOpacity>
</View>
<TextInput
placeholder="例如:午餐吃了一碗米饭(150g)、红烧肉(100g)、青菜(80g)"
placeholderTextColor={'#687076'}
@@ -917,23 +848,22 @@ export default function CoachScreen() {
value={currentText}
onChangeText={(text) => setDietTextInputs(prev => ({ ...prev, [cardId]: text }))}
returnKeyType="done"
blurOnSubmit={false}
/>
<TouchableOpacity
accessibilityRole="button"
<TouchableOpacity
accessibilityRole="button"
style={[styles.dietSubmitBtn, { opacity: currentText.trim() ? 1 : 0.5 }]}
disabled={!currentText.trim()}
onPress={() => handleSubmitDietText(currentText, cardId)}
>
<Text style={{ color: '#192126', fontWeight: '700' }}></Text>
</TouchableOpacity>
<Text style={{ color: '#687076', fontSize: 12 }}>Health Bot给出更精准的营养分析和建议</Text>
</View>
);
}
return (
<Markdown style={markdownStyles} mergeStyle>
{item.content || ''}
@@ -944,45 +874,32 @@ export default function CoachScreen() {
function insertWeightInputCard() {
const id = `wcard_${Date.now()}`;
const preset = userProfile?.weight ? Number(userProfile.weight) : undefined;
const payload = `__WEIGHT_INPUT_CARD__\n${preset ?? ''}`;
const payload = `${CardType.WEIGHT_INPUT}\n${preset ?? ''}`;
setMessages((prev) => [...prev, { id, role: 'assistant', content: payload }]);
setTimeout(scrollToEnd, 100);
}
async function handleSubmitWeight(text?: string) {
async function handleSubmitWeight(text?: string, cardId?: string) {
const val = parseFloat(String(text ?? '').trim());
if (isNaN(val) || val <= 0 || val > 500) {
Alert.alert('请输入有效体重', '请填写合理的公斤数,例如 60.5');
return;
}
try {
// 本地更新
dispatch(updateProfile({ weight: String(val) }));
// 后端同步尝试从不同可能的字段获取用户ID
try {
// 从后端响应的原始数据中查找可能的用户ID字段
const rawUserData = await api.get<any>('/api/users/info');
const userId = rawUserData?.id || rawUserData?.userId || rawUserData?._id ||
rawUserData?.user?.id || rawUserData?.user?.userId || rawUserData?.user?._id ||
rawUserData?.profile?.id || rawUserData?.profile?.userId || rawUserData?.profile?._id;
if (userId) {
await updateUserApi({ userId, weight: val });
await dispatch(fetchMyProfile() as any);
// 刷新体重历史记录
await dispatch(fetchWeightHistory() as any);
} else {
console.warn('[AI_CHAT] No user ID found for weight sync');
}
} catch (syncError) {
console.warn('[AI_CHAT] Failed to sync weight to server:', syncError);
// 不阻断对话体验,但可以给用户一个提示
// 清理该卡片的输入状态
if (cardId) {
setWeightInputs(prev => {
const { [cardId]: _, ...rest } = prev;
return rest;
});
// 移除体重输入卡片
setMessages((prev) => prev.filter(msg => msg.id !== cardId));
}
// 在对话中插入"确认消息"并发送给教练
const textMsg = `记录了今日体重:${val} kg`;
const textMsg = `#记体重:\n\n${val} kg`;
await send(textMsg);
} catch (e: any) {
console.error('[AI_CHAT] Error handling weight submission:', e);
@@ -992,25 +909,30 @@ export default function CoachScreen() {
function insertDietInputCard() {
const id = `dcard_${Date.now()}`;
const payload = `__DIET_INPUT_CARD__\n${id}`;
const payload = `${CardType.DIET_INPUT}\n${id}`;
setMessages((prev) => [...prev, { id, role: 'assistant', content: payload }]);
setTimeout(scrollToEnd, 100);
}
function handleDietTextInput(cardId: string) {
// 替换当前的饮食选择卡片为文字输入卡片
const payload = `__DIET_TEXT_INPUT__\n${cardId}`;
setMessages((prev) => prev.map(msg =>
msg.id === cardId
const payload = `${CardType.DIET_TEXT_INPUT}\n${cardId}`;
setMessages((prev) => prev.map(msg =>
msg.id === cardId
? { ...msg, content: payload }
: msg
));
setTimeout(scrollToEnd, 100);
}
async function handleDietPhotoInput(cardId: string) {
function handleDietPhotoInput(cardId: string) {
console.log('[DIET] handleDietPhotoInput called with cardId:', cardId);
setCurrentCardId(cardId);
setShowDietPhotoActionSheet(true);
}
async function handleCameraPhoto() {
try {
// 使用现有的拍照功能
const permissionResult = await ImagePicker.requestCameraPermissionsAsync();
if (permissionResult.status !== 'granted') {
Alert.alert('权限不足', '需要相机权限以拍摄食物照片');
@@ -1025,24 +947,7 @@ export default function CoachScreen() {
});
if (!result.canceled && result.assets?.[0]) {
const asset = result.assets[0];
try {
// 上传图片
const { url } = await upload(
{ uri: asset.uri, name: `diet-${Date.now()}.jpg`, type: 'image/jpeg' },
{ prefix: 'images/diet' }
);
// 移除饮食选择卡片
setMessages((prev) => prev.filter(msg => msg.id !== cardId));
// 发送包含图片的饮食记录消息
const dietMsg = `拍摄了食物照片请Health Bot帮我分析这餐的营养成分、热量和健康建议\n\n![食物照片](${url})`;
await send(dietMsg);
} catch (uploadError) {
console.error('[DIET] 图片上传失败:', uploadError);
Alert.alert('上传失败', '图片上传失败,请重试');
}
await processSelectedImage(result.assets[0]);
}
} catch (e: any) {
console.error('[DIET] 拍照失败:', e);
@@ -1050,11 +955,57 @@ export default function CoachScreen() {
}
}
async function handleLibraryPhoto() {
try {
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (permissionResult.status !== 'granted') {
Alert.alert('权限不足', '需要相册权限以选择食物照片');
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images'],
allowsEditing: true,
quality: 0.9,
aspect: [4, 3],
});
if (!result.canceled && result.assets?.[0]) {
await processSelectedImage(result.assets[0]);
}
} catch (e: any) {
console.error('[DIET] 选择照片失败:', e);
Alert.alert('选择照片失败', e?.message || '选择照片失败,请重试');
}
}
async function processSelectedImage(asset: ImagePicker.ImagePickerAsset) {
if (!currentCardId) return;
try {
// 上传图片
const { url } = await upload(
{ uri: asset.uri, name: `diet-${Date.now()}.jpg`, type: 'image/jpeg' },
{ prefix: 'images/diet' }
);
// 移除饮食选择卡片
setMessages((prev) => prev.filter(msg => msg.id !== currentCardId));
// 发送包含图片的饮食记录消息
const dietMsg = `#记饮食:\n\n![食物照片](${url})`;
await send(dietMsg);
} catch (uploadError) {
console.error('[DIET] 图片上传失败:', uploadError);
Alert.alert('上传失败', '图片上传失败,请重试');
}
}
function handleBackToDietOptions(cardId: string) {
// 返回到饮食选择界面
const payload = `__DIET_INPUT_CARD__\n${cardId}`;
setMessages((prev) => prev.map(msg =>
msg.id === cardId
const payload = `${CardType.DIET_INPUT}\n${cardId}`;
setMessages((prev) => prev.map(msg =>
msg.id === cardId
? { ...msg, content: payload }
: msg
));
@@ -1071,13 +1022,13 @@ export default function CoachScreen() {
try {
// 移除饮食输入卡片
setMessages((prev) => prev.filter(msg => msg.id !== cardId));
// 清理输入状态
setDietTextInputs(prev => {
const { [cardId]: _, ...rest } = prev;
return rest;
});
// 发送饮食记录消息
const dietMsg = `记录了今日饮食:${trimmedText}`;
await send(dietMsg);
@@ -1090,7 +1041,7 @@ export default function CoachScreen() {
return (
<View style={[styles.screen, { backgroundColor: theme.background }]}>
{/* 顶部标题区域,显示教练名称、新建会话和历史按钮 */}
<View
<View
style={[styles.header, { paddingTop: insets.top + 10 }]}
onLayout={(e) => {
const h = e.nativeEvent.layout.height;
@@ -1099,16 +1050,16 @@ export default function CoachScreen() {
>
<Text style={[styles.headerTitle, { color: theme.text }]}>{botName}</Text>
<View style={styles.headerActions}>
<TouchableOpacity
accessibilityRole="button"
onPress={startNewConversation}
<TouchableOpacity
accessibilityRole="button"
onPress={startNewConversation}
style={[styles.headerActionButton, { backgroundColor: 'rgba(187,242,70,0.2)' }]}
>
<Ionicons name="add-outline" size={18} color={theme.onPrimary} />
</TouchableOpacity>
<TouchableOpacity
accessibilityRole="button"
onPress={openHistory}
<TouchableOpacity
accessibilityRole="button"
onPress={openHistory}
style={[styles.headerActionButton, { backgroundColor: 'rgba(187,242,70,0.2)' }]}
>
<Ionicons name="time-outline" size={18} color={theme.onPrimary} />
@@ -1117,9 +1068,9 @@ export default function CoachScreen() {
</View>
{/* 消息列表容器 - 设置固定高度避免输入框重叠 */}
<View style={{
flex: 1,
marginBottom: composerHeight + keyboardOffset
<View style={{
flex: 1,
marginBottom: composerHeight + keyboardOffset
}}>
<FlatList
ref={listRef}
@@ -1133,10 +1084,10 @@ export default function CoachScreen() {
setTimeout(scrollToEnd, 100);
}
}}
contentContainerStyle={{
paddingHorizontal: 14,
paddingTop: 8,
paddingBottom: 16
contentContainerStyle={{
paddingHorizontal: 14,
paddingTop: 8,
paddingBottom: 16
}}
onContentSizeChange={() => {
// 首次内容变化强制滚底,其余仅在接近底部时滚动
@@ -1199,9 +1150,9 @@ export default function CoachScreen() {
)}
{img.error && (
<View style={styles.imageErrorOverlay}>
<TouchableOpacity
accessibilityRole="button"
onPress={() => uploadImage(img)}
<TouchableOpacity
accessibilityRole="button"
onPress={() => uploadImage(img)}
style={styles.imageRetryBtn}
>
<Ionicons name="refresh" size={12} color="#fff" />
@@ -1232,7 +1183,7 @@ export default function CoachScreen() {
onChangeText={setInput}
multiline
onSubmitEditing={() => send(input)}
blurOnSubmit={false}
submitBehavior="blurAndSubmit"
/>
<TouchableOpacity
accessibilityRole="button"
@@ -1256,9 +1207,9 @@ export default function CoachScreen() {
<TouchableOpacity
accessibilityRole="button"
onPress={scrollToEnd}
style={[styles.scrollToBottomFab, {
bottom: composerHeight + keyboardOffset + 10,
backgroundColor: theme.primary
style={[styles.scrollToBottomFab, {
bottom: composerHeight + keyboardOffset + 10,
backgroundColor: theme.primary
}]}
>
<Ionicons name="chevron-down" size={18} color={theme.onPrimary} />
@@ -1320,6 +1271,16 @@ export default function CoachScreen() {
</View>
</TouchableOpacity>
</Modal>
<ActionSheet
visible={showDietPhotoActionSheet}
onClose={() => setShowDietPhotoActionSheet(false)}
title="选择图片来源"
options={[
{ id: 'camera', title: '拍照', onPress: handleCameraPhoto },
{ id: 'library', title: '从相册选择', onPress: handleLibraryPhoto },
{ id: 'cancel', title: '取消', onPress: () => setShowDietPhotoActionSheet(false) }
]}
/>
</View>
);
}