fix:同意隐私
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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": "系統權限已被拒絕,請前往手機設定開啟通知。"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user