diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 31aab2f..cca2d01 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -18,6 +18,7 @@ export default function TabLayout() { screenOptions={({ route }) => { const routeName = route.name; const isSelected = (routeName === 'index' && pathname === '/') || + (routeName === 'coach' && pathname === '/coach') || (routeName === 'explore' && pathname === '/explore') || pathname.includes(routeName); @@ -39,6 +40,8 @@ export default function TabLayout() { switch (routeName) { case 'index': return { icon: 'house.fill', title: '首页' } as const; + case 'coach': + return { icon: 'person.3.fill', title: '教练' } as const; case 'explore': return { icon: 'paperplane.fill', title: '探索' } as const; case 'personal': @@ -150,6 +153,34 @@ export default function TabLayout() { }, }} /> + { + const isCoachSelected = pathname === '/coach'; + return ( + + + {isCoachSelected && ( + + 教练 + + )} + + ); + }, + }} + /> (); + const insets = useSafeAreaInsets(); + + const { isLoggedIn, pushIfAuthedElseLogin } = useAuthGuard(); + // 为了让页面更贴近品牌主题与更亮的观感,这里使用亮色系配色 + const theme = Colors.light; + const coachName = (params?.name || 'Sarah').toString(); + const [input, setInput] = useState(''); + const [isSending, setIsSending] = useState(false); + const [isStreaming, setIsStreaming] = useState(false); + const [conversationId, setConversationId] = useState(undefined); + const [messages, setMessages] = useState([{ + id: 'm_welcome', + role: 'assistant', + content: `你好,我是你的普拉提教练 ${coachName}。可以向我咨询训练、体态、康复、柔韧等问题~`, + }]); + const [historyVisible, setHistoryVisible] = useState(false); + const [historyLoading, setHistoryLoading] = useState(false); + const [historyPage, setHistoryPage] = useState(1); + const [historyTotal, setHistoryTotal] = useState(0); + const [historyItems, setHistoryItems] = useState([]); + const listRef = useRef>(null); + const [isAtBottom, setIsAtBottom] = useState(true); + const didInitialScrollRef = useRef(false); + const [composerHeight, setComposerHeight] = useState(80); + const shouldAutoScrollRef = useRef(false); + const [keyboardOffset, setKeyboardOffset] = useState(0); + const [headerHeight, setHeaderHeight] = useState(60); + const pendingAssistantIdRef = useRef(null); + const [selectedImages, setSelectedImages] = useState>([]); + const [previewImageUri, setPreviewImageUri] = useState(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(() => { + 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]); + // 启动页面时尝试恢复当次应用会话缓存 + useEffect(() => { + (async () => { + try { + const cached = await loadAiCoachSessionCache(); + if (cached && Array.isArray(cached.messages) && cached.messages.length > 0) { + setConversationId(cached.conversationId); + setMessages(cached.messages as any); + setTimeout(scrollToEnd, 0); + } + } catch { } + })(); + }, [scrollToEnd]); + + // 会话变动时,轻量防抖写入缓存(在本次应用生命周期内可跨页面恢复;下次冷启动会被根布局清空) + const saveCacheTimerRef = useRef | null>(null); + useEffect(() => { + if (saveCacheTimerRef.current) clearTimeout(saveCacheTimerRef.current); + saveCacheTimerRef.current = setTimeout(() => { + saveAiCoachSessionCache({ conversationId, messages: messages as any, updatedAt: Date.now() }).catch(() => { }); + }, 150); + return () => { if (saveCacheTimerRef.current) clearTimeout(saveCacheTimerRef.current); }; + // 仅在 messages 或 conversationId 变化时触发 + }, [messages, conversationId]); + + + // 取消对 messages.length 的全局监听滚动,改为在"消息实际追加完成"后再判断与滚动,避免突兀与多次触发 + + useEffect(() => { + // 输入区高度变化时,若用户在底部则轻柔跟随一次 + if (isAtBottom) { + const id = setTimeout(scrollToEnd, 0); + return () => clearTimeout(id); + } + }, [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); } + }); + hideSub = Keyboard.addListener('keyboardWillHide', () => 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(() => { + return () => { + try { streamAbortRef.current?.abort(); } catch { } + }; + }, []); + + function ensureConversationId(): string { + if (conversationId && conversationId.trim()) return conversationId; + const cid = `mobile-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + setConversationId(cid); + try { console.log('[AI_CHAT][ui] create temp conversationId', cid); } catch { } + return cid; + } + + function convertToServerMessages(history: ChatMessage[]): Array<{ role: 'user' | 'assistant' | 'system'; content: string }> { + // 仅映射 user/assistant 消息;系统提示由后端自动注入 + return history + .filter((m) => m.role === 'user' || m.role === 'assistant') + .map((m) => ({ role: m.role, content: m.content })); + } + + async function openHistory() { + if (isStreaming) { + try { streamAbortRef.current?.abort(); } catch { } + } + setHistoryVisible(true); + await refreshHistory(1); + } + + async function refreshHistory(page = 1) { + try { + setHistoryLoading(true); + const resp = await listConversations(page, 20); + setHistoryPage(resp.page); + setHistoryTotal(resp.total); + setHistoryItems(resp.items || []); + } catch (e) { + Alert.alert('错误', (e as any)?.message || '获取会话列表失败'); + } finally { + setHistoryLoading(false); + } + } + + async function handleSelectConversation(id: string) { + try { + if (isStreaming) { + try { streamAbortRef.current?.abort(); } catch { } + } + const detail = await getConversationDetail(id); + if (!detail || !(detail as any).messages) { + Alert.alert('提示', '会话不存在或已删除'); + return; + } + const mapped: ChatMessage[] = (detail.messages || []) + .filter((m) => m.role === 'user' || m.role === 'assistant') + .map((m, idx) => ({ id: `${m.role}_${idx}_${Date.now()}`, role: m.role as Role, content: m.content || '' })); + setConversationId(detail.conversationId); + setMessages(mapped.length ? mapped : [{ id: 'm_welcome', role: 'assistant', content: `你好,我是你的普拉提教练 ${coachName}。可以向我咨询训练、体态、康复、柔韧等问题~` }]); + setHistoryVisible(false); + setTimeout(scrollToEnd, 0); + } catch (e) { + Alert.alert('错误', (e as any)?.message || '加载会话失败'); + } + } + + function confirmDeleteConversation(id: string) { + Alert.alert('删除会话', '删除后将无法恢复,确定要删除该会话吗?', [ + { text: '取消', style: 'cancel' }, + { + text: '删除', style: 'destructive', onPress: async () => { + try { + await deleteConversation(id); + if (conversationId === id) { + setConversationId(undefined); + setMessages([{ id: 'm_welcome', role: 'assistant', content: `你好,我是你的普拉提教练 ${coachName}。可以向我咨询训练、体态、康复、柔韧等问题~` }]); + } + await refreshHistory(historyPage); + } catch (e) { + Alert.alert('错误', (e as any)?.message || '删除失败'); + } + } + } + ]); + } + + async function sendStream(text: string) { + const tokenExists = !!getAuthToken(); + try { console.log('[AI_CHAT][ui] send start', { tokenExists, conversationId, textPreview: text.slice(0, 50) }); } catch { } + + // 终止上一次未完成的流 + if (streamAbortRef.current) { + try { console.log('[AI_CHAT][ui] abort previous stream'); } catch { } + try { streamAbortRef.current.abort(); } catch { } + streamAbortRef.current = null; + } + + // 发送 body:尽量提供历史消息,后端会优先使用 conversationId 关联上下文 + const historyForServer = convertToServerMessages(messages); + const cid = ensureConversationId(); + const body = { + conversationId: cid, + messages: [...historyForServer, { role: 'user' as const, content: text }], + stream: true, + }; + + // 在 UI 中先放置占位回答,随后持续增量更新 + const assistantId = `a_${Date.now()}`; + const userMsgId = `u_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + + 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); + + let receivedAnyChunk = false; + + const updateAssistantContent = (delta: string) => { + setMessages((prev) => { + const next = prev.map((msg) => { + if (msg.id === assistantId) { + return { ...msg, content: msg.content + delta }; + } + return msg; + }); + return next; + }); + }; + + const onChunk = (chunk: string) => { + receivedAnyChunk = true; + const atBottomNow = isAtBottom; + updateAssistantContent(chunk); + if (atBottomNow) { + // 在底部时,持续开启自动滚动,并主动触发一次滚动以避免极小增量未触发 onContentSizeChange 的情况 + shouldAutoScrollRef.current = true; + setTimeout(scrollToEnd, 0); + } + try { console.log('[AI_CHAT][api] chunk', { length: chunk.length, preview: chunk.slice(0, 40) }); } catch { } + }; + + const onEnd = (cidFromHeader?: string) => { + setIsSending(false); + setIsStreaming(false); + streamAbortRef.current = null; + if (cidFromHeader && !conversationId) setConversationId(cidFromHeader); + pendingAssistantIdRef.current = null; + try { console.log('[AI_CHAT][api] end', { cidFromHeader, hadChunks: receivedAnyChunk }); } catch { } + }; + + const onError = async (err: any) => { + try { console.warn('[AI_CHAT][api] error', err); } catch { } + setIsSending(false); + setIsStreaming(false); + streamAbortRef.current = null; + pendingAssistantIdRef.current = null; + // 流式失败时的降级:尝试一次性非流式 + try { + const bodyNoStream = { ...body, stream: false }; + try { console.log('[AI_CHAT][fallback] try non-stream'); } catch { } + const resp = await api.post<{ conversationId?: string; text: string }>('/api/ai-coach/chat', bodyNoStream); + const textCombined = (resp as any)?.text ?? ''; + if ((resp as any)?.conversationId && !conversationId) { + setConversationId((resp as any).conversationId); + } + setMessages((prev) => prev.map((msg) => msg.id === assistantId ? { ...msg, content: textCombined || '(空响应)' } : msg)); + } catch (e2: any) { + setMessages((prev) => prev.map((msg) => msg.id === assistantId ? { ...msg, content: '抱歉,请求失败,请稍后再试。' } : msg)); + try { console.warn('[AI_CHAT][fallback] non-stream error', e2); } catch { } + } + }; + + try { + const controller = postTextStream('/api/ai-coach/chat', body, { onChunk, onEnd, onError }, { timeoutMs: 120000 }); + streamAbortRef.current = controller; + } catch (e) { + onError(e); + } + } + + async function send(text: string) { + if (!isLoggedIn) { + pushIfAuthedElseLogin('/auth/login'); + return; + } + if (isSending) return; + const trimmed = text.trim(); + if (!trimmed && selectedImages.length === 0) return; + + async function ensureImagesUploaded(): Promise { + 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() { + const goalMap: Record = { + 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 = {}; + const exerciseCount: Record = {}; + 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); + } + + 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 ( + + {!isUser && ( + + )} + + {renderBubbleContent(item)} + + {false} + + ); + } + + function renderBubbleContent(item: ChatMessage) { + if (!item.content?.trim() && isStreaming && pendingAssistantIdRef.current === item.id) { + return 正在思考…; + } + 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 ( + + 记录今日体重 + + handleSubmitWeight(e.nativeEvent.text)} + returnKeyType="done" + submitBehavior="blurAndSubmit" + /> + kg + handleSubmitWeight((preset || '').toString())}> + 保存 + + + 按回车或点击保存,即可将该体重同步到账户并发送到对话。 + + ); + } + return ( + + {item.content} + + ); + } + + 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: String(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 ( + + {/* 顶部标题区域,只显示教练名称和历史按钮 */} + { + const h = e.nativeEvent.layout.height; + if (h && Math.abs(h - headerHeight) > 0.5) setHeaderHeight(h); + }} + > + 教练 {coachName} + + + + + + {/* 消息列表容器 - 设置固定高度避免输入框重叠 */} + + m.id} + renderItem={renderItem} + onLayout={() => { + // 确保首屏布局后也尝试滚动 + if (!didInitialScrollRef.current) { + didInitialScrollRef.current = true; + setTimeout(scrollToEnd, 0); + requestAnimationFrame(scrollToEnd); + } + }} + contentContainerStyle={{ + paddingHorizontal: 14, + paddingTop: 8, + paddingBottom: 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} + /> + + + { + const h = e.nativeEvent.layout.height; + if (h && Math.abs(h - composerHeight) > 0.5) setComposerHeight(h); + }} + > + + {chips.map((c) => ( + + {c.label} + + ))} + + + {!!selectedImages.length && ( + + {selectedImages.map((img) => ( + + setPreviewImageUri(img.uploadedUrl || img.localUri)}> + + + {!!(img.progress > 0 && img.progress < 1) && ( + + {Math.round((img.progress || 0) * 100)}% + + )} + removeSelectedImage(img.id)} style={styles.imageRemoveBtn}> + + + + ))} + + )} + + + + + + send(input)} + blurOnSubmit={false} + /> + send(input)} + style={[ + styles.sendBtn, + { backgroundColor: theme.primary, opacity: (input.trim() || selectedImages.length > 0) && !isSending ? 1 : 0.5 } + ]} + > + {isSending ? ( + + ) : ( + + )} + + + + + {!isAtBottom && ( + + + + )} + + setHistoryVisible(false)}> + setHistoryVisible(false)}> + + + 历史会话 + refreshHistory(historyPage)} style={styles.modalRefreshBtn}> + + + + {historyLoading ? ( + + + 加载中... + + ) : ( + + {historyItems.length === 0 ? ( + 暂无会话 + ) : ( + historyItems.map((it) => ( + + handleSelectConversation(it.conversationId)} + > + {it.title || '未命名会话'} + + {dayjs(it.lastMessageAt || it.createdAt).format('YYYY/MM/DD HH:mm')} + + + confirmDeleteConversation(it.conversationId)} style={styles.historyDeleteBtn}> + + + + )) + )} + + )} + + setHistoryVisible(false)} style={styles.modalCloseBtn}> + 关闭 + + + + + + setPreviewImageUri(null)}> + setPreviewImageUri(null)}> + + {previewImageUri ? ( + + ) : null} + + + + + ); +} + +const styles = StyleSheet.create({ + screen: { + flex: 1, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingBottom: 10, + }, + headerTitle: { + fontSize: 20, + fontWeight: '800', + }, + historyButton: { + width: 32, + height: 32, + borderRadius: 16, + alignItems: 'center', + justifyContent: 'center', + }, + 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, + }, + 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, + right: 0, + bottom: 0, + paddingTop: 8, + paddingHorizontal: 10, + borderTopWidth: 0, + }, + chipsRow: { + flexDirection: 'row', + gap: 8, + paddingHorizontal: 6, + marginBottom: 8, + }, + chipsRowScroll: { + marginBottom: 8, + }, + chip: { + paddingHorizontal: 10, + height: 34, + borderRadius: 18, + borderWidth: 1, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'transparent', + }, + chipText: { + 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', + padding: 8, + borderWidth: 1, + 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, + 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, + }, + modalBackdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.35)', + padding: 16, + justifyContent: 'flex-end', + }, + modalSheet: { + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + paddingHorizontal: 12, + paddingTop: 10, + paddingBottom: 12, + }, + modalHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 4, + paddingBottom: 8, + }, + modalTitle: { + fontSize: 16, + fontWeight: '800', + color: '#192126', + }, + modalRefreshBtn: { + width: 28, + height: 28, + borderRadius: 14, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(0,0,0,0.06)' + }, + historyRow: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 10, + paddingHorizontal: 8, + borderRadius: 10, + }, + historyTitle: { + fontSize: 15, + color: '#192126', + fontWeight: '600', + }, + historyMeta: { + marginTop: 2, + fontSize: 12, + color: '#687076', + }, + historyDeleteBtn: { + width: 28, + height: 28, + borderRadius: 14, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(255,68,68,0.08)' + }, + modalFooter: { + paddingTop: 8, + alignItems: 'flex-end', + }, + modalCloseBtn: { + paddingHorizontal: 14, + paddingVertical: 8, + 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; diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index b316076..5637f32 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -75,9 +75,8 @@ export default function HomeScreen() { const snapX = distLeft <= distRight ? minX : maxX; Animated.spring(pan, { toValue: { x: snapX, y: clampedY }, useNativeDriver: false, bounciness: 6 }).start(() => { if (!dragState.current.moved) { - // Treat as tap - // @ts-ignore - expo-router string ok - router.push('/ai-coach-chat?name=Iris' as any); + // 切换到教练 tab,并传递name参数 + router.push('/coach?name=Iris' as any); } }); }, @@ -91,7 +90,6 @@ export default function HomeScreen() { title: string; subtitle: string; level?: '初学者' | '中级' | '高级'; - progress: number; onPress?: () => void; } | { @@ -115,7 +113,6 @@ export default function HomeScreen() { title: '今日训练', subtitle: '完成一次普拉提训练,记录你的坚持', level: '初学者', - progress: 0, onPress: () => pushIfAuthedElseLogin('/workout/today'), }, { @@ -126,7 +123,6 @@ export default function HomeScreen() { title: '体态评估', subtitle: '评估你的体态,制定训练计划', level: '初学者', - progress: 0, onPress: () => router.push('/ai-posture-assessment'), }, ...listRecommendedArticles().map((a) => ({ @@ -194,7 +190,6 @@ export default function HomeScreen() { image: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/imagedemo.jpeg', title: c.title || '今日训练', subtitle: c.subtitle || '完成一次普拉提训练,记录你的坚持', - progress: 0, onPress: () => pushIfAuthedElseLogin('/workout/today'), }); } @@ -282,6 +277,22 @@ export default function HomeScreen() { 热点功能 + + pushIfAuthedElseLogin('/workout/today')} + > + + + + + + 训练 + + router.push('/ai-posture-assessment')} @@ -292,7 +303,7 @@ export default function HomeScreen() { style={styles.featureIconImage} /> - AI体态评估 + 体态 - 计划管理 + 计划 + + + @@ -344,7 +358,6 @@ export default function HomeScreen() { title={item.title} subtitle={item.subtitle} level={item.level} - progress={item.progress} /> ); return item.onPress ? ( @@ -490,6 +503,9 @@ const styles = StyleSheet.create({ featureCardQuaternary: { backgroundColor: '#fa709a', }, + featureCardQuinary: { + backgroundColor: '#f59e0b', + }, featureIconWrapper: { width: 32, height: 32, diff --git a/app/(tabs)/personal.tsx b/app/(tabs)/personal.tsx index 3b3789b..4594b75 100644 --- a/app/(tabs)/personal.tsx +++ b/app/(tabs)/personal.tsx @@ -1,23 +1,23 @@ +import { PRIVACY_POLICY_URL, USER_AGREEMENT_URL } from '@/constants/Agree'; import { Colors } from '@/constants/Colors'; import { getTabBarBottomPadding } from '@/constants/TabBar'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; +import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useColorScheme } from '@/hooks/useColorScheme'; -import { api } from '@/services/api'; -import { DEFAULT_MEMBER_NAME, fetchMyProfile, logout } from '@/store/userSlice'; +import { DEFAULT_MEMBER_NAME, fetchMyProfile } from '@/store/userSlice'; import { Ionicons } from '@expo/vector-icons'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; import { useFocusEffect } from '@react-navigation/native'; -import type { Href } from 'expo-router'; -import { router } from 'expo-router'; import React, { useMemo, useState } from 'react'; -import { Alert, Image, SafeAreaView, ScrollView, StatusBar, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native'; +import { Alert, Image, Linking, SafeAreaView, ScrollView, StatusBar, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; const DEFAULT_AVATAR_URL = 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/avatar/avatarGirl01.jpeg'; export default function PersonalScreen() { const dispatch = useAppDispatch(); + const { confirmLogout, confirmDeleteAccount, isLoggedIn, pushIfAuthedElseLogin } = useAuthGuard(); const insets = useSafeAreaInsets(); const tabBarHeight = useBottomTabBarHeight(); const colorScheme = useColorScheme(); @@ -95,32 +95,7 @@ export default function PersonalScreen() { - const handleDeleteAccount = () => { - Alert.alert( - '确认注销帐号', - '此操作不可恢复,将删除您的帐号及相关数据。确定继续吗?', - [ - { text: '取消', style: 'cancel' }, - { - text: '确认注销', - style: 'destructive', - onPress: async () => { - try { - await api.delete('/api/users/delete-account'); - await AsyncStorage.multiRemove(['@user_personal_info']); - await dispatch(logout()).unwrap(); - Alert.alert('帐号已注销', '您的帐号已成功注销'); - router.replace('/auth/login'); - } catch (err: any) { - const message = err?.message || '注销失败,请稍后重试'; - Alert.alert('注销失败', message); - } - }, - }, - ], - { cancelable: true } - ); - }; + const UserInfoSection = () => ( @@ -138,7 +113,7 @@ export default function PersonalScreen() { {/* 编辑按钮 */} - router.push('/profile/edit')}> + pushIfAuthedElseLogin('/profile/edit')}> 编辑 @@ -170,7 +145,8 @@ export default function PersonalScreen() { {item.type === 'switch' ? ( { + if (!isLoggedIn) { + pushIfAuthedElseLogin('/profile/notification-settings'); + return; + } + setNotificationEnabled(value); + }} trackColor={{ false: '#E5E5E5', true: colors.primary }} thumbColor="#FFFFFF" style={styles.switch} @@ -243,12 +225,20 @@ export default function PersonalScreen() { { icon: 'flag-outline' as const, title: '目标管理', - onPress: () => router.push('/profile/goals' as Href), - }, - { - icon: 'stats-chart-outline' as const, - title: '训练进度', + onPress: () => pushIfAuthedElseLogin('/profile/goals'), }, + // { + // icon: 'stats-chart-outline' as const, + // title: '训练进度', + // onPress: () => { + // // 训练进度页面暂未实现,先显示提示 + // if (isLoggedIn) { + // Alert.alert('提示', '训练进度功能正在开发中'); + // } else { + // pushIfAuthedElseLogin('/profile/training-progress'); + // } + // }, + // }, ], }, { @@ -264,42 +254,36 @@ export default function PersonalScreen() { { title: '其他', items: [ - { - icon: 'mail-outline' as const, - title: '联系我们', - }, { icon: 'shield-checkmark-outline' as const, title: '隐私政策', + onPress: () => Linking.openURL(PRIVACY_POLICY_URL), }, { - icon: 'settings-outline' as const, - title: '设置', + icon: 'document-text-outline' as const, + title: '用户协议', + onPress: () => Linking.openURL(USER_AGREEMENT_URL), }, ], }, - { + // 只有登录用户才显示账号与安全菜单 + ...(isLoggedIn ? [{ title: '账号与安全', items: [ + { + icon: 'log-out-outline' as const, + title: '退出登录', + onPress: confirmLogout, + isDanger: false, + }, { icon: 'trash-outline' as const, title: '注销帐号', - onPress: handleDeleteAccount, + onPress: confirmDeleteAccount, isDanger: true, }, ], - }, - { - title: '开发者', - items: [ - { - icon: 'refresh-outline' as const, - title: '重置引导流程', - onPress: handleResetOnboarding, - isDanger: true, - }, - ], - }, + }] : []), ]; return ( @@ -317,12 +301,6 @@ export default function PersonalScreen() { ))} - {/* 底部浮动按钮 */} - - - - - @@ -430,6 +408,7 @@ const styles = StyleSheet.create({ menuItemText: { fontSize: 16, flex: 1, + fontWeight: '600', }, switch: { transform: [{ scaleX: 0.8 }, { scaleY: 0.8 }], diff --git a/app/auth/login.tsx b/app/auth/login.tsx index 2c9bfb2..ee1f1b9 100644 --- a/app/auth/login.tsx +++ b/app/auth/login.tsx @@ -3,11 +3,12 @@ import * as AppleAuthentication from 'expo-apple-authentication'; import { LinearGradient } from 'expo-linear-gradient'; import { useLocalSearchParams, useRouter } from 'expo-router'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Alert, Animated, Pressable, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { Alert, Animated, Linking, Pressable, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { ThemedText } from '@/components/ThemedText'; import { ThemedView } from '@/components/ThemedView'; +import { PRIVACY_POLICY_URL, USER_AGREEMENT_URL } from '@/constants/Agree'; import { Colors } from '@/constants/Colors'; import { useAppDispatch } from '@/hooks/redux'; import { useColorScheme } from '@/hooks/useColorScheme'; @@ -230,8 +231,8 @@ export default function LoginScreen() { - 数字普拉提 - 欢迎登录 + 普拉提助手 + 欢迎登录普拉提星球 {/* Apple 登录 */} @@ -252,22 +253,6 @@ export default function LoginScreen() { )} - {/* 游客登录(弱化样式) */} - guardAgreement(onGuestLogin)} - disabled={loading} - style={({ pressed }) => [ - styles.guestButton, - { borderColor: color.border, backgroundColor: color.surface }, - loading && { opacity: 0.7 }, - pressed && { transform: [{ scale: 0.98 }] }, - ]} - > - - 以游客身份继续 - - {/* 协议勾选 */} setHasAgreed((v) => !v)} style={styles.checkboxWrap} accessibilityRole="checkbox" accessibilityState={{ checked: hasAgreed }}> @@ -281,11 +266,11 @@ export default function LoginScreen() { 我已阅读并同意 - router.push('/legal/privacy-policy')}> + Linking.openURL(PRIVACY_POLICY_URL)}> 《隐私政策》 - router.push('/legal/user-agreement')}> + Linking.openURL(USER_AGREEMENT_URL)}> 《用户协议》 diff --git a/app/index.tsx b/app/index.tsx index 5f7cb80..0756595 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -1,6 +1,5 @@ import { ThemedView } from '@/components/ThemedView'; import { useThemeColor } from '@/hooks/useThemeColor'; -import AsyncStorage from '@react-native-async-storage/async-storage'; import { router } from 'expo-router'; import React, { useEffect, useState } from 'react'; import { ActivityIndicator, View } from 'react-native'; @@ -18,22 +17,24 @@ export default function SplashScreen() { const checkOnboardingStatus = async () => { try { - const onboardingCompleted = await AsyncStorage.getItem(ONBOARDING_COMPLETED_KEY); + // const onboardingCompleted = await AsyncStorage.getItem(ONBOARDING_COMPLETED_KEY); - if (onboardingCompleted === 'true') { - router.replace('/(tabs)'); - } else { - router.replace('/onboarding'); - } - setIsLoading(false); + // if (onboardingCompleted === 'true') { + // router.replace('/(tabs)'); + // } else { + // router.replace('/onboarding'); + // } + // setIsLoading(false); + router.replace('/(tabs)'); } catch (error) { console.error('检查引导状态失败:', error); // 如果出现错误,默认显示引导页面 - setTimeout(() => { - router.replace('/onboarding'); - setIsLoading(false); - }, 1000); + // setTimeout(() => { + // router.replace('/onboarding'); + // setIsLoading(false); + // }, 1000); } + setIsLoading(false); }; if (!isLoading) { diff --git a/app/workout/today.tsx b/app/workout/today.tsx index 247a510..0769907 100644 --- a/app/workout/today.tsx +++ b/app/workout/today.tsx @@ -427,7 +427,7 @@ export default function TodayWorkoutScreen() { router.back()} withSafeTop={false} transparent={true} diff --git a/assets/images/icons/iconWorkout.png b/assets/images/icons/iconWorkout.png new file mode 100644 index 0000000..8635eb1 Binary files /dev/null and b/assets/images/icons/iconWorkout.png differ diff --git a/components/PlanCard.tsx b/components/PlanCard.tsx index fc0b9db..d67e8b1 100644 --- a/components/PlanCard.tsx +++ b/components/PlanCard.tsx @@ -1,4 +1,4 @@ -import { ProgressBar } from '@/components/ProgressBar'; + import React from 'react'; import { Image, StyleSheet, Text, View } from 'react-native'; @@ -9,10 +9,9 @@ type PlanCardProps = { title: string; subtitle: string; level?: Level; - progress: number; // 0 - 1 }; -export function PlanCard({ image, title, subtitle, level, progress }: PlanCardProps) { +export function PlanCard({ image, title, subtitle, level }: PlanCardProps) { return ( @@ -27,10 +26,6 @@ export function PlanCard({ image, title, subtitle, level, progress }: PlanCardPr {title} {subtitle} - - - - ); @@ -86,9 +81,6 @@ const styles = StyleSheet.create({ fontSize: 12, color: '#B1B6BD', }, - progressWrapper: { - marginTop: 18, - }, }); diff --git a/components/PrivacyConsentModal.tsx b/components/PrivacyConsentModal.tsx index 18b580f..75202a2 100644 --- a/components/PrivacyConsentModal.tsx +++ b/components/PrivacyConsentModal.tsx @@ -1,12 +1,13 @@ -import { router } from 'expo-router'; +import { PRIVACY_POLICY_URL, USER_AGREEMENT_URL } from '@/constants/Agree'; import React from 'react'; import { - Dimensions, - Modal, - StyleSheet, - Text, - TouchableOpacity, - View + Dimensions, + Linking, + Modal, + StyleSheet, + Text, + TouchableOpacity, + View } from 'react-native'; const { width } = Dimensions.get('window'); @@ -23,11 +24,11 @@ export default function PrivacyConsentModal({ onDisagree, }: PrivacyConsentModalProps) { const handleUserAgreementPress = () => { - router.push('/legal/user-agreement'); + Linking.openURL(USER_AGREEMENT_URL); }; const handlePrivacyPolicyPress = () => { - router.push('/legal/privacy-policy'); + Linking.openURL(PRIVACY_POLICY_URL); }; return ( diff --git a/components/ui/IconSymbol.tsx b/components/ui/IconSymbol.tsx index 87c1c52..c2de0cf 100644 --- a/components/ui/IconSymbol.tsx +++ b/components/ui/IconSymbol.tsx @@ -19,6 +19,7 @@ const MAPPING = { 'chevron.left.forwardslash.chevron.right': 'code', 'chevron.right': 'chevron-right', 'person.fill': 'person', + 'person.3.fill': 'people', } as IconMapping; /** diff --git a/constants/Agree.ts b/constants/Agree.ts new file mode 100644 index 0000000..ba8f498 --- /dev/null +++ b/constants/Agree.ts @@ -0,0 +1,4 @@ +// 用户协议 +export const USER_AGREEMENT_URL = 'https://docs.qq.com/doc/DWkVYZ3RhRmZCaUdn?nlc=1'; +// 隐私政策 +export const PRIVACY_POLICY_URL = 'https://docs.qq.com/doc/DWkh2Y1Zxb21BVGNY?nlc=1'; \ No newline at end of file diff --git a/hooks/useAuthGuard.ts b/hooks/useAuthGuard.ts index d3d41a2..bf68749 100644 --- a/hooks/useAuthGuard.ts +++ b/hooks/useAuthGuard.ts @@ -1,7 +1,11 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; import { usePathname, useRouter } from 'expo-router'; import { useCallback } from 'react'; +import { Alert } from 'react-native'; -import { useAppSelector } from '@/hooks/redux'; +import { useAppDispatch, useAppSelector } from '@/hooks/redux'; +import { api } from '@/services/api'; +import { logout as logoutAction } from '@/store/userSlice'; type RedirectParams = Record; @@ -12,6 +16,7 @@ type EnsureOptions = { export function useAuthGuard() { const router = useRouter(); + const dispatch = useAppDispatch(); const currentPath = usePathname(); const token = useAppSelector((s) => (s as any)?.user?.token as string | null); const isLoggedIn = !!token; @@ -52,11 +57,93 @@ export function useAuthGuard() { [ensureLoggedIn] ); + // 退出登录功能 + const handleLogout = useCallback(async () => { + try { + // 调用 Redux action 清除本地状态和缓存 + await dispatch(logoutAction()).unwrap(); + + // 跳转到登录页面 + router.replace('/auth/login'); + } catch (error) { + console.error('退出登录失败:', error); + Alert.alert('错误', '退出登录失败,请稍后重试'); + } + }, [dispatch, router]); + + // 带确认对话框的退出登录 + const confirmLogout = useCallback(() => { + Alert.alert( + '确认退出', + '确定要退出当前账号吗?', + [ + { + text: '取消', + style: 'cancel', + }, + { + text: '确定', + style: 'default', + onPress: handleLogout, + }, + ] + ); + }, [handleLogout]); + + // 注销账号功能 + const handleDeleteAccount = useCallback(async () => { + try { + // 调用注销账号API + await api.delete('/api/users/delete-account'); + + // 清除额外的本地数据 + await AsyncStorage.multiRemove(['@user_personal_info', '@onboarding_completed']); + + // 执行退出登录逻辑 + await dispatch(logoutAction()).unwrap(); + + Alert.alert('账号已注销', '您的账号已成功注销', [ + { + text: '确定', + onPress: () => router.replace('/auth/login'), + }, + ]); + } catch (error: any) { + console.error('注销账号失败:', error); + const message = error?.message || '注销失败,请稍后重试'; + Alert.alert('注销失败', message); + } + }, [dispatch, router]); + + // 带确认对话框的注销账号 + const confirmDeleteAccount = useCallback(() => { + Alert.alert( + '确认注销账号', + '此操作不可恢复,将删除您的账号及相关数据。确定继续吗?', + [ + { + text: '取消', + style: 'cancel', + }, + { + text: '确认注销', + style: 'destructive', + onPress: handleDeleteAccount, + }, + ], + { cancelable: true } + ); + }, [handleDeleteAccount]); + return { isLoggedIn, ensureLoggedIn, pushIfAuthedElseLogin, guardHandler, + handleLogout, + confirmLogout, + handleDeleteAccount, + confirmDeleteAccount, } as const; }