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,
+ });
+ }
}