This commit is contained in:
1
2026-02-02 17:33:20 +08:00
113 changed files with 11785 additions and 280 deletions

View File

@@ -14,12 +14,20 @@ import Animated, {
import { MOCK_CONTENT } from '@/src/constants/mockContent';
import {
addFavorite,
getRecoFeedCache,
getRecoFeedHistory,
getThemeMode,
getUserProfile,
getUserProfileScoring,
recordRecoFeedServed,
recordRecoFeedTouched,
setRecoFeedCache,
setReaction,
setThemeMode,
type RecoFeedCacheItem,
type ThemeMode,
} from '@/src/storage/appStorage';
import { fetchRecoFeed } from '@/src/services/recoApi';
import ProfileModal from '@/components/home/ProfileModal';
import ThemeModal from '@/components/home/ThemeModal';
@@ -75,8 +83,11 @@ 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 item = useMemo(() => MOCK_CONTENT[index % MOCK_CONTENT.length], [index]);
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;
// 动画相关 Shared Values
const translateY = useSharedValue(0);
@@ -100,6 +111,55 @@ export default function HomeScreen() {
}, [])
);
// 首次进入:先读缓存,再拉后端 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;
@@ -153,6 +213,11 @@ 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) => {
@@ -173,7 +238,7 @@ export default function HomeScreen() {
});
}
});
}, [busy, index, translateY, opacity]);
}, [busy, currentContentId, index, translateY, opacity]);
const lastTapRef = useRef<number>(0);

View File

@@ -5,7 +5,16 @@ import { OnboardingLayout } from '@/components/onboarding/OnboardingLayout';
import { NameInputStep } from '@/components/onboarding/NameInputStep';
import { SelectionStep } from '@/components/onboarding/SelectionStep';
import { ReminderStep } from '@/components/onboarding/ReminderStep';
import { setOnboardingCompleted, setUserProfile, setDailyReminderSettings } from '@/src/storage/appStorage';
import { buildUserProfileFromQuestionnaire, mapOnboardingSelectionsToQuestionnaireAnswers } from '@/src/features/userProfileScoring';
import { fetchRecoFeed } from '@/src/services/recoApi';
import {
recordRecoFeedServed,
setOnboardingCompleted,
setUserProfile,
setDailyReminderSettings,
setUserProfileScoring,
setRecoFeedCache,
} from '@/src/storage/appStorage';
const STEPS = [
{ id: 'name', type: 'name', title: '我可以怎么称呼你?' },
@@ -71,6 +80,40 @@ export default function OnboardingScreen() {
const { status } = await Notifications.requestPermissionsAsync();
const pushEnabled = status === 'granted';
// 将 Onboarding 选择映射为标准问卷枚举(允许跳过)
const answers = mapOnboardingSelectionsToQuestionnaireAnswers(selections);
// 生成用户画像(供推荐/Push/Widget 复用)
const scoringProfile = buildUserProfileFromQuestionnaire(answers);
await setUserProfileScoring(scoringProfile);
// Onboarding 结束后预拉取一次 Feed 文案(失败不阻塞进入首页)
try {
const { items, meta } = await fetchRecoFeed({
k: 30,
user_profile: {
profile_version: scoringProfile.profile_version,
profile_source: scoringProfile.profile_source,
profile_generated_at: scoringProfile.profile_generated_at,
profile_confidence: scoringProfile.profile_confidence,
profile_answered: scoringProfile.profile_answered,
stage: scoringProfile.stage,
emotion_score: scoringProfile.emotion_score,
context: scoringProfile.context,
need: scoringProfile.need,
},
});
await setRecoFeedCache({
saved_at: new Date().toISOString(),
items: items.map((x) => ({ content_id: x.content_id, text: x.text })),
meta: meta as Record<string, unknown>,
});
await recordRecoFeedServed(items.map((x) => x.content_id));
} catch {
// 网络失败时使用首页本地 mock 兜底
}
await setUserProfile({
name,
intents: Object.values(selections).flat()
@@ -98,20 +141,29 @@ export default function OnboardingScreen() {
};
const onSkip = async () => {
// 跳过整个 Onboarding仍生成一个“全跳过”的最小画像保证下游可用
const scoringProfile = buildUserProfileFromQuestionnaire({});
await setUserProfileScoring(scoringProfile);
// 标记已完成,避免下次启动再次进入 Onboarding
await setOnboardingCompleted(true);
router.replace('/(app)/home');
};
// 题目为单选:再次点击可取消;选择其他选项会替换为唯一选项
const handleToggleSelection = (id: string) => {
setSelections(prev => {
const currentIds = prev[currentStep.id] || [];
const nextIds = currentIds.includes(id)
? currentIds.filter(i => i !== id)
: [...currentIds, id];
const nextIds = currentIds.includes(id) ? [] : [id];
return { ...prev, [currentStep.id]: nextIds };
});
};
const handleSkipStep = () => {
setSelections((prev) => ({ ...prev, [currentStep.id]: [] }));
onNext();
};
return (
<OnboardingLayout
title={currentStep.title}
@@ -135,6 +187,7 @@ export default function OnboardingScreen() {
selectedIds={selections[currentStep.id] || []}
onToggle={handleToggleSelection}
onNext={onNext}
onSkip={handleSkipStep}
/>
)}

View File

@@ -1,9 +1,8 @@
import { useEffect } from 'react';
import { ActivityIndicator, StyleSheet, View } from 'react-native';
import { useRouter } from 'expo-router';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { getOnboardingCompleted, getConsentAccepted, setOnboardingCompleted, setConsentAccepted } from '@/src/storage/appStorage';
import { getOnboardingCompleted, getConsentAccepted } from '@/src/storage/appStorage';
/**
* 启动分发:根据 consent 和 onboarding 状态跳转
@@ -14,10 +13,6 @@ export default function Index() {
useEffect(() => {
let cancelled = false;
(async () => {
// 【临时清除数据】:用于测试完整流程
await AsyncStorage.clear();
console.log('AsyncStorage has been cleared for testing.');
// 1. 检查是否同意协议
const consentAccepted = await getConsentAccepted();
if (cancelled) return;
@@ -54,4 +49,3 @@ export default function Index() {
const styles = StyleSheet.create({
container: { flex: 1, alignItems: 'center', justifyContent: 'center' },
});