fix:重复点击

This commit is contained in:
吕新雨
2026-02-10 15:06:50 +08:00
parent b5532df161
commit 1fbc0aa3f8
10 changed files with 236 additions and 36 deletions

View File

@@ -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));
try {
await addFavorite(favItem);
} catch (error) {
console.error('Home: addFavorite 失败', error);
}
// 3. 记录到后端 Reaction喜欢
console.log('Home: Triggering setReaction', item.id);
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)();

View File

@@ -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);

View File

@@ -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"
/>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

View File

@@ -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 });

View File

@@ -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 (
<View style={styles.container}>
<View style={styles.counterContainer}>
<TouchableOpacity onPress={handleReduce} activeOpacity={0.7}>
<TouchableOpacity onPress={handleReduce} disabled={loading} activeOpacity={0.7}>
<ReduceIcon width={47} height={47} />
</TouchableOpacity>
@@ -41,14 +44,27 @@ export function ReminderStep({ value, onChange, onFinish }: ReminderStepProps) {
</Text>
</View>
<TouchableOpacity onPress={handleAdd} activeOpacity={0.7}>
<TouchableOpacity onPress={handleAdd} disabled={loading} activeOpacity={0.7}>
<AddIcon width={47} height={47} />
</TouchableOpacity>
</View>
<View style={[styles.footer, { bottom: insets.bottom + 16 }]}>
<TouchableOpacity onPress={onFinish} activeOpacity={0.8}>
<TouchableOpacity onPress={onFinish} disabled={loading} activeOpacity={0.8}>
<View style={styles.finishWrap}>
{loading ? (
<LinearGradient
colors={['#F69F7B', '#F99CC0']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={[styles.loadingPill, styles.finishDisabled]}
>
<ActivityIndicator size="small" color="#FFFFFF" />
</LinearGradient>
) : (
<BtnClicked width={87} height={57} />
)}
</View>
</TouchableOpacity>
</View>
</View>
@@ -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',
},
});

View File

@@ -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;

View File

@@ -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<vo
});
}
export async function ensurePushTokenRegisteredIfPermitted(): Promise<{ ok: boolean; reason: string }> {
// 只要系统权限已授权,就应尽早把 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<PushPreferencesResponse> {
const clientUserId = await getOrCreateClientUserId();
const tz = pickTimezone();

View File

@@ -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<string | null> {
const raw = await AsyncStorage.getItem(KEY_PUSH_LAST_REGISTERED_TOKEN);
return raw ? String(raw) : null;
}
export async function setLastRegisteredPushToken(token: string): Promise<void> {
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<FavoriteItem[]> {
return await getJson<FavoriteItem[]>(KEY_FAVORITES_ITEMS, []);
const raw = await getJson<FavoriteItem[]>(KEY_FAVORITES_ITEMS, []);
// 去重:同一条文案只保留最新的一条,防止重复点击喜欢导致弹窗重复展示
const seen = new Set<string>();
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<void> {
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);
}

View File

@@ -39,6 +39,7 @@
- **已完成编码(阶段性)**
- 客户端:新增 `client_user_id`UUID v4生成与持久化每日提醒次数范围修正为 **05**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默认 ENWidget 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`
- **接入情况**
- HomeAPP已在 `client/app/(app)/home.tsx` 接入 `wrapText()` 渲染 `wrappedText`(含 `\n`
- iOS WidgetWidgetKitApp 侧在写入 `widget.dailyReco.v1` 缓存时,额外生成 `wrapped_text_by_family`small/medium/large并写入 App GroupWidget 侧按 `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