feat(client): i18n copy + reco lang refresh + home polish
This commit is contained in:
@@ -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<ThemeMode>('scenery');
|
||||
@@ -83,83 +86,115 @@ export default function HomeScreen() {
|
||||
const [profileName, setProfileName] = useState<string | undefined>(undefined);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [likeFilled, setLikeFilled] = useState(false);
|
||||
const [feedItems, setFeedItems] = useState<Array<{ content_id: number; text: string }>>([]);
|
||||
const [feedItems, setFeedItems] = useState<FeedItem[]>([]);
|
||||
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<FeedItem[]>([]);
|
||||
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<string, unknown>,
|
||||
};
|
||||
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<string, unknown>,
|
||||
});
|
||||
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: () => (
|
||||
<View style={styles.headerRight}>
|
||||
<CircleIconButton
|
||||
@@ -213,11 +250,6 @@ export default function HomeScreen() {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
|
||||
// 记录“看过/划过”的内容 id(用于下一次向后端请求时去重/频控)
|
||||
if (typeof currentContentId === 'number') {
|
||||
void recordRecoFeedTouched(currentContentId);
|
||||
}
|
||||
|
||||
// 1. 当前文案向上移动并消失
|
||||
translateY.value = withTiming(-40, { duration: 300, easing: Easing.out(Easing.quad) });
|
||||
opacity.value = withTiming(0, { duration: 300 }, (finished) => {
|
||||
@@ -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<number>(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() {
|
||||
/>
|
||||
)}
|
||||
<Animated.View style={[styles.card, textAnimatedStyle, themeMode === 'scenery' && styles.sceneryCard]}>
|
||||
<Text style={[styles.text, themeMode === 'scenery' && styles.sceneryText]}>{item.text}</Text>
|
||||
<Text style={[styles.text, isEnglish && styles.textEnglish, themeMode === 'scenery' && styles.sceneryText]}>
|
||||
{item.text}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
|
||||
<View style={styles.actions}>
|
||||
@@ -419,6 +466,11 @@ const styles = StyleSheet.create({
|
||||
fontWeight: '700',
|
||||
textAlign: 'center',
|
||||
},
|
||||
textEnglish: {
|
||||
fontFamily: 'STIXTwoText',
|
||||
// 英文字体观感更细一点,避免过粗
|
||||
fontWeight: '600',
|
||||
},
|
||||
sceneryCard: {
|
||||
// 风景模式下稍微收窄文案宽度,增加呼吸感
|
||||
paddingHorizontal: 50,
|
||||
|
||||
Reference in New Issue
Block a user