diff --git a/client/app/(app)/home.tsx b/client/app/(app)/home.tsx index b64c666..97609ae 100644 --- a/client/app/(app)/home.tsx +++ b/client/app/(app)/home.tsx @@ -113,6 +113,18 @@ export default function HomeScreen() { const [cardWidth, setCardWidth] = useState(null); const [wrappedText, setWrappedText] = useState(''); const wrapLogRef = useRef<{ key: string } | null>(null); + const busyRef = useRef(false); + const indexRef = useRef(0); + const currentFeedRef = useRef([]); + const likedIdsRef = useRef>(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(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)(); diff --git a/client/app/(onboarding)/onboarding.tsx b/client/app/(onboarding)/onboarding.tsx index 29664fe..f4b2e98 100644 --- a/client/app/(onboarding)/onboarding.tsx +++ b/client/app/(onboarding)/onboarding.tsx @@ -44,6 +44,7 @@ export default function OnboardingScreen() { const [name, setName] = useState(''); const [selections, setSelections] = useState>({}); 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); diff --git a/client/app/_layout.tsx b/client/app/_layout.tsx index 2e3b819..c9f09dc 100644 --- a/client/app/_layout.tsx +++ b/client/app/_layout.tsx @@ -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() { diff --git a/client/assets/images/splashScreen.png b/client/assets/images/splashScreen.png deleted file mode 100644 index c41c96f..0000000 Binary files a/client/assets/images/splashScreen.png and /dev/null differ diff --git a/client/components/home/ProfileModal.tsx b/client/components/home/ProfileModal.tsx index 49b07e7..d484486 100644 --- a/client/components/home/ProfileModal.tsx +++ b/client/components/home/ProfileModal.tsx @@ -41,7 +41,7 @@ import QuestionIcon from '@/assets/images/home/Profile/widget/question_icon.svg' import * as Notifications from 'expo-notifications'; import { changeLanguage } from '@/src/i18n'; import { fetchLegalLinks } from '@/src/services/legalApi'; -import { getExpoPushTokenOrThrow, registerPushToken, setPushPreferences } from '@/src/services/pushApi'; +import { ensurePushTokenRegisteredIfPermitted, getExpoPushTokenOrThrow, registerPushToken, setPushPreferences } from '@/src/services/pushApi'; const { width } = Dimensions.get('window'); @@ -479,6 +479,13 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () = const next: DailyReminderSettings = { timesPerDay: nextTimes, pushEnabled: nextEnabled }; await setDailyReminderSettings(next); + // 若系统权限已授予且用户意愿为开启:兜底同步一次 token(避免“没点开关/没触发 toggle 导致后端无 token”) + if (nextEnabled) { + ensurePushTokenRegisteredIfPermitted().catch(() => { + // ignore:不阻塞保存 + }); + } + // 同步后端偏好(幂等;失败不阻塞) try { await setPushPreferences({ enabled: nextEnabled, timesPerDay: nextTimes }); diff --git a/client/components/onboarding/ReminderStep.tsx b/client/components/onboarding/ReminderStep.tsx index a48b15a..7e9e61b 100644 --- a/client/components/onboarding/ReminderStep.tsx +++ b/client/components/onboarding/ReminderStep.tsx @@ -1,7 +1,8 @@ import React from 'react'; -import { View, StyleSheet, TouchableOpacity, Text, Platform } from 'react-native'; +import { View, StyleSheet, TouchableOpacity, Text, Platform, ActivityIndicator } from 'react-native'; import { useTranslation } from 'react-i18next'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { LinearGradient } from 'expo-linear-gradient'; import { OnboardingColors } from '@/constants/OnboardingTheme'; import AddIcon from '@/assets/images/icon/add_icon.svg'; import ReduceIcon from '@/assets/images/icon/reduce_icon.svg'; @@ -12,9 +13,11 @@ interface ReminderStepProps { onChange: (value: number) => void; onFinish: () => void; onSkip?: () => void; + /** 完成后请求通知权限时的加载态 */ + loading?: boolean; } -export function ReminderStep({ value, onChange, onFinish }: ReminderStepProps) { +export function ReminderStep({ value, onChange, onFinish, loading = false }: ReminderStepProps) { const { t } = useTranslation(); const insets = useSafeAreaInsets(); @@ -30,7 +33,7 @@ export function ReminderStep({ value, onChange, onFinish }: ReminderStepProps) { return ( - + @@ -41,14 +44,27 @@ export function ReminderStep({ value, onChange, onFinish }: ReminderStepProps) { - + - - + + + {loading ? ( + + + + ) : ( + + )} + @@ -92,4 +108,20 @@ const styles = StyleSheet.create({ position: 'absolute', alignItems: 'center', }, + finishWrap: { + width: 87, + height: 57, + alignItems: 'center', + justifyContent: 'center', + }, + finishDisabled: { + opacity: 0.7, + }, + loadingPill: { + width: 87, + height: 57, + borderRadius: 28.5, + alignItems: 'center', + justifyContent: 'center', + }, }); diff --git a/client/src/constants/env.ts b/client/src/constants/env.ts index 0361ef4..b069af3 100644 --- a/client/src/constants/env.ts +++ b/client/src/constants/env.ts @@ -27,7 +27,7 @@ export type AppRuntimeEnv = 'local' | 'dev' | 'prod'; * 默认回退到 prod(避免误打到 localhost 导致真机“无法发起网络请求”)。 */ const DEFAULT_RUNTIME_ENV: AppRuntimeEnv = - typeof __DEV__ !== 'undefined' && __DEV__ ? 'local' : 'prod'; + typeof __DEV__ !== 'undefined' && __DEV__ ? 'local' : 'dev'; export const APP_ENV = (getOptionalEnv('EXPO_PUBLIC_ENV', DEFAULT_RUNTIME_ENV) as AppRuntimeEnv) ?? DEFAULT_RUNTIME_ENV; diff --git a/client/src/services/pushApi.ts b/client/src/services/pushApi.ts index e2be153..38b06b7 100644 --- a/client/src/services/pushApi.ts +++ b/client/src/services/pushApi.ts @@ -5,7 +5,13 @@ import { Platform } from 'react-native'; import { httpJson } from '../utils/http'; import { APP_ENV } from '../constants/env'; -import { getDailyReminderSettings, getOrCreateClientUserId, getUserProfileScoring } from '../storage/appStorage'; +import { + getDailyReminderSettings, + getLastRegisteredPushToken, + getOrCreateClientUserId, + getUserProfileScoring, + setLastRegisteredPushToken, +} from '../storage/appStorage'; import type { UserProfileScoring } from '../storage/appStorage'; import { toBackendLocaleFromLanguageTag } from '../i18n/locale'; @@ -154,6 +160,22 @@ export async function registerPushToken(args: { pushToken: string }): Promise { + // 只要系统权限已授权,就应尽早把 token 写入后端(不依赖用户在“每日提醒”里点确认) + const settings = await Notifications.getPermissionsAsync(); + if (settings.status !== 'granted') return { ok: false, reason: 'permission_not_granted' }; + + const token = await getExpoPushTokenOrThrow(); + + // 简单去重:token 未变化则不重复上报(减少网络与日志噪音) + const last = await getLastRegisteredPushToken().catch(() => null); + if (last && String(last) === String(token)) return { ok: true, reason: 'already_registered' }; + + await registerPushToken({ pushToken: token }); + await setLastRegisteredPushToken(token); + return { ok: true, reason: 'registered' }; +} + export async function setPushPreferences(args: { enabled: boolean; timesPerDay: number }): Promise { const clientUserId = await getOrCreateClientUserId(); const tz = pickTimezone(); diff --git a/client/src/storage/appStorage.ts b/client/src/storage/appStorage.ts index db6bea1..84f7b2d 100644 --- a/client/src/storage/appStorage.ts +++ b/client/src/storage/appStorage.ts @@ -18,6 +18,8 @@ const KEY_RECO_FEED_HISTORY = 'reco.feedHistory'; const KEY_UI_THEME_MODE = 'ui.theme.mode'; const KEY_UI_THEME_SUIXIN_STATE = 'ui.theme.suixin.state'; const KEY_DAILY_REMINDER_SETTINGS = 'dailyReminder.settings'; +const KEY_PUSH_LAST_REGISTERED_TOKEN = 'push.lastRegisteredToken'; +const KEY_PUSH_LAST_REGISTERED_AT = 'push.lastRegisteredAt'; export type PushPromptState = 'enabled' | 'skipped' | 'unknown'; export type Reaction = 'like' | 'dislike'; @@ -69,6 +71,16 @@ export type DailyReminderSettings = { pushEnabled: boolean; }; +export async function getLastRegisteredPushToken(): Promise { + const raw = await AsyncStorage.getItem(KEY_PUSH_LAST_REGISTERED_TOKEN); + return raw ? String(raw) : null; +} + +export async function setLastRegisteredPushToken(token: string): Promise { + await AsyncStorage.setItem(KEY_PUSH_LAST_REGISTERED_TOKEN, String(token)); + await AsyncStorage.setItem(KEY_PUSH_LAST_REGISTERED_AT, new Date().toISOString()); +} + export type RecoFeedCacheItem = { content_id: number; text: string; @@ -188,7 +200,7 @@ export async function setReaction(contentId: string, reaction: Reaction): Promis } export type FavoriteItem = { - favId: string; // 唯一标识,支持重复点赞同一文案 + favId: string; // 唯一标识 id: string; /** * 收藏时的文案快照(强烈建议写入,避免后续 cache 覆盖导致无法还原文案) @@ -200,13 +212,28 @@ export type FavoriteItem = { }; export async function getFavorites(): Promise { - return await getJson(KEY_FAVORITES_ITEMS, []); + const raw = await getJson(KEY_FAVORITES_ITEMS, []); + // 去重:同一条文案只保留最新的一条,防止重复点击喜欢导致弹窗重复展示 + const seen = new Set(); + const deduped: FavoriteItem[] = []; + for (const item of raw) { + const key = String(item.id); + if (seen.has(key)) continue; + seen.add(key); + deduped.push(item); + } + // 若发现历史数据有重复,顺便写回清理后的版本 + if (deduped.length !== raw.length) { + await setJson(KEY_FAVORITES_ITEMS, deduped); + } + return deduped; } export async function addFavorite(item: FavoriteItem): Promise { const list = await getFavorites(); - // 允许重复点赞,不再根据 id 去重 - const newList = [item, ...list]; + // 幂等写入:同一条文案只保留一条(最新),避免重复点击喜欢产生重复项 + const filtered = list.filter(x => String(x.id) !== String(item.id)); + const newList = [item, ...filtered]; 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 72dc344..34f8e1c 100644 --- a/spec_kit/overview.md +++ b/spec_kit/overview.md @@ -39,6 +39,7 @@ - **已完成编码(阶段性)**: - 客户端:新增 `client_user_id`(UUID v4)生成与持久化;每日提醒次数范围修正为 **0~5**(0 表示关闭) - 客户端:Onboarding 结束页(每日提醒)在用户选择次数 > 0 时**直接触发系统权限申请**;授权后获取 Expo Push Token 并调用后端 `register/preferences`(移除单独的 push 引导页) + - 客户端:Onboarding 问卷完成后“开通推送权限”流程增加 **loading 态**(完成按钮转圈 + 全页禁用交互,避免重复触发/重复上报) - 客户端:个人主页“每日提醒”弹窗移除测试模式强制无权限逻辑,改为真实读取系统权限;并在开关/点击 OK 时同步后端偏好 - 客户端:新增推送接口封装 `client/src/services/pushApi.ts`(token 获取、register/preferences/get、自动上报时区与 locale,并携带用户画像供后端 Push 模板使用) - 后端:新增 Push 数据模型 + Alembic 迁移(`push_tokens` / `push_preferences` / `push_send_log`) @@ -89,6 +90,8 @@ - 推送 entitlements 的 `aps-environment` 已切到 `production`(用于 TestFlight/线上包) - 清理未接入编译的 WidgetKit 骨架残留:移除磁盘上的 `client/ios/MindfulnessWidget/` 文件,并从 `client/ios/client.xcodeproj/project.pbxproj` 删除对应工程引用(避免 Xcode 显示幽灵文件) - 修复 Xcode Archive 偶发显示 “Generic Xcode Archive”:在共享 scheme `Hey Mama` 的 Archive Post-actions 自动补齐 `.xcarchive/Info.plist` 的 `ApplicationProperties`,并在缺失时补齐 `Name`/`SchemeName` + 自检提示(根治 Organizer 无法识别主 App、无法分发/上传 TestFlight 的问题) + - Widget 名称与描述支持多语言(TC/EN,默认 EN):Widget Extension 增加 `Localizable.strings`(`en.lproj` / `zh-Hant.lproj`),`EmotionWidget.swift` 使用本地化 key 作为 `.configurationDisplayName/.description` + - 个人主页弹窗:小工具入口**暂时隐藏**锁屏小工具说明;桌面小工具引导弹窗标题(繁中/TC)更新为“**如何加入小工具**”(并统一弹窗标题使用该文案);品牌文案“正念”改为 **Hey Mama**(含引导搜索词与 Widget 标题) ## Text Wrap @@ -169,6 +172,10 @@ - `spec_kit/Text Wrap/modules/integration/tasks.md` - **接入情况**: - Home(APP):已在 `client/app/(app)/home.tsx` 接入 `wrapText()` 渲染 `wrappedText`(含 `\n`) + - iOS Widget(WidgetKit):App 侧在写入 `widget.dailyReco.v1` 缓存时,额外生成 `wrapped_text_by_family`(small/medium/large)并写入 App Group;Widget 侧按 `WidgetFamily` 优先读取该字段渲染(保证换行一致且无需在 Extension 内跑 JS) + - **近期变更**: + - Widget 接入:`client/src/modules/dailyWidgetReco/index.ts` 生成 `wrapped_text_by_family`;`client/ios/情绪小组件/EmotionWidget.swift` 按 family 读取;`client/app/(app)/home.tsx` 前台触发一次“尽力而为”的补齐/刷新 + - Home 排版风格微调:支持 `scoringOverrides`,在 Home 里对 TC 做“更偏好标点停顿/更好看”的权重与理想宽度微调(不影响默认 v1) ## Splash Consent