From 1e1e49ea57576459765d56d5a79a5f77c336c691 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=95=E6=96=B0=E9=9B=A8?= Date: Thu, 5 Feb 2026 16:33:58 +0800 Subject: [PATCH] =?UTF-8?q?fix:=E5=90=8C=E6=84=8F=E9=9A=90=E7=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/app/(onboarding)/onboarding.tsx | 14 ++- client/app/(splash)/splash.tsx | 129 +++++++++++++++--------- client/components/home/ProfileModal.tsx | 14 ++- client/src/i18n/locales/all.json | 16 ++- client/src/utils/http.ts | 18 +++- 5 files changed, 138 insertions(+), 53 deletions(-) diff --git a/client/app/(onboarding)/onboarding.tsx b/client/app/(onboarding)/onboarding.tsx index ef9ae47..3f3ee98 100644 --- a/client/app/(onboarding)/onboarding.tsx +++ b/client/app/(onboarding)/onboarding.tsx @@ -136,12 +136,20 @@ export default function OnboardingScreen() { return; } - // 1) 获取 Expo Push Token + // 1) 获取 Expo Push Token(失败才认为“推送开启失败”) const expoPushToken = await getExpoPushTokenOrThrow(); - // 2) 上报 token 到后端(幂等) + // 2) 上报 token 到后端(幂等;失败才认为“推送开启失败”) await registerPushToken({ pushToken: expoPushToken }); + // 3) 上报推送偏好(幂等) - await setPushPreferences({ enabled: wantsPush, timesPerDay: reminderTimes }); + // 注意:这一步失败时,后端仍可能已成功接收 token。 + // 为避免出现“后端已接收 token 但前端弹窗提示失败”的错觉,这里改为:偏好同步失败不弹“开启失败”,仅记录并继续。 + try { + await setPushPreferences({ enabled: wantsPush, timesPerDay: reminderTimes }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + console.warn('[PushPreferences] 同步失败(Onboarding,不阻塞)', msg); + } await setPushPromptState('enabled'); } catch (e) { diff --git a/client/app/(splash)/splash.tsx b/client/app/(splash)/splash.tsx index f3065bf..bc5fa3e 100644 --- a/client/app/(splash)/splash.tsx +++ b/client/app/(splash)/splash.tsx @@ -1,12 +1,13 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { View, Text, StyleSheet, TouchableOpacity, Dimensions, Platform, Alert, Image } from 'react-native'; import { useRouter } from 'expo-router'; import * as WebBrowser from 'expo-web-browser'; -import { useTranslation } from 'react-i18next'; +import { Trans, useTranslation } from 'react-i18next'; import { SafeAreaView } from 'react-native-safe-area-context'; import { setConsentAccepted, getConsentAccepted } from '../../src/storage/appStorage'; import { fetchLegalLinks } from '@/src/services/legalApi'; import { getOnboardingCompleted } from '@/src/storage/appStorage'; +import { API_BASE_URL } from '@/src/constants/env'; // 导入 SVG 组件 import FlowersBg from '../../assets/images/index/flowers_endbg.svg'; @@ -19,11 +20,19 @@ export default function SplashScreen() { const { t } = useTranslation(); const [showConsent, setShowConsent] = useState(false); const [links, setLinks] = useState<{ privacy?: string; terms?: string }>({}); + const [linksLoading, setLinksLoading] = useState(false); + const mountedRef = useRef(true); useEffect(() => { checkConsent(); }, []); + useEffect(() => { + return () => { + mountedRef.current = false; + }; + }, []); + const checkConsent = async () => { const accepted = await getConsentAccepted(); setShowConsent(!accepted); @@ -53,23 +62,48 @@ export default function SplashScreen() { } }; + async function refreshLegalLinks(): Promise<{ privacy?: string; terms?: string }> { + if (mountedRef.current) setLinksLoading(true); + try { + const res = await fetchLegalLinks(); + const next = { privacy: res.privacyPolicyUrl, terms: res.termsOfUseUrl }; + if (mountedRef.current) setLinks(next); + return next; + } catch (e) { + // 不阻塞主流程:失败时不崩溃,链接入口仍可点(会提示) + if (__DEV__) console.log('[LegalLinks] 拉取失败(splash):', e); + if (mountedRef.current) setLinks({}); + return {}; + } finally { + if (mountedRef.current) setLinksLoading(false); + } + } + + async function handleOpenLegal(type: 'privacy' | 'terms') { + const currentUrl = type === 'privacy' ? links.privacy : links.terms; + if (currentUrl) { + await openLink(currentUrl); + return; + } + + // 链接还没拿到/拉取失败:点击时主动再拉一次,避免“点了没反应” + const next = await refreshLegalLinks(); + const nextUrl = type === 'privacy' ? next.privacy : next.terms; + if (nextUrl) { + await openLink(nextUrl); + return; + } + + const msg = + typeof __DEV__ !== 'undefined' && __DEV__ + ? t('consent.linkUnavailableDev', { baseUrl: API_BASE_URL }) + : t('consent.linkUnavailable'); + Alert.alert(t('common.notice'), msg); + } + // 拉取协议链接(由后端按语言下发;默认 EN) useEffect(() => { - let cancelled = false; - (async () => { - try { - const res = await fetchLegalLinks(); - if (cancelled) return; - setLinks({ privacy: res.privacyPolicyUrl, terms: res.termsOfUseUrl }); - } catch (e) { - // 不阻塞主流程:失败时不崩溃,链接入口可不展示 - if (__DEV__) console.log('[LegalLinks] 拉取失败(splash):', e); - if (!cancelled) setLinks({}); - } - })(); - return () => { - cancelled = true; - }; + void refreshLegalLinks(); }, []); const bgDecorationTop = 363; @@ -114,23 +148,33 @@ export default function SplashScreen() { - - (links.privacy ? openLink(links.privacy) : undefined)} - > - {t('consent.privacy')} - - - (links.terms ? openLink(links.terms) : undefined)} - > - {t('consent.terms')} - - - - {t('consent.notice')} + + void handleOpenLegal('privacy')} + suppressHighlighting + /> + ), + terms: ( + void handleOpenLegal('terms')} + suppressHighlighting + /> + ), + }} + /> + )} @@ -179,10 +223,6 @@ const styles = StyleSheet.create({ buttonWrapper: { marginBottom: 40, }, - linksContainer: { - flexDirection: 'row', - alignItems: 'center', - }, noticeText: { marginTop: 10, paddingHorizontal: 28, @@ -191,15 +231,14 @@ const styles = StyleSheet.create({ textAlign: 'center', color: 'rgba(119, 47, 0, 0.45)', }, - linkText: { + noticeLinkText: { fontSize: 12, - color: 'rgba(119, 47, 0, 0.5)', // 使用半透明的文字颜色 + // 颜色区分:协议链接更醒目 + color: 'rgba(119, 47, 0, 0.75)', textDecorationLine: 'underline', + fontWeight: '600', }, - divider: { - width: 1, - height: 12, - backgroundColor: 'rgba(119, 47, 0, 0.2)', - marginHorizontal: 15, + noticeLinkTextDisabled: { + opacity: 0.55, }, }); diff --git a/client/components/home/ProfileModal.tsx b/client/components/home/ProfileModal.tsx index 19fab72..35c6c60 100644 --- a/client/components/home/ProfileModal.tsx +++ b/client/components/home/ProfileModal.tsx @@ -139,7 +139,10 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props const openLink = useCallback( async (url?: string) => { - if (!url) return; + if (!url) { + Alert.alert(t('common.notice'), t('consent.linkUnavailable')); + return; + } try { await WebBrowser.openBrowserAsync(url); } catch (error) { @@ -435,7 +438,14 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () = try { const expoPushToken = await getExpoPushTokenOrThrow(); await registerPushToken({ pushToken: expoPushToken }); - await setPushPreferences({ enabled: true, timesPerDay }); + // 偏好同步失败不应被用户感知为“开启失败” + // (常见现象:后端已接收 token,但偏好接口短暂失败/超时) + try { + await setPushPreferences({ enabled: true, timesPerDay }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + console.warn('[PushPreferences] 同步失败(ProfileModal,不阻塞)', msg); + } } catch (e) { const msg = e instanceof Error ? e.message : String(e); Alert.alert(t('common.notice'), msg); diff --git a/client/src/i18n/locales/all.json b/client/src/i18n/locales/all.json index a73c368..aa4fddc 100644 --- a/client/src/i18n/locales/all.json +++ b/client/src/i18n/locales/all.json @@ -4,6 +4,7 @@ "ok": "OK", "cancel": "Cancel", "error": "Error", + "notice": "Notice", "openLinkError": "Cannot open link", "back": "Back", "close": "Close" @@ -146,7 +147,11 @@ "agree": "Agree & Continue", "privacy": "Privacy Policy", "terms": "Terms of Use", - "notice": "By continuing, you agree to the Privacy Policy and Terms of Use." + "notice": "By continuing, you agree to the Privacy Policy and Terms of Use.", + "noticeRich": "By continuing, you agree to the {{privacyLabel}}{{privacySuffix}} and {{termsLabel}}{{termsSuffix}}.", + "linkUnavailable": "Failed to load the policy link. Please check your network and try again.", + "linkUnavailableDev": "Failed to load the policy link. Please check your network or API_BASE_URL: {{baseUrl}}", + "linkLoadingSuffix": " (loading…)" }, "permissions": { "notificationsDenied": "Notifications are denied. Please enable them in Settings." @@ -168,6 +173,9 @@ "ok": "確定", "cancel": "取消", "back": "返回", + "error": "錯誤", + "notice": "提示", + "openLinkError": "無法打開鏈接", "close": "關閉" }, "onboarding": { @@ -308,7 +316,11 @@ "agree": "同意並繼續", "privacy": "隱私協議", "terms": "用戶使用協議", - "notice": "繼續使用即代表你同意《隱私協議》與《用戶使用協議》。" + "notice": "繼續使用即代表你同意《隱私協議》與《用戶使用協議》。", + "noticeRich": "繼續使用即代表你同意《{{privacyLabel}}》{{privacySuffix}}《{{termsLabel}}》{{termsSuffix}}。", + "linkUnavailable": "協議鏈接載入失敗,請檢查網路後重試。", + "linkUnavailableDev": "協議鏈接載入失敗,請檢查網路或 API_BASE_URL 設定:{{baseUrl}}", + "linkLoadingSuffix": "(載入中…)" }, "permissions": { "notificationsDenied": "系統權限已被拒絕,請前往手機設定開啟通知。" diff --git a/client/src/utils/http.ts b/client/src/utils/http.ts index 4a4d30d..f165247 100644 --- a/client/src/utils/http.ts +++ b/client/src/utils/http.ts @@ -142,6 +142,22 @@ export async function httpJson(opts: HttpJsonOptions): Promise { return undefined as unknown as T; } - return (await res.json()) as T; + // 某些后端/网关会返回 200 但 body 为空;此时 res.json() 会抛错,导致客户端误判“失败”。 + // 这里改为:先读 text,空则返回 undefined;非空再 parse JSON。 + const text = await res.text().catch(() => ''); + if (!text || !String(text).trim()) { + return undefined as unknown as T; + } + try { + return JSON.parse(text) as T; + } catch { + throw new HttpError({ + message: `HTTP 响应不是合法 JSON:${res.status} ${res.statusText} ${text}`.trim(), + url, + status: res.status, + statusText: res.statusText, + responseText: text, + }); + } }