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

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

View File

@@ -1,5 +1,6 @@
import { Ionicons } from '@expo/vector-icons';
import { BlurView } from 'expo-blur';
import * as ImagePicker from 'expo-image-picker';
import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
@@ -7,7 +8,7 @@ import {
Alert,
FlatList,
Image,
KeyboardAvoidingView,
Keyboard,
Modal,
Platform,
ScrollView,
@@ -17,17 +18,22 @@ import {
TouchableOpacity,
View,
} from 'react-native';
import Markdown from 'react-native-markdown-display';
import Animated, { FadeInDown, FadeInUp, Layout } from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useAppSelector } from '@/hooks/redux';
import { buildCosKey, buildPublicUrl } from '@/constants/Cos';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { deleteConversation, getConversationDetail, listConversations, type AiConversationListItem } from '@/services/aiCoach';
import { loadAiCoachSessionCache, saveAiCoachSessionCache } from '@/services/aiCoachSession';
import { api, getAuthToken, postTextStream } from '@/services/api';
import { uploadWithRetry } from '@/services/cos';
import { updateUser as updateUserApi } from '@/services/users';
import type { CheckinRecord } from '@/store/checkinSlice';
import { fetchMyProfile, updateProfile } from '@/store/userSlice';
import dayjs from 'dayjs';
type Role = 'user' | 'assistant';
@@ -67,14 +73,30 @@ export default function AICoachChatScreen() {
const didInitialScrollRef = useRef(false);
const [composerHeight, setComposerHeight] = useState<number>(80);
const shouldAutoScrollRef = useRef(false);
const [keyboardOffset, setKeyboardOffset] = useState(0);
const pendingAssistantIdRef = useRef<string | null>(null);
const [selectedImages, setSelectedImages] = useState<Array<{
id: string;
localUri: string;
width?: number;
height?: number;
progress: number;
uploadedKey?: string;
uploadedUrl?: string;
error?: string;
}>>([]);
const [previewImageUri, setPreviewImageUri] = useState<string | null>(null);
const planDraft = useAppSelector((s) => s.trainingPlan?.draft);
const checkin = useAppSelector((s) => (s as any).checkin);
const dispatch = useAppDispatch();
const userProfile = useAppSelector((s) => (s as any)?.user?.profile);
const chips = useMemo(() => [
{ key: 'posture', label: '体态评估', action: () => router.push('/ai-posture-assessment') },
{ key: 'plan', label: 'AI制定训练计划', action: () => handleQuickPlan() },
{ key: 'analyze', label: '分析运动记录', action: () => handleAnalyzeRecords() },
{ key: 'weight', label: '记体重', action: () => insertWeightInputCard() },
], [router, planDraft, checkin]);
const scrollToEnd = useCallback(() => {
@@ -132,6 +154,29 @@ export default function AICoachChatScreen() {
}
}, [composerHeight, isAtBottom, scrollToEnd]);
// 键盘事件:在键盘弹出时,将输入区与悬浮按钮一起上移,避免遮挡
useEffect(() => {
let showSub: any = null;
let hideSub: any = null;
if (Platform.OS === 'ios') {
showSub = Keyboard.addListener('keyboardWillChangeFrame', (e: any) => {
try {
const height = Math.max(0, (e.endCoordinates?.height ?? 0) - insets.bottom);
setKeyboardOffset(height);
} catch { setKeyboardOffset(0); }
});
} else {
showSub = Keyboard.addListener('keyboardDidShow', (e: any) => {
try { setKeyboardOffset(Math.max(0, e.endCoordinates?.height ?? 0)); } catch { setKeyboardOffset(0); }
});
hideSub = Keyboard.addListener('keyboardDidHide', () => setKeyboardOffset(0));
}
return () => {
try { showSub?.remove?.(); } catch { }
try { hideSub?.remove?.(); } catch { }
};
}, [insets.bottom]);
const streamAbortRef = useRef<{ abort: () => void } | null>(null);
useEffect(() => {
@@ -246,6 +291,7 @@ export default function AICoachChatScreen() {
const userMsg: ChatMessage = { id: userMsgId, role: 'user', content: text };
shouldAutoScrollRef.current = isAtBottom;
setMessages((m) => [...m, userMsg, { id: assistantId, role: 'assistant', content: '' }]);
pendingAssistantIdRef.current = assistantId;
setIsSending(true);
setIsStreaming(true);
@@ -281,6 +327,7 @@ export default function AICoachChatScreen() {
setIsStreaming(false);
streamAbortRef.current = null;
if (cidFromHeader && !conversationId) setConversationId(cidFromHeader);
pendingAssistantIdRef.current = null;
try { console.log('[AI_CHAT][api] end', { cidFromHeader, hadChunks: receivedAnyChunk }); } catch { }
};
@@ -289,6 +336,7 @@ export default function AICoachChatScreen() {
setIsSending(false);
setIsStreaming(false);
streamAbortRef.current = null;
pendingAssistantIdRef.current = null;
// 流式失败时的降级:尝试一次性非流式
try {
const bodyNoStream = { ...body, stream: false };
@@ -314,10 +362,59 @@ export default function AICoachChatScreen() {
}
async function send(text: string) {
if (!text.trim() || isSending) return;
if (isSending) return;
const trimmed = text.trim();
setInput('');
await sendStream(trimmed);
if (!trimmed && selectedImages.length === 0) return;
async function ensureImagesUploaded(): Promise<string[]> {
const urls: string[] = [];
for (const img of selectedImages) {
if (img.uploadedUrl) {
urls.push(img.uploadedUrl);
continue;
}
try {
const resp = await fetch(img.localUri);
const blob = await resp.blob();
const ext = (() => {
const t = (blob.type || '').toLowerCase();
if (t.includes('png')) return 'png';
if (t.includes('webp')) return 'webp';
if (t.includes('heic')) return 'heic';
if (t.includes('heif')) return 'heif';
return 'jpg';
})();
const key = buildCosKey({ prefix: 'images/chat', ext });
const res = await uploadWithRetry({
key,
body: blob,
contentType: blob.type || 'image/jpeg',
onProgress: ({ percent }: { percent?: number }) => {
const p = typeof percent === 'number' ? percent : 0;
setSelectedImages((prev) => prev.map((it) => it.id === img.id ? { ...it, progress: p } : it));
},
} as any);
const url = buildPublicUrl(res.key);
urls.push(url);
setSelectedImages((prev) => prev.map((it) => it.id === img.id ? { ...it, uploadedKey: res.key, uploadedUrl: url, progress: 1 } : it));
} catch (e: any) {
setSelectedImages((prev) => prev.map((it) => it.id === img.id ? { ...it, error: e?.message || '上传失败' } : it));
throw e;
}
}
return urls;
}
try {
const urls = await ensureImagesUploaded();
const mdImages = urls.map((u) => `![image](${u})`).join('\n\n');
const composed = [trimmed, mdImages].filter(Boolean).join('\n\n');
setInput('');
setSelectedImages([]);
await sendStream(composed);
} catch (e: any) {
Alert.alert('上传失败', e?.message || '图片上传失败,请稍后重试');
}
}
function handleQuickPlan() {
@@ -378,6 +475,37 @@ export default function AICoachChatScreen() {
send(prompt);
}
const pickImages = useCallback(async () => {
try {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsMultipleSelection: true,
selectionLimit: 4,
quality: 0.9,
} as any);
if ((result as any).canceled) return;
const assets = (result as any).assets || [];
const next = assets.map((a: any) => ({
id: `${a.assetId || a.fileName || a.uri}_${Math.random().toString(36).slice(2, 8)}`,
localUri: a.uri,
width: a.width,
height: a.height,
progress: 0,
}));
setSelectedImages((prev) => {
const merged = [...prev, ...next];
return merged.slice(0, 4);
});
setTimeout(scrollToEnd, 0);
} catch (e: any) {
Alert.alert('错误', e?.message || '选择图片失败');
}
}, [scrollToEnd]);
const removeSelectedImage = useCallback((id: string) => {
setSelectedImages((prev) => prev.filter((it) => it.id !== id));
}, []);
function renderItem({ item }: { item: ChatMessage }) {
const isUser = item.role === 'user';
return (
@@ -399,12 +527,88 @@ export default function AICoachChatScreen() {
},
]}
>
<Text style={[styles.bubbleText, { color: isUser ? theme.onPrimary : '#192126' }]}>{item.content}</Text>
{renderBubbleContent(item)}
</View>
{false}
</Animated.View>
);
}
function renderBubbleContent(item: ChatMessage) {
if (!item.content?.trim() && isStreaming && pendingAssistantIdRef.current === item.id) {
return <Text style={[styles.bubbleText, { color: '#687076' }]}></Text>;
}
if (item.content?.startsWith('__WEIGHT_INPUT_CARD__')) {
const preset = (() => {
const m = item.content.split('\n')?.[1];
const v = parseFloat(m || '');
return isNaN(v) ? '' : String(v);
})();
return (
<View style={{ gap: 8 }}>
<Text style={[styles.bubbleText, { color: '#192126', fontWeight: '700' }]}></Text>
<View style={styles.weightRow}>
<TextInput
placeholder="例如 60.5"
keyboardType="decimal-pad"
defaultValue={preset}
placeholderTextColor={'#687076'}
style={styles.weightInput}
onSubmitEditing={(e) => handleSubmitWeight(e.nativeEvent.text)}
returnKeyType="done"
blurOnSubmit
/>
<Text style={styles.weightUnit}>kg</Text>
<TouchableOpacity accessibilityRole="button" style={styles.weightSaveBtn} onPress={() => handleSubmitWeight((preset || '').toString())}>
<Text style={{ color: '#192126', fontWeight: '700' }}></Text>
</TouchableOpacity>
</View>
<Text style={{ color: '#687076', fontSize: 12 }}></Text>
</View>
);
}
return (
<Markdown style={markdownStyles} mergeStyle>
{item.content}
</Markdown>
);
}
function insertWeightInputCard() {
const id = `wcard_${Date.now()}`;
const preset = userProfile?.weight ? Number(userProfile.weight) : undefined;
const payload = `__WEIGHT_INPUT_CARD__\n${preset ?? ''}`;
setMessages((prev) => [...prev, { id, role: 'assistant', content: payload }]);
setTimeout(scrollToEnd, 0);
}
async function handleSubmitWeight(text?: string) {
const val = parseFloat(String(text ?? '').trim());
if (isNaN(val) || val <= 0 || val > 500) {
Alert.alert('请输入有效体重', '请填写合理的公斤数,例如 60.5');
return;
}
try {
// 本地更新
dispatch(updateProfile({ weight: val }));
// 后端同步(若有 userId 则更稳妥;后端实现容错)
try {
const userId = (userProfile as any)?.userId || (userProfile as any)?.id || (userProfile as any)?._id;
if (userId) {
await updateUserApi({ userId, weight: val });
await dispatch(fetchMyProfile() as any);
}
} catch (e) {
// 不阻断对话体验
}
// 在对话中插入“确认消息”并发送给教练
const textMsg = `我记录了今日体重:${val} kg。请基于这一变化给出训练/营养建议。`;
await send(textMsg);
} catch (e: any) {
Alert.alert('保存失败', e?.message || '请稍后重试');
}
}
return (
<View style={[styles.screen, { backgroundColor: theme.background }]}>
<HeaderBar
@@ -434,7 +638,7 @@ export default function AICoachChatScreen() {
}}
contentContainerStyle={{ paddingHorizontal: 14, paddingTop: 8 }}
ListFooterComponent={() => (
<View style={{ height: insets.bottom + composerHeight + (isAtBottom ? 0 : 56) + 16 }} />
<View style={{ height: insets.bottom + keyboardOffset + composerHeight + (isAtBottom ? 0 : 56) + 16 }} />
)}
onContentSizeChange={() => {
// 首次内容变化强制滚底,其余仅在接近底部时滚动
@@ -454,53 +658,90 @@ export default function AICoachChatScreen() {
showsVerticalScrollIndicator={false}
/>
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'} keyboardVerticalOffset={insets.top}>
<BlurView
intensity={18}
tint={'light'}
style={[styles.composerWrap, { paddingBottom: insets.bottom + 10 }]}
onLayout={(e) => {
const h = e.nativeEvent.layout.height;
if (h && Math.abs(h - composerHeight) > 0.5) setComposerHeight(h);
}}
<BlurView
intensity={18}
tint={'light'}
style={[styles.composerWrap, { paddingBottom: insets.bottom + 10, bottom: keyboardOffset }]}
onLayout={(e) => {
const h = e.nativeEvent.layout.height;
if (h && Math.abs(h - composerHeight) > 0.5) setComposerHeight(h);
}}
>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
decelerationRate="fast"
snapToAlignment="start"
style={styles.chipsRowScroll}
contentContainerStyle={{ paddingHorizontal: 6, gap: 8 }}
>
<View style={styles.chipsRow}>
{chips.map((c) => (
<TouchableOpacity key={c.key} style={[styles.chip, { borderColor: 'rgba(187,242,70,0.35)', backgroundColor: 'rgba(187,242,70,0.12)' }]} onPress={c.action}>
<Text style={[styles.chipText, { color: '#192126' }]}>{c.label}</Text>
</TouchableOpacity>
))}
</View>
<View style={[styles.inputRow, { borderColor: 'rgba(187,242,70,0.35)', backgroundColor: 'rgba(187,242,70,0.08)' }]}>
<TextInput
placeholder="问我任何与普拉提相关的问题..."
placeholderTextColor={theme.textMuted}
style={[styles.input, { color: '#192126' }]}
value={input}
onChangeText={setInput}
multiline
onSubmitEditing={() => send(input)}
blurOnSubmit={false}
/>
<TouchableOpacity
accessibilityRole="button"
disabled={!input.trim() || isSending}
onPress={() => send(input)}
style={[
styles.sendBtn,
{ backgroundColor: theme.primary, opacity: input.trim() && !isSending ? 1 : 0.5 }
]}
>
{isSending ? (
<ActivityIndicator color={theme.onPrimary} />
) : (
<Ionicons name="arrow-up" size={18} color={theme.onPrimary} />
)}
{chips.map((c) => (
<TouchableOpacity key={c.key} style={[styles.chip, { borderColor: 'rgba(187,242,70,0.35)', backgroundColor: 'rgba(187,242,70,0.12)' }]} onPress={c.action}>
<Text style={[styles.chipText, { color: '#192126' }]}>{c.label}</Text>
</TouchableOpacity>
</View>
</BlurView>
</KeyboardAvoidingView>
))}
</ScrollView>
{!!selectedImages.length && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.imagesRow}
contentContainerStyle={{ paddingHorizontal: 6, gap: 8 }}
>
{selectedImages.map((img) => (
<View key={img.id} style={styles.imageThumbWrap}>
<TouchableOpacity accessibilityRole="imagebutton" onPress={() => setPreviewImageUri(img.uploadedUrl || img.localUri)}>
<Image source={{ uri: img.uploadedUrl || img.localUri }} style={styles.imageThumb} />
</TouchableOpacity>
{!!(img.progress > 0 && img.progress < 1) && (
<View style={styles.imageProgressOverlay}>
<Text style={styles.imageProgressText}>{Math.round((img.progress || 0) * 100)}%</Text>
</View>
)}
<TouchableOpacity accessibilityRole="button" onPress={() => removeSelectedImage(img.id)} style={styles.imageRemoveBtn}>
<Ionicons name="close" size={12} color="#fff" />
</TouchableOpacity>
</View>
))}
</ScrollView>
)}
<View style={[styles.inputRow, { borderColor: 'rgba(187,242,70,0.35)', backgroundColor: 'rgba(187,242,70,0.08)' }]}>
<TouchableOpacity
accessibilityRole="button"
onPress={pickImages}
style={[styles.mediaBtn, { backgroundColor: 'rgba(187,242,70,0.16)' }]}
>
<Ionicons name="image-outline" size={18} color={'#192126'} />
</TouchableOpacity>
<TextInput
placeholder="问我任何与普拉提相关的问题..."
placeholderTextColor={theme.textMuted}
style={[styles.input, { color: '#192126' }]}
value={input}
onChangeText={setInput}
multiline
onSubmitEditing={() => send(input)}
blurOnSubmit={false}
/>
<TouchableOpacity
accessibilityRole="button"
disabled={(!input.trim() && selectedImages.length === 0) || isSending}
onPress={() => send(input)}
style={[
styles.sendBtn,
{ backgroundColor: theme.primary, opacity: (input.trim() || selectedImages.length > 0) && !isSending ? 1 : 0.5 }
]}
>
{isSending ? (
<ActivityIndicator color={theme.onPrimary} />
) : (
<Ionicons name="arrow-up" size={18} color={theme.onPrimary} />
)}
</TouchableOpacity>
</View>
</BlurView>
{!isAtBottom && (
<TouchableOpacity
@@ -558,6 +799,15 @@ export default function AICoachChatScreen() {
</View>
</TouchableOpacity>
</Modal>
<Modal transparent visible={!!previewImageUri} animationType="fade" onRequestClose={() => setPreviewImageUri(null)}>
<TouchableOpacity activeOpacity={1} style={styles.previewBackdrop} onPress={() => setPreviewImageUri(null)}>
<View style={styles.previewBox}>
{previewImageUri ? (
<Image source={{ uri: previewImageUri }} style={styles.previewImage} resizeMode="contain" />
) : null}
</View>
</TouchableOpacity>
</Modal>
</View>
);
}
@@ -613,6 +863,34 @@ const styles = StyleSheet.create({
fontSize: 15,
lineHeight: 22,
},
weightRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
weightInput: {
flex: 1,
height: 36,
borderWidth: 1,
borderColor: 'rgba(0,0,0,0.08)',
borderRadius: 8,
paddingHorizontal: 10,
backgroundColor: 'rgba(255,255,255,0.9)',
color: '#192126',
},
weightUnit: {
color: '#192126',
fontWeight: '700',
},
weightSaveBtn: {
height: 36,
paddingHorizontal: 12,
borderRadius: 8,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(187,242,70,0.6)'
},
// markdown 基础样式承载容器的字体尺寸保持与气泡一致
composerWrap: {
position: 'absolute',
left: 0,
@@ -624,11 +902,13 @@ const styles = StyleSheet.create({
},
chipsRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
paddingHorizontal: 6,
marginBottom: 8,
},
chipsRowScroll: {
marginBottom: 8,
},
chip: {
paddingHorizontal: 10,
height: 34,
@@ -642,6 +922,47 @@ const styles = StyleSheet.create({
fontSize: 13,
fontWeight: '600',
},
imagesRow: {
maxHeight: 92,
marginBottom: 8,
},
imageThumbWrap: {
width: 72,
height: 72,
borderRadius: 12,
overflow: 'hidden',
position: 'relative',
backgroundColor: 'rgba(0,0,0,0.06)'
},
imageThumb: {
width: '100%',
height: '100%'
},
imageRemoveBtn: {
position: 'absolute',
right: 4,
top: 4,
width: 20,
height: 20,
borderRadius: 10,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0,0,0,0.45)'
},
imageProgressOverlay: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.35)',
alignItems: 'center',
justifyContent: 'center',
},
imageProgressText: {
color: '#fff',
fontWeight: '700'
},
inputRow: {
flexDirection: 'row',
alignItems: 'center',
@@ -650,6 +971,14 @@ const styles = StyleSheet.create({
borderRadius: 16,
backgroundColor: 'rgba(0,0,0,0.04)'
},
mediaBtn: {
width: 40,
height: 40,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
marginRight: 6,
},
input: {
flex: 1,
fontSize: 15,
@@ -748,6 +1077,66 @@ const styles = StyleSheet.create({
borderRadius: 10,
backgroundColor: 'rgba(0,0,0,0.06)'
},
previewBackdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.85)',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
},
previewBox: {
width: '100%',
height: '80%',
borderRadius: 12,
overflow: 'hidden',
},
previewImage: {
width: '100%',
height: '100%',
},
});
const markdownStyles = {
body: {
color: '#192126',
fontSize: 15,
lineHeight: 22,
},
paragraph: {
marginTop: 2,
marginBottom: 2,
},
bullet_list: {
marginVertical: 4,
},
ordered_list: {
marginVertical: 4,
},
list_item: {
flexDirection: 'row',
},
code_inline: {
backgroundColor: 'rgba(0,0,0,0.06)',
borderRadius: 4,
paddingHorizontal: 4,
paddingVertical: 2,
},
code_block: {
backgroundColor: 'rgba(0,0,0,0.06)',
borderRadius: 8,
paddingHorizontal: 8,
paddingVertical: 6,
},
fence: {
backgroundColor: 'rgba(0,0,0,0.06)',
borderRadius: 8,
paddingHorizontal: 8,
paddingVertical: 6,
},
heading1: { fontSize: 20, fontWeight: '800', marginVertical: 6 },
heading2: { fontSize: 18, fontWeight: '800', marginVertical: 6 },
heading3: { fontSize: 16, fontWeight: '800', marginVertical: 6 },
link: { color: '#246BFD' },
} as const;