diff --git a/client/app.json b/client/app.json index 79baa7c..29266a5 100644 --- a/client/app.json +++ b/client/app.json @@ -9,9 +9,9 @@ "userInterfaceStyle": "automatic", "newArchEnabled": true, "splash": { - "image": "./assets/images/splash-icon.png", + "image": "./assets/images/splashScreen.png", "resizeMode": "contain", - "backgroundColor": "#ffffff" + "backgroundColor": "#EAD2BA" }, "ios": { "supportsTablet": true, @@ -23,7 +23,8 @@ "backgroundColor": "#ffffff" }, "edgeToEdgeEnabled": true, - "predictiveBackGestureEnabled": false + "predictiveBackGestureEnabled": false, + "package": "com.damer.mindfulness" }, "web": { "bundler": "metro", diff --git a/client/app/(app)/home.tsx b/client/app/(app)/home.tsx index b7191f8..d403fd5 100644 --- a/client/app/(app)/home.tsx +++ b/client/app/(app)/home.tsx @@ -28,6 +28,7 @@ import { } from '@/src/storage/appStorage'; import { fetchRecoFeed } from '@/src/services/recoApi'; +import { toBackendLocaleFromLanguageTag } from '@/src/i18n/locale'; import ProfileModal from '@/components/home/ProfileModal'; import ThemeModal from '@/components/home/ThemeModal'; @@ -78,7 +79,7 @@ type FeedItem = { content_id: string; text: string }; export default function HomeScreen() { const { t, i18n } = useTranslation(); const isEnglish = i18n.language?.startsWith('en'); - const recoLang: 'en' | 'tc' = i18n.language?.toLowerCase().startsWith('zh') ? 'tc' : 'en'; + const recoLang: 'en' | 'tc' = toBackendLocaleFromLanguageTag(i18n.language); const insets = useSafeAreaInsets(); const [index, setIndex] = useState(0); const [themeMode, setThemeModeState] = useState('scenery'); diff --git a/client/app/(onboarding)/onboarding.tsx b/client/app/(onboarding)/onboarding.tsx index 1c95c26..ef9ae47 100644 --- a/client/app/(onboarding)/onboarding.tsx +++ b/client/app/(onboarding)/onboarding.tsx @@ -10,6 +10,7 @@ import { SelectionStep } from '@/components/onboarding/SelectionStep'; import { ReminderStep } from '@/components/onboarding/ReminderStep'; import { buildUserProfileFromQuestionnaire, mapOnboardingSelectionsToQuestionnaireAnswers } from '@/src/features/userProfileScoring'; import { ensureDailyWidgetRecoUpToDate, syncWidgetConfig, syncWidgetUserProfileFromScoring } from '@/src/modules/dailyWidgetReco'; +import { toBackendLocaleFromLanguageTag } from '@/src/i18n/locale'; import { fetchRecoFeed } from '@/src/services/recoApi'; import { getExpoPushTokenOrThrow, registerPushToken, setPushPreferences } from '@/src/services/pushApi'; import { @@ -30,7 +31,7 @@ type Step = const STEPS: Step[] = [ { id: 'name', type: 'name' }, { id: 'status', type: 'selection', optionIds: ['pregnant', 'has_kids', 'no_fill'] }, - { id: 'emotion', type: 'selection', optionIds: ['happy', 'calm', 'stressed', 'low'] }, + { id: 'emotion', type: 'selection', optionIds: ['happy', 'calm', 'okay', 'tired', 'stressed', 'low'] }, { id: 'influence', type: 'selection', optionIds: ['family', 'work', 'relationship', 'friends', 'health'] }, { id: 'support', type: 'selection', optionIds: ['emotional', 'parenting', 'self_worth', 'anxiety', 'balance'] }, { id: 'reminder', type: 'reminder' }, @@ -74,7 +75,7 @@ export default function OnboardingScreen() { // Onboarding 结束后预拉取一次 Feed 文案(失败不阻塞进入首页) try { - const lang = i18n.language?.toLowerCase().startsWith('zh') ? 'tc' : 'en'; + const lang = toBackendLocaleFromLanguageTag(i18n.language); const { items, meta } = await fetchRecoFeed({ k: 30, user_profile: { diff --git a/client/app/(splash)/splash.tsx b/client/app/(splash)/splash.tsx index 8847a51..d69c861 100644 --- a/client/app/(splash)/splash.tsx +++ b/client/app/(splash)/splash.tsx @@ -88,16 +88,22 @@ export default function SplashScreen() { {/* 文案内容 */} - You Are Perfect.{"\n"} - Everything{"\n"} - Will Be Better. + {t('consent.title')} + {'\n'} + {t('consent.subtitle')} {showConsent && ( <> - + @@ -116,6 +122,8 @@ export default function SplashScreen() { {t('consent.terms')} + + {t('consent.notice')} )} @@ -168,6 +176,14 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', }, + noticeText: { + marginTop: 10, + paddingHorizontal: 28, + fontSize: 12, + lineHeight: 16, + textAlign: 'center', + color: 'rgba(119, 47, 0, 0.45)', + }, linkText: { fontSize: 12, color: 'rgba(119, 47, 0, 0.5)', // 使用半透明的文字颜色 diff --git a/client/app/_layout.tsx b/client/app/_layout.tsx index 5ccd140..a73c1ae 100644 --- a/client/app/_layout.tsx +++ b/client/app/_layout.tsx @@ -4,9 +4,9 @@ import { useFonts } from 'expo-font'; import { Stack } from 'expo-router'; import * as SplashScreen from 'expo-splash-screen'; import * as Notifications from 'expo-notifications'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import 'react-native-reanimated'; -import { AppState } from 'react-native'; +import { Animated, AppState, Image, StyleSheet, View } from 'react-native'; import { useColorScheme } from '@/components/useColorScheme'; import { initI18n } from '@/src/i18n'; @@ -41,6 +41,10 @@ export default function RootLayout() { ...FontAwesome.font, }); const [i18nReady, setI18nReady] = useState(false); + const [appReady, setAppReady] = useState(false); + const [splashOverlayVisible, setSplashOverlayVisible] = useState(true); + const splashOpacity = useRef(new Animated.Value(1)).current; + const hasHiddenNativeSplashRef = useRef(false); // Expo Router uses Error Boundaries to catch errors in the navigation tree. useEffect(() => { @@ -68,17 +72,52 @@ export default function RootLayout() { }, []); useEffect(() => { - // 等字体与 i18n 都准备好后再隐藏启动页,避免文案闪烁 - if (loaded && i18nReady) { - SplashScreen.hideAsync(); - } + // 字体与 i18n 都准备好后,允许渲染 App(原生 splash 的隐藏交给 onLayout,避免“硬切/闪白”) + if (loaded && i18nReady) setAppReady(true); }, [loaded, i18nReady]); + const onLayoutRootView = useCallback(() => { + if (!appReady) return; + if (hasHiddenNativeSplashRef.current) return; + hasHiddenNativeSplashRef.current = true; + + // 先隐藏原生 splash,再把同款覆盖层淡出,视觉上实现平滑过渡 + void SplashScreen.hideAsync().finally(() => { + Animated.timing(splashOpacity, { + toValue: 0, + duration: 380, + useNativeDriver: true, + }).start(({ finished }) => { + if (finished) setSplashOverlayVisible(false); + }); + }); + }, [appReady, splashOpacity]); + + const content = useMemo(() => { + if (!appReady) return null; + return ; + }, [appReady]); + if (!loaded || !i18nReady) { return null; } - return ; + return ( + + {content} + {splashOverlayVisible && ( + + + + + + )} + + ); } function RootLayoutNav() { @@ -118,3 +157,20 @@ function RootLayoutNav() { ); } + +const styles = StyleSheet.create({ + root: { + flex: 1, + }, + splashOverlay: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + // 与 app.json 的 expo.splash.backgroundColor 保持一致 + backgroundColor: '#EAD2BA', + }, + splashImage: { + width: '80%', + height: '80%', + }, +}); diff --git a/client/assets/images/splashScreen.png b/client/assets/images/splashScreen.png new file mode 100644 index 0000000..c41c96f Binary files /dev/null and b/client/assets/images/splashScreen.png differ diff --git a/client/components/home/ProfileModal.tsx b/client/components/home/ProfileModal.tsx index a05d7c7..19fab72 100644 --- a/client/components/home/ProfileModal.tsx +++ b/client/components/home/ProfileModal.tsx @@ -162,23 +162,13 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props const duration = 220; const easing = Easing.out(Easing.cubic); - // 进入二级页:从右侧滑入;返回:从左侧滑入 - const entering = - navDirection === 'forward' - ? SlideInRight.duration(duration).easing(easing) - : SlideInLeft.duration(duration).easing(easing); - - // 离开:进入二级页时旧页面向左滑出;返回时旧页面向右滑出 - const exiting = - navDirection === 'forward' - ? SlideOutLeft.duration(duration).easing(easing) - : SlideOutRight.duration(duration).easing(easing); + // 需求:去掉左右滑动的切页动效,改为纯淡入淡出 + const entering = FadeIn.duration(duration).easing(easing); + const exiting = FadeOut.duration(duration).easing(easing); return { entering, exiting, - fadeIn: FadeIn.duration(duration).easing(easing), - fadeOut: FadeOut.duration(duration).easing(easing), }; }, [navDirection]); @@ -196,11 +186,6 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props exiting={transition.exiting} style={!isRoot ? { flex: 1 } : undefined} > - {page === 'root' ? ( go('widgetHowTo', 'forward')} /> )} - diff --git a/client/components/onboarding/NameInputStep.tsx b/client/components/onboarding/NameInputStep.tsx index 1472d0f..b02c289 100644 --- a/client/components/onboarding/NameInputStep.tsx +++ b/client/components/onboarding/NameInputStep.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useRef, useState } from 'react'; import { View, StyleSheet, TextInput, Platform, Animated, TouchableOpacity, Dimensions, Text } from 'react-native'; +import { useTranslation } from 'react-i18next'; import { OnboardingColors } from '@/constants/OnboardingTheme'; import BtnNotClicked from '@/assets/images/icon/btn_Notclicked.svg'; import BtnClicked from '@/assets/images/icon/btn_clicked.svg'; @@ -14,6 +15,7 @@ interface NameInputStepProps { } export function NameInputStep({ value, onChangeText, onNext }: NameInputStepProps) { + const { t } = useTranslation(); const [isFocused, setIsFocused] = useState(false); const blinkAnim = useRef(new Animated.Value(1)).current; const hasInput = value.trim().length > 0; @@ -46,7 +48,7 @@ export function NameInputStep({ value, onChangeText, onNext }: NameInputStepProp (!isFocused && !hasInput) && { color: OnboardingColors.textSecondary } ]} > - {hasInput ? value : (isFocused ? "" : "Mama")} + {hasInput ? value : isFocused ? '' : t('onboardingSurvey.steps.name.placeholder')} {isFocused && ( diff --git a/client/components/onboarding/OnboardingLayout.tsx b/client/components/onboarding/OnboardingLayout.tsx index 9644db7..5b864dd 100644 --- a/client/components/onboarding/OnboardingLayout.tsx +++ b/client/components/onboarding/OnboardingLayout.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { View, StyleSheet, SafeAreaView, TouchableOpacity, StatusBar, Text, Image, Platform } from 'react-native'; +import { useTranslation } from 'react-i18next'; import { OnboardingColors } from '@/constants/OnboardingTheme'; interface OnboardingLayoutProps { @@ -21,6 +22,7 @@ export function OnboardingLayout({ onBack, showBackButton = false }: OnboardingLayoutProps) { + const { t } = useTranslation(); return ( @@ -39,7 +41,7 @@ export function OnboardingLayout({ - skip + {t('onboarding.skipAll')} CA92.1 + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategorySystemBootTime + NSPrivacyAccessedAPITypeReasons + + 35F9.1 + + NSPrivacyAccessedAPIType NSPrivacyAccessedAPICategoryDiskSpace @@ -31,14 +39,6 @@ 85F4.1 - - NSPrivacyAccessedAPIType - NSPrivacyAccessedAPICategorySystemBootTime - NSPrivacyAccessedAPITypeReasons - - 35F9.1 - - NSPrivacyCollectedDataTypes diff --git a/client/ios/client/SplashScreen.storyboard b/client/ios/client/SplashScreen.storyboard index 1f114c8..2323717 100644 --- a/client/ios/client/SplashScreen.storyboard +++ b/client/ios/client/SplashScreen.storyboard @@ -42,7 +42,7 @@ - + \ No newline at end of file diff --git a/client/ios/client/client-Bridging-Header.h b/client/ios/client/client-Bridging-Header.h index 49ccee5..74ec715 100644 --- a/client/ios/client/client-Bridging-Header.h +++ b/client/ios/client/client-Bridging-Header.h @@ -6,5 +6,6 @@ // - 部分环境下仅 `import React` 可能无法在 Swift 中解析到 RCTBridge 等类型 // - 通过 Bridging Header 显式引入需要的 React 头文件,保证 AppDelegate.swift 可编译 #import +#import #import #import diff --git a/client/ios/情绪小组件/EmotionWidget.swift b/client/ios/情绪小组件/EmotionWidget.swift index 1fe928b..e389007 100644 --- a/client/ios/情绪小组件/EmotionWidget.swift +++ b/client/ios/情绪小组件/EmotionWidget.swift @@ -237,48 +237,22 @@ struct EmotionWidgetView: View { var entry: EmotionProvider.Entry @Environment(\.widgetFamily) var family private let deepLink = URL(string: "client:///(app)/home") + private let widgetBackgroundColor = Color(red: 1.0, green: 250.0 / 255.0, blue: 229.0 / 255.0) // #FFFAE5 + private let widgetTextColor = Color(red: 98.0 / 255.0, green: 59.0 / 255.0, blue: 59.0 / 255.0) // #623B3B var body: some View { - ZStack { - cardBackground(colors: [ - Color(red: 0.06, green: 0.08, blue: 0.12), - Color(red: 0.14, green: 0.18, blue: 0.28), - ]) - - // 只显示一句话(不显示标题/提示/时间等装饰元素) - Text(entry.text) - .font(fontForFamily()) - .foregroundColor(Color.white.opacity(0.92)) - .multilineTextAlignment(.leading) - .lineSpacing(lineSpacingForFamily()) - .lineLimit(lineLimitForFamily()) - .minimumScaleFactor(0.78) - .padding(paddingForFamily()) - } - .widgetURL(deepLink) - } - - // 统一的“卡片背景”风格(iOS 15 兼容) - private func cardBackground(colors: [Color]) -> some View { - ZStack { - LinearGradient( - colors: colors, - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - // 轻微光斑,增加层次 - RadialGradient( - gradient: Gradient(colors: [Color.white.opacity(0.16), Color.white.opacity(0.0)]), - center: .topTrailing, - startRadius: 10, - endRadius: 180 - ) - } - .overlay( - RoundedRectangle(cornerRadius: 18, style: .continuous) - .stroke(Color.white.opacity(0.14), lineWidth: 1) - ) - .cornerRadius(18) + // 只显示一句话(不显示标题/提示/时间等装饰元素) + Text(entry.text) + .font(fontForFamily()) + .foregroundColor(widgetTextColor) + .multilineTextAlignment(.leading) + .lineSpacing(lineSpacingForFamily()) + .lineLimit(lineLimitForFamily()) + .minimumScaleFactor(0.78) + .padding(paddingForFamily()) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .widgetSolidBackground(widgetBackgroundColor) + .widgetURL(deepLink) } private func fontForFamily() -> Font { @@ -330,6 +304,26 @@ struct EmotionWidgetView: View { } } +private struct WidgetSolidBackgroundModifier: ViewModifier { + let color: Color + + func body(content: Content) -> some View { + if #available(iOSApplicationExtension 17.0, *) { + content.containerBackground(for: .widget) { color } + } else { + content + .background(color) + .ignoresSafeArea() + } + } +} + +private extension View { + func widgetSolidBackground(_ color: Color) -> some View { + modifier(WidgetSolidBackgroundModifier(color: color)) + } +} + @main struct EmotionWidget: Widget { let kind: String = "EmotionWidget" diff --git a/client/src/features/userProfileScoring/onboardingMapping.ts b/client/src/features/userProfileScoring/onboardingMapping.ts index 4e1072f..d86a6b3 100644 --- a/client/src/features/userProfileScoring/onboardingMapping.ts +++ b/client/src/features/userProfileScoring/onboardingMapping.ts @@ -33,9 +33,13 @@ function mapMomStage(raw: string | undefined): QuestionnaireAnswersV1_2['mom_sta function mapEmotion(raw: string | undefined): QuestionnaireAnswersV1_2['emotion'] { if (!raw) return null; - // UI 当前选项:happy/calm/stressed/low + // UI 选项: + // - happy/calm/stressed/low:历史选项(仍保留兼容) + // - okay/tired:新增选项 if (raw === 'happy') return 'joyful'; if (raw === 'calm') return 'calm'; + if (raw === 'okay') return 'neutral'; + if (raw === 'tired') return 'tired'; if (raw === 'stressed') return 'overwhelmed'; if (raw === 'low') return 'low'; return null; diff --git a/client/src/i18n/index.ts b/client/src/i18n/index.ts index 27b6b7a..6fb1e42 100644 --- a/client/src/i18n/index.ts +++ b/client/src/i18n/index.ts @@ -3,6 +3,8 @@ import * as Localization from 'expo-localization'; import i18n from 'i18next'; import { initReactI18next } from 'react-i18next'; +import { isTraditionalChineseLocaleTag } from './locale'; + // 用 require 避免 TS 的 json module 配置差异导致无法编译 // eslint-disable-next-line @typescript-eslint/no-var-requires const all = require('./locales/all.json') as { en: Record; 'zh-TW': Record }; @@ -29,13 +31,8 @@ function isSupportedLanguage(lang: string): lang is AppLanguage { function normalizeDeviceLanguageTagToAppLanguage(languageTag: string): AppLanguage { const tag = languageTag.toLowerCase(); - // 中文:当前仅支持繁体中文(zh-TW) - if (tag.startsWith('zh')) { - return 'zh-TW'; - } - - // 其他语言:按前缀匹配(当前仅支持英文) if (tag.startsWith('en')) return 'en'; + if (isTraditionalChineseLocaleTag(tag)) return 'zh-TW'; return DEFAULT_FALLBACK_LANGUAGE; } diff --git a/client/src/i18n/locale.ts b/client/src/i18n/locale.ts new file mode 100644 index 0000000..fda1dcb --- /dev/null +++ b/client/src/i18n/locale.ts @@ -0,0 +1,35 @@ +export type BackendLocale = 'en' | 'tc'; + +/** + * 判断一个 BCP-47 language tag 是否应视为「繁体中文」(TC)。 + * + * 规则(面向当前产品约束:只支持 EN/TC,默认 EN): + * - 仅在语言为中文(zh)且脚本为 Hant 或地区为 TW/HK/MO 时,判定为 TC + * - 兼容后端/历史写法:明确包含 tc 也视为 TC + * - 其他情况一律视为 EN + */ +export function isTraditionalChineseLocaleTag(languageTag: string): boolean { + const tag = (languageTag || '').trim().toLowerCase(); + if (!tag) return false; + + // 兼容:有些链路可能直接传 tc + const parts = tag.split(/[-_]/g).filter(Boolean); + if (parts.includes('tc')) return true; + + const lang = parts[0]; + if (lang !== 'zh') return false; + + // 脚本:zh-Hant / zh-Hant-TW / zh-Hant-HK ... + if (tag.includes('hant') || parts.includes('hant')) return true; + + // 地区:zh-TW / zh-HK / zh-MO + if (parts.includes('tw') || parts.includes('hk') || parts.includes('mo')) return true; + + // 其他中文(如 zh / zh-CN / zh-Hans)不属于 TC → 回退 EN + return false; +} + +export function toBackendLocaleFromLanguageTag(languageTag: string | null | undefined): BackendLocale { + return isTraditionalChineseLocaleTag(languageTag ?? '') ? 'tc' : 'en'; +} + diff --git a/client/src/i18n/locales/all.json b/client/src/i18n/locales/all.json index e8699f2..8b69153 100644 --- a/client/src/i18n/locales/all.json +++ b/client/src/i18n/locales/all.json @@ -25,39 +25,41 @@ }, "onboardingSurvey": { "steps": { - "name": { "title": "What should I call you?" }, + "name": { "title": "What do you want to be called?", "placeholder": "Mama" }, "status": { - "title": "Your current stage?", + "title": "Which stage of motherhood are you in?", "options": { - "pregnant": "Pregnant / preparing for motherhood", - "has_kids": "Already have kids", + "pregnant": "Pregnant / Preparing", + "has_kids": "Parenting", "no_fill": "Prefer not to say" } }, "emotion": { "title": "How are you feeling right now?", "options": { - "happy": "Happy / satisfied", - "calm": "Calm / grounded", - "stressed": "Stressed / overwhelmed", - "low": "Down / low mood" + "happy": "Joyful", + "calm": "Calm", + "okay": "Okay", + "tired": "Tired", + "stressed": "Overwhelmed", + "low": "Low" } }, "influence": { - "title": "What has been affecting you lately?", + "title": "What’s been influencing how you feel?", "options": { - "family": "Family & kids", + "family": "Family", "work": "Work or study", - "relationship": "Intimate relationship", - "friends": "Friends & social life", - "health": "Mental & physical health" + "relationship": "Relationship", + "friends": "Friends", + "health": "Health" } }, "support": { - "title": "What support do you need most?", + "title": "What kind of support do you need most right now?", "options": { "emotional": "Emotional support", - "parenting": "Parenting stress", + "parenting": "Parenting pressure", "self_worth": "Self-worth", "anxiety": "Anxiety relief", "balance": "Rest & balance" @@ -119,6 +121,9 @@ "widget": { "lockScreen": "Lock Screen Widget", "homeScreen": "Home Screen Widget", + "howToTitle": "How to add the widget", + "howToDesc1": "Long-press on the Home Screen until the apps jiggle, then tap “+” in the top-left corner.", + "howToDesc2": "Search “Mindfulness”, choose a widget size you like, then tap “Add Widget”.", "previewDate": "Thu, Jan 29", "previewQuote": "I’m proud of who I am, even while becoming who I want to be." }, @@ -136,10 +141,11 @@ }, "consent": { "title": "You Are Perfect.", - "subtitle": "Everything Will Be Better.", + "subtitle": "Everything\nWill Be Better.", "agree": "Agree & Continue", "privacy": "Privacy Policy", - "terms": "Terms of Use" + "terms": "Terms of Use", + "notice": "By continuing, you agree to the Privacy Policy and Terms of Use." }, "permissions": { "notificationsDenied": "Notifications are denied. Please enable them in Settings." @@ -180,7 +186,7 @@ }, "onboardingSurvey": { "steps": { - "name": { "title": "我可以怎麼稱呼你?" }, + "name": { "title": "我可以怎麼稱呼你?", "placeholder": "媽媽" }, "status": { "title": "媽媽的狀態?", "options": { @@ -194,6 +200,8 @@ "options": { "happy": "愉悅、滿足", "calm": "平靜、安穩", + "okay": "還可以、普通", + "tired": "疲累、沒什麼力氣", "stressed": "被壓得有點喘不過氣", "low": "情緒低落" } @@ -274,6 +282,9 @@ "widget": { "lockScreen": "鎖屏小工具", "homeScreen": "桌面小工具", + "howToTitle": "如何添加小工具", + "howToDesc1": "長按主畫面空白處進入編輯,點左上角「+」新增小工具。", + "howToDesc2": "搜尋「正念」,選擇喜歡的尺寸,點「加入小工具」。", "previewDate": "1月29日週四 · 已至臘月十一", "previewQuote": "我也對現在的自己感到滿意,即使我仍在努力成為想成為的人。" }, @@ -290,9 +301,12 @@ "widgetDesc": "把溫柔提醒放到桌面上:長按主畫面 → 點「+」 → 搜尋「正念」 → 添加你喜歡的尺寸。" }, "consent": { + "title": "你很完美。", + "subtitle": "一切\n都會更好。", "agree": "同意並繼續", "privacy": "隱私協議", - "terms": "用戶使用協議" + "terms": "用戶使用協議", + "notice": "繼續使用即代表你同意《隱私協議》與《用戶使用協議》。" }, "permissions": { "notificationsDenied": "系統權限已被拒絕,請前往手機設定開啟通知。" diff --git a/client/src/modules/dailyWidgetReco/index.ts b/client/src/modules/dailyWidgetReco/index.ts index 0cb9ff1..fcbb03f 100644 --- a/client/src/modules/dailyWidgetReco/index.ts +++ b/client/src/modules/dailyWidgetReco/index.ts @@ -1,5 +1,6 @@ import { API_BASE_URL } from '@/src/constants/env'; import type { UserProfileV1_2, UserProfileV1_2_Extended } from '@/src/features/userProfileScoring/types'; +import { toBackendLocaleFromLanguageTag } from '@/src/i18n/locale'; import { fetchRecoWidget } from '@/src/services/recoApi'; import i18n from 'i18next'; import { getUserProfileScoring } from '@/src/storage/appStorage'; @@ -144,7 +145,7 @@ export async function ensureDailyWidgetRecoUpToDate(args?: { const top = items?.[0]; if (!top?.text) return; - const lang = i18n.language?.toLowerCase().startsWith('zh') ? 'tc' : 'en'; + const lang = toBackendLocaleFromLanguageTag(i18n.language); await setWidgetDailyRecoCache({ schema_version: 1, saved_at: new Date().toISOString(), diff --git a/client/src/services/__tests__/legalApi.test.ts b/client/src/services/__tests__/legalApi.test.ts index 92d4ad5..c8a83d4 100644 --- a/client/src/services/__tests__/legalApi.test.ts +++ b/client/src/services/__tests__/legalApi.test.ts @@ -20,10 +20,15 @@ describe('legalApi.buildAcceptLanguage', () => { expect(buildAcceptLanguage()).toBe('en'); }); - it('任意 zh* 归一为 tc', () => { + it('简中/其他中文不支持时回退为 en', () => { setLang('zh-CN'); - expect(buildAcceptLanguage()).toBe('tc'); + expect(buildAcceptLanguage()).toBe('en'); + setLang('zh'); + expect(buildAcceptLanguage()).toBe('en'); + }); + + it('繁体中文归一为 tc', () => { setLang('zh-TW'); expect(buildAcceptLanguage()).toBe('tc'); }); diff --git a/client/src/services/legalApi.ts b/client/src/services/legalApi.ts index 662f466..6852f19 100644 --- a/client/src/services/legalApi.ts +++ b/client/src/services/legalApi.ts @@ -1,6 +1,7 @@ import i18n from 'i18next'; import { httpJson } from '../utils/http'; +import { toBackendLocaleFromLanguageTag } from '../i18n/locale'; export type LegalLinks = { privacyPolicyUrl: string; @@ -9,17 +10,8 @@ export type LegalLinks = { }; export function buildAcceptLanguage(): 'en' | 'tc' { - const lang = (i18n.language || '').trim(); - const lower = lang.toLowerCase(); - - // 当前多语言仅支持 EN / TC(与 reco 链路一致);其他语言统一回退到 en - if (lower.startsWith('zh')) { - return 'tc'; - } - if (lower.includes('tc') || lower.includes('hant') || lower.includes('hk') || lower.includes('mo') || lower.includes('tw')) { - return 'tc'; - } - return 'en'; + // 当前多语言仅支持 EN / TC;其他语言统一回退到 en + return toBackendLocaleFromLanguageTag(i18n.language); } export async function fetchLegalLinks(): Promise { diff --git a/client/src/services/pushApi.ts b/client/src/services/pushApi.ts index 2be2e2c..7262df4 100644 --- a/client/src/services/pushApi.ts +++ b/client/src/services/pushApi.ts @@ -7,6 +7,7 @@ import { httpJson } from '../utils/http'; import { APP_ENV } from '../constants/env'; import { getDailyReminderSettings, getOrCreateClientUserId, getUserProfileScoring } from '../storage/appStorage'; import type { UserProfileScoring } from '../storage/appStorage'; +import { toBackendLocaleFromLanguageTag } from '../i18n/locale'; export type PushEnv = 'dev' | 'prod'; @@ -44,11 +45,7 @@ export type PushPreferencesResponse = PushPreferencesRequest & { }; export function buildAcceptLanguage(): 'en' | 'tc' { - const lang = (i18n.language || '').trim(); - const lower = lang.toLowerCase(); - if (lower.startsWith('zh')) return 'tc'; - if (lower.includes('tc') || lower.includes('hant') || lower.includes('hk') || lower.includes('mo') || lower.includes('tw')) return 'tc'; - return 'en'; + return toBackendLocaleFromLanguageTag(i18n.language); } function toPushEnv(appEnv: typeof APP_ENV): PushEnv { diff --git a/client/src/services/recoApi.ts b/client/src/services/recoApi.ts index e5bd6bf..369db48 100644 --- a/client/src/services/recoApi.ts +++ b/client/src/services/recoApi.ts @@ -1,6 +1,7 @@ import i18n from 'i18next'; import type { UserProfileV1_2 } from '../features/userProfileScoring'; +import { toBackendLocaleFromLanguageTag } from '../i18n/locale'; import { httpJson } from '../utils/http'; export type RecommendedItem = { @@ -27,7 +28,7 @@ export type RecoRequest = { }; export async function fetchRecoFeed(req: RecoRequest): Promise { - const acceptLanguage = i18n.language?.toLowerCase().startsWith('zh') ? 'tc' : 'en'; + const acceptLanguage = toBackendLocaleFromLanguageTag(i18n.language); const headers: Record = { // 让后端做 locale 选择(目前后端只区分 en/tc) @@ -52,7 +53,7 @@ export async function fetchRecoFeed(req: RecoRequest): Promise } export async function fetchRecoWidget(req: RecoRequest): Promise { - const acceptLanguage = i18n.language?.toLowerCase().startsWith('zh') ? 'tc' : 'en'; + const acceptLanguage = toBackendLocaleFromLanguageTag(i18n.language); const headers: Record = { // 让后端做 locale 选择(目前后端只区分 en/tc)