fix:同意隐私

This commit is contained in:
吕新雨
2026-02-05 16:33:58 +08:00
parent 8e71503169
commit 1e1e49ea57
5 changed files with 138 additions and 53 deletions

View File

@@ -136,12 +136,20 @@ export default function OnboardingScreen() {
return; return;
} }
// 1) 获取 Expo Push Token // 1) 获取 Expo Push Token(失败才认为“推送开启失败”)
const expoPushToken = await getExpoPushTokenOrThrow(); const expoPushToken = await getExpoPushTokenOrThrow();
// 2) 上报 token 到后端(幂等) // 2) 上报 token 到后端(幂等;失败才认为“推送开启失败”
await registerPushToken({ pushToken: expoPushToken }); await registerPushToken({ pushToken: expoPushToken });
// 3) 上报推送偏好(幂等) // 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'); await setPushPromptState('enabled');
} catch (e) { } catch (e) {

View File

@@ -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 { View, Text, StyleSheet, TouchableOpacity, Dimensions, Platform, Alert, Image } from 'react-native';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import * as WebBrowser from 'expo-web-browser'; 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 { SafeAreaView } from 'react-native-safe-area-context';
import { setConsentAccepted, getConsentAccepted } from '../../src/storage/appStorage'; import { setConsentAccepted, getConsentAccepted } from '../../src/storage/appStorage';
import { fetchLegalLinks } from '@/src/services/legalApi'; import { fetchLegalLinks } from '@/src/services/legalApi';
import { getOnboardingCompleted } from '@/src/storage/appStorage'; import { getOnboardingCompleted } from '@/src/storage/appStorage';
import { API_BASE_URL } from '@/src/constants/env';
// 导入 SVG 组件 // 导入 SVG 组件
import FlowersBg from '../../assets/images/index/flowers_endbg.svg'; import FlowersBg from '../../assets/images/index/flowers_endbg.svg';
@@ -19,11 +20,19 @@ export default function SplashScreen() {
const { t } = useTranslation(); const { t } = useTranslation();
const [showConsent, setShowConsent] = useState(false); const [showConsent, setShowConsent] = useState(false);
const [links, setLinks] = useState<{ privacy?: string; terms?: string }>({}); const [links, setLinks] = useState<{ privacy?: string; terms?: string }>({});
const [linksLoading, setLinksLoading] = useState(false);
const mountedRef = useRef(true);
useEffect(() => { useEffect(() => {
checkConsent(); checkConsent();
}, []); }, []);
useEffect(() => {
return () => {
mountedRef.current = false;
};
}, []);
const checkConsent = async () => { const checkConsent = async () => {
const accepted = await getConsentAccepted(); const accepted = await getConsentAccepted();
setShowConsent(!accepted); 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 // 拉取协议链接(由后端按语言下发;默认 EN
useEffect(() => { useEffect(() => {
let cancelled = false; void refreshLegalLinks();
(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;
};
}, []); }, []);
const bgDecorationTop = 363; const bgDecorationTop = 363;
@@ -114,23 +148,33 @@ export default function SplashScreen() {
<WelcomeBtn width={87} height={57} /> <WelcomeBtn width={87} height={57} />
</TouchableOpacity> </TouchableOpacity>
<View style={styles.linksContainer}> <Text style={styles.noticeText}>
<TouchableOpacity <Trans
disabled={!links.privacy} i18nKey="consent.noticeRich"
onPress={() => (links.privacy ? openLink(links.privacy) : undefined)} values={{
> privacyLabel: t('consent.privacy'),
<Text style={styles.linkText}>{t('consent.privacy')}</Text> termsLabel: t('consent.terms'),
</TouchableOpacity> privacySuffix: !links.privacy && linksLoading ? t('consent.linkLoadingSuffix') : '',
<View style={styles.divider} /> termsSuffix: !links.terms && linksLoading ? t('consent.linkLoadingSuffix') : '',
<TouchableOpacity }}
disabled={!links.terms} components={{
onPress={() => (links.terms ? openLink(links.terms) : undefined)} privacy: (
> <Text
<Text style={styles.linkText}>{t('consent.terms')}</Text> style={[styles.noticeLinkText, !links.privacy && styles.noticeLinkTextDisabled]}
</TouchableOpacity> onPress={() => void handleOpenLegal('privacy')}
</View> suppressHighlighting
/>
<Text style={styles.noticeText}>{t('consent.notice')}</Text> ),
terms: (
<Text
style={[styles.noticeLinkText, !links.terms && styles.noticeLinkTextDisabled]}
onPress={() => void handleOpenLegal('terms')}
suppressHighlighting
/>
),
}}
/>
</Text>
</> </>
)} )}
</SafeAreaView> </SafeAreaView>
@@ -179,10 +223,6 @@ const styles = StyleSheet.create({
buttonWrapper: { buttonWrapper: {
marginBottom: 40, marginBottom: 40,
}, },
linksContainer: {
flexDirection: 'row',
alignItems: 'center',
},
noticeText: { noticeText: {
marginTop: 10, marginTop: 10,
paddingHorizontal: 28, paddingHorizontal: 28,
@@ -191,15 +231,14 @@ const styles = StyleSheet.create({
textAlign: 'center', textAlign: 'center',
color: 'rgba(119, 47, 0, 0.45)', color: 'rgba(119, 47, 0, 0.45)',
}, },
linkText: { noticeLinkText: {
fontSize: 12, fontSize: 12,
color: 'rgba(119, 47, 0, 0.5)', // 使用半透明的文字颜色 // 颜色区分:协议链接更醒目
color: 'rgba(119, 47, 0, 0.75)',
textDecorationLine: 'underline', textDecorationLine: 'underline',
fontWeight: '600',
}, },
divider: { noticeLinkTextDisabled: {
width: 1, opacity: 0.55,
height: 12,
backgroundColor: 'rgba(119, 47, 0, 0.2)',
marginHorizontal: 15,
}, },
}); });

View File

@@ -139,7 +139,10 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
const openLink = useCallback( const openLink = useCallback(
async (url?: string) => { async (url?: string) => {
if (!url) return; if (!url) {
Alert.alert(t('common.notice'), t('consent.linkUnavailable'));
return;
}
try { try {
await WebBrowser.openBrowserAsync(url); await WebBrowser.openBrowserAsync(url);
} catch (error) { } catch (error) {
@@ -435,7 +438,14 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
try { try {
const expoPushToken = await getExpoPushTokenOrThrow(); const expoPushToken = await getExpoPushTokenOrThrow();
await registerPushToken({ pushToken: expoPushToken }); 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) { } catch (e) {
const msg = e instanceof Error ? e.message : String(e); const msg = e instanceof Error ? e.message : String(e);
Alert.alert(t('common.notice'), msg); Alert.alert(t('common.notice'), msg);

View File

@@ -4,6 +4,7 @@
"ok": "OK", "ok": "OK",
"cancel": "Cancel", "cancel": "Cancel",
"error": "Error", "error": "Error",
"notice": "Notice",
"openLinkError": "Cannot open link", "openLinkError": "Cannot open link",
"back": "Back", "back": "Back",
"close": "Close" "close": "Close"
@@ -146,7 +147,11 @@
"agree": "Agree & Continue", "agree": "Agree & Continue",
"privacy": "Privacy Policy", "privacy": "Privacy Policy",
"terms": "Terms of Use", "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 <privacy>{{privacyLabel}}{{privacySuffix}}</privacy> and <terms>{{termsLabel}}{{termsSuffix}}</terms>.",
"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": { "permissions": {
"notificationsDenied": "Notifications are denied. Please enable them in Settings." "notificationsDenied": "Notifications are denied. Please enable them in Settings."
@@ -168,6 +173,9 @@
"ok": "確定", "ok": "確定",
"cancel": "取消", "cancel": "取消",
"back": "返回", "back": "返回",
"error": "錯誤",
"notice": "提示",
"openLinkError": "無法打開鏈接",
"close": "關閉" "close": "關閉"
}, },
"onboarding": { "onboarding": {
@@ -308,7 +316,11 @@
"agree": "同意並繼續", "agree": "同意並繼續",
"privacy": "隱私協議", "privacy": "隱私協議",
"terms": "用戶使用協議", "terms": "用戶使用協議",
"notice": "繼續使用即代表你同意《隱私協議》與《用戶使用協議》。" "notice": "繼續使用即代表你同意《隱私協議》與《用戶使用協議》。",
"noticeRich": "繼續使用即代表你同意<privacy>《{{privacyLabel}}》{{privacySuffix}}</privacy>與<terms>《{{termsLabel}}》{{termsSuffix}}</terms>。",
"linkUnavailable": "協議鏈接載入失敗,請檢查網路後重試。",
"linkUnavailableDev": "協議鏈接載入失敗,請檢查網路或 API_BASE_URL 設定:{{baseUrl}}",
"linkLoadingSuffix": "(載入中…)"
}, },
"permissions": { "permissions": {
"notificationsDenied": "系統權限已被拒絕,請前往手機設定開啟通知。" "notificationsDenied": "系統權限已被拒絕,請前往手機設定開啟通知。"

View File

@@ -142,6 +142,22 @@ export async function httpJson<T>(opts: HttpJsonOptions): Promise<T> {
return undefined as unknown as T; 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,
});
}
} }