diff --git a/client/app/(app)/home.tsx b/client/app/(app)/home.tsx index 9699192..dd03030 100644 --- a/client/app/(app)/home.tsx +++ b/client/app/(app)/home.tsx @@ -14,19 +14,18 @@ import Animated, { import { MOCK_CONTENT } from '@/src/constants/mockContent'; import { addFavorite, - getRecoFeedCache, - getRecoFeedHistory, getThemeMode, getUserProfile, - getUserProfileScoring, - recordRecoFeedServed, - recordRecoFeedTouched, - setRecoFeedCache, setReaction, setThemeMode, - type RecoFeedCacheItem, + getRecoFeedCache, + setRecoFeedCache, + getUserProfileScoring, + getRecoFeedHistory, + recordRecoFeedServed, type ThemeMode, } from '@/src/storage/appStorage'; + import { fetchRecoFeed } from '@/src/services/recoApi'; import ProfileModal from '@/components/home/ProfileModal'; @@ -73,8 +72,12 @@ const THEME_COLORS = [ '#CBD9F2', ]; +type FeedItem = { content_id: string; text: string }; + export default function HomeScreen() { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const isEnglish = i18n.language?.startsWith('en'); + const recoLang: 'en' | 'tc' = i18n.language?.toLowerCase().startsWith('zh') ? 'tc' : 'en'; const navigation = useNavigation(); const [index, setIndex] = useState(0); const [themeMode, setThemeModeState] = useState('scenery'); @@ -83,83 +86,115 @@ export default function HomeScreen() { const [profileName, setProfileName] = useState(undefined); const [busy, setBusy] = useState(false); const [likeFilled, setLikeFilled] = useState(false); - const [feedItems, setFeedItems] = useState>([]); + const [feedItems, setFeedItems] = useState([]); + const [isFetching, setIsFetching] = useState(false); - const currentList = feedItems.length > 0 ? feedItems : MOCK_CONTENT; - const item = useMemo(() => currentList[index % currentList.length], [currentList, index]); - const currentContentId = typeof (item as any)?.content_id === 'number' ? Number((item as any).content_id) : null; + // 解决语言切换时重复触发拉取/清空导致“文案不停跳动”的问题: + // 用 ref 持有最新状态,避免 useCallback 依赖 feedItems/isFetching 造成函数 identity 变化 → effect 重复执行 + const feedItemsRef = useRef([]); + const isFetchingRef = useRef(false); + useEffect(() => { + feedItemsRef.current = feedItems; + }, [feedItems]); + useEffect(() => { + isFetchingRef.current = isFetching; + }, [isFetching]); // 动画相关 Shared Values const translateY = useSharedValue(0); const opacity = useSharedValue(1); const likeScale = useSharedValue(1); - // 每次进入页面或页面获得焦点时刷新个人信息 + // 统一文案对象结构 + const currentFeed = useMemo(() => { + if (feedItems.length > 0) { + return feedItems; + } + return MOCK_CONTENT.map(item => ({ + content_id: item.id, + text: t(item.textKey) + })); + }, [feedItems, t]); + + const item = useMemo(() => { + const data = currentFeed[index % currentFeed.length]; + return { + id: String(data.content_id), + text: data.text + }; + }, [currentFeed, index]); + + // 异步拉取新文案 + const fetchNewFeed = useCallback(async () => { + if (isFetchingRef.current) return; + isFetchingRef.current = true; + setIsFetching(true); + try { + const scoringProfile = await getUserProfileScoring(); + if (!scoringProfile) return; + + const history = await getRecoFeedHistory(); + + const { items, meta } = await fetchRecoFeed({ + k: 30, + user_profile: scoringProfile, + already_recommended_ids: history.already_recommended_ids, + touched_or_viewed_ids: history.touched_or_viewed_ids, + }); + + if (items.length > 0) { + const wasEmpty = feedItemsRef.current.length === 0; + const newCache = { + saved_at: new Date().toISOString(), + lang: recoLang, + items: items.map((x) => ({ content_id: x.content_id, text: x.text })), + meta: meta as Record, + }; + await setRecoFeedCache(newCache); + await recordRecoFeedServed(items.map((x) => x.content_id)); + setFeedItems(newCache.items.map((x) => ({ content_id: String(x.content_id), text: x.text }))); + // 如果当前是 mock 数据,切换到新数据的第一条 + if (wasEmpty) { + setIndex(0); + } + } + } catch (error) { + console.error('Failed to fetch new feed:', error); + } finally { + isFetchingRef.current = false; + setIsFetching(false); + } + }, [recoLang]); + + // 每次进入页面或页面获得焦点时刷新个人信息和缓存文案 useFocusEffect( useCallback(() => { let cancelled = false; (async () => { const mode = await getThemeMode(); const profile = await getUserProfile(); + const cache = await getRecoFeedCache(); + if (cancelled) return; setThemeModeState(mode); setProfileName(profile.name); + + // 语言切换时:旧语言缓存不复用,触发重新拉取 + if (cache && cache.items.length > 0 && (cache.lang ?? 'en') === recoLang) { + setFeedItems(cache.items.map((x) => ({ content_id: String(x.content_id), text: x.text }))); + } else { + // 语言不匹配或没有缓存:先清空回落到本地 mock(会立即随语言切换),再拉取对应语言的推荐文案 + setFeedItems([]); + setIndex(0); + fetchNewFeed(); + } })(); return () => { cancelled = true; }; - }, []) + }, [fetchNewFeed, recoLang]) ); - // 首次进入:先读缓存,再拉后端 feed(失败则保持 mock/缓存) - useEffect(() => { - let cancelled = false; - (async () => { - const cache = await getRecoFeedCache(); - if (!cancelled && cache?.items?.length) { - setFeedItems(cache.items.map((x: RecoFeedCacheItem) => ({ content_id: x.content_id, text: x.text }))); - } - - const scoring = await getUserProfileScoring(); - if (!scoring) return; - - try { - const hist = await getRecoFeedHistory(); - const out = await fetchRecoFeed({ - k: 30, - user_profile: { - profile_version: scoring.profile_version, - profile_source: scoring.profile_source, - profile_generated_at: scoring.profile_generated_at, - profile_confidence: scoring.profile_confidence, - profile_answered: scoring.profile_answered, - stage: scoring.stage, - emotion_score: scoring.emotion_score, - context: scoring.context, - need: scoring.need, - }, - already_recommended_ids: hist.already_recommended_ids, - touched_or_viewed_ids: hist.touched_or_viewed_ids, - }); - - if (!cancelled && out.items?.length) { - setFeedItems(out.items.map((x) => ({ content_id: x.content_id, text: x.text }))); - await setRecoFeedCache({ - saved_at: new Date().toISOString(), - items: out.items.map((x) => ({ content_id: x.content_id, text: x.text })), - meta: out.meta as Record, - }); - await recordRecoFeedServed(out.items.map((x) => x.content_id)); - } - } catch { - // 忽略:保持缓存/本地 mock - } - })(); - return () => { - cancelled = true; - }; - }, []); - const backgroundColor = useMemo(() => { if (themeMode === 'color') { const colorIndex = Math.floor(index / 10) % THEME_COLORS.length; @@ -178,8 +213,10 @@ export default function HomeScreen() { useLayoutEffect(() => { navigation.setOptions({ headerShadowVisible: false, - headerStyle: { backgroundColor: themeMode === 'scenery' ? 'transparent' : backgroundColor }, - headerTransparent: themeMode === 'scenery', + // 为了让风景/颜色两种主题下“文案的视觉居中位置”一致,统一使用透明 Header + // 颜色主题下 Header 透明也不会影响观感(背景就是纯色) + headerStyle: { backgroundColor: 'transparent' }, + headerTransparent: true, headerRight: () => ( { @@ -226,6 +258,11 @@ export default function HomeScreen() { runOnJS(setIndex)(index + 1); runOnJS(setLikeFilled)(false); + // 检查是否需要拉取新文案(当接近当前列表末尾时,例如还剩 5 条) + if (index + 5 >= currentFeed.length && !isFetching) { + runOnJS(fetchNewFeed)(); + } + // 3. 准备下一条文案:先瞬移到下方 40pt translateY.value = 40; @@ -238,7 +275,7 @@ export default function HomeScreen() { }); } }); - }, [busy, currentContentId, index, translateY, opacity]); + }, [busy, index, currentFeed.length, isFetching, fetchNewFeed, translateY, opacity]); const lastTapRef = useRef(0); @@ -288,20 +325,28 @@ export default function HomeScreen() { const dateStr = `${now.getFullYear()}.${String(now.getMonth() + 1).padStart(2, '0')}.${String(now.getDate()).padStart(2, '0')}`; // 2. 保存到收藏夹,包含当前背景信息 - await addFavorite({ + const favItem = { favId: String(Date.now()), // 生成唯一 ID id: item.id, + text: item.text, date: dateStr, themeMode: themeMode, background: themeMode === 'scenery' ? String(natureImageIndex) : backgroundColor, - }); + }; + console.log('Home: Triggering addFavorite', JSON.stringify(favItem)); + await addFavorite(favItem); - // 3. 爱心缩放动画 + // 3. 记录到后端 Reaction(喜欢) + console.log('Home: Triggering setReaction', item.id); + await setReaction(item.id, 'like'); + + // 4. 爱心缩放动画 likeScale.value = withSequence( withTiming(0.8, { duration: 100 }), withTiming(1.2, { duration: 150 }), withTiming(1, { duration: 100 }, (finished) => { if (finished) { + console.log('Home: Like animation finished, triggering next content'); runOnJS(triggerNextContent)(); } }) @@ -324,7 +369,9 @@ export default function HomeScreen() { /> )} - {item.text} + + {item.text} + @@ -419,6 +466,11 @@ const styles = StyleSheet.create({ fontWeight: '700', textAlign: 'center', }, + textEnglish: { + fontFamily: 'STIXTwoText', + // 英文字体观感更细一点,避免过粗 + fontWeight: '600', + }, sceneryCard: { // 风景模式下稍微收窄文案宽度,增加呼吸感 paddingHorizontal: 50, diff --git a/client/app/(onboarding)/onboarding.tsx b/client/app/(onboarding)/onboarding.tsx index 42bbfe8..1d9aae8 100644 --- a/client/app/(onboarding)/onboarding.tsx +++ b/client/app/(onboarding)/onboarding.tsx @@ -1,6 +1,7 @@ -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { useRouter } from 'expo-router'; import * as Notifications from 'expo-notifications'; +import { useTranslation } from 'react-i18next'; import { OnboardingLayout } from '@/components/onboarding/OnboardingLayout'; import { NameInputStep } from '@/components/onboarding/NameInputStep'; import { SelectionStep } from '@/components/onboarding/SelectionStep'; @@ -16,64 +17,37 @@ import { setRecoFeedCache, } from '@/src/storage/appStorage'; -const STEPS = [ - { id: 'name', type: 'name', title: '我可以怎么称呼你?' }, - { - id: 'status', - type: 'selection', - title: '媽媽的狀態?', - options: [ - { id: 'pregnant', label: '懷孕中/準備成為媽媽' }, - { id: 'has_kids', label: '已經有孩子' }, - { id: 'no_fill', label: '不想填寫' }, - ] - }, - { - id: 'emotion', - type: 'selection', - title: '當下情緒狀態?', - options: [ - { id: 'happy', label: '愉悅、滿足' }, - { id: 'calm', label: '平靜、安穩' }, - { id: 'stressed', label: '被壓得有點喘不過氣' }, - { id: 'low', label: '情緒低落' }, - ] - }, - { - id: 'influence', - type: 'selection', - title: '是什麼影響了你最近的感受?', - options: [ - { id: 'family', label: '家庭與孩子' }, - { id: 'work', label: '工作或學習' }, - { id: 'relationship', label: '親密關係' }, - { id: 'friends', label: '朋友與人際' }, - { id: 'health', label: '身心健康' }, - ] - }, - { - id: 'support', - type: 'selection', - title: '最需要什麼支持?', - options: [ - { id: 'emotional', label: '情緒支持' }, - { id: 'parenting', label: '育兒壓力' }, - { id: 'self_worth', label: '自我價值' }, - { id: 'anxiety', label: '焦慮舒緩' }, - { id: 'balance', label: '休息與平衡' }, - ] - }, - { id: 'reminder', type: 'reminder', title: '你需要每天几次提醒?' }, +type Step = + | { id: 'name'; type: 'name' } + | { id: 'status' | 'emotion' | 'influence' | 'support'; type: 'selection'; optionIds: string[] } + | { id: 'reminder'; type: 'reminder' }; + +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: 'influence', type: 'selection', optionIds: ['family', 'work', 'relationship', 'friends', 'health'] }, + { id: 'support', type: 'selection', optionIds: ['emotional', 'parenting', 'self_worth', 'anxiety', 'balance'] }, + { id: 'reminder', type: 'reminder' }, ]; export default function OnboardingScreen() { const router = useRouter(); + const { t, i18n } = useTranslation(); const [stepIndex, setStepIndex] = useState(0); const [name, setName] = useState(''); const [selections, setSelections] = useState>({}); const [reminderTimes, setReminderTimes] = useState(3); const currentStep = STEPS[stepIndex]; + const currentTitle = useMemo(() => t(`onboardingSurvey.steps.${currentStep.id}.title`), [t, currentStep.id]); + const currentOptions = useMemo(() => { + if (currentStep.type !== 'selection') return []; + return currentStep.optionIds.map((optId) => ({ + id: optId, + label: t(`onboardingSurvey.steps.${currentStep.id}.options.${optId}`), + })); + }, [t, currentStep]); async function onFinish() { // 请求推送权限 @@ -89,6 +63,7 @@ export default function OnboardingScreen() { // Onboarding 结束后预拉取一次 Feed 文案(失败不阻塞进入首页) try { + const lang = i18n.language?.toLowerCase().startsWith('zh') ? 'tc' : 'en'; const { items, meta } = await fetchRecoFeed({ k: 30, user_profile: { @@ -106,6 +81,7 @@ export default function OnboardingScreen() { await setRecoFeedCache({ saved_at: new Date().toISOString(), + lang, items: items.map((x) => ({ content_id: x.content_id, text: x.text })), meta: meta as Record, }); @@ -150,11 +126,13 @@ export default function OnboardingScreen() { router.replace('/(app)/home'); }; - // 题目为单选:再次点击可取消;选择其他选项会替换为唯一选项 + // 题目为多选:点击切换选中状态 const handleToggleSelection = (id: string) => { setSelections(prev => { const currentIds = prev[currentStep.id] || []; - const nextIds = currentIds.includes(id) ? [] : [id]; + const nextIds = currentIds.includes(id) + ? currentIds.filter(i => i !== id) + : [...currentIds, id]; return { ...prev, [currentStep.id]: nextIds }; }); }; @@ -166,7 +144,7 @@ export default function OnboardingScreen() { return ( )} diff --git a/client/app/_layout.tsx b/client/app/_layout.tsx index b5f5188..9d8e4a1 100644 --- a/client/app/_layout.tsx +++ b/client/app/_layout.tsx @@ -34,7 +34,7 @@ SplashScreen.preventAutoHideAsync(); export default function RootLayout() { const [loaded, error] = useFonts({ - SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'), + STIXTwoText: require('../assets/fonts/STIXTwoText-VariableFont_wght.ttf'), ...FontAwesome.font, }); const [i18nReady, setI18nReady] = useState(false); @@ -48,7 +48,7 @@ export default function RootLayout() { initI18n() .catch((e) => { // i18n 初始化失败不应阻塞 App 启动,先打印错误再继续 - console.error('i18n 初始化失败', e); + console.error('i18n init failed', e); }) .finally(() => setI18nReady(true)); }, []); diff --git a/client/assets/fonts/STIXTwoText-VariableFont_wght.ttf b/client/assets/fonts/STIXTwoText-VariableFont_wght.ttf new file mode 100644 index 0000000..088918f Binary files /dev/null and b/client/assets/fonts/STIXTwoText-VariableFont_wght.ttf differ diff --git a/client/assets/images/home/like.svg b/client/assets/images/home/like.svg index f88951c..7d666bc 100644 --- a/client/assets/images/home/like.svg +++ b/client/assets/images/home/like.svg @@ -1,3 +1,3 @@ - + diff --git a/client/assets/images/home/like_filled.svg b/client/assets/images/home/like_filled.svg index f8749df..cb3c2c1 100644 --- a/client/assets/images/home/like_filled.svg +++ b/client/assets/images/home/like_filled.svg @@ -1,4 +1,4 @@ - + diff --git a/client/assets/images/icon/like_icon.svg b/client/assets/images/icon/like_icon.svg index a52fe32..bd94d57 100644 --- a/client/assets/images/icon/like_icon.svg +++ b/client/assets/images/icon/like_icon.svg @@ -1,3 +1,3 @@ - + diff --git a/client/components/home/FavoritesModal.tsx b/client/components/home/FavoritesModal.tsx index 53f2b49..b36bba2 100644 --- a/client/components/home/FavoritesModal.tsx +++ b/client/components/home/FavoritesModal.tsx @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'; import SheetModal from '@/components/ui/SheetModal'; import { MOCK_CONTENT } from '@/src/constants/mockContent'; -import { getFavorites } from '@/src/storage/appStorage'; +import { getFavorites, getRecoFeedCache, type FavoriteItem } from '@/src/storage/appStorage'; type Props = { visible: boolean; @@ -13,25 +13,50 @@ type Props = { export default function FavoritesModal({ visible, onClose }: Props) { const { t } = useTranslation(); - const [ids, setIds] = useState([]); + const [items, setItems] = useState<(FavoriteItem & { text: string })[]>([]); // 每次打开时刷新一次,确保展示最新“喜欢” useEffect(() => { if (!visible) return; let cancelled = false; (async () => { - const list = await getFavorites(); - if (!cancelled) setIds(list); + const [favList, cache] = await Promise.all([ + getFavorites(), + getRecoFeedCache() + ]); + console.log('FavoritesModal: Loaded favList', favList.length, 'items'); + console.log('FavoritesModal: Loaded cache items', cache?.items?.length || 0); + + if (cancelled) return; + + // 建立文案查找表 + const textMap = new Map(); + + // 1. 放入 Mock 数据 + MOCK_CONTENT.forEach(c => textMap.set(String(c.id), t(c.textKey))); + + // 2. 放入缓存数据 + if (cache?.items) { + cache.items.forEach(c => textMap.set(String(c.content_id), c.text)); + } + + // 3. 组装最终展示列表 + const enriched = favList.map(fav => { + const favIdStr = String(fav.id); + const text = fav.text || textMap.get(favIdStr); + console.log(`FavoritesModal: Matching fav.id=${favIdStr}, found text=${!!text}, textValue=${text?.substring(0, 10)}...`); + return { + ...fav, + text: text || t('favorites.unknownText') + }; + }); + + setItems(enriched); })(); return () => { cancelled = true; }; - }, [visible]); - - const items = useMemo(() => { - const map = new Map(MOCK_CONTENT.map((c) => [c.id, c])); - return ids.map((id) => map.get(id)).filter(Boolean) as { id: string; text: string }[]; - }, [ids]); + }, [visible, t]); return ( @@ -41,7 +66,7 @@ export default function FavoritesModal({ visible, onClose }: Props) { ) : ( it.id} + keyExtractor={(it) => it.favId} contentContainerStyle={styles.list} renderItem={({ item }) => ( diff --git a/client/components/home/ProfileModal.tsx b/client/components/home/ProfileModal.tsx index 9381112..758e0fb 100644 --- a/client/components/home/ProfileModal.tsx +++ b/client/components/home/ProfileModal.tsx @@ -22,6 +22,7 @@ import { getFavorites, setDailyReminderSettings, removeFavorite, + getRecoFeedCache, getUserProfile, type DailyReminderSettings, type FavoriteItem, @@ -271,15 +272,21 @@ function FavoritesPage({ visible, page }: { visible: boolean; page: Page }) { async function refreshFavorites() { const storedFavs = await getFavorites(); - const map = new Map(MOCK_CONTENT.map((c) => [c.id, c.text])); - - const list = storedFavs - .map((fav) => ({ - ...fav, - text: map.get(fav.id) || '' - })) - .filter(item => item.text !== ''); - + const textMap = new Map(); + + // 1) Mock 文案 + MOCK_CONTENT.forEach((c) => textMap.set(String(c.id), t(c.textKey))); + + // 2) 后端推荐缓存文案(避免收藏后 cache 覆盖就丢文案) + const cache = await getRecoFeedCache(); + cache?.items?.forEach((c) => textMap.set(String(c.content_id), c.text)); + + // 3) 组装:优先使用收藏时写入的 text,其次从 map 回填 + const list = storedFavs.map((fav) => ({ + ...fav, + text: fav.text || textMap.get(String(fav.id)) || t('favorites.unknownText'), + })); + setFavorites(list); } @@ -390,7 +397,7 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () = if (settings.status === 'denied') { Alert.alert( t('common.notice'), - "系统权限已被拒绝,请前往手机设置开启通知。" + t('permissions.notificationsDenied') ); setPushEnabled(false); return; @@ -607,12 +614,12 @@ function WidgetHowToPage() { } function LanguagePage() { - const { i18n } = useTranslation(); + const { t, i18n } = useTranslation(); const currentLang = i18n.language; const languages = [ - { id: 'zh-TW', label: '繁体' }, - { id: 'en', label: 'English' }, + { id: 'zh-TW', label: t('language.zhTW') }, + { id: 'en', label: t('language.en') }, ]; return ( diff --git a/client/components/onboarding/IntentSelectionStep.tsx b/client/components/onboarding/IntentSelectionStep.tsx index 89b6974..6ecf290 100644 --- a/client/components/onboarding/IntentSelectionStep.tsx +++ b/client/components/onboarding/IntentSelectionStep.tsx @@ -1,13 +1,14 @@ import React from 'react'; import { View, StyleSheet, TouchableOpacity } from 'react-native'; +import { useTranslation } from 'react-i18next'; import { SerifText } from './SerifText'; import { OnboardingColors } from '@/constants/OnboardingTheme'; export const INTENTS = [ - { id: 'love', label: '爱情', icon: '❤️' }, - { id: 'life', label: '生活', icon: '⛅' }, - { id: 'travel', label: '旅游', icon: '🌴' }, - { id: 'work', label: '职场', icon: '💼' }, + { id: 'love', labelKey: 'intent.love', icon: '❤️' }, + { id: 'life', labelKey: 'intent.life', icon: '⛅' }, + { id: 'travel', labelKey: 'intent.travel', icon: '🌴' }, + { id: 'work', labelKey: 'intent.work', icon: '💼' }, ]; interface IntentSelectionStepProps { @@ -16,9 +17,10 @@ interface IntentSelectionStepProps { } export function IntentSelectionStep({ selectedIds, onToggle }: IntentSelectionStepProps) { + const { t } = useTranslation(); return ( - 你希望得到什么帮助? + {t('intent.title')} {INTENTS.map((intent) => { @@ -35,7 +37,7 @@ export function IntentSelectionStep({ selectedIds, onToggle }: IntentSelectionSt > {intent.icon} - {intent.label} + {t(intent.labelKey)} ); diff --git a/client/components/onboarding/SelectionStep.tsx b/client/components/onboarding/SelectionStep.tsx index b70ad59..5cd265a 100644 --- a/client/components/onboarding/SelectionStep.tsx +++ b/client/components/onboarding/SelectionStep.tsx @@ -49,17 +49,9 @@ export function SelectionStep({ options, selectedIds, onToggle, onNext, onSkip } {/* 底部按钮:距离底部 12% 高度 */} - - {onSkip && ( - - 跳过 - - )} - - - {hasSelection ? : } - - + + {hasSelection ? : } + ); diff --git a/client/components/ui/SheetModal.tsx b/client/components/ui/SheetModal.tsx index bdb9d97..67db110 100644 --- a/client/components/ui/SheetModal.tsx +++ b/client/components/ui/SheetModal.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useMemo, useState, useRef } from 'react'; import { Modal, Pressable, StyleSheet, Text, View, PanResponder, Animated as RNAnimated, Dimensions, Image, ImageSourcePropType } from 'react-native'; +import { useTranslation } from 'react-i18next'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import Animated, { Easing, @@ -27,6 +28,7 @@ type Props = { * - 高度固定:默认距离顶部固定间距,也支持传入指定高度 */ export default function SheetModal({ visible, title, onClose, children, leftIcon, height: customHeight }: Props) { + const { t } = useTranslation(); const insets = useSafeAreaInsets(); const [mounted, setMounted] = useState(false); const progress = useSharedValue(0); // 0: 关闭, 1: 打开 @@ -121,7 +123,7 @@ export default function SheetModal({ visible, title, onClose, children, leftIcon 说明:项目运行时只读取 `all.json`;请不要再改 `locales/en.json`、`locales/zh-TW.json`(它们已不再作为运行时数据源)。 + +### 快速索引(高频文案) + +- **Home**:`home.*` +- **Push 提示**:`push.*` +- **主题**:`theme.*` +- **我的/Profile**:`profile.*` +- **收藏**:`favorites.*` +- **设置**:`settings.*` +- **Onboarding(问卷)**:`onboardingSurvey.steps.*` +- **Onboarding(兴趣)**:`intent.*` +- **Mock 文案**:`mock.*` + + diff --git a/client/src/i18n/index.ts b/client/src/i18n/index.ts index f32fabd..27b6b7a 100644 --- a/client/src/i18n/index.ts +++ b/client/src/i18n/index.ts @@ -3,8 +3,9 @@ import * as Localization from 'expo-localization'; import i18n from 'i18next'; import { initReactI18next } from 'react-i18next'; -import en from './locales/en.json'; -import zhTW from './locales/zh-TW.json'; +// 用 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 }; /** * 语言码约定: @@ -83,8 +84,8 @@ export async function initI18n(): Promise { await i18n.use(initReactI18next).init({ resources: { - 'zh-TW': { translation: zhTW }, - en: { translation: en }, + 'zh-TW': { translation: all['zh-TW'] as any }, + en: { translation: all.en as any }, }, lng: initialLang, fallbackLng: DEFAULT_FALLBACK_LANGUAGE, diff --git a/client/src/i18n/locales/all.json b/client/src/i18n/locales/all.json new file mode 100644 index 0000000..e8699f2 --- /dev/null +++ b/client/src/i18n/locales/all.json @@ -0,0 +1,314 @@ +{ + "en": { + "common": { + "ok": "OK", + "cancel": "Cancel", + "error": "Error", + "openLinkError": "Cannot open link", + "back": "Back", + "close": "Close" + }, + "onboarding": { + "title": "Welcome", + "progress": "{{current}}/{{total}}", + "next": "Next", + "skip": "Skip", + "skipAll": "Skip onboarding", + "q1Title": "How are you feeling lately?", + "q1Desc": "No right or wrong. You can skip and adjust later.", + "q2Title": "What kind of support do you want?", + "q2Desc": "For example: gentle reminders, mindfulness, emotional support.", + "q3Title": "When do you need comfort the most?", + "q3Desc": "Morning, afternoon, late night, or specific moments.", + "q4Title": "A gentle sentence for yourself", + "q4Desc": "You can skip. We’ll stay with you along the way." + }, + "onboardingSurvey": { + "steps": { + "name": { "title": "What should I call you?" }, + "status": { + "title": "Your current stage?", + "options": { + "pregnant": "Pregnant / preparing for motherhood", + "has_kids": "Already have kids", + "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" + } + }, + "influence": { + "title": "What has been affecting you lately?", + "options": { + "family": "Family & kids", + "work": "Work or study", + "relationship": "Intimate relationship", + "friends": "Friends & social life", + "health": "Mental & physical health" + } + }, + "support": { + "title": "What support do you need most?", + "options": { + "emotional": "Emotional support", + "parenting": "Parenting stress", + "self_worth": "Self-worth", + "anxiety": "Anxiety relief", + "balance": "Rest & balance" + } + }, + "reminder": { "title": "How many reminders do you want per day?" } + } + }, + "intent": { + "title": "What kind of help do you want?", + "love": "Love", + "life": "Life", + "travel": "Travel", + "work": "Career" + }, + "push": { + "title": "Notifications", + "cardTitle": "Turn on gentle reminders", + "cardDesc": "We’ll send a short mindful phrase when you may need it. You can change this anytime in Settings.", + "enable": "Enable", + "later": "Later", + "loading": "Working…", + "errorTitle": "Notice", + "errorDesc": "It’s okay if enabling fails. You can keep using the app." + }, + "home": { + "title": "Mindfulness", + "like": "Like", + "dislike": "Dislike", + "favorites": "Favorites", + "settings": "Settings", + "theme": "Theme", + "profile": "Me" + }, + "theme": { + "title": "Theme", + "scenery": "Scenery", + "color": "Color" + }, + "profile": { + "title": "Me", + "favorites": "My Likes", + "widget": "Widget", + "dailyReminder": "Daily Reminder", + "privacy": "Privacy Policy", + "terms": "Terms of Use", + "language": "Language", + "todoTitle": "Notice", + "todoDesc": "This feature is a placeholder for this iteration." + }, + "dailyReminder": { + "title": "Daily Reminder", + "timesUnit": "times", + "pushLabel": "Push Reminder", + "ok": "Ok", + "minus": "Decrease", + "plus": "Increase" + }, + "widget": { + "lockScreen": "Lock Screen Widget", + "homeScreen": "Home Screen Widget", + "previewDate": "Thu, Jan 29", + "previewQuote": "I’m proud of who I am, even while becoming who I want to be." + }, + "favorites": { + "title": "Favorites", + "empty": "No favorites yet.", + "unknownText": "This quote is no longer available." + }, + "settings": { + "title": "Settings", + "language": "Language", + "version": "Version", + "widgetTitle": "iOS Widget", + "widgetDesc": "Put gentle reminders on your home screen: long-press → tap “+” → search “Mindfulness” → add a size you like." + }, + "consent": { + "title": "You Are Perfect.", + "subtitle": "Everything Will Be Better.", + "agree": "Agree & Continue", + "privacy": "Privacy Policy", + "terms": "Terms of Use" + }, + "permissions": { + "notificationsDenied": "Notifications are denied. Please enable them in Settings." + }, + "language": { + "zhTW": "繁體中文", + "en": "English" + }, + "mock": { + "c1": "You’ve been trying your best. You deserve kindness today.", + "c2": "Take three deep breaths and return to the present moment.", + "c3": "It’s okay to slow down. Emotions pass like clouds.", + "c4": "You don’t need to be perfect. You are enough.", + "c5": "Place a hand on your heart and say: You did well today." + } + }, + "zh-TW": { + "common": { + "ok": "確定", + "cancel": "取消", + "back": "返回", + "close": "關閉" + }, + "onboarding": { + "title": "歡迎", + "progress": "{{current}}/{{total}}", + "next": "下一步", + "skip": "跳過", + "skipAll": "跳過整個引導", + "q1Title": "你最近的感受更接近哪一種?", + "q1Desc": "沒有對錯,你可以跳過,之後也能慢慢調整。", + "q2Title": "你更希望獲得哪種支持?", + "q2Desc": "例如:溫柔提醒、正念練習、情緒陪伴。", + "q3Title": "你通常在什麼時候最需要被安慰?", + "q3Desc": "例如:清晨、午后、深夜,或某些特定時刻。", + "q4Title": "給自己一句溫柔的話", + "q4Desc": "你可以直接跳過,我們會在之後繼續陪你。" + }, + "onboardingSurvey": { + "steps": { + "name": { "title": "我可以怎麼稱呼你?" }, + "status": { + "title": "媽媽的狀態?", + "options": { + "pregnant": "懷孕中/準備成為媽媽", + "has_kids": "已經有孩子", + "no_fill": "不想填寫" + } + }, + "emotion": { + "title": "當下情緒狀態?", + "options": { + "happy": "愉悅、滿足", + "calm": "平靜、安穩", + "stressed": "被壓得有點喘不過氣", + "low": "情緒低落" + } + }, + "influence": { + "title": "是什麼影響了你最近的感受?", + "options": { + "family": "家庭與孩子", + "work": "工作或學習", + "relationship": "親密關係", + "friends": "朋友與人際", + "health": "身心健康" + } + }, + "support": { + "title": "最需要什麼支持?", + "options": { + "emotional": "情緒支持", + "parenting": "育兒壓力", + "self_worth": "自我價值", + "anxiety": "焦慮舒緩", + "balance": "休息與平衡" + } + }, + "reminder": { "title": "你需要每天幾次提醒?" } + } + }, + "intent": { + "title": "你希望得到什麼幫助?", + "love": "愛情", + "life": "生活", + "travel": "旅遊", + "work": "職場" + }, + "push": { + "title": "通知", + "cardTitle": "開啟溫柔提醒", + "cardDesc": "我們會在你需要的時候,送上一句正念短句或溫柔提醒(可隨時在設定中調整)。", + "enable": "立即開啟", + "later": "稍後", + "loading": "處理中…", + "errorTitle": "提示", + "errorDesc": "開啟失敗也沒關係,你仍然可以繼續使用應用。" + }, + "home": { + "title": "正念", + "like": "喜歡", + "dislike": "不喜歡", + "favorites": "收藏", + "settings": "設定", + "theme": "主題", + "profile": "我的" + }, + "theme": { + "title": "主題", + "scenery": "風景", + "color": "顏色" + }, + "profile": { + "title": "我的", + "favorites": "我的喜歡", + "widget": "小工具", + "dailyReminder": "每日提醒", + "privacy": "隱私政策", + "terms": "使用條款", + "language": "語言", + "todoTitle": "提示", + "todoDesc": "此功能本期先占位,後續迭代補齊。" + }, + "dailyReminder": { + "title": "每日提醒", + "timesUnit": "次", + "pushLabel": "推送提醒", + "ok": "確定", + "minus": "減少次數", + "plus": "增加次數" + }, + "widget": { + "lockScreen": "鎖屏小工具", + "homeScreen": "桌面小工具", + "previewDate": "1月29日週四 · 已至臘月十一", + "previewQuote": "我也對現在的自己感到滿意,即使我仍在努力成為想成為的人。" + }, + "favorites": { + "title": "收藏夾", + "empty": "這裡還沒有收藏內容。", + "unknownText": "這條文案暫時無法顯示。" + }, + "settings": { + "title": "設定", + "language": "語言", + "version": "版本", + "widgetTitle": "iOS 小工具", + "widgetDesc": "把溫柔提醒放到桌面上:長按主畫面 → 點「+」 → 搜尋「正念」 → 添加你喜歡的尺寸。" + }, + "consent": { + "agree": "同意並繼續", + "privacy": "隱私協議", + "terms": "用戶使用協議" + }, + "permissions": { + "notificationsDenied": "系統權限已被拒絕,請前往手機設定開啟通知。" + }, + "language": { + "zhTW": "繁體中文", + "en": "English" + }, + "mock": { + "c1": "你已經很努力了,今天也值得被溫柔對待。", + "c2": "深呼吸三次,把注意力帶回當下。", + "c3": "允許自己慢一點,情緒會像雲一樣飄過。", + "c4": "你不需要完美,你已經足夠好。", + "c5": "把手放在心口,對自己說一句:辛苦了。" + } + } +} + + diff --git a/client/src/services/recoApi.ts b/client/src/services/recoApi.ts index 46cdb10..41000fc 100644 --- a/client/src/services/recoApi.ts +++ b/client/src/services/recoApi.ts @@ -35,13 +35,14 @@ function withTimeout(ms: number): AbortController { export async function fetchRecoFeed(req: RecoRequest): Promise { const controller = withTimeout(12_000); const url = `${API_BASE_URL}/v1/reco/feed`; + const acceptLanguage = i18n.language?.toLowerCase().startsWith('zh') ? 'tc' : 'en'; const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', // 让后端做 locale 选择(目前后端只区分 en/tc) - 'Accept-Language': i18n.language || 'en', + 'Accept-Language': acceptLanguage, }, body: JSON.stringify({ k: req.k, diff --git a/client/src/storage/appStorage.ts b/client/src/storage/appStorage.ts index a148837..97d1587 100644 --- a/client/src/storage/appStorage.ts +++ b/client/src/storage/appStorage.ts @@ -42,6 +42,12 @@ export type RecoFeedCacheItem = { export type RecoFeedCache = { saved_at: string; // ISO8601 + /** + * 缓存文案的语言(后端目前只区分 en / tc) + * - en: English + * - tc: 繁体中文 + */ + lang?: 'en' | 'tc'; items: RecoFeedCacheItem[]; meta?: Record; }; @@ -107,6 +113,10 @@ export async function setReaction(contentId: string, reaction: Reaction): Promis export type FavoriteItem = { favId: string; // 唯一标识,支持重复点赞同一文案 id: string; + /** + * 收藏时的文案快照(强烈建议写入,避免后续 cache 覆盖导致无法还原文案) + */ + text?: string; date: string; themeMode: ThemeMode; background: string; // 颜色值或图片路径 @@ -120,7 +130,7 @@ export async function addFavorite(item: FavoriteItem): Promise { const list = await getFavorites(); // 允许重复点赞,不再根据 id 去重 const newList = [item, ...list]; - console.log('Adding to favorites, new list size:', newList.length); + console.log('Adding to favorites:', JSON.stringify(item)); await setJson(KEY_FAVORITES_ITEMS, newList); } diff --git a/spec_kit/overview.md b/spec_kit/overview.md index 2a9098a..d8bdb69 100644 --- a/spec_kit/overview.md +++ b/spec_kit/overview.md @@ -91,6 +91,7 @@ - 新增 `SheetModal`、`ThemeModal`、`ProfileModal` 并在 Home 中接入 - 新增 `DailyReminderModal` 并从个人主页弹窗打开 - Home:右上角 icon 按钮、主题切换背景、喜欢/讨厌 icon + 动效 + - **修复**:收藏(“我的喜欢”)不展示后端推荐文案的问题——收藏时持久化写入 `text` 快照,并在 `ProfileModal` 的 Favorites 页面同时兼容 Mock 与推荐缓存内容回填 ## User Profile Scoring