|
|
@@ -141,23 +141,12 @@ export const ChatBotPage = () => {
|
|
|
// 初始化时检查 localStorage,确保第一次打开时立即显示提示
|
|
|
const [showTapToChat, setShowTapToChat] = useState(() => {
|
|
|
const hasShown = getStorage(STORAGE_KEYS.HAS_SHOWN_TAP_TO_CHAT);
|
|
|
- if (!hasShown) {
|
|
|
- // 第一次打开,立即保存标记并显示
|
|
|
- setStorage(STORAGE_KEYS.HAS_SHOWN_TAP_TO_CHAT, 'true');
|
|
|
-
|
|
|
- // 同步到后端 UI 状态(如果已有 user_id)
|
|
|
- const userId = getUserId();
|
|
|
- if (userId) {
|
|
|
- updateUserUIState(userId, {
|
|
|
- has_shown_tap_to_chat: true,
|
|
|
- }).catch(() => {
|
|
|
- // 同步失败不影响前端提示展示
|
|
|
- });
|
|
|
- }
|
|
|
-
|
|
|
- return true;
|
|
|
+ // 如果已经显示过,直接返回 false
|
|
|
+ if (hasShown) {
|
|
|
+ return false;
|
|
|
}
|
|
|
- return false;
|
|
|
+ // 第一次打开,返回 true(但不立即保存到 localStorage,等 WebSocket 连接成功后再保存)
|
|
|
+ return true;
|
|
|
});
|
|
|
const sendingTimeoutRef = useRef<number | null>(null);
|
|
|
const updateRequestId = useCallback(
|
|
|
@@ -175,9 +164,63 @@ export const ChatBotPage = () => {
|
|
|
const isLoggedIn = getStorage(STORAGE_KEYS.IS_LOGGED_IN) === 'true';
|
|
|
if (!isLoggedIn && userLevelValue >= 5 && !levelRedirectRef.current) {
|
|
|
levelRedirectRef.current = true;
|
|
|
- navigate('/level5-welcome');
|
|
|
+
|
|
|
+ // 等待音频播放完成后再跳转
|
|
|
+ const performRedirect = async () => {
|
|
|
+ // 检查是否正在等待AI回复(sending状态)或正在播放音频(playing状态)
|
|
|
+ const isWaitingOrPlaying = currentState === 'sending' || currentState === 'playing';
|
|
|
+ const hasActiveRequest = currentRequestId !== null;
|
|
|
+ const isAudioPlaying = playerRef.current?.isPlaying() || false;
|
|
|
+
|
|
|
+ if (isWaitingOrPlaying || hasActiveRequest || isAudioPlaying) {
|
|
|
+ console.log('[ChatBotPage] 检测到正在等待AI回复或播放音频,等待完成后再跳转...', {
|
|
|
+ state: currentState,
|
|
|
+ hasRequest: hasActiveRequest,
|
|
|
+ isPlaying: isAudioPlaying
|
|
|
+ });
|
|
|
+
|
|
|
+ // 如果正在等待回复(sending状态),等待状态变为playing或idle
|
|
|
+ if (currentState === 'sending' || hasActiveRequest) {
|
|
|
+ await new Promise<void>((resolve) => {
|
|
|
+ // 设置超时保护,最多等待30秒
|
|
|
+ const timeoutId = setTimeout(() => {
|
|
|
+ console.warn('[ChatBotPage] 等待AI回复超时,继续执行跳转');
|
|
|
+ resolve();
|
|
|
+ }, 30000);
|
|
|
+
|
|
|
+ // 使用轮询检查状态,直到状态变为idle且没有活跃请求
|
|
|
+ const checkInterval = setInterval(() => {
|
|
|
+ const appState = useAppStore.getState();
|
|
|
+ // 如果状态变为idle且没有活跃请求,说明对话已完成
|
|
|
+ if (appState.currentState === 'idle' && appState.currentRequestId === null) {
|
|
|
+ clearInterval(checkInterval);
|
|
|
+ clearTimeout(timeoutId);
|
|
|
+ resolve();
|
|
|
+ }
|
|
|
+ }, 100);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // 如果音频正在播放或状态为playing,等待播放完成
|
|
|
+ if (currentState === 'playing' || isAudioPlaying || playerRef.current?.isPlaying()) {
|
|
|
+ if (playerRef.current) {
|
|
|
+ try {
|
|
|
+ await playerRef.current.waitForPlaybackComplete();
|
|
|
+ console.log('[ChatBotPage] 音频播放完成');
|
|
|
+ } catch (error) {
|
|
|
+ console.warn('[ChatBotPage] 等待音频播放完成时出错:', error);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ console.log('[ChatBotPage] 所有等待完成,执行跳转');
|
|
|
+ }
|
|
|
+ navigate('/level5-welcome');
|
|
|
+ };
|
|
|
+
|
|
|
+ performRedirect();
|
|
|
}
|
|
|
- }, [userLevelValue, navigate]);
|
|
|
+ }, [userLevelValue, navigate, currentState, currentRequestId]);
|
|
|
const [showCancelTooltip, setShowCancelTooltip] = useState(false);
|
|
|
const [showSendTooltip, setShowSendTooltip] = useState(false);
|
|
|
const [showProgressBarTooltip, setShowProgressBarTooltip] = useState(false);
|
|
|
@@ -240,7 +283,7 @@ export const ChatBotPage = () => {
|
|
|
});
|
|
|
// GIF 切换定时器
|
|
|
const emotionGifTimerRef = useRef<number | null>(null);
|
|
|
- const handleAvatarClick = useCallback(() => {
|
|
|
+ const handleAvatarClick = useCallback(async () => {
|
|
|
if (!isAvatarCUnlocked) {
|
|
|
return;
|
|
|
}
|
|
|
@@ -253,6 +296,18 @@ export const ChatBotPage = () => {
|
|
|
// 未登录:根据用户等级跳转
|
|
|
if (userLevelValue >= 5) {
|
|
|
levelRedirectRef.current = true;
|
|
|
+
|
|
|
+ // 等待音频播放完成后再跳转
|
|
|
+ if (playerRef.current?.isPlaying()) {
|
|
|
+ console.log('[ChatBotPage] 检测到音频正在播放,等待播放完成后再跳转...');
|
|
|
+ try {
|
|
|
+ await playerRef.current.waitForPlaybackComplete();
|
|
|
+ console.log('[ChatBotPage] 音频播放完成,执行跳转');
|
|
|
+ } catch (error) {
|
|
|
+ console.warn('[ChatBotPage] 等待音频播放完成时出错:', error);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
navigate('/level5-welcome');
|
|
|
return;
|
|
|
}
|
|
|
@@ -326,6 +381,29 @@ export const ChatBotPage = () => {
|
|
|
const showNegativeGif = useCallback(() => setGifByState('negative'), [setGifByState]);
|
|
|
const showTalkingGif = useCallback(() => setGifByState('talking'), [setGifByState]);
|
|
|
|
|
|
+ // 预加载动画形象的 GIF
|
|
|
+ const preloadAvatarGifs = useCallback(
|
|
|
+ (avatarId: string | null) => {
|
|
|
+ const gifSet = resolveAvatarGifSet(avatarId);
|
|
|
+ const gifUrls = [gifSet.default, gifSet.positive, gifSet.negative, gifSet.talking];
|
|
|
+
|
|
|
+ // 使用 Image 对象预加载所有 GIF
|
|
|
+ gifUrls.forEach((url) => {
|
|
|
+ if (url) {
|
|
|
+ const img = new Image();
|
|
|
+ img.src = url;
|
|
|
+ // 静默处理加载错误,避免影响主流程
|
|
|
+ img.onerror = () => {
|
|
|
+ console.warn(`[预加载] GIF 加载失败: ${url}`);
|
|
|
+ };
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ console.log(`[预加载] 已开始预加载形象 ${avatarId || 'default'} 的所有 GIF`);
|
|
|
+ },
|
|
|
+ [resolveAvatarGifSet]
|
|
|
+ );
|
|
|
+
|
|
|
// 拉取头像 GIF 配置
|
|
|
useEffect(() => {
|
|
|
const fetchAvatarGifs = async () => {
|
|
|
@@ -358,6 +436,15 @@ export const ChatBotPage = () => {
|
|
|
fetchAvatarGifs();
|
|
|
}, []);
|
|
|
|
|
|
+ // 当 avatarGifSets 更新或 currentAvatarId 变化时,预加载当前形象的 GIF
|
|
|
+ useEffect(() => {
|
|
|
+ if (Object.keys(avatarGifSets).length > 0) {
|
|
|
+ // 如果 currentAvatarId 为 null,预加载默认形象
|
|
|
+ const avatarIdToPreload = currentAvatarId || AVATAR_ID_DEFAULT;
|
|
|
+ preloadAvatarGifs(avatarIdToPreload);
|
|
|
+ }
|
|
|
+ }, [avatarGifSets, currentAvatarId, preloadAvatarGifs]);
|
|
|
+
|
|
|
// 监听 storage(跨标签页同步当前形象)
|
|
|
useEffect(() => {
|
|
|
const handleStorage = (event: StorageEvent) => {
|
|
|
@@ -864,7 +951,11 @@ export const ChatBotPage = () => {
|
|
|
}
|
|
|
if (state.has_shown_tap_to_chat) {
|
|
|
setStorage(STORAGE_KEYS.HAS_SHOWN_TAP_TO_CHAT, 'true');
|
|
|
- setShowTapToChat(false);
|
|
|
+ // 只有在 WebSocket 已连接时才隐藏提示(确保用户能看到提示)
|
|
|
+ // 如果 WebSocket 还未连接,保持 showTapToChat 为 true,等连接成功后再隐藏
|
|
|
+ if (isConnected) {
|
|
|
+ setShowTapToChat(false);
|
|
|
+ }
|
|
|
}
|
|
|
if (state.has_shown_cancel_tooltip) {
|
|
|
setStorage(STORAGE_KEYS.HAS_SHOWN_CANCEL_TOOLTIP, 'true');
|
|
|
@@ -1052,6 +1143,28 @@ export const ChatBotPage = () => {
|
|
|
// 记录WebSocket连接建立时间
|
|
|
performanceLogger.recordWebSocketConnected();
|
|
|
|
|
|
+ // 如果这是首次连接成功,处理 TapToChat 提示
|
|
|
+ if (!connectionLostRef.current) {
|
|
|
+ const hasShown = getStorage(STORAGE_KEYS.HAS_SHOWN_TAP_TO_CHAT);
|
|
|
+ if (!hasShown && showTapToChat) {
|
|
|
+ // 第一次连接成功,保存标记
|
|
|
+ setStorage(STORAGE_KEYS.HAS_SHOWN_TAP_TO_CHAT, 'true');
|
|
|
+
|
|
|
+ // 同步到后端 UI 状态(如果已有 user_id)
|
|
|
+ const userId = getUserId();
|
|
|
+ if (userId) {
|
|
|
+ updateUserUIState(userId, {
|
|
|
+ has_shown_tap_to_chat: true,
|
|
|
+ }).catch(() => {
|
|
|
+ // 同步失败不影响前端提示展示
|
|
|
+ });
|
|
|
+ }
|
|
|
+ } else if (hasShown) {
|
|
|
+ // 如果已经标记为已显示(可能是从后端同步的),现在 WebSocket 已连接,可以安全隐藏
|
|
|
+ setShowTapToChat(false);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
if (connectionLostRef.current) {
|
|
|
antMessage.success('语音服务已恢复,可以继续对话');
|
|
|
connectionLostRef.current = false;
|
|
|
@@ -1381,9 +1494,23 @@ export const ChatBotPage = () => {
|
|
|
|
|
|
// 其他状态(idle/error/sending)都允许开始录音
|
|
|
|
|
|
- // 首次点击录音按钮,隐藏 TapToChat 提示
|
|
|
+ // 首次点击录音按钮,隐藏 TapToChat 提示并保存标记
|
|
|
if (showTapToChat) {
|
|
|
setShowTapToChat(false);
|
|
|
+ // 确保标记已保存(防止 WebSocket 连接较慢时未保存)
|
|
|
+ const hasShown = getStorage(STORAGE_KEYS.HAS_SHOWN_TAP_TO_CHAT);
|
|
|
+ if (!hasShown) {
|
|
|
+ setStorage(STORAGE_KEYS.HAS_SHOWN_TAP_TO_CHAT, 'true');
|
|
|
+ // 同步到后端 UI 状态(如果已有 user_id)
|
|
|
+ const userId = getUserId();
|
|
|
+ if (userId) {
|
|
|
+ updateUserUIState(userId, {
|
|
|
+ has_shown_tap_to_chat: true,
|
|
|
+ }).catch(() => {
|
|
|
+ // 同步失败不影响主流程
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
// 点击录音按钮时,隐藏进度条提示
|