fix:重复点击
This commit is contained in:
@@ -113,6 +113,18 @@ export default function HomeScreen() {
|
||||
const [cardWidth, setCardWidth] = useState<number | null>(null);
|
||||
const [wrappedText, setWrappedText] = useState<string>('');
|
||||
const wrapLogRef = useRef<{ key: string } | null>(null);
|
||||
const busyRef = useRef(false);
|
||||
const indexRef = useRef(0);
|
||||
const currentFeedRef = useRef<FeedItem[]>([]);
|
||||
const likedIdsRef = useRef<Set<string>>(new Set());
|
||||
const likeInFlightRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
busyRef.current = busy;
|
||||
}, [busy]);
|
||||
useEffect(() => {
|
||||
indexRef.current = index;
|
||||
}, [index]);
|
||||
|
||||
// 解决语言切换时重复触发拉取/清空导致“文案不停跳动”的问题:
|
||||
// 用 ref 持有最新状态,避免 useCallback 依赖 feedItems/isFetching 造成函数 identity 变化 → effect 重复执行
|
||||
@@ -183,6 +195,9 @@ export default function HomeScreen() {
|
||||
text: t(item.textKey)
|
||||
}));
|
||||
}, [feedItems, t]);
|
||||
useEffect(() => {
|
||||
currentFeedRef.current = currentFeed;
|
||||
}, [currentFeed]);
|
||||
|
||||
const item = useMemo(() => {
|
||||
const data = currentFeed[index % currentFeed.length];
|
||||
@@ -430,24 +445,60 @@ export default function HomeScreen() {
|
||||
transform: [{ scale: likeScale.value }],
|
||||
}));
|
||||
|
||||
const setBusySafe = useCallback((next: boolean) => {
|
||||
busyRef.current = next;
|
||||
setBusy(next);
|
||||
}, []);
|
||||
|
||||
const setLikeInFlight = useCallback((next: boolean) => {
|
||||
likeInFlightRef.current = next;
|
||||
}, []);
|
||||
|
||||
const syncLikeFilledByIndex = useCallback((nextIndex: number) => {
|
||||
const list = currentFeedRef.current;
|
||||
const len = list.length;
|
||||
if (!len) {
|
||||
setLikeFilled(false);
|
||||
return;
|
||||
}
|
||||
const safe = ((nextIndex % len) + len) % len;
|
||||
const nextId = String(list[safe]?.content_id);
|
||||
setLikeFilled(likedIdsRef.current.has(nextId));
|
||||
}, []);
|
||||
|
||||
const applyIndexChange = useCallback((nextIndex: number) => {
|
||||
indexRef.current = nextIndex;
|
||||
setIndex(nextIndex);
|
||||
syncLikeFilledByIndex(nextIndex);
|
||||
}, [syncLikeFilledByIndex]);
|
||||
|
||||
const maybeFetchNewFeedIfNeeded = useCallback((nextIndex: number) => {
|
||||
const len = currentFeedRef.current.length;
|
||||
if (!len) return;
|
||||
// 当接近当前列表末尾时(例如还剩 5 条)提前拉取
|
||||
if (nextIndex + 5 >= len && !isFetchingRef.current) {
|
||||
fetchNewFeed();
|
||||
}
|
||||
}, [fetchNewFeed]);
|
||||
|
||||
// 切换到下一条文案的统一动画逻辑
|
||||
const triggerNextContent = useCallback(() => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
if (busyRef.current) return;
|
||||
setBusySafe(true);
|
||||
|
||||
// 注意:不要在 Reanimated worklet 回调里读取 React ref(例如 indexRef/currentFeedRef),会导致值不更新或异常
|
||||
const nextIndex = indexRef.current + 1;
|
||||
|
||||
// 1. 当前文案向上移动并消失
|
||||
translateY.value = withTiming(-40, { duration: 300, easing: Easing.out(Easing.quad) });
|
||||
opacity.value = withTiming(0, { duration: 300 }, (finished) => {
|
||||
if (finished) {
|
||||
// 2. 切换数据索引
|
||||
runOnJS(setIndex)(index + 1);
|
||||
runOnJS(setLikeFilled)(false);
|
||||
runOnJS(applyIndexChange)(nextIndex);
|
||||
runOnJS(advanceSuixinOnNextContent)();
|
||||
|
||||
// 检查是否需要拉取新文案(当接近当前列表末尾时,例如还剩 5 条)
|
||||
if (index + 5 >= currentFeed.length && !isFetching) {
|
||||
runOnJS(fetchNewFeed)();
|
||||
}
|
||||
// 检查是否需要拉取新文案(注意:不要把匿名函数塞进 runOnJS,可能导致原生崩溃)
|
||||
runOnJS(maybeFetchNewFeedIfNeeded)(nextIndex);
|
||||
|
||||
// 3. 准备下一条文案:先瞬移到下方 40pt
|
||||
translateY.value = 40;
|
||||
@@ -456,26 +507,29 @@ export default function HomeScreen() {
|
||||
translateY.value = withTiming(0, { duration: 400, easing: Easing.out(Easing.back(1)) });
|
||||
opacity.value = withTiming(1, { duration: 400 }, (finished) => {
|
||||
if (finished) {
|
||||
runOnJS(setBusy)(false);
|
||||
runOnJS(setBusySafe)(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [busy, index, currentFeed.length, isFetching, fetchNewFeed, translateY, opacity]);
|
||||
}, [applyIndexChange, setBusySafe, translateY, opacity, advanceSuixinOnNextContent, maybeFetchNewFeedIfNeeded]);
|
||||
|
||||
// 切换到上一条文案的统一动画逻辑(下滑触发)
|
||||
const triggerPrevContent = useCallback(() => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
if (busyRef.current) return;
|
||||
setBusySafe(true);
|
||||
|
||||
// 注意:同上,不要在 worklet 里读取 React ref
|
||||
const len = currentFeedRef.current.length;
|
||||
const raw = indexRef.current - 1;
|
||||
const nextIndex = len ? ((raw % len) + len) % len : Math.max(0, raw);
|
||||
|
||||
// 1. 当前文案向下移动并消失
|
||||
translateY.value = withTiming(40, { duration: 300, easing: Easing.out(Easing.quad) });
|
||||
opacity.value = withTiming(0, { duration: 300 }, (finished) => {
|
||||
if (finished) {
|
||||
// 2. 切换数据索引(循环回退)
|
||||
const nextIndex = index - 1 < 0 ? Math.max(0, currentFeed.length - 1) : index - 1;
|
||||
runOnJS(setIndex)(nextIndex);
|
||||
runOnJS(setLikeFilled)(false);
|
||||
runOnJS(applyIndexChange)(nextIndex);
|
||||
|
||||
// 3. 准备上一条文案:先瞬移到上方 40pt
|
||||
translateY.value = -40;
|
||||
@@ -484,12 +538,12 @@ export default function HomeScreen() {
|
||||
translateY.value = withTiming(0, { duration: 400, easing: Easing.out(Easing.back(1)) });
|
||||
opacity.value = withTiming(1, { duration: 400 }, (finished) => {
|
||||
if (finished) {
|
||||
runOnJS(setBusy)(false);
|
||||
runOnJS(setBusySafe)(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [busy, index, currentFeed.length, translateY, opacity]);
|
||||
}, [applyIndexChange, setBusySafe, translateY, opacity]);
|
||||
|
||||
const lastTapRef = useRef<number>(0);
|
||||
|
||||
@@ -533,7 +587,21 @@ export default function HomeScreen() {
|
||||
).current;
|
||||
|
||||
async function onPressLike() {
|
||||
if (busy) return;
|
||||
if (busyRef.current) return;
|
||||
if (likeInFlightRef.current) return;
|
||||
|
||||
// 已经喜欢过:不重复写入收藏,直接当作“下一条”
|
||||
if (likeFilled || likedIdsRef.current.has(item.id)) {
|
||||
triggerNextContent();
|
||||
return;
|
||||
}
|
||||
|
||||
likeInFlightRef.current = true;
|
||||
|
||||
const likedItemId = item.id;
|
||||
const likedItemText = item.text;
|
||||
// 先记下“已喜欢”,保证回退时能恢复点亮状态(即便异步保存稍后才完成)
|
||||
likedIdsRef.current.add(likedItemId);
|
||||
setLikeFilled(true);
|
||||
|
||||
// 1. 获取当前日期
|
||||
@@ -543,24 +611,33 @@ export default function HomeScreen() {
|
||||
// 2. 保存到收藏夹,包含当前背景信息
|
||||
const favItem = {
|
||||
favId: String(Date.now()), // 生成唯一 ID
|
||||
id: item.id,
|
||||
text: item.text,
|
||||
id: likedItemId,
|
||||
text: likedItemText,
|
||||
date: dateStr,
|
||||
themeMode: themeMode,
|
||||
background: themeMode === 'scenery' ? String(natureImageIndex) : backgroundColor,
|
||||
};
|
||||
console.log('Home: Triggering addFavorite', JSON.stringify(favItem));
|
||||
await addFavorite(favItem);
|
||||
try {
|
||||
await addFavorite(favItem);
|
||||
} catch (error) {
|
||||
console.error('Home: addFavorite 失败', error);
|
||||
}
|
||||
|
||||
// 3. 记录到后端 Reaction(喜欢)
|
||||
console.log('Home: Triggering setReaction', item.id);
|
||||
await setReaction(item.id, 'like');
|
||||
try {
|
||||
await setReaction(item.id, 'like');
|
||||
} catch (error) {
|
||||
console.error('Home: setReaction 失败', error);
|
||||
}
|
||||
|
||||
// 4. 爱心缩放动画
|
||||
likeScale.value = withSequence(
|
||||
withTiming(0.8, { duration: 100 }),
|
||||
withTiming(1.2, { duration: 150 }),
|
||||
withTiming(1, { duration: 100 }, (finished) => {
|
||||
runOnJS(setLikeInFlight)(false);
|
||||
if (finished) {
|
||||
console.log('Home: Like animation finished, triggering next content');
|
||||
runOnJS(triggerNextContent)();
|
||||
|
||||
@@ -44,6 +44,7 @@ export default function OnboardingScreen() {
|
||||
const [name, setName] = useState('');
|
||||
const [selections, setSelections] = useState<Record<string, string[]>>({});
|
||||
const [reminderTimes, setReminderTimes] = useState(3);
|
||||
const [finishing, setFinishing] = useState(false);
|
||||
|
||||
const currentStep = STEPS[stepIndex];
|
||||
const currentTitle = useMemo(() => t(`onboardingSurvey.steps.${currentStep.id}.title`), [t, currentStep.id]);
|
||||
@@ -56,6 +57,9 @@ export default function OnboardingScreen() {
|
||||
}, [t, currentStep]);
|
||||
|
||||
async function onFinish() {
|
||||
if (finishing) return;
|
||||
setFinishing(true);
|
||||
try {
|
||||
// 用户选择每日次数 > 0:在此页直接触发系统通知权限(已移除单独的 push 引导页)。
|
||||
const wantsPush = reminderTimes > 0;
|
||||
|
||||
@@ -159,9 +163,17 @@ export default function OnboardingScreen() {
|
||||
} finally {
|
||||
router.replace('/(app)/home');
|
||||
}
|
||||
} catch (e) {
|
||||
// 极端情况下(例如本地存储/初始化异常)避免卡死在 loading:提示并允许用户重试
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.warn('[OnboardingFinish] 异常:', msg);
|
||||
Alert.alert(t('push.errorTitle'), t('push.errorDesc'));
|
||||
setFinishing(false);
|
||||
}
|
||||
}
|
||||
|
||||
const onNext = () => {
|
||||
if (finishing) return;
|
||||
if (stepIndex < STEPS.length - 1) {
|
||||
setStepIndex(stepIndex + 1);
|
||||
} else {
|
||||
@@ -170,6 +182,7 @@ export default function OnboardingScreen() {
|
||||
};
|
||||
|
||||
const onBack = () => {
|
||||
if (finishing) return;
|
||||
if (stepIndex > 0) {
|
||||
setStepIndex(stepIndex - 1);
|
||||
}
|
||||
@@ -177,6 +190,7 @@ export default function OnboardingScreen() {
|
||||
|
||||
/** 只跳過當前這一步(不填/不選當前題,進入下一步) */
|
||||
const handleSkipCurrentStep = () => {
|
||||
if (finishing) return;
|
||||
if (currentStep.type === 'name') {
|
||||
onNext();
|
||||
} else if (currentStep.type === 'selection') {
|
||||
@@ -200,6 +214,7 @@ export default function OnboardingScreen() {
|
||||
};
|
||||
|
||||
const handleSkipStep = () => {
|
||||
if (finishing) return;
|
||||
setSelections((prev) => ({ ...prev, [currentStep.id]: [] }));
|
||||
onNext();
|
||||
};
|
||||
@@ -236,6 +251,7 @@ export default function OnboardingScreen() {
|
||||
value={Math.max(1, reminderTimes)}
|
||||
onChange={setReminderTimes}
|
||||
onFinish={onFinish}
|
||||
loading={finishing}
|
||||
onSkip={() => {
|
||||
// 跳过每日提醒:视为 0 次(关闭)
|
||||
setReminderTimes(0);
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useColorScheme } from '@/components/useColorScheme';
|
||||
import { initI18n } from '@/src/i18n';
|
||||
import { ensureDailyWidgetRecoUpToDate, syncWidgetConfig, syncWidgetUserProfileFromStorage } from '@/src/modules/dailyWidgetReco';
|
||||
import { getOrCreateClientUserId } from '@/src/storage/appStorage';
|
||||
import { ensurePushTokenRegisteredIfPermitted } from '@/src/services/pushApi';
|
||||
|
||||
// 配置通知处理方式(即使不发送也建议配置,以确保权限接口正常)
|
||||
Notifications.setNotificationHandler({
|
||||
@@ -75,6 +76,17 @@ export default function RootLayout() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// 只要系统通知权限已经 granted,就主动上报 Push Token(不依赖用户在“每日提醒”里点确认)
|
||||
ensurePushTokenRegisteredIfPermitted()
|
||||
.then((res) => {
|
||||
if (__DEV__) console.log('[push_token_sync]', res);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (__DEV__) console.warn('[push_token_sync] 失败(不阻塞启动)', e);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// 字体与 i18n 都准备好后,允许渲染 App(原生 splash 的隐藏交给 onLayout,避免“硬切/闪白”)
|
||||
if (loaded && i18nReady) setAppReady(true);
|
||||
@@ -113,7 +125,7 @@ export default function RootLayout() {
|
||||
<Animated.View pointerEvents="none" style={[StyleSheet.absoluteFill, { opacity: splashOpacity }]}>
|
||||
<View style={styles.splashOverlay}>
|
||||
<Image
|
||||
source={require('../assets/images/splashScreen.png')}
|
||||
source={require('../assets/images/Screen_page.png')}
|
||||
style={styles.splashImage}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user