|
|
@@ -0,0 +1,3091 @@
|
|
|
+import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react';
|
|
|
+import { useNavigate, useSearchParams, useLocation } from 'react-router-dom';
|
|
|
+import { message as antMessage } from 'antd';
|
|
|
+import { v4 as uuidv4 } from 'uuid';
|
|
|
+import { useAppStore } from '@/store';
|
|
|
+import { AudioRecorder, type FrequencyFrame } from '@/utils/audioRecorder';
|
|
|
+import { AudioPlayer } from '@/utils/audioPlayer';
|
|
|
+import { WebSocketManager } from '@/utils/websocket';
|
|
|
+import { prepareMobileAudio, requestVolumeControl, isMobileDevice } from '@/utils/audioHelper';
|
|
|
+import {
|
|
|
+ setStorage,
|
|
|
+ getStorage,
|
|
|
+ getUserLevel,
|
|
|
+ getHasSharedAvatar,
|
|
|
+ STORAGE_KEYS,
|
|
|
+ markRouteVisited,
|
|
|
+ getUserId,
|
|
|
+ getVisitedRoutes,
|
|
|
+ setUserLevel,
|
|
|
+ getCurrentBackgroundImageUrl,
|
|
|
+ getCurrentBackgroundImageId,
|
|
|
+ getCurrentBackgroundImageOpacity,
|
|
|
+ syncBackgroundImageToStorage,
|
|
|
+ setUserName,
|
|
|
+} from '@/utils/storage';
|
|
|
+import { onScreenResize, calculateLayout, type CalculatedLayout, updateTooSmallFlag } from '@/utils/screenSize';
|
|
|
+import { performanceLogger } from '@/utils/logger';
|
|
|
+import { getUserInfo, updateUserUIState, getUserUIState, getAvatarGifs, type AvatarGifSet } from '@/utils/api';
|
|
|
+import { px2vw, px2vh, px2vmin } from '@/utils/viewport';
|
|
|
+import type { Message, EnergyStoneSpawnMessage } from '@/types';
|
|
|
+import '@/styles/chatbot-fix.css';
|
|
|
+import { getOssUrl } from '@/utils/oss';
|
|
|
+import { AudioWave, type AudioWaveHandle } from '@/components/AudioWave';
|
|
|
+
|
|
|
+const avatarC = getOssUrl('Avatar-C.svg');
|
|
|
+const avatarGift = 'https://lyrria-pro.oss-cn-beijing.aliyuncs.com/Gift.gif';
|
|
|
+const audioButton = getOssUrl('audio-button.png');
|
|
|
+const avatarHint = getOssUrl('Avatar-hint.png');
|
|
|
+const microphoneIcon = getOssUrl('microphone-icon.svg');
|
|
|
+const progressRing = getOssUrl('progress-ring.svg');
|
|
|
+const progressFull = getOssUrl('progress-full.svg');
|
|
|
+const tapToChat = getOssUrl('TapToChat.svg');
|
|
|
+const cancelIcon = getOssUrl('cancel-icon.svg');
|
|
|
+const sendIcon = getOssUrl('send-icon.svg');
|
|
|
+const tooltipsCancel = getOssUrl('tooltips-cancel.svg');
|
|
|
+const tooltipsSend = getOssUrl('tooltips-send.svg');
|
|
|
+const tooltipsProgressBar = getOssUrl('tooltips-progress-bar.svg');
|
|
|
+const missionEnergyStone = getOssUrl('Mission-Energy-Stone.png');
|
|
|
+const conversationEnergyStone = getOssUrl('Conversation-Energy-Stone.png');
|
|
|
+const missionEnergyStoneHint = getOssUrl('Mission-Energy-Stone-hint.png');
|
|
|
+const conversationEnergyStoneHint = getOssUrl('Conversation-Energy-Stone-hint.png');
|
|
|
+import { useEnergyStones, type EnergyStoneEventPayload } from '@/hooks/useEnergyStones';
|
|
|
+import { MissionEnergyStoneModal } from '@/components/MissionEnergyStoneModal';
|
|
|
+
|
|
|
+const MAX_RECORDING_DURATION_MS = 60_000;
|
|
|
+const AVATAR_C_UNLOCK_THRESHOLD_MS = 60_000;
|
|
|
+const COUNTDOWN_THRESHOLD_MS = 10_000;
|
|
|
+
|
|
|
+const WS_URL = import.meta.env.VITE_WS_URL || 'ws://localhost:8000/ws';
|
|
|
+
|
|
|
+// GIF URL 兜底常量(基础形象)
|
|
|
+const FALLBACK_DEFAULT_AVATAR_GIF = 'https://lyrria-pro.oss-cn-beijing.aliyuncs.com/01-Default.gif';
|
|
|
+const FALLBACK_POSITIVE_EMOTION_GIF = 'https://lyrria-pro.oss-cn-beijing.aliyuncs.com/04-Positive-emotion.gif';
|
|
|
+const FALLBACK_NEGATIVE_EMOTION_GIF = 'https://lyrria-pro.oss-cn-beijing.aliyuncs.com/05-Negative-emotion.gif';
|
|
|
+const FALLBACK_TALKING_GIF = 'https://lyrria-pro.oss-cn-beijing.aliyuncs.com/06-talking.gif';
|
|
|
+
|
|
|
+// GIF URL 兜底常量(注册赠送形象:带帽子)
|
|
|
+const FALLBACK_REGISTERED_DEFAULT_AVATAR_GIF = 'https://lyrria-pro.oss-cn-beijing.aliyuncs.com/01-Default-Registered.gif';
|
|
|
+const FALLBACK_REGISTERED_POSITIVE_EMOTION_GIF = 'https://lyrria-pro.oss-cn-beijing.aliyuncs.com/04-Positive-emotion-Registered.gif';
|
|
|
+const FALLBACK_REGISTERED_NEGATIVE_EMOTION_GIF = 'https://lyrria-pro.oss-cn-beijing.aliyuncs.com/05-Negative-emotion-Registered.gif';
|
|
|
+const FALLBACK_REGISTERED_TALKING_GIF = 'https://lyrria-pro.oss-cn-beijing.aliyuncs.com/06-talking-Registered.gif';
|
|
|
+
|
|
|
+// 形象 ID 常量(与后端/迁移脚本一致)
|
|
|
+const AVATAR_ID_DEFAULT = 'avatar_default';
|
|
|
+const AVATAR_ID_REGISTERED = 'avatar_registered_cap';
|
|
|
+
|
|
|
+type AvatarState = 'default' | 'positive' | 'negative' | 'talking';
|
|
|
+
|
|
|
+// 情绪分类函数
|
|
|
+const getEmotionCategory = (emotion: string): 'positive' | 'negative' | 'neutral' => {
|
|
|
+ // 正向情绪
|
|
|
+ const positiveEmotions = ['happy', 'excited', 'encouraging', 'satisfied', 'friendly'];
|
|
|
+ // 反向情绪
|
|
|
+ const negativeEmotions = ['sad', 'angry', 'worried', 'confused', 'sorry'];
|
|
|
+
|
|
|
+ if (positiveEmotions.includes(emotion)) {
|
|
|
+ return 'positive';
|
|
|
+ }
|
|
|
+ if (negativeEmotions.includes(emotion)) {
|
|
|
+ return 'negative';
|
|
|
+ }
|
|
|
+ // neutral, surprised, thoughtful 等归为中性
|
|
|
+ return 'neutral';
|
|
|
+};
|
|
|
+
|
|
|
+export const ChatBotPage = () => {
|
|
|
+ const navigate = useNavigate();
|
|
|
+ const [searchParams] = useSearchParams();
|
|
|
+ const location = useLocation();
|
|
|
+ const {
|
|
|
+ currentState,
|
|
|
+ currentRequestId,
|
|
|
+ currentEmotion,
|
|
|
+ conversationProgress,
|
|
|
+ progressLoopCount,
|
|
|
+ setState,
|
|
|
+ setRequestId,
|
|
|
+ setEmotion,
|
|
|
+ setRecognizedText,
|
|
|
+ setError,
|
|
|
+ setConnected,
|
|
|
+ updateProgressByDuration,
|
|
|
+ setProgress,
|
|
|
+ reset,
|
|
|
+ isConnected,
|
|
|
+ } = useAppStore();
|
|
|
+
|
|
|
+ const recorderRef = useRef<AudioRecorder | null>(null);
|
|
|
+ const playerRef = useRef<AudioPlayer | null>(null);
|
|
|
+ const wsRef = useRef<WebSocketManager | null>(null);
|
|
|
+ const chunkIndexRef = useRef(0);
|
|
|
+ const connectionLostRef = useRef(false);
|
|
|
+ const activeRequestIdRef = useRef<string | null>(null);
|
|
|
+ const cancelledRequestIdsRef = useRef<Set<string>>(new Set());
|
|
|
+ const mobileAudioPreparedRef = useRef(false);
|
|
|
+ const sendTimeRef = useRef<number>(0);
|
|
|
+ const audioWaveRef = useRef<AudioWaveHandle | null>(null);
|
|
|
+ const isMountedRef = useRef(true);
|
|
|
+ // 调试开关:开发模式或 URL ?debug=stone 时显示测试能量石按钮
|
|
|
+ const isDebugStoneEnabled = useMemo(
|
|
|
+ () => import.meta.env.DEV || searchParams.get('debug') === 'stone',
|
|
|
+ [searchParams],
|
|
|
+ );
|
|
|
+ const [userLevelValue, setUserLevelValue] = useState(() => getUserLevel());
|
|
|
+ const levelRedirectRef = useRef(false);
|
|
|
+ const [hasSharedAvatar, setHasSharedAvatarState] = useState(() => getHasSharedAvatar());
|
|
|
+ // 背景图片 URL
|
|
|
+ const [backgroundImageUrl, setBackgroundImageUrl] = useState<string | null>(() => {
|
|
|
+ // 初始化时从 localStorage 获取背景图片 URL
|
|
|
+ const url = getCurrentBackgroundImageUrl();
|
|
|
+ return url || null;
|
|
|
+ });
|
|
|
+ // 背景图片 ID(只保留 setter,因为变量本身未使用)
|
|
|
+ const [, setBackgroundImageId] = useState<string | null>(() => {
|
|
|
+ return getCurrentBackgroundImageId();
|
|
|
+ });
|
|
|
+ // 初始化时检查 localStorage,确保第一次打开时立即显示提示
|
|
|
+ const [showTapToChat, setShowTapToChat] = useState(() => {
|
|
|
+ const hasShown = getStorage(STORAGE_KEYS.HAS_SHOWN_TAP_TO_CHAT);
|
|
|
+ // 如果已经显示过,直接返回 false
|
|
|
+ if (hasShown) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ // 第一次打开,返回 true(但不立即保存到 localStorage,等 WebSocket 连接成功后再保存)
|
|
|
+ return true;
|
|
|
+ });
|
|
|
+ const sendingTimeoutRef = useRef<number | null>(null);
|
|
|
+ const updateRequestId = useCallback(
|
|
|
+ (id: string | null) => {
|
|
|
+ activeRequestIdRef.current = id;
|
|
|
+ setRequestId(id);
|
|
|
+ },
|
|
|
+ [setRequestId]
|
|
|
+ );
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ activeRequestIdRef.current = currentRequestId;
|
|
|
+ }, [currentRequestId]);
|
|
|
+ useEffect(() => {
|
|
|
+ const isLoggedIn = getStorage(STORAGE_KEYS.IS_LOGGED_IN) === 'true';
|
|
|
+ if (!isLoggedIn && userLevelValue >= 5 && !levelRedirectRef.current) {
|
|
|
+ levelRedirectRef.current = true;
|
|
|
+
|
|
|
+ // 等待音频播放完成后再跳转
|
|
|
+ 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, currentState, currentRequestId]);
|
|
|
+ const [showCancelTooltip, setShowCancelTooltip] = useState(false);
|
|
|
+ const [showSendTooltip, setShowSendTooltip] = useState(false);
|
|
|
+ const [showProgressBarTooltip, setShowProgressBarTooltip] = useState(false);
|
|
|
+ const [showMissionStoneModal, setShowMissionStoneModal] = useState(false);
|
|
|
+ const [energyStoneModalType, setEnergyStoneModalType] = useState<'mission' | 'conversation'>('mission');
|
|
|
+ const [hasClickedMissionStone, setHasClickedMissionStone] = useState(() => {
|
|
|
+ return getStorage(STORAGE_KEYS.HAS_CLICKED_MISSION_STONE) === 'true';
|
|
|
+ });
|
|
|
+ const [hasClickedConversationStone, setHasClickedConversationStone] = useState(() => {
|
|
|
+ return getStorage(STORAGE_KEYS.HAS_CLICKED_CONVERSATION_STONE) === 'true';
|
|
|
+ });
|
|
|
+ const [hasShownProgressBarTooltip, setHasShownProgressBarTooltip] = useState(() => {
|
|
|
+ const stored = getStorage(STORAGE_KEYS.HAS_SHOWN_PROGRESS_BAR_TOOLTIP);
|
|
|
+ return stored === 'true';
|
|
|
+ });
|
|
|
+ const progressBarTooltipTimerRef = useRef<number | null>(null);
|
|
|
+ const progressBarTooltipRef = useRef<HTMLImageElement | null>(null);
|
|
|
+ const [isAvatarCUnlocked, setIsAvatarCUnlocked] = useState(() => {
|
|
|
+ const stored = getStorage(STORAGE_KEYS.AVATAR_C_UNLOCKED);
|
|
|
+ return stored === 'true';
|
|
|
+ });
|
|
|
+ const avatarCUnlockedRef = useRef(isAvatarCUnlocked);
|
|
|
+ useEffect(() => {
|
|
|
+ avatarCUnlockedRef.current = isAvatarCUnlocked;
|
|
|
+ }, [isAvatarCUnlocked]);
|
|
|
+ const totalSpeechDurationRef = useRef<number>(
|
|
|
+ isAvatarCUnlocked ? AVATAR_C_UNLOCK_THRESHOLD_MS : 0
|
|
|
+ );
|
|
|
+
|
|
|
+ // 控制声纹容器高度(延迟降低,等待衰减动画完成)
|
|
|
+ const [shouldExpandWaveform, setShouldExpandWaveform] = useState(false);
|
|
|
+ const waveformCollapseTimerRef = useRef<number | null>(null);
|
|
|
+ const WAVEFORM_HEIGHT_PX = 40;
|
|
|
+ const waveformHeightStyle = '40PX'; // 始终保持 40PX,不随屏幕尺寸变化
|
|
|
+ const waveformHeightPx = WAVEFORM_HEIGHT_PX;
|
|
|
+
|
|
|
+ // 用于动画的显示进度(平滑过渡)
|
|
|
+ const [displayProgress, setDisplayProgress] = useState(0);
|
|
|
+ const animationFrameRef = useRef<number>();
|
|
|
+ const totalProgressRef = useRef(0);
|
|
|
+ // 默认置为 true,避免组件初次挂载或路由返回时尚未完成数据同步就触发历史进度动画。
|
|
|
+ const shouldSkipProgressAnimationRef = useRef(true);
|
|
|
+ // 保存上一次的进度状态,用于计算动画起始值
|
|
|
+ const prevProgressStateRef = useRef<{ progress: number; loopCount: number }>({
|
|
|
+ progress: 0,
|
|
|
+ loopCount: 0,
|
|
|
+ });
|
|
|
+
|
|
|
+ const recordingTimerRef = useRef<number | null>(null);
|
|
|
+ const recordingDeadlineRef = useRef<number>(0);
|
|
|
+ const handleSendRef = useRef<() => void>();
|
|
|
+ const [remainingTimeMs, setRemainingTimeMs] = useState<number | null>(null);
|
|
|
+
|
|
|
+ const pushFrequencyFrame = useCallback((frame: FrequencyFrame) => {
|
|
|
+ audioWaveRef.current?.pushFrequencyFrame(frame.frequency);
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ const resetFrequencyVisualization = useCallback(() => {
|
|
|
+ audioWaveRef.current?.pushFrequencyFrame(null);
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ // V2: 完全计算式布局系统
|
|
|
+ const [layout, setLayout] = useState<CalculatedLayout>(() => {
|
|
|
+ const initialLayout = calculateLayout();
|
|
|
+ return initialLayout;
|
|
|
+ });
|
|
|
+ // GIF 切换定时器
|
|
|
+ const emotionGifTimerRef = useRef<number | null>(null);
|
|
|
+ const handleAvatarClick = useCallback(async () => {
|
|
|
+ if (!isAvatarCUnlocked) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const isLoggedIn = getStorage(STORAGE_KEYS.IS_LOGGED_IN) === 'true';
|
|
|
+ if (isLoggedIn) {
|
|
|
+ // 已登录:跳转到 profile 界面
|
|
|
+ navigate('/profile');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ // 未登录:根据用户等级跳转
|
|
|
+ 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;
|
|
|
+ }
|
|
|
+ navigate('/level34-welcome');
|
|
|
+ }, [isAvatarCUnlocked, navigate, userLevelValue]);
|
|
|
+ const handleAvatarKeyDown = useCallback(
|
|
|
+ (event: KeyboardEvent<HTMLDivElement>) => {
|
|
|
+ if (event.key === 'Enter' || event.key === ' ') {
|
|
|
+ event.preventDefault();
|
|
|
+ handleAvatarClick();
|
|
|
+ }
|
|
|
+ },
|
|
|
+ [handleAvatarClick],
|
|
|
+ );
|
|
|
+
|
|
|
+ const timingRef = useRef({
|
|
|
+ recordStart: 0,
|
|
|
+ recordEnd: 0,
|
|
|
+ firstAudioReceived: 0,
|
|
|
+ emotionReceived: 0,
|
|
|
+ });
|
|
|
+ // 使用 Web Animations API 驱动飞行动画,避免部分端 CSS 动画被覆盖
|
|
|
+ const runStoneAnimation = useCallback(
|
|
|
+ (container: HTMLElement | null, dx: number, dy: number) => {
|
|
|
+ if (!container) return;
|
|
|
+ const img = container.querySelector('.energy-stone') as HTMLElement | null;
|
|
|
+ const keyframes: Keyframe[] = [
|
|
|
+ {
|
|
|
+ transform: 'translate(-50%, -50%) translate3d(0, 0, 0)',
|
|
|
+ opacity: 1,
|
|
|
+ offset: 0,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ transform: `translate(-50%, -50%) translate3d(${dx}px, ${dy}px, 0)`,
|
|
|
+ opacity: 1,
|
|
|
+ offset: 0.85,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ transform: `translate(-50%, -50%) translate3d(${dx}px, ${dy}px, 0)`,
|
|
|
+ opacity: 0,
|
|
|
+ offset: 1,
|
|
|
+ },
|
|
|
+ ];
|
|
|
+ const timing: KeyframeAnimationOptions = {
|
|
|
+ duration: 1400,
|
|
|
+ easing: 'ease-in-out',
|
|
|
+ fill: 'forwards',
|
|
|
+ };
|
|
|
+ try {
|
|
|
+ container.animate(keyframes, timing);
|
|
|
+ if (img) {
|
|
|
+ img.animate(
|
|
|
+ [
|
|
|
+ { transform: 'translate(-50%, -50%)', opacity: 1, offset: 0 },
|
|
|
+ { transform: 'translate(-50%, -50%)', opacity: 1, offset: 0.85 },
|
|
|
+ { transform: 'translate(-50%, -50%)', opacity: 0, offset: 1 },
|
|
|
+ ],
|
|
|
+ timing
|
|
|
+ );
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ // 如果 WAAPI 不可用,忽略,保留 CSS 动画兜底
|
|
|
+ console.warn('[EnergyStone] WAAPI animate failed, fallback to CSS animation', error);
|
|
|
+ }
|
|
|
+ },
|
|
|
+ []
|
|
|
+ );
|
|
|
+ // 当前形象ID(支持后端返回/本地缓存)
|
|
|
+ const [currentAvatarId, setCurrentAvatarId] = useState<string | null>(() => {
|
|
|
+ return getStorage(STORAGE_KEYS.CURRENT_AVATAR_ID);
|
|
|
+ });
|
|
|
+ const [avatarGifSets, setAvatarGifSets] = useState<Record<string, AvatarGifSet>>({
|
|
|
+ [AVATAR_ID_DEFAULT]: {
|
|
|
+ default: FALLBACK_DEFAULT_AVATAR_GIF,
|
|
|
+ positive: FALLBACK_POSITIVE_EMOTION_GIF,
|
|
|
+ negative: FALLBACK_NEGATIVE_EMOTION_GIF,
|
|
|
+ talking: FALLBACK_TALKING_GIF,
|
|
|
+ },
|
|
|
+ [AVATAR_ID_REGISTERED]: {
|
|
|
+ default: FALLBACK_REGISTERED_DEFAULT_AVATAR_GIF,
|
|
|
+ positive: FALLBACK_REGISTERED_POSITIVE_EMOTION_GIF,
|
|
|
+ negative: FALLBACK_REGISTERED_NEGATIVE_EMOTION_GIF,
|
|
|
+ talking: FALLBACK_REGISTERED_TALKING_GIF,
|
|
|
+ },
|
|
|
+ });
|
|
|
+
|
|
|
+ const resolveAvatarGifSet = useCallback(
|
|
|
+ (avatarId: string | null | undefined): AvatarGifSet => {
|
|
|
+ const fallback = avatarGifSets[AVATAR_ID_DEFAULT];
|
|
|
+ if (!avatarId) {
|
|
|
+ console.log(`[resolveAvatarGifSet] avatarId 为 null,返回默认形象`);
|
|
|
+ return fallback;
|
|
|
+ }
|
|
|
+ const set = avatarGifSets[avatarId];
|
|
|
+ const result = {
|
|
|
+ default: set?.default || fallback.default,
|
|
|
+ positive: set?.positive || fallback.positive,
|
|
|
+ negative: set?.negative || fallback.negative,
|
|
|
+ talking: set?.talking || fallback.talking,
|
|
|
+ };
|
|
|
+ console.log(`[resolveAvatarGifSet] avatarId=${avatarId}, set存在=${!!set}, result=`, result);
|
|
|
+ return result;
|
|
|
+ },
|
|
|
+ [avatarGifSets],
|
|
|
+ );
|
|
|
+ const avatarGifSet = useMemo(() => {
|
|
|
+ const gifSet = resolveAvatarGifSet(currentAvatarId);
|
|
|
+ console.log(`[avatarGifSet] currentAvatarId=${currentAvatarId}, gifSet=`, gifSet);
|
|
|
+ return gifSet;
|
|
|
+ }, [currentAvatarId, resolveAvatarGifSet]);
|
|
|
+ const [currentAvatarGif, setCurrentAvatarGif] = useState<string>(() => {
|
|
|
+ const storedId = getStorage(STORAGE_KEYS.CURRENT_AVATAR_ID);
|
|
|
+ return resolveAvatarGifSet(storedId).default;
|
|
|
+ });
|
|
|
+ const setGifByState = useCallback(
|
|
|
+ (state: AvatarState) => {
|
|
|
+ // 实时获取最新的 avatarGifSet,避免闭包问题
|
|
|
+ const latestAvatarId = getStorage(STORAGE_KEYS.CURRENT_AVATAR_ID);
|
|
|
+ // 使用最新的 avatarGifSets 状态来计算 GIF
|
|
|
+ const fallback = avatarGifSets[AVATAR_ID_DEFAULT];
|
|
|
+ let latestGifSet: AvatarGifSet;
|
|
|
+ if (!latestAvatarId) {
|
|
|
+ latestGifSet = fallback;
|
|
|
+ } else {
|
|
|
+ const set = avatarGifSets[latestAvatarId];
|
|
|
+ latestGifSet = {
|
|
|
+ default: set?.default || fallback.default,
|
|
|
+ positive: set?.positive || fallback.positive,
|
|
|
+ negative: set?.negative || fallback.negative,
|
|
|
+ talking: set?.talking || fallback.talking,
|
|
|
+ };
|
|
|
+ }
|
|
|
+ const nextGif = latestGifSet[state] || latestGifSet.default;
|
|
|
+ console.log(`[setGifByState] state=${state}, currentAvatarId=${currentAvatarId}, latestAvatarId=${latestAvatarId}, nextGif=${nextGif}`);
|
|
|
+ setCurrentAvatarGif(nextGif);
|
|
|
+ },
|
|
|
+ [avatarGifSet, currentAvatarId, avatarGifSets],
|
|
|
+ );
|
|
|
+ const showDefaultGif = useCallback(() => setGifByState('default'), [setGifByState]);
|
|
|
+ const showPositiveGif = useCallback(() => setGifByState('positive'), [setGifByState]);
|
|
|
+ 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 () => {
|
|
|
+ try {
|
|
|
+ const res = await getAvatarGifs();
|
|
|
+ if (!res.success || !res.data) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ setAvatarGifSets((prev) => {
|
|
|
+ const next = { ...prev };
|
|
|
+ const applyPartial = (key: string, partial?: Partial<AvatarGifSet>) => {
|
|
|
+ if (!partial) return;
|
|
|
+ const base = next[key] || prev[AVATAR_ID_DEFAULT];
|
|
|
+ next[key] = {
|
|
|
+ default: partial.default || base.default,
|
|
|
+ positive: partial.positive || base.positive,
|
|
|
+ negative: partial.negative || base.negative,
|
|
|
+ talking: partial.talking || base.talking,
|
|
|
+ };
|
|
|
+ };
|
|
|
+ applyPartial(AVATAR_ID_DEFAULT, res.data.avatar_default);
|
|
|
+ applyPartial(AVATAR_ID_REGISTERED, res.data.avatar_registered_cap);
|
|
|
+ return next;
|
|
|
+ });
|
|
|
+ } catch (error) {
|
|
|
+ // 静默降级使用兜底
|
|
|
+ console.warn('获取头像 GIF 配置失败,使用兜底资源', error);
|
|
|
+ }
|
|
|
+ };
|
|
|
+ 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) => {
|
|
|
+ if (event.key === STORAGE_KEYS.CURRENT_AVATAR_ID) {
|
|
|
+ setCurrentAvatarId(event.newValue);
|
|
|
+ }
|
|
|
+ if (event.key === STORAGE_KEYS.IS_LOGGED_IN && event.newValue === 'false') {
|
|
|
+ // 游客回退默认形象
|
|
|
+ setCurrentAvatarId(AVATAR_ID_DEFAULT);
|
|
|
+ }
|
|
|
+ };
|
|
|
+ window.addEventListener('storage', handleStorage);
|
|
|
+ return () => {
|
|
|
+ window.removeEventListener('storage', handleStorage);
|
|
|
+ };
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ // 监听路由变化,同步 localStorage 中的 currentAvatarId(处理从其他页面返回的情况)
|
|
|
+ useEffect(() => {
|
|
|
+ if (location.pathname === '/chatbot') {
|
|
|
+ const storedAvatarId = getStorage(STORAGE_KEYS.CURRENT_AVATAR_ID);
|
|
|
+ // 只有当 localStorage 中的值与当前状态不一致时才更新
|
|
|
+ if (storedAvatarId !== currentAvatarId) {
|
|
|
+ console.log(`[ChatBotPage] 检测到 localStorage 中的形象ID变化: ${currentAvatarId} -> ${storedAvatarId}`);
|
|
|
+ setCurrentAvatarId(storedAvatarId);
|
|
|
+ // 立即更新显示的 GIF,使用最新的 avatarGifSets
|
|
|
+ const newGifSet = resolveAvatarGifSet(storedAvatarId);
|
|
|
+ console.log(`[ChatBotPage] 更新显示的 GIF: ${newGifSet.default}`);
|
|
|
+ setCurrentAvatarGif(newGifSet.default);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
+ }, [location.pathname]);
|
|
|
+ // 当形象变化时立即更新显示 GIF
|
|
|
+ useEffect(() => {
|
|
|
+ showDefaultGif();
|
|
|
+ }, [avatarGifSet, showDefaultGif]);
|
|
|
+
|
|
|
+ const syncUserLevel = useCallback((level: number) => {
|
|
|
+ setUserLevel(level);
|
|
|
+ setUserLevelValue(level);
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ const unlockAvatarC = useCallback(() => {
|
|
|
+ if (avatarCUnlockedRef.current) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ avatarCUnlockedRef.current = true;
|
|
|
+ setIsAvatarCUnlocked(true);
|
|
|
+ setStorage(STORAGE_KEYS.AVATAR_C_UNLOCKED, 'true');
|
|
|
+
|
|
|
+ const userId = getUserId();
|
|
|
+ if (userId) {
|
|
|
+ updateUserUIState(userId, {
|
|
|
+ avatar_c_unlocked: true,
|
|
|
+ }).catch(() => {
|
|
|
+ // 同步失败不影响主流程
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }, []);
|
|
|
+ const registerSpeechDuration = useCallback(
|
|
|
+ (durationMs: number) => {
|
|
|
+ if (durationMs <= 0) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ totalSpeechDurationRef.current += durationMs;
|
|
|
+ if (
|
|
|
+ !avatarCUnlockedRef.current &&
|
|
|
+ totalSpeechDurationRef.current >= AVATAR_C_UNLOCK_THRESHOLD_MS
|
|
|
+ ) {
|
|
|
+ unlockAvatarC();
|
|
|
+ }
|
|
|
+ },
|
|
|
+ [unlockAvatarC]
|
|
|
+ );
|
|
|
+
|
|
|
+ const emitStoneEvent = useCallback(
|
|
|
+ (stoneId: string, event: 'click' | 'timeout', position: EnergyStoneEventPayload) => {
|
|
|
+ if (!wsRef.current) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ wsRef.current.send({
|
|
|
+ type: 'energy_stone_event',
|
|
|
+ requestId: stoneId,
|
|
|
+ stoneId,
|
|
|
+ event,
|
|
|
+ clientTs: Date.now(),
|
|
|
+ position,
|
|
|
+ });
|
|
|
+ },
|
|
|
+ []
|
|
|
+ );
|
|
|
+
|
|
|
+ const resolveStoneSize = useCallback(
|
|
|
+ (_stoneType: 'mission' | 'conversation') => {
|
|
|
+ // 设计稿统一尺寸 48px,基于 vw/vh 最小值
|
|
|
+ return 48;
|
|
|
+ },
|
|
|
+ []
|
|
|
+ );
|
|
|
+
|
|
|
+ // 创建一个隐藏的容器元素用于 useEnergyStones hook 计算位置
|
|
|
+ const hiddenEnergyStoneZoneRef = useRef<HTMLDivElement>(null);
|
|
|
+
|
|
|
+ const { stones: energyStones, spawnStone, handleStoneClick } = useEnergyStones(hiddenEnergyStoneZoneRef, {
|
|
|
+ onEvent: emitStoneEvent,
|
|
|
+ resolveStoneSize,
|
|
|
+ });
|
|
|
+
|
|
|
+ // 当能量石开始消失时,设置动画的起点和终点(处理超时自动消失的情况)
|
|
|
+ useEffect(() => {
|
|
|
+ const timers: number[] = [];
|
|
|
+ const resetAnimation = (element?: HTMLElement | null) => {
|
|
|
+ if (!element) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ element.style.animation = 'none';
|
|
|
+ // 强制重排,确保重新读取起点坐标
|
|
|
+ void element.offsetWidth;
|
|
|
+ element.style.animation = '';
|
|
|
+ };
|
|
|
+
|
|
|
+ energyStones.forEach((stone) => {
|
|
|
+ const container = document.querySelector(
|
|
|
+ `[data-stone-id="${stone.stoneId}"]`
|
|
|
+ ) as HTMLElement | null;
|
|
|
+
|
|
|
+ // 非消失状态,移除就绪标记,避免错误启动动画
|
|
|
+ if (!stone.fading) {
|
|
|
+ container?.removeAttribute('data-anim-ready');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 使用 setTimeout 确保 DOM 已更新
|
|
|
+ const timer = window.setTimeout(() => {
|
|
|
+ const target = document.querySelector(
|
|
|
+ `[data-stone-id="${stone.stoneId}"]`
|
|
|
+ ) as HTMLElement | null;
|
|
|
+
|
|
|
+ if (!target) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const rect = target.getBoundingClientRect();
|
|
|
+ const startLeft = rect.left + rect.width / 2; // 中心点 x 坐标(像素)
|
|
|
+ const startTop = rect.top + rect.height / 2; // 中心点 y 坐标(像素)
|
|
|
+
|
|
|
+ // 计算终点位置(动画形象中心,距离顶部60%位置)
|
|
|
+ const viewportWidth = window.innerWidth;
|
|
|
+ const viewportHeight = window.innerHeight;
|
|
|
+
|
|
|
+ // 计算终点 top 位置(像素)
|
|
|
+ const endTopPx = Math.min(
|
|
|
+ (452 * Math.min(viewportWidth, 600) / 375),
|
|
|
+ (452 / 812) * viewportHeight
|
|
|
+ );
|
|
|
+ const endLeft = viewportWidth / 2; // 50% 位置(像素)
|
|
|
+
|
|
|
+ // 计算位移量(像素)
|
|
|
+ const dx = endLeft - startLeft;
|
|
|
+ const dy = endTopPx - startTop;
|
|
|
+
|
|
|
+ // 设置 CSS 变量,用于动画(使用像素值)
|
|
|
+ target.style.setProperty('--animation-start-left', `${startLeft}px`);
|
|
|
+ target.style.setProperty('--animation-start-top', `${startTop}px`);
|
|
|
+ target.style.setProperty('--animation-end-left', `${endLeft}px`);
|
|
|
+ target.style.setProperty('--animation-end-top', `${endTopPx}px`);
|
|
|
+ target.style.setProperty('--animation-dx', `${dx}px`);
|
|
|
+ target.style.setProperty('--animation-dy', `${dy}px`);
|
|
|
+
|
|
|
+ // 强制写入动画样式,避免某些终端上样式覆盖/未生效
|
|
|
+ target.style.left = `${startLeft}px`;
|
|
|
+ target.style.top = `${startTop}px`;
|
|
|
+ target.style.transform = 'translate(-50%, -50%) translate3d(0, 0, 0)';
|
|
|
+ target.style.animation = 'energyStoneMoveToAvatar 1s ease-in-out forwards';
|
|
|
+ target.style.willChange = 'transform, opacity';
|
|
|
+ target.style.position = 'fixed';
|
|
|
+ target.style.pointerEvents = 'none';
|
|
|
+
|
|
|
+ runStoneAnimation(target, dx, dy);
|
|
|
+
|
|
|
+ console.log('[EnergyStone][fade] set anim vars', {
|
|
|
+ stoneId: stone.stoneId,
|
|
|
+ fading: stone.fading,
|
|
|
+ viewportWidth,
|
|
|
+ viewportHeight,
|
|
|
+ startLeft,
|
|
|
+ startTop,
|
|
|
+ endLeft,
|
|
|
+ endTopPx,
|
|
|
+ dx,
|
|
|
+ dy,
|
|
|
+ rect,
|
|
|
+ });
|
|
|
+
|
|
|
+ // 标记动画就绪,允许 CSS 启动
|
|
|
+ target.setAttribute('data-anim-ready', 'true');
|
|
|
+
|
|
|
+ // 打印当前 animationName,确认真正生效的动画
|
|
|
+ const computed = window.getComputedStyle(target);
|
|
|
+ console.log('[EnergyStone][fade] computed animation', {
|
|
|
+ stoneId: stone.stoneId,
|
|
|
+ animationName: computed.animationName,
|
|
|
+ animationDuration: computed.animationDuration,
|
|
|
+ animationTimingFunction: computed.animationTimingFunction,
|
|
|
+ });
|
|
|
+
|
|
|
+ // 重新启动动画,确保使用更新后的起点
|
|
|
+ resetAnimation(target);
|
|
|
+ resetAnimation(target.querySelector('.energy-stone'));
|
|
|
+ resetAnimation(target.querySelector('.energy-stone-hint'));
|
|
|
+ }, 0);
|
|
|
+
|
|
|
+ timers.push(timer);
|
|
|
+ });
|
|
|
+
|
|
|
+ return () => {
|
|
|
+ timers.forEach((timer) => {
|
|
|
+ window.clearTimeout(timer);
|
|
|
+ });
|
|
|
+ };
|
|
|
+ }, [energyStones, runStoneAnimation]);
|
|
|
+
|
|
|
+ // 监听能量石容器动画事件,辅助调试不同设备上的动画表现
|
|
|
+ useEffect(() => {
|
|
|
+ const handleAnimationEvent = (event: AnimationEvent) => {
|
|
|
+ const target = event.target as HTMLElement | null;
|
|
|
+ if (!target) return;
|
|
|
+ if (!target.classList.contains('energy-stone-container')) return;
|
|
|
+
|
|
|
+ const stoneId = target.getAttribute('data-stone-id') ?? 'unknown';
|
|
|
+ console.log('[EnergyStone][animation]', {
|
|
|
+ phase: event.type,
|
|
|
+ animationName: event.animationName,
|
|
|
+ stoneId,
|
|
|
+ fadingAttr: target.getAttribute('data-fading'),
|
|
|
+ animReadyAttr: target.getAttribute('data-anim-ready'),
|
|
|
+ });
|
|
|
+ };
|
|
|
+
|
|
|
+ document.addEventListener('animationstart', handleAnimationEvent);
|
|
|
+ document.addEventListener('animationend', handleAnimationEvent);
|
|
|
+
|
|
|
+ return () => {
|
|
|
+ document.removeEventListener('animationstart', handleAnimationEvent);
|
|
|
+ document.removeEventListener('animationend', handleAnimationEvent);
|
|
|
+ };
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ // 能量石展示区域:宽高 & 位置按设计稿 375×812 固定,以 px2vw/px2vh/px2vmin 适配
|
|
|
+
|
|
|
+ const stopRecordingTimer = useCallback((options?: { preserveCountdown?: boolean }) => {
|
|
|
+ if (recordingTimerRef.current) {
|
|
|
+ clearInterval(recordingTimerRef.current);
|
|
|
+ recordingTimerRef.current = null;
|
|
|
+ }
|
|
|
+ recordingDeadlineRef.current = 0;
|
|
|
+ if (!options?.preserveCountdown) {
|
|
|
+ setRemainingTimeMs(null);
|
|
|
+ }
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ const startRecordingTimer = useCallback(() => {
|
|
|
+ stopRecordingTimer();
|
|
|
+ recordingDeadlineRef.current = Date.now() + MAX_RECORDING_DURATION_MS;
|
|
|
+ setRemainingTimeMs(MAX_RECORDING_DURATION_MS);
|
|
|
+ recordingTimerRef.current = window.setInterval(() => {
|
|
|
+ const remaining = recordingDeadlineRef.current - Date.now();
|
|
|
+ if (remaining <= 0) {
|
|
|
+ setRemainingTimeMs(0);
|
|
|
+ stopRecordingTimer({ preserveCountdown: true });
|
|
|
+ if (useAppStore.getState().currentState === 'recording') {
|
|
|
+ handleSendRef.current?.();
|
|
|
+ }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ setRemainingTimeMs(remaining);
|
|
|
+ }, 250);
|
|
|
+ }, [stopRecordingTimer]);
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ return () => {
|
|
|
+ stopRecordingTimer();
|
|
|
+ };
|
|
|
+ }, [stopRecordingTimer]);
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ if (currentState !== 'recording') {
|
|
|
+ stopRecordingTimer();
|
|
|
+ }
|
|
|
+ }, [currentState, stopRecordingTimer]);
|
|
|
+
|
|
|
+ const formatCountdown = (ms: number) => {
|
|
|
+ const totalSeconds = Math.max(0, Math.ceil(ms / 1000));
|
|
|
+ const minutes = Math.floor(totalSeconds / 60);
|
|
|
+ const seconds = totalSeconds % 60;
|
|
|
+ return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
|
|
+ };
|
|
|
+
|
|
|
+ // 进度动画效果(支持多圈连续动画)
|
|
|
+ useEffect(() => {
|
|
|
+ const targetTotal = progressLoopCount * 100 + conversationProgress;
|
|
|
+
|
|
|
+ // 🔍 日志:记录 useEffect 触发时的状态
|
|
|
+ console.log('[Progress] [useEffect] 触发:', {
|
|
|
+ conversationProgress,
|
|
|
+ progressLoopCount,
|
|
|
+ targetTotal,
|
|
|
+ prevProgressStateRef: { ...prevProgressStateRef.current },
|
|
|
+ totalProgressRef: totalProgressRef.current,
|
|
|
+ displayProgress,
|
|
|
+ shouldSkipAnimation: shouldSkipProgressAnimationRef.current,
|
|
|
+ hasActiveAnimation: !!animationFrameRef.current,
|
|
|
+ });
|
|
|
+
|
|
|
+ // 🔧 如果 shouldSkipProgressAnimation 为 true,说明处于初始化/同步阶段
|
|
|
+ // 直接同步 ref 并返回,避免播放历史动画。标志由后续用户动作(如 handleSend)再置为 false。
|
|
|
+ if (shouldSkipProgressAnimationRef.current) {
|
|
|
+ console.log('[Progress] [useEffect] 初始化/同步阶段,跳过动画并同步 ref');
|
|
|
+ if (animationFrameRef.current) {
|
|
|
+ cancelAnimationFrame(animationFrameRef.current);
|
|
|
+ animationFrameRef.current = undefined;
|
|
|
+ }
|
|
|
+ prevProgressStateRef.current = {
|
|
|
+ progress: conversationProgress,
|
|
|
+ loopCount: progressLoopCount,
|
|
|
+ };
|
|
|
+ totalProgressRef.current = targetTotal;
|
|
|
+ const normalized = ((targetTotal % 100) + 100) % 100;
|
|
|
+ setDisplayProgress(normalized);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 使用上一次保存的状态值作为起始值(确保动画起始值正确)
|
|
|
+ // 这样可以避免 displayProgress 异步更新导致的时序问题
|
|
|
+ let prevTotal = prevProgressStateRef.current.loopCount * 100 + prevProgressStateRef.current.progress;
|
|
|
+
|
|
|
+ // 立即保存当前状态到 prevProgressStateRef(供下次动画使用)
|
|
|
+ // 这样即使本次动画还没完成,新的状态更新也能正确工作
|
|
|
+ const currentState = {
|
|
|
+ progress: conversationProgress,
|
|
|
+ loopCount: progressLoopCount,
|
|
|
+ };
|
|
|
+
|
|
|
+ // 如果 prevTotal 是初始值 0,但目标值已经大于 0,说明是首次使用或状态被重置
|
|
|
+ // 此时应该使用 totalProgressRef.current 或 displayProgress 作为起始值
|
|
|
+ // 但如果 shouldSkipProgressAnimation 为 true,说明是初始化,应该直接使用目标值作为起始值
|
|
|
+ if (prevTotal === 0 && targetTotal > 0) {
|
|
|
+ if (shouldSkipProgressAnimationRef.current) {
|
|
|
+ // 初始化场景:直接使用目标值作为起始值,避免播放动画
|
|
|
+ console.log('[Progress] [useEffect] 初始化场景,使用目标值作为起始值,跳过动画');
|
|
|
+ prevTotal = targetTotal;
|
|
|
+ prevProgressStateRef.current = currentState;
|
|
|
+ totalProgressRef.current = targetTotal;
|
|
|
+ } else {
|
|
|
+ console.log('[Progress] [useEffect] 检测到初始值情况,使用 displayProgress 或 totalProgressRef');
|
|
|
+ // 如果 totalProgressRef 有值,使用它;否则使用 displayProgress
|
|
|
+ if (totalProgressRef.current > 0) {
|
|
|
+ prevTotal = totalProgressRef.current;
|
|
|
+ console.log('[Progress] [useEffect] 使用 totalProgressRef:', totalProgressRef.current);
|
|
|
+ } else {
|
|
|
+ prevTotal = displayProgress;
|
|
|
+ console.log('[Progress] [useEffect] 使用 displayProgress:', displayProgress);
|
|
|
+ }
|
|
|
+ // 更新 prevProgressStateRef 为当前显示的状态
|
|
|
+ prevProgressStateRef.current = {
|
|
|
+ progress: displayProgress,
|
|
|
+ loopCount: Math.floor(totalProgressRef.current / 100),
|
|
|
+ };
|
|
|
+ console.log('[Progress] [useEffect] 更新 prevProgressStateRef 为:', prevProgressStateRef.current);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 始终使用上一次保存的状态作为起始值,而不是 totalProgressRef.current
|
|
|
+ // 因为 totalProgressRef.current 可能在动画过程中,不准确
|
|
|
+ let startTotal = prevTotal;
|
|
|
+
|
|
|
+ // 如果 totalProgressRef 与上一次状态不一致,同步它
|
|
|
+ // 这样可以确保 totalProgressRef 与起始值一致,避免动画跳变
|
|
|
+ if (!shouldSkipProgressAnimationRef.current) {
|
|
|
+ const diffFromPrev = Math.abs(totalProgressRef.current - prevTotal);
|
|
|
+
|
|
|
+ console.log('[Progress] [useEffect] 检查 totalProgressRef 同步:', {
|
|
|
+ diffFromPrev,
|
|
|
+ totalProgressRef: totalProgressRef.current,
|
|
|
+ prevTotal,
|
|
|
+ needSync: diffFromPrev > 1,
|
|
|
+ });
|
|
|
+
|
|
|
+ // 如果差异较大(超过1%),说明可能有动画在进行,需要取消它并同步起始值
|
|
|
+ if (diffFromPrev > 1) {
|
|
|
+ console.log('[Progress] [useEffect] 取消旧动画并同步 totalProgressRef');
|
|
|
+ // 取消可能正在进行的动画
|
|
|
+ if (animationFrameRef.current) {
|
|
|
+ cancelAnimationFrame(animationFrameRef.current);
|
|
|
+ animationFrameRef.current = undefined;
|
|
|
+ }
|
|
|
+ // 同步 totalProgressRef 到起始值
|
|
|
+ totalProgressRef.current = startTotal;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 立即更新 prevProgressStateRef 为当前状态(供下次动画使用)
|
|
|
+ // 注意:这里保存的是本次动画的目标值,下次动画会使用这个值作为起始值
|
|
|
+ prevProgressStateRef.current = currentState;
|
|
|
+
|
|
|
+ const diff = targetTotal - startTotal;
|
|
|
+
|
|
|
+ console.log('[Progress] [useEffect] 计算动画参数:', {
|
|
|
+ startTotal,
|
|
|
+ targetTotal,
|
|
|
+ diff,
|
|
|
+ willAnimate: Math.abs(diff) >= 0.01 && !shouldSkipProgressAnimationRef.current,
|
|
|
+ });
|
|
|
+
|
|
|
+ // 如果 diff 为 0 或非常小,直接同步,不播放动画
|
|
|
+ if (Math.abs(diff) < 0.01) {
|
|
|
+ console.log('[Progress] [useEffect] 跳过动画(diff 太小):', {
|
|
|
+ diff,
|
|
|
+ startTotal,
|
|
|
+ targetTotal,
|
|
|
+ });
|
|
|
+ totalProgressRef.current = targetTotal;
|
|
|
+ const normalized = ((targetTotal % 100) + 100) % 100;
|
|
|
+ setDisplayProgress(normalized);
|
|
|
+ // prevProgressStateRef 已经在上面更新了,这里不需要再次更新
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 处理整数圈:如果 diff 接近 0 且是整数圈,播放完整一圈动画
|
|
|
+ let actualDiff = diff;
|
|
|
+ if (Math.abs(diff) < 0.1) {
|
|
|
+ // 判断是否是整数圈(目标值是 100 的整数倍)
|
|
|
+ const isIntegerLoop = Math.abs(targetTotal % 100) < 0.1;
|
|
|
+ if (isIntegerLoop && targetTotal > startTotal) {
|
|
|
+ // 整数圈增长,播放完整一圈动画
|
|
|
+ actualDiff = 100;
|
|
|
+ } else if (isIntegerLoop && targetTotal < startTotal) {
|
|
|
+ // 整数圈减少,播放完整一圈反向动画
|
|
|
+ actualDiff = -100;
|
|
|
+ } else {
|
|
|
+ // 非整数圈的微小变化,直接同步
|
|
|
+ totalProgressRef.current = targetTotal;
|
|
|
+ const normalized = ((targetTotal % 100) + 100) % 100;
|
|
|
+ setDisplayProgress(normalized);
|
|
|
+ // prevProgressStateRef 已经在上面更新了,这里不需要再次更新
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 每 1% 的进度约 8ms,限制在 0.4s~2.4s 之间
|
|
|
+ const durationPerPercent = 8;
|
|
|
+ const duration = Math.max(400, Math.min(2400, Math.abs(actualDiff) * durationPerPercent));
|
|
|
+ const startTime = performance.now();
|
|
|
+
|
|
|
+ console.log('[Progress] [useEffect] 开始动画:', {
|
|
|
+ startTotal,
|
|
|
+ targetTotal,
|
|
|
+ actualDiff,
|
|
|
+ duration,
|
|
|
+ startTime,
|
|
|
+ });
|
|
|
+
|
|
|
+ if (animationFrameRef.current) {
|
|
|
+ console.log('[Progress] [useEffect] 取消旧动画');
|
|
|
+ cancelAnimationFrame(animationFrameRef.current);
|
|
|
+ }
|
|
|
+
|
|
|
+ let animationFrameCount = 0;
|
|
|
+ const animate = (currentTime: number) => {
|
|
|
+ animationFrameCount++;
|
|
|
+ const elapsed = currentTime - startTime;
|
|
|
+ const progress = Math.min(elapsed / duration, 1);
|
|
|
+
|
|
|
+ // easeOutCubic 缓动
|
|
|
+ const easeProgress = 1 - Math.pow(1 - progress, 3);
|
|
|
+ const currentTotal = startTotal + actualDiff * easeProgress;
|
|
|
+ totalProgressRef.current = currentTotal;
|
|
|
+
|
|
|
+ const normalized = ((currentTotal % 100) + 100) % 100;
|
|
|
+ setDisplayProgress(normalized);
|
|
|
+
|
|
|
+ // 🔍 日志:每10帧记录一次动画进度
|
|
|
+ if (animationFrameCount % 10 === 0 || progress >= 1) {
|
|
|
+ console.log('[Progress] [animate] 动画进度:', {
|
|
|
+ frame: animationFrameCount,
|
|
|
+ progress: (progress * 100).toFixed(2) + '%',
|
|
|
+ currentTotal: currentTotal.toFixed(2),
|
|
|
+ normalized: normalized.toFixed(2),
|
|
|
+ elapsed: elapsed.toFixed(2) + 'ms',
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ if (progress < 1) {
|
|
|
+ animationFrameRef.current = requestAnimationFrame(animate);
|
|
|
+ } else {
|
|
|
+ // 动画完成后,同步到实际的目标值
|
|
|
+ console.log('[Progress] [animate] 动画完成:', {
|
|
|
+ finalTotal: targetTotal,
|
|
|
+ finalNormalized: ((targetTotal % 100) + 100) % 100,
|
|
|
+ totalFrames: animationFrameCount,
|
|
|
+ });
|
|
|
+ totalProgressRef.current = targetTotal;
|
|
|
+ const finalNormalized = ((targetTotal % 100) + 100) % 100;
|
|
|
+ setDisplayProgress(finalNormalized);
|
|
|
+ // prevProgressStateRef 已经在 useEffect 开始时更新了,这里不需要再次更新
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ animationFrameRef.current = requestAnimationFrame(animate);
|
|
|
+
|
|
|
+ return () => {
|
|
|
+ if (animationFrameRef.current) {
|
|
|
+ cancelAnimationFrame(animationFrameRef.current);
|
|
|
+ }
|
|
|
+ };
|
|
|
+ }, [conversationProgress, progressLoopCount]);
|
|
|
+
|
|
|
+ // 控制声纹容器高度变化
|
|
|
+ useEffect(() => {
|
|
|
+ // 清除之前的定时器
|
|
|
+ if (waveformCollapseTimerRef.current) {
|
|
|
+ clearTimeout(waveformCollapseTimerRef.current);
|
|
|
+ waveformCollapseTimerRef.current = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (currentState === 'recording' || currentState === 'playing') {
|
|
|
+ // 录音或播放时,立即展开
|
|
|
+ setShouldExpandWaveform(true);
|
|
|
+ } else {
|
|
|
+ // 其他状态(sending/idle 等),延迟 3 秒后再收起(等待衰减动画完成)
|
|
|
+ waveformCollapseTimerRef.current = window.setTimeout(() => {
|
|
|
+ setShouldExpandWaveform(false);
|
|
|
+ }, 3000); // 3秒足够让所有竖条衰减为静态高度
|
|
|
+ }
|
|
|
+
|
|
|
+ return () => {
|
|
|
+ if (waveformCollapseTimerRef.current) {
|
|
|
+ clearTimeout(waveformCollapseTimerRef.current);
|
|
|
+ }
|
|
|
+ };
|
|
|
+ }, [currentState]);
|
|
|
+
|
|
|
+ // 监听录音状态,首次显示取消提示(3秒后自动隐藏)
|
|
|
+ useEffect(() => {
|
|
|
+ if (currentState === 'recording') {
|
|
|
+ const hasShownCancel = getStorage(STORAGE_KEYS.HAS_SHOWN_CANCEL_TOOLTIP);
|
|
|
+ if (!hasShownCancel) {
|
|
|
+ setShowCancelTooltip(true);
|
|
|
+ setStorage(STORAGE_KEYS.HAS_SHOWN_CANCEL_TOOLTIP, 'true');
|
|
|
+ const userId = getUserId();
|
|
|
+ if (userId) {
|
|
|
+ updateUserUIState(userId, {
|
|
|
+ has_shown_cancel_tooltip: true,
|
|
|
+ }).catch(() => {
|
|
|
+ // 同步失败不影响主流程
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // 🔧 调试模式:注释掉自动隐藏,方便调试
|
|
|
+ // 3秒后自动隐藏
|
|
|
+ // const timer = setTimeout(() => {
|
|
|
+ // setShowCancelTooltip(false);
|
|
|
+ // }, 3000);
|
|
|
+
|
|
|
+ // return () => clearTimeout(timer);
|
|
|
+ } else {
|
|
|
+ // 已显示过,确保隐藏
|
|
|
+ setShowCancelTooltip(false);
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ // 非录音状态,隐藏提示
|
|
|
+ setShowCancelTooltip(false);
|
|
|
+ }
|
|
|
+ }, [currentState]);
|
|
|
+
|
|
|
+ // 监听录音状态,首次显示发送提示(3秒后自动隐藏)
|
|
|
+ useEffect(() => {
|
|
|
+ if (currentState === 'recording') {
|
|
|
+ const hasShownSend = getStorage(STORAGE_KEYS.HAS_SHOWN_SEND_TOOLTIP);
|
|
|
+ if (!hasShownSend) {
|
|
|
+ setShowSendTooltip(true);
|
|
|
+ setStorage(STORAGE_KEYS.HAS_SHOWN_SEND_TOOLTIP, 'true');
|
|
|
+ const userId = getUserId();
|
|
|
+ if (userId) {
|
|
|
+ updateUserUIState(userId, {
|
|
|
+ has_shown_send_tooltip: true,
|
|
|
+ }).catch(() => {
|
|
|
+ // 同步失败不影响主流程
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // 🔧 调试模式:注释掉自动隐藏,方便调试
|
|
|
+ // 3秒后自动隐藏
|
|
|
+ // const timer = setTimeout(() => {
|
|
|
+ // setShowSendTooltip(false);
|
|
|
+ // }, 3000);
|
|
|
+
|
|
|
+ // return () => clearTimeout(timer);
|
|
|
+ } else {
|
|
|
+ // 已显示过,确保隐藏
|
|
|
+ setShowSendTooltip(false);
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ // 非录音状态,隐藏提示
|
|
|
+ setShowSendTooltip(false);
|
|
|
+ }
|
|
|
+ }, [currentState]);
|
|
|
+
|
|
|
+ const dismissProgressBarTooltip = useCallback(() => {
|
|
|
+ if (progressBarTooltipTimerRef.current) {
|
|
|
+ clearTimeout(progressBarTooltipTimerRef.current);
|
|
|
+ progressBarTooltipTimerRef.current = null;
|
|
|
+ }
|
|
|
+ if (progressBarTooltipRef.current) {
|
|
|
+ progressBarTooltipRef.current.style.opacity = '0';
|
|
|
+ progressBarTooltipRef.current.style.pointerEvents = 'none';
|
|
|
+ progressBarTooltipRef.current.style.display = 'none';
|
|
|
+ }
|
|
|
+ setShowProgressBarTooltip(false);
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ if (showProgressBarTooltip && progressBarTooltipRef.current) {
|
|
|
+ progressBarTooltipRef.current.style.display = 'block';
|
|
|
+ progressBarTooltipRef.current.style.opacity = '1';
|
|
|
+ progressBarTooltipRef.current.style.pointerEvents = 'none';
|
|
|
+ }
|
|
|
+ }, [showProgressBarTooltip]);
|
|
|
+
|
|
|
+ // 监听进度条变化,首次有进度时显示进度条提示(只出现一次)
|
|
|
+ useEffect(() => {
|
|
|
+ if (
|
|
|
+ displayProgress > 0 &&
|
|
|
+ !hasShownProgressBarTooltip &&
|
|
|
+ isConnected &&
|
|
|
+ currentState !== 'recording'
|
|
|
+ ) {
|
|
|
+ setShowProgressBarTooltip(true);
|
|
|
+ setStorage(STORAGE_KEYS.HAS_SHOWN_PROGRESS_BAR_TOOLTIP, 'true');
|
|
|
+ setHasShownProgressBarTooltip(true);
|
|
|
+ const userId = getUserId();
|
|
|
+ if (userId) {
|
|
|
+ updateUserUIState(userId, {
|
|
|
+ has_shown_progress_bar_tooltip: true,
|
|
|
+ }).catch(() => {
|
|
|
+ // 同步失败不影响主流程
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }, [displayProgress, hasShownProgressBarTooltip, isConnected, currentState]);
|
|
|
+
|
|
|
+ // 进度条提示显示后自动隐藏(2 秒)
|
|
|
+ useEffect(() => {
|
|
|
+ if (!showProgressBarTooltip) {
|
|
|
+ if (progressBarTooltipTimerRef.current) {
|
|
|
+ clearTimeout(progressBarTooltipTimerRef.current);
|
|
|
+ progressBarTooltipTimerRef.current = null;
|
|
|
+ }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ progressBarTooltipTimerRef.current = window.setTimeout(() => {
|
|
|
+ setShowProgressBarTooltip(false);
|
|
|
+ progressBarTooltipTimerRef.current = null;
|
|
|
+ }, 2000);
|
|
|
+
|
|
|
+ return () => {
|
|
|
+ if (progressBarTooltipTimerRef.current) {
|
|
|
+ clearTimeout(progressBarTooltipTimerRef.current);
|
|
|
+ progressBarTooltipTimerRef.current = null;
|
|
|
+ }
|
|
|
+ };
|
|
|
+ }, [showProgressBarTooltip]);
|
|
|
+
|
|
|
+ // 录音状态下强制隐藏进度条提示,避免在用户开始录音后残留
|
|
|
+ useEffect(() => {
|
|
|
+ if (currentState === 'recording') {
|
|
|
+ dismissProgressBarTooltip();
|
|
|
+ }
|
|
|
+ }, [currentState, dismissProgressBarTooltip]);
|
|
|
+
|
|
|
+ // 监听屏幕尺寸变化
|
|
|
+ useEffect(() => {
|
|
|
+ // 初始化时更新标记
|
|
|
+ updateTooSmallFlag();
|
|
|
+
|
|
|
+ const cleanup = onScreenResize(() => {
|
|
|
+ const newLayout = calculateLayout();
|
|
|
+ setLayout(newLayout); // V2: 更新完全计算式布局
|
|
|
+
|
|
|
+ // 更新 data-too-small 标记
|
|
|
+ updateTooSmallFlag();
|
|
|
+ });
|
|
|
+
|
|
|
+ return cleanup;
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ // 同步背景图片状态的函数
|
|
|
+ const syncBackgroundImageState = useCallback(() => {
|
|
|
+ const bgUrl = getCurrentBackgroundImageUrl();
|
|
|
+ const bgId = getCurrentBackgroundImageId();
|
|
|
+ // 直接更新状态,React 会自动处理重复更新
|
|
|
+ setBackgroundImageUrl(bgUrl);
|
|
|
+ setBackgroundImageId(bgId);
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ // 初始化背景图片(从 localStorage 读取)
|
|
|
+ useEffect(() => {
|
|
|
+ const bgUrl = getCurrentBackgroundImageUrl();
|
|
|
+ const bgId = getCurrentBackgroundImageId();
|
|
|
+ if (bgUrl) {
|
|
|
+ setBackgroundImageUrl(bgUrl);
|
|
|
+ }
|
|
|
+ if (bgId) {
|
|
|
+ setBackgroundImageId(bgId);
|
|
|
+ }
|
|
|
+ if (!bgUrl || !bgId) {
|
|
|
+ // 如果没有背景图片,尝试从用户信息同步
|
|
|
+ const user_id = getUserId();
|
|
|
+ if (user_id) {
|
|
|
+ getUserInfo(user_id).then((userInfo) => {
|
|
|
+ if (userInfo.success && userInfo.data?.current_background_image_id) {
|
|
|
+ syncBackgroundImageToStorage(
|
|
|
+ userInfo.data.current_background_image_id,
|
|
|
+ userInfo.data.current_background_image_metadata
|
|
|
+ );
|
|
|
+ const newBgUrl = getCurrentBackgroundImageUrl();
|
|
|
+ const newBgId = getCurrentBackgroundImageId();
|
|
|
+ if (newBgUrl) {
|
|
|
+ setBackgroundImageUrl(newBgUrl);
|
|
|
+ }
|
|
|
+ if (newBgId) {
|
|
|
+ setBackgroundImageId(newBgId);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }).catch(() => {
|
|
|
+ // 静默处理错误
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ // 能量石点击后直接消失,不再需要监听 fading 状态
|
|
|
+
|
|
|
+ // 监听 localStorage 中背景图片的变化
|
|
|
+ useEffect(() => {
|
|
|
+ // 监听 storage 事件(其他标签页/窗口的变化)
|
|
|
+ const handleStorageChange = (e: StorageEvent) => {
|
|
|
+ if (e.key === STORAGE_KEYS.CURRENT_BACKGROUND_IMAGE_ID ||
|
|
|
+ e.key === STORAGE_KEYS.CURRENT_BACKGROUND_IMAGE_URL) {
|
|
|
+ syncBackgroundImageState();
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // 监听页面可见性变化(当页面重新获得焦点时检查)
|
|
|
+ const handleVisibilityChange = () => {
|
|
|
+ if (!document.hidden) {
|
|
|
+ syncBackgroundImageState();
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // 定期检查背景图片变化(作为后备机制,每5秒检查一次)
|
|
|
+ const intervalId = setInterval(() => {
|
|
|
+ syncBackgroundImageState();
|
|
|
+ }, 5000);
|
|
|
+
|
|
|
+ window.addEventListener('storage', handleStorageChange);
|
|
|
+ document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
|
+
|
|
|
+ return () => {
|
|
|
+ window.removeEventListener('storage', handleStorageChange);
|
|
|
+ document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
|
+ clearInterval(intervalId);
|
|
|
+ };
|
|
|
+ }, [syncBackgroundImageState]);
|
|
|
+
|
|
|
+ // 处理OAuth登录回调(QQ登录成功后直接跳转到chatbot)
|
|
|
+ useEffect(() => {
|
|
|
+ const oauth = searchParams.get('oauth');
|
|
|
+ const userId = searchParams.get('user_id');
|
|
|
+ const usernameParam = searchParams.get('username');
|
|
|
+
|
|
|
+ if (oauth === 'qq' && userId && usernameParam) {
|
|
|
+ // QQ登录成功,保存用户信息
|
|
|
+ setStorage(STORAGE_KEYS.USER_ID, userId);
|
|
|
+ setStorage(STORAGE_KEYS.USER_NAME, decodeURIComponent(usernameParam));
|
|
|
+ antMessage.success('QQ登录成功');
|
|
|
+ // 清除URL参数
|
|
|
+ navigate('/chatbot', { replace: true });
|
|
|
+ }
|
|
|
+ }, [searchParams, navigate]);
|
|
|
+
|
|
|
+ // 初始化
|
|
|
+ useEffect(() => {
|
|
|
+ // 🔧 立即设置 shouldSkipProgressAnimation = true,避免在异步加载完成前触发动画
|
|
|
+ // 这可以防止从其他页面返回时播放历史动画
|
|
|
+ shouldSkipProgressAnimationRef.current = true;
|
|
|
+ console.log('[Progress] [初始化] 组件挂载,设置 shouldSkipProgressAnimation = true');
|
|
|
+
|
|
|
+ // 记录用户当前所在页面
|
|
|
+ setStorage(STORAGE_KEYS.CURRENT_PAGE, 'chatbot');
|
|
|
+
|
|
|
+ // 记录路由访问
|
|
|
+ markRouteVisited('/chatbot');
|
|
|
+
|
|
|
+ const visited = getVisitedRoutes();
|
|
|
+ const userId = getUserId();
|
|
|
+ if (userId) {
|
|
|
+ // 从数据库加载所有用户信息到 localStorage
|
|
|
+ // 1. 先加载用户基本信息(getUserInfo)
|
|
|
+ // 2. 再加载用户 UI 状态(getUserUIState)
|
|
|
+ Promise.all([
|
|
|
+ getUserInfo(userId).catch(() => null),
|
|
|
+ getUserUIState(userId).catch(() => null),
|
|
|
+ ]).then(([userInfoResult, uiStateResult]) => {
|
|
|
+ // 加载用户基本信息到 localStorage
|
|
|
+ if (userInfoResult?.success && userInfoResult.data) {
|
|
|
+ const userInfo = userInfoResult.data;
|
|
|
+
|
|
|
+ // Lyrria_user_name
|
|
|
+ if (userInfo.username) {
|
|
|
+ setUserName(userInfo.username);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Lyrria_lumi_name
|
|
|
+ if (userInfo.bot_name) {
|
|
|
+ setStorage(STORAGE_KEYS.LUMI_NAME, userInfo.bot_name);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Lyrria_user_level
|
|
|
+ if (typeof userInfo.user_level === 'number') {
|
|
|
+ syncUserLevel(userInfo.user_level);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Lyrria_is_logged_in (根据 user_type 判断:registered 为已登录)
|
|
|
+ if (userInfo.user_type === 'registered') {
|
|
|
+ setStorage(STORAGE_KEYS.IS_LOGGED_IN, 'true');
|
|
|
+ } else {
|
|
|
+ setStorage(STORAGE_KEYS.IS_LOGGED_IN, 'false');
|
|
|
+ }
|
|
|
+
|
|
|
+ // Lyrria_current_background_image_id, Lyrria_current_background_image_url, Lyrria_current_background_image_opacity
|
|
|
+ if (userInfo.current_background_image_id) {
|
|
|
+ syncBackgroundImageToStorage(
|
|
|
+ userInfo.current_background_image_id,
|
|
|
+ userInfo.current_background_image_metadata
|
|
|
+ );
|
|
|
+ const bgUrl = getCurrentBackgroundImageUrl();
|
|
|
+ const bgId = getCurrentBackgroundImageId();
|
|
|
+ if (bgUrl) {
|
|
|
+ setBackgroundImageUrl(bgUrl);
|
|
|
+ }
|
|
|
+ if (bgId) {
|
|
|
+ setBackgroundImageId(bgId);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 初始化进度条(基于 total_speech_duration_ms)
|
|
|
+ const totalDurationMs = userInfo.total_speech_duration_ms ?? 0;
|
|
|
+ if (totalDurationMs > 0) {
|
|
|
+ const PROGRESS_FULL_DURATION_MS = 30 * 1000; // 30000ms
|
|
|
+ totalSpeechDurationRef.current = Math.max(
|
|
|
+ totalDurationMs,
|
|
|
+ avatarCUnlockedRef.current ? AVATAR_C_UNLOCK_THRESHOLD_MS : 0
|
|
|
+ );
|
|
|
+ if (totalDurationMs >= AVATAR_C_UNLOCK_THRESHOLD_MS) {
|
|
|
+ unlockAvatarC();
|
|
|
+ }
|
|
|
+ const totalProgressPercent = (totalDurationMs / PROGRESS_FULL_DURATION_MS) * 100;
|
|
|
+ const initialProgress = totalProgressPercent % 100;
|
|
|
+ const initialLoopCount = Math.floor(totalProgressPercent / 100);
|
|
|
+
|
|
|
+ console.log('[Progress] [初始化] 设置 shouldSkipProgressAnimation = true (Promise.all)', {
|
|
|
+ totalDurationMs,
|
|
|
+ totalProgressPercent,
|
|
|
+ initialProgress,
|
|
|
+ initialLoopCount,
|
|
|
+ });
|
|
|
+ // 在设置进度之前,先同步 prevProgressStateRef 和 totalProgressRef,避免动画计算错误
|
|
|
+ prevProgressStateRef.current = {
|
|
|
+ progress: initialProgress,
|
|
|
+ loopCount: initialLoopCount,
|
|
|
+ };
|
|
|
+ totalProgressRef.current = initialLoopCount * 100 + initialProgress;
|
|
|
+ shouldSkipProgressAnimationRef.current = true;
|
|
|
+ // 设置正确的圈数,而不是重置为 0
|
|
|
+ setProgress(initialProgress, initialLoopCount);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 加载用户 UI 状态到 localStorage
|
|
|
+ if (uiStateResult?.success && uiStateResult.data) {
|
|
|
+ const state = uiStateResult.data;
|
|
|
+
|
|
|
+ // Lyrria_current_avatar_id
|
|
|
+ if (state.current_avatar_id) {
|
|
|
+ setCurrentAvatarId(state.current_avatar_id);
|
|
|
+ setStorage(STORAGE_KEYS.CURRENT_AVATAR_ID, state.current_avatar_id);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Lyrria_current_page
|
|
|
+ if (state.current_page) {
|
|
|
+ setStorage(STORAGE_KEYS.CURRENT_PAGE, state.current_page);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Lyrria_visited_routes (无论是否为空数组,都从数据库同步)
|
|
|
+ if (Array.isArray(state.visited_routes)) {
|
|
|
+ setStorage(STORAGE_KEYS.VISITED_ROUTES, JSON.stringify(state.visited_routes));
|
|
|
+ } else if (state.visited_routes === null || state.visited_routes === undefined) {
|
|
|
+ // 如果数据库中是 null 或 undefined,设置为空数组
|
|
|
+ setStorage(STORAGE_KEYS.VISITED_ROUTES, JSON.stringify([]));
|
|
|
+ }
|
|
|
+
|
|
|
+ // Lyrria_has_shown_tap_to_chat
|
|
|
+ if (state.has_shown_tap_to_chat) {
|
|
|
+ setStorage(STORAGE_KEYS.HAS_SHOWN_TAP_TO_CHAT, 'true');
|
|
|
+ if (isConnected) {
|
|
|
+ setShowTapToChat(false);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Lyrria_has_shown_cancel_tooltip
|
|
|
+ if (state.has_shown_cancel_tooltip) {
|
|
|
+ setStorage(STORAGE_KEYS.HAS_SHOWN_CANCEL_TOOLTIP, 'true');
|
|
|
+ setShowCancelTooltip(false);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Lyrria_has_shown_send_tooltip
|
|
|
+ if (state.has_shown_send_tooltip) {
|
|
|
+ setStorage(STORAGE_KEYS.HAS_SHOWN_SEND_TOOLTIP, 'true');
|
|
|
+ setShowSendTooltip(false);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Lyrria_has_shown_progress_bar_tooltip
|
|
|
+ if (state.has_shown_progress_bar_tooltip) {
|
|
|
+ setStorage(STORAGE_KEYS.HAS_SHOWN_PROGRESS_BAR_TOOLTIP, 'true');
|
|
|
+ setHasShownProgressBarTooltip(true);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Lyrria_avatar_c_unlocked
|
|
|
+ if (state.avatar_c_unlocked && !avatarCUnlockedRef.current) {
|
|
|
+ avatarCUnlockedRef.current = true;
|
|
|
+ setIsAvatarCUnlocked(true);
|
|
|
+ setStorage(STORAGE_KEYS.AVATAR_C_UNLOCKED, 'true');
|
|
|
+ }
|
|
|
+
|
|
|
+ // Lyrria_has_shared_avatar (无论 true/false,都从数据库同步)
|
|
|
+ setStorage(STORAGE_KEYS.HAS_SHARED_AVATAR, state.has_shared_avatar ? 'true' : 'false');
|
|
|
+ setHasSharedAvatarState(state.has_shared_avatar);
|
|
|
+
|
|
|
+ // Lyrria_has_shown_mission_stone_modal
|
|
|
+ if (state.has_shown_mission_stone_modal) {
|
|
|
+ setStorage(STORAGE_KEYS.HAS_SHOWN_MISSION_STONE_MODAL, 'true');
|
|
|
+ }
|
|
|
+
|
|
|
+ // Lyrria_has_shown_conversation_stone_modal
|
|
|
+ if (state.has_shown_conversation_stone_modal) {
|
|
|
+ setStorage(STORAGE_KEYS.HAS_SHOWN_CONVERSATION_STONE_MODAL, 'true');
|
|
|
+ }
|
|
|
+
|
|
|
+ // Lyrria_has_clicked_mission_stone
|
|
|
+ if (state.has_clicked_mission_stone) {
|
|
|
+ setStorage(STORAGE_KEYS.HAS_CLICKED_MISSION_STONE, 'true');
|
|
|
+ setHasClickedMissionStone(true);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Lyrria_has_clicked_conversation_stone
|
|
|
+ if (state.has_clicked_conversation_stone) {
|
|
|
+ setStorage(STORAGE_KEYS.HAS_CLICKED_CONVERSATION_STONE, 'true');
|
|
|
+ setHasClickedConversationStone(true);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 补偿同步:如果本地记录为 true 但后端为 false,尝试补偿同步
|
|
|
+ const localMissionShown = getStorage(STORAGE_KEYS.HAS_SHOWN_MISSION_STONE_MODAL) === 'true';
|
|
|
+ if (localMissionShown && !state.has_shown_mission_stone_modal) {
|
|
|
+ updateUserUIState(userId, {
|
|
|
+ has_shown_mission_stone_modal: true,
|
|
|
+ }).catch(() => {
|
|
|
+ // 静默处理
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ const localConversationShown =
|
|
|
+ getStorage(STORAGE_KEYS.HAS_SHOWN_CONVERSATION_STONE_MODAL) === 'true';
|
|
|
+ if (localConversationShown && !state.has_shown_conversation_stone_modal) {
|
|
|
+ updateUserUIState(userId, {
|
|
|
+ has_shown_conversation_stone_modal: true,
|
|
|
+ }).catch(() => {
|
|
|
+ // 静默处理
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ const localMissionClicked = getStorage(STORAGE_KEYS.HAS_CLICKED_MISSION_STONE) === 'true';
|
|
|
+ if (localMissionClicked && !state.has_clicked_mission_stone) {
|
|
|
+ updateUserUIState(userId, {
|
|
|
+ has_clicked_mission_stone: true,
|
|
|
+ }).catch(() => {
|
|
|
+ // 静默处理
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ const localConversationClicked = getStorage(STORAGE_KEYS.HAS_CLICKED_CONVERSATION_STONE) === 'true';
|
|
|
+ if (localConversationClicked && !state.has_clicked_conversation_stone) {
|
|
|
+ updateUserUIState(userId, {
|
|
|
+ has_clicked_conversation_stone: true,
|
|
|
+ }).catch(() => {
|
|
|
+ // 静默处理
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }).catch(() => {
|
|
|
+ // 加载失败不影响主流程
|
|
|
+ console.warn('[ChatBotPage] 从数据库加载用户信息失败');
|
|
|
+ });
|
|
|
+
|
|
|
+ // 同步当前页面与已访问路由
|
|
|
+ updateUserUIState(userId, {
|
|
|
+ current_page: 'chatbot',
|
|
|
+ visited_routes: visited,
|
|
|
+ }).catch(() => {
|
|
|
+ // 同步失败不影响主流程
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ const init = async () => {
|
|
|
+ try {
|
|
|
+ // 获取用户信息并初始化进度条(已在上面 Promise.all 中处理,这里保留作为兜底)
|
|
|
+ const user_id = getUserId();
|
|
|
+ if (user_id) {
|
|
|
+ try {
|
|
|
+ const userInfo = await getUserInfo(user_id);
|
|
|
+ if (userInfo.success && userInfo.data) {
|
|
|
+ // 同步用户名到 localStorage
|
|
|
+ if (userInfo.data.username) {
|
|
|
+ setUserName(userInfo.data.username);
|
|
|
+ }
|
|
|
+ // 同步机器人名字到 localStorage
|
|
|
+ if (userInfo.data.bot_name) {
|
|
|
+ setStorage(STORAGE_KEYS.LUMI_NAME, userInfo.data.bot_name);
|
|
|
+ }
|
|
|
+ // 同步用户等级
|
|
|
+ const totalDurationMs = userInfo.data.total_speech_duration_ms ?? 0;
|
|
|
+ if (typeof userInfo.data.user_level === 'number') {
|
|
|
+ syncUserLevel(userInfo.data.user_level);
|
|
|
+ }
|
|
|
+ // 同步背景图片
|
|
|
+ if (userInfo.data.current_background_image_id) {
|
|
|
+ syncBackgroundImageToStorage(
|
|
|
+ userInfo.data.current_background_image_id,
|
|
|
+ userInfo.data.current_background_image_metadata
|
|
|
+ );
|
|
|
+ const bgUrl = getCurrentBackgroundImageUrl();
|
|
|
+ const bgId = getCurrentBackgroundImageId();
|
|
|
+ if (bgUrl) {
|
|
|
+ setBackgroundImageUrl(bgUrl);
|
|
|
+ }
|
|
|
+ if (bgId) {
|
|
|
+ setBackgroundImageId(bgId);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (totalDurationMs > 0) {
|
|
|
+ // 根据总说话时长计算初始进度(30秒=100%)
|
|
|
+ const PROGRESS_FULL_DURATION_MS = 30 * 1000; // 30000ms
|
|
|
+ totalSpeechDurationRef.current = Math.max(
|
|
|
+ totalDurationMs,
|
|
|
+ avatarCUnlockedRef.current ? AVATAR_C_UNLOCK_THRESHOLD_MS : 0
|
|
|
+ );
|
|
|
+ if (totalDurationMs >= AVATAR_C_UNLOCK_THRESHOLD_MS) {
|
|
|
+ unlockAvatarC();
|
|
|
+ }
|
|
|
+ // 计算总进度百分比和圈数
|
|
|
+ const totalProgressPercent = (totalDurationMs / PROGRESS_FULL_DURATION_MS) * 100;
|
|
|
+ const initialProgress = totalProgressPercent % 100;
|
|
|
+ const initialLoopCount = Math.floor(totalProgressPercent / 100);
|
|
|
+
|
|
|
+ console.log('[Progress] [初始化] 设置 shouldSkipProgressAnimation = true (init)', {
|
|
|
+ totalDurationMs,
|
|
|
+ totalProgressPercent,
|
|
|
+ initialProgress,
|
|
|
+ initialLoopCount,
|
|
|
+ });
|
|
|
+ // 在设置进度之前,先同步 prevProgressStateRef 和 totalProgressRef,避免动画计算错误
|
|
|
+ prevProgressStateRef.current = {
|
|
|
+ progress: initialProgress,
|
|
|
+ loopCount: initialLoopCount,
|
|
|
+ };
|
|
|
+ totalProgressRef.current = initialLoopCount * 100 + initialProgress;
|
|
|
+ shouldSkipProgressAnimationRef.current = true;
|
|
|
+ // 设置正确的圈数,而不是重置为 0
|
|
|
+ setProgress(initialProgress, initialLoopCount);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ // 获取用户信息失败不影响主流程,静默处理
|
|
|
+ console.warn('获取用户信息失败:', error);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 初始化录音器
|
|
|
+ const recorder = new AudioRecorder();
|
|
|
+ await recorder.initialize();
|
|
|
+ recorderRef.current = recorder;
|
|
|
+
|
|
|
+ // 初始化播放器
|
|
|
+ const player = new AudioPlayer();
|
|
|
+ await player.initialize();
|
|
|
+ player.onEnd(() => {
|
|
|
+ const requestId = useAppStore.getState().currentRequestId;
|
|
|
+ if (requestId) {
|
|
|
+ performanceLogger.recordPlaybackEnd(requestId);
|
|
|
+ }
|
|
|
+ // 进度条已基于用户说话时长更新,无需在播放完成后再次更新
|
|
|
+ // 清理情绪 GIF 切换定时器
|
|
|
+ if (emotionGifTimerRef.current) {
|
|
|
+ clearTimeout(emotionGifTimerRef.current);
|
|
|
+ emotionGifTimerRef.current = null;
|
|
|
+ }
|
|
|
+ // 恢复默认头像 GIF
|
|
|
+ showDefaultGif();
|
|
|
+ setState('idle');
|
|
|
+ reset();
|
|
|
+ });
|
|
|
+ playerRef.current = player;
|
|
|
+
|
|
|
+ // 初始化WebSocket(添加user_id查询参数)
|
|
|
+ const ws = new WebSocketManager();
|
|
|
+ const wsUrl = user_id ? `${WS_URL}?user_id=${encodeURIComponent(user_id)}` : WS_URL;
|
|
|
+ ws.connect(wsUrl, (connected) => {
|
|
|
+ // 如果组件已卸载,不执行任何操作
|
|
|
+ if (!isMountedRef.current) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const appState = useAppStore.getState();
|
|
|
+ const wasConnected = appState.isConnected;
|
|
|
+
|
|
|
+ setConnected(connected);
|
|
|
+
|
|
|
+ if (connected) {
|
|
|
+ // 记录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;
|
|
|
+ recorderRef.current?.stop();
|
|
|
+ resetFrequencyVisualization();
|
|
|
+ playerRef.current?.stop();
|
|
|
+ // 清理情绪 GIF 切换定时器
|
|
|
+ if (emotionGifTimerRef.current) {
|
|
|
+ clearTimeout(emotionGifTimerRef.current);
|
|
|
+ emotionGifTimerRef.current = null;
|
|
|
+ }
|
|
|
+ // 恢复默认头像 GIF
|
|
|
+ showDefaultGif();
|
|
|
+ reset();
|
|
|
+ setError(null);
|
|
|
+ }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!wasConnected) {
|
|
|
+ // 初次连接失败,静默等待自动重连
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ connectionLostRef.current = true;
|
|
|
+
|
|
|
+ if (appState.currentRequestId) {
|
|
|
+ performanceLogger.clearRequest(appState.currentRequestId, 'websocket_disconnected');
|
|
|
+ }
|
|
|
+
|
|
|
+ recorderRef.current?.stop();
|
|
|
+ resetFrequencyVisualization();
|
|
|
+ playerRef.current?.stop();
|
|
|
+
|
|
|
+ // 清理情绪 GIF 切换定时器
|
|
|
+ if (emotionGifTimerRef.current) {
|
|
|
+ clearTimeout(emotionGifTimerRef.current);
|
|
|
+ emotionGifTimerRef.current = null;
|
|
|
+ }
|
|
|
+ // 恢复默认头像 GIF
|
|
|
+ showDefaultGif();
|
|
|
+
|
|
|
+ antMessage.error('语音服务连接断开,请稍后重试');
|
|
|
+ setError('语音服务连接断开,请稍后重试');
|
|
|
+ });
|
|
|
+ ws.onMessage('app', handleMessage);
|
|
|
+ wsRef.current = ws;
|
|
|
+ } catch (error) {
|
|
|
+ antMessage.error('初始化失败,请检查麦克风权限');
|
|
|
+ setError('初始化失败');
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ init();
|
|
|
+
|
|
|
+ return () => {
|
|
|
+ // 标记组件已卸载,防止 WebSocket 回调显示错误消息
|
|
|
+ isMountedRef.current = false;
|
|
|
+ recorderRef.current?.release();
|
|
|
+ playerRef.current?.release();
|
|
|
+ wsRef.current?.disconnect();
|
|
|
+ resetFrequencyVisualization();
|
|
|
+ if (sendingTimeoutRef.current) {
|
|
|
+ clearTimeout(sendingTimeoutRef.current);
|
|
|
+ }
|
|
|
+ if (animationFrameRef.current) {
|
|
|
+ cancelAnimationFrame(animationFrameRef.current);
|
|
|
+ }
|
|
|
+ if (progressBarTooltipTimerRef.current) {
|
|
|
+ clearTimeout(progressBarTooltipTimerRef.current);
|
|
|
+ }
|
|
|
+ if (emotionGifTimerRef.current) {
|
|
|
+ clearTimeout(emotionGifTimerRef.current);
|
|
|
+ }
|
|
|
+ };
|
|
|
+ }, [resetFrequencyVisualization]);
|
|
|
+
|
|
|
+ // 处理WebSocket消息
|
|
|
+ const handleMessage = (message: Message) => {
|
|
|
+ const requestId = message.requestId || currentRequestId;
|
|
|
+ if (!requestId) {
|
|
|
+ console.warn('[ChatBotPage] 收到缺少 requestId 的消息,已忽略', message.type);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const activeRequestId = activeRequestIdRef.current;
|
|
|
+ const isCancelledRequest = cancelledRequestIdsRef.current.has(requestId);
|
|
|
+
|
|
|
+ if (isCancelledRequest) {
|
|
|
+ if (message.type === 'audio_response' && message.isLast) {
|
|
|
+ cancelledRequestIdsRef.current.delete(requestId);
|
|
|
+ }
|
|
|
+ console.log(`[${message.type}] [${requestId.substring(0, 8)}] 已取消的请求,忽略响应`);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ switch (message.type) {
|
|
|
+ case 'text_result':
|
|
|
+ // 如果当前状态是 recording,且 requestId 不匹配当前请求,说明这是旧请求的返回,直接丢弃
|
|
|
+ // 如果 activeRequestId 为 null,说明是新请求的开始,允许处理
|
|
|
+ if (activeRequestId !== null && requestId !== activeRequestId) {
|
|
|
+ console.log(`[文本结果] [${requestId.substring(0, 8)}] 请求ID不匹配,丢弃旧请求的文本结果`);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (message.isFinal) {
|
|
|
+ performanceLogger.recordFinalText(requestId, message.text, message.timestamp);
|
|
|
+ } else if (!performanceLogger.hasFirstText(requestId)) {
|
|
|
+ performanceLogger.recordFirstText(requestId, message.text, message.timestamp);
|
|
|
+ }
|
|
|
+ setRecognizedText(message.text);
|
|
|
+ break;
|
|
|
+ case 'user_level':
|
|
|
+ if (typeof message.level === 'number') {
|
|
|
+ syncUserLevel(message.level);
|
|
|
+ }
|
|
|
+ // 🔥 使用后端返回的 totalDurationMs 同步进度条,确保与数据库一致
|
|
|
+ if (typeof message.totalDurationMs === 'number' && message.totalDurationMs > 0) {
|
|
|
+ const PROGRESS_FULL_DURATION_MS = 30 * 1000; // 30000ms
|
|
|
+ // 更新本地累加器,确保与数据库一致
|
|
|
+ totalSpeechDurationRef.current = Math.max(
|
|
|
+ message.totalDurationMs,
|
|
|
+ avatarCUnlockedRef.current ? AVATAR_C_UNLOCK_THRESHOLD_MS : 0
|
|
|
+ );
|
|
|
+ // 根据总时长计算进度条位置(只显示非整数圈的进度)
|
|
|
+ const totalProgressPercent = (message.totalDurationMs / PROGRESS_FULL_DURATION_MS) * 100;
|
|
|
+ const progress = totalProgressPercent % 100;
|
|
|
+
|
|
|
+ // 🔍 检查当前状态,如果用户刚刚更新了进度,不要覆盖用户的更新
|
|
|
+ const currentState = useAppStore.getState();
|
|
|
+ const currentTotal = currentState.progressLoopCount * 100 + currentState.conversationProgress;
|
|
|
+ const newTotal = Math.floor(totalProgressPercent / 100) * 100 + progress;
|
|
|
+
|
|
|
+ console.log('[Progress] [user_level] 收到后端同步请求', {
|
|
|
+ totalDurationMs: message.totalDurationMs,
|
|
|
+ progress,
|
|
|
+ currentTotal,
|
|
|
+ newTotal,
|
|
|
+ requestId: requestId?.substring(0, 8),
|
|
|
+ shouldSync: Math.abs(currentTotal - newTotal) > 0.1, // 只有差异较大时才同步
|
|
|
+ });
|
|
|
+
|
|
|
+ // 只有在差异较大时才同步(避免覆盖用户刚刚的更新)
|
|
|
+ // 并且只在初始化时跳过动画,用户主动更新后不应该跳过动画
|
|
|
+ if (Math.abs(currentTotal - newTotal) > 0.1) {
|
|
|
+ // 计算应该保持的圈数(基于总时长)
|
|
|
+ const targetLoopCount = Math.floor(totalProgressPercent / 100);
|
|
|
+ // 只在初始化时跳过动画(如果当前进度接近0)
|
|
|
+ const isInitialSync = currentTotal < 1;
|
|
|
+ if (isInitialSync) {
|
|
|
+ console.log('[Progress] [user_level] 初始化同步,跳过动画');
|
|
|
+ shouldSkipProgressAnimationRef.current = true;
|
|
|
+ } else {
|
|
|
+ console.log('[Progress] [user_level] 同步进度,保持圈数:', targetLoopCount);
|
|
|
+ // 保持当前的圈数,不重置为0
|
|
|
+ setProgress(progress, targetLoopCount);
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ console.log('[Progress] [user_level] 差异太小,跳过同步');
|
|
|
+ }
|
|
|
+ }
|
|
|
+ break;
|
|
|
+
|
|
|
+ case 'emotion':
|
|
|
+ // 如果当前状态是 recording,且 requestId 不匹配当前请求,说明这是旧请求的返回,直接丢弃
|
|
|
+ // 如果 activeRequestId 为 null,说明是新请求的开始,允许处理
|
|
|
+ if (activeRequestId !== null && requestId !== activeRequestId) {
|
|
|
+ console.log(`[情绪] [${requestId.substring(0, 8)}] 请求ID不匹配,丢弃旧请求的情绪`);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
+ performanceLogger.recordEmotion(requestId, message.emotion, message.timestamp);
|
|
|
+ setEmotion(message.emotion);
|
|
|
+
|
|
|
+ // 获取最新状态,确保能正确判断是否正在播放
|
|
|
+ const appState = useAppStore.getState();
|
|
|
+ // 如果正在播放或发送,根据新收到的情绪更新 GIF
|
|
|
+ if (appState.currentState === 'playing' || appState.currentState === 'sending') {
|
|
|
+ const emotionCategory = getEmotionCategory(message.emotion);
|
|
|
+ if (emotionCategory === 'positive') {
|
|
|
+ // 正向情绪:先显示正向情绪 GIF,2秒后切换到 talking GIF
|
|
|
+ showPositiveGif();
|
|
|
+ if (emotionGifTimerRef.current) {
|
|
|
+ clearTimeout(emotionGifTimerRef.current);
|
|
|
+ }
|
|
|
+ emotionGifTimerRef.current = window.setTimeout(() => {
|
|
|
+ showTalkingGif();
|
|
|
+ emotionGifTimerRef.current = null;
|
|
|
+ }, 2000);
|
|
|
+ } else if (emotionCategory === 'negative') {
|
|
|
+ // 反向情绪:先显示反向情绪 GIF,5秒后切换到 talking GIF
|
|
|
+ showNegativeGif();
|
|
|
+ if (emotionGifTimerRef.current) {
|
|
|
+ clearTimeout(emotionGifTimerRef.current);
|
|
|
+ }
|
|
|
+ emotionGifTimerRef.current = window.setTimeout(() => {
|
|
|
+ showTalkingGif();
|
|
|
+ emotionGifTimerRef.current = null;
|
|
|
+ }, 5000);
|
|
|
+ } else {
|
|
|
+ // 中性情绪:直接显示 talking GIF
|
|
|
+ showTalkingGif();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ break;
|
|
|
+
|
|
|
+ case 'audio_response':
|
|
|
+ // 如果当前状态是 recording,说明用户已经点击了录音按钮,忽略后续的音频响应
|
|
|
+ if (currentState === 'recording') {
|
|
|
+ console.log(`[音频响应] [${requestId.substring(0, 8)}] 用户已开始录音,忽略音频响应`);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 如果 currentRequestId 不为 null,且 requestId 不匹配当前请求,说明这是旧请求的返回,直接丢弃
|
|
|
+ // 如果 activeRequestId 为 null,说明是新请求的开始,允许处理
|
|
|
+ if (activeRequestId !== null && requestId !== activeRequestId) {
|
|
|
+ console.log(`[音频响应] [${requestId.substring(0, 8)}] 请求ID不匹配,丢弃旧请求的音频响应`);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 清除发送超时
|
|
|
+ if (sendingTimeoutRef.current) {
|
|
|
+ clearTimeout(sendingTimeoutRef.current);
|
|
|
+ sendingTimeoutRef.current = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (currentState !== 'playing') {
|
|
|
+ performanceLogger.recordFirstAudio(requestId, message.timestamp);
|
|
|
+ performanceLogger.recordPlaybackStart(requestId);
|
|
|
+ setState('playing');
|
|
|
+
|
|
|
+ // 获取最新的情绪状态(可能情绪消息已经到达)
|
|
|
+ const latestEmotion = useAppStore.getState().currentEmotion;
|
|
|
+ // 根据情绪类型设置 GIF
|
|
|
+ const emotionCategory = getEmotionCategory(latestEmotion);
|
|
|
+ if (emotionCategory === 'positive') {
|
|
|
+ // 正向情绪:先显示正向情绪 GIF,2秒后切换到 talking GIF
|
|
|
+ showPositiveGif();
|
|
|
+ if (emotionGifTimerRef.current) {
|
|
|
+ clearTimeout(emotionGifTimerRef.current);
|
|
|
+ }
|
|
|
+ emotionGifTimerRef.current = window.setTimeout(() => {
|
|
|
+ showTalkingGif();
|
|
|
+ emotionGifTimerRef.current = null;
|
|
|
+ }, 2000);
|
|
|
+ } else if (emotionCategory === 'negative') {
|
|
|
+ // 反向情绪:先显示反向情绪 GIF,5秒后切换到 talking GIF
|
|
|
+ showNegativeGif();
|
|
|
+ if (emotionGifTimerRef.current) {
|
|
|
+ clearTimeout(emotionGifTimerRef.current);
|
|
|
+ }
|
|
|
+ emotionGifTimerRef.current = window.setTimeout(() => {
|
|
|
+ showTalkingGif();
|
|
|
+ emotionGifTimerRef.current = null;
|
|
|
+ }, 5000);
|
|
|
+ } else {
|
|
|
+ // 中性情绪或无情绪:直接显示 talking GIF
|
|
|
+ showTalkingGif();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (message.data) {
|
|
|
+ const audioData = atob(message.data);
|
|
|
+ const audioBytes = new Uint8Array(audioData.length);
|
|
|
+
|
|
|
+ for (let i = 0; i < audioData.length; i++) {
|
|
|
+ audioBytes[i] = audioData.charCodeAt(i);
|
|
|
+ }
|
|
|
+
|
|
|
+ playerRef.current?.addChunk(audioBytes, message.sampleRate || 24000);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (message.isLast) {
|
|
|
+ performanceLogger.recordLastAudio(requestId);
|
|
|
+ playerRef.current?.markStreamEnd();
|
|
|
+ // 进度已在发送时增加,这里不再重复增加
|
|
|
+ }
|
|
|
+ break;
|
|
|
+
|
|
|
+ case 'energy_stone_spawn':
|
|
|
+ handleEnergyStoneSpawn(message);
|
|
|
+ break;
|
|
|
+
|
|
|
+ case 'error':
|
|
|
+ console.error(`[错误] [${requestId?.substring(0, 8) || ''}]`, message.message, message.code);
|
|
|
+ antMessage.error(message.message);
|
|
|
+ setError(message.message);
|
|
|
+ // 清理情绪 GIF 切换定时器
|
|
|
+ if (emotionGifTimerRef.current) {
|
|
|
+ clearTimeout(emotionGifTimerRef.current);
|
|
|
+ emotionGifTimerRef.current = null;
|
|
|
+ }
|
|
|
+ // 恢复默认头像 GIF
|
|
|
+ showDefaultGif();
|
|
|
+ performanceLogger.clearRequest(requestId, `${message.code}: ${message.message}`);
|
|
|
+ break;
|
|
|
+
|
|
|
+ case 'pong':
|
|
|
+ break;
|
|
|
+
|
|
|
+ default:
|
|
|
+ console.warn(`[未知消息] [${requestId?.substring(0, 8) || ''}]`, message.type);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // 开始录音
|
|
|
+ const handleStartRecord = async () => {
|
|
|
+ // 如果正在录音,忽略
|
|
|
+ if (currentState === 'recording') {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!isConnected) {
|
|
|
+ antMessage.warning('语音服务正在重连,请稍后再试');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!wsRef.current?.isReady()) {
|
|
|
+ antMessage.warning('语音服务正在恢复,请稍后重试');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 保存旧请求ID(用于取消和过滤旧消息)
|
|
|
+ const oldRequestId = currentRequestId;
|
|
|
+ const wasPlaying = currentState === 'playing' || currentState === 'sending';
|
|
|
+
|
|
|
+ // 如果正在播放,先停止播放并重置状态
|
|
|
+ if (wasPlaying) {
|
|
|
+ // 停止音频播放
|
|
|
+ playerRef.current?.stop();
|
|
|
+
|
|
|
+ // 清理情绪 GIF 切换定时器
|
|
|
+ if (emotionGifTimerRef.current) {
|
|
|
+ clearTimeout(emotionGifTimerRef.current);
|
|
|
+ emotionGifTimerRef.current = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 立即恢复默认头像 GIF
|
|
|
+ showDefaultGif();
|
|
|
+
|
|
|
+ // 取消当前的请求(如果有),丢弃上一次请求的返回
|
|
|
+ if (oldRequestId) {
|
|
|
+ wsRef.current?.sendCancel(oldRequestId);
|
|
|
+ cancelledRequestIdsRef.current.add(oldRequestId);
|
|
|
+ performanceLogger.clearRequest(oldRequestId, '用户中断播放开始新录音');
|
|
|
+ }
|
|
|
+
|
|
|
+ // 清理旧请求的状态(丢弃上一次请求的返回)
|
|
|
+ setRecognizedText('');
|
|
|
+ setEmotion('neutral');
|
|
|
+
|
|
|
+ // 清理发送超时
|
|
|
+ if (sendingTimeoutRef.current) {
|
|
|
+ clearTimeout(sendingTimeoutRef.current);
|
|
|
+ sendingTimeoutRef.current = null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 其他状态(idle/error/sending)都允许开始录音
|
|
|
+
|
|
|
+ // 首次点击录音按钮,隐藏 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(() => {
|
|
|
+ // 同步失败不影响主流程
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 点击录音按钮时,隐藏进度条提示
|
|
|
+ dismissProgressBarTooltip();
|
|
|
+
|
|
|
+ // 🔥 清理播放器状态,确保上一次对话的残留状态被重置
|
|
|
+ playerRef.current?.stop();
|
|
|
+
|
|
|
+ // 生成新的请求ID并立即设置,确保后续消息能正确匹配
|
|
|
+ const requestId = uuidv4();
|
|
|
+ performanceLogger.startRequest(requestId);
|
|
|
+ updateRequestId(requestId);
|
|
|
+ setState('recording');
|
|
|
+ chunkIndexRef.current = 0;
|
|
|
+ setRecognizedText('');
|
|
|
+
|
|
|
+ // 准备移动端音频(在设置状态之后,避免阻塞状态更新)
|
|
|
+ if (isMobileDevice() && !mobileAudioPreparedRef.current) {
|
|
|
+ // 异步准备,不阻塞录音开始
|
|
|
+ prepareMobileAudio()
|
|
|
+ .then(() => {
|
|
|
+ requestVolumeControl();
|
|
|
+ mobileAudioPreparedRef.current = true;
|
|
|
+ antMessage.info('请确保设备音量已调高');
|
|
|
+ })
|
|
|
+ .catch(() => {
|
|
|
+ // 准备移动端音频失败,静默处理
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ timingRef.current = {
|
|
|
+ recordStart: Date.now(),
|
|
|
+ recordEnd: 0,
|
|
|
+ firstAudioReceived: 0,
|
|
|
+ emotionReceived: 0,
|
|
|
+ };
|
|
|
+
|
|
|
+ // 立即启动录音,确保第一次点击就能开始
|
|
|
+ try {
|
|
|
+ await recorderRef.current?.start((pcmData) => {
|
|
|
+ // 检查当前状态,如果已经不是recording,说明用户可能又点击了,忽略
|
|
|
+ const currentStateCheck = useAppStore.getState().currentState;
|
|
|
+ if (currentStateCheck !== 'recording') {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (chunkIndexRef.current === 0) {
|
|
|
+ performanceLogger.recordFirstChunkSent(requestId);
|
|
|
+ }
|
|
|
+ const uint8Array = new Uint8Array(pcmData.buffer);
|
|
|
+ const base64 = btoa(String.fromCharCode(...Array.from(uint8Array)));
|
|
|
+ wsRef.current?.sendAudioChunk(requestId, base64, chunkIndexRef.current);
|
|
|
+ chunkIndexRef.current++;
|
|
|
+ }, pushFrequencyFrame);
|
|
|
+
|
|
|
+ // 录音启动成功,开始计时
|
|
|
+ startRecordingTimer();
|
|
|
+ } catch (error) {
|
|
|
+ console.error(`[录音] [${requestId.substring(0, 8)}] 启动失败`, error);
|
|
|
+ antMessage.error('麦克风启动失败,请检查权限或重试');
|
|
|
+ performanceLogger.clearRequest(requestId, 'recorder_start_failed');
|
|
|
+ resetFrequencyVisualization();
|
|
|
+ // 清理情绪 GIF 切换定时器
|
|
|
+ if (emotionGifTimerRef.current) {
|
|
|
+ clearTimeout(emotionGifTimerRef.current);
|
|
|
+ emotionGifTimerRef.current = null;
|
|
|
+ }
|
|
|
+ // 恢复默认头像 GIF
|
|
|
+ showDefaultGif();
|
|
|
+ setState('idle');
|
|
|
+ reset();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // 发送录音
|
|
|
+ const handleSend = () => {
|
|
|
+ if (currentState !== 'recording' || !currentRequestId) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ stopRecordingTimer();
|
|
|
+
|
|
|
+ if (!isConnected) {
|
|
|
+ antMessage.error('语音服务未连接,请重新录音发送');
|
|
|
+ recorderRef.current?.stop();
|
|
|
+ resetFrequencyVisualization();
|
|
|
+ // 清理情绪 GIF 切换定时器
|
|
|
+ if (emotionGifTimerRef.current) {
|
|
|
+ clearTimeout(emotionGifTimerRef.current);
|
|
|
+ emotionGifTimerRef.current = null;
|
|
|
+ }
|
|
|
+ // 恢复默认头像 GIF
|
|
|
+ showDefaultGif();
|
|
|
+ performanceLogger.clearRequest(currentRequestId, 'websocket_disconnected_before_send');
|
|
|
+ reset();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!wsRef.current?.isReady()) {
|
|
|
+ antMessage.error('语音服务尚未准备完成,请重新录音发送');
|
|
|
+ recorderRef.current?.stop();
|
|
|
+ resetFrequencyVisualization();
|
|
|
+ // 清理情绪 GIF 切换定时器
|
|
|
+ if (emotionGifTimerRef.current) {
|
|
|
+ clearTimeout(emotionGifTimerRef.current);
|
|
|
+ emotionGifTimerRef.current = null;
|
|
|
+ }
|
|
|
+ // 恢复默认头像 GIF
|
|
|
+ showDefaultGif();
|
|
|
+ performanceLogger.clearRequest(currentRequestId, 'websocket_unready_before_send');
|
|
|
+ reset();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ timingRef.current.recordEnd = Date.now();
|
|
|
+ sendTimeRef.current = Date.now();
|
|
|
+ performanceLogger.recordEnd(currentRequestId, chunkIndexRef.current);
|
|
|
+
|
|
|
+ // 计算说话时长(毫秒)
|
|
|
+ const speechDuration = timingRef.current.recordEnd - timingRef.current.recordStart;
|
|
|
+
|
|
|
+ // 更新进度条(基于说话时长,30秒=100%)
|
|
|
+ if (speechDuration > 0) {
|
|
|
+ // 🔍 日志:记录更新前的状态
|
|
|
+ const beforeState = useAppStore.getState();
|
|
|
+ console.log('[Progress] [handleSend] 准备更新进度:', {
|
|
|
+ speechDurationMs: speechDuration,
|
|
|
+ beforeProgress: beforeState.conversationProgress,
|
|
|
+ beforeLoopCount: beforeState.progressLoopCount,
|
|
|
+ beforeTotal: beforeState.progressLoopCount * 100 + beforeState.conversationProgress,
|
|
|
+ prevProgressStateRef: { ...prevProgressStateRef.current },
|
|
|
+ totalProgressRef: totalProgressRef.current,
|
|
|
+ displayProgress: displayProgress,
|
|
|
+ shouldSkipAnimation: shouldSkipProgressAnimationRef.current,
|
|
|
+ });
|
|
|
+
|
|
|
+ // 🔧 确保 shouldSkipProgressAnimation 在用户主动更新时是 false
|
|
|
+ // 初始化完成后,用户主动更新进度时应该播放动画
|
|
|
+ if (shouldSkipProgressAnimationRef.current) {
|
|
|
+ console.log('[Progress] [handleSend] 检测到 shouldSkipProgressAnimation 为 true,重置为 false');
|
|
|
+ shouldSkipProgressAnimationRef.current = false;
|
|
|
+ }
|
|
|
+
|
|
|
+ updateProgressByDuration(speechDuration);
|
|
|
+ registerSpeechDuration(speechDuration);
|
|
|
+
|
|
|
+ // 🔍 日志:记录更新后的状态
|
|
|
+ const afterState = useAppStore.getState();
|
|
|
+ console.log('[Progress] [handleSend] 更新后状态:', {
|
|
|
+ afterProgress: afterState.conversationProgress,
|
|
|
+ afterLoopCount: afterState.progressLoopCount,
|
|
|
+ afterTotal: afterState.progressLoopCount * 100 + afterState.conversationProgress,
|
|
|
+ expectedIncrement: (speechDuration / 30000) * 100,
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ recorderRef.current?.stop();
|
|
|
+ resetFrequencyVisualization();
|
|
|
+
|
|
|
+ // 发送音频结束消息,包含说话时长信息
|
|
|
+ wsRef.current?.sendAudioEnd(currentRequestId, chunkIndexRef.current, speechDuration);
|
|
|
+ setState('sending');
|
|
|
+
|
|
|
+ // 设置超时保护:10秒后如果还在 sending 或 playing 状态,自动重置
|
|
|
+ if (sendingTimeoutRef.current) {
|
|
|
+ clearTimeout(sendingTimeoutRef.current);
|
|
|
+ }
|
|
|
+ sendingTimeoutRef.current = window.setTimeout(() => {
|
|
|
+ const state = useAppStore.getState().currentState;
|
|
|
+ if (state === 'sending' || state === 'playing') {
|
|
|
+ const shortId = currentRequestId ? currentRequestId.substring(0, 8) : '';
|
|
|
+ console.warn(`[超时] [${shortId}] 响应超时,自动重置`);
|
|
|
+ antMessage.warning('响应超时,请重试');
|
|
|
+ // 清理情绪 GIF 切换定时器
|
|
|
+ if (emotionGifTimerRef.current) {
|
|
|
+ clearTimeout(emotionGifTimerRef.current);
|
|
|
+ emotionGifTimerRef.current = null;
|
|
|
+ }
|
|
|
+ // 恢复默认头像 GIF
|
|
|
+ showDefaultGif();
|
|
|
+ setState('idle');
|
|
|
+ reset();
|
|
|
+ performanceLogger.clearRequest(currentRequestId, '响应超时');
|
|
|
+ }
|
|
|
+ }, 10000);
|
|
|
+ };
|
|
|
+
|
|
|
+ handleSendRef.current = handleSend;
|
|
|
+
|
|
|
+ // 取消录音
|
|
|
+ const handleCancel = () => {
|
|
|
+ if (currentState !== 'recording' || !currentRequestId) return;
|
|
|
+
|
|
|
+ const shortId = currentRequestId.substring(0, 8);
|
|
|
+ console.log(`[取消] [${shortId}] 取消录音`);
|
|
|
+ recorderRef.current?.stop();
|
|
|
+ resetFrequencyVisualization();
|
|
|
+ stopRecordingTimer();
|
|
|
+
|
|
|
+ // 清理情绪 GIF 切换定时器
|
|
|
+ if (emotionGifTimerRef.current) {
|
|
|
+ clearTimeout(emotionGifTimerRef.current);
|
|
|
+ emotionGifTimerRef.current = null;
|
|
|
+ }
|
|
|
+ // 恢复默认头像 GIF
|
|
|
+ showDefaultGif();
|
|
|
+
|
|
|
+ wsRef.current?.sendCancel(currentRequestId);
|
|
|
+ cancelledRequestIdsRef.current.add(currentRequestId);
|
|
|
+ performanceLogger.clearRequest(currentRequestId);
|
|
|
+ reset();
|
|
|
+ };
|
|
|
+
|
|
|
+ const avatarHintSize = Math.round(layout.avatarCSize * 1.8);
|
|
|
+
|
|
|
+ const currentAvatarAlt = currentState === 'playing' ? 'AnimaZone Avatar Animated' : 'AnimaZone Avatar';
|
|
|
+ const energyStoneAssets: Record<'mission' | 'conversation', string> = {
|
|
|
+ mission: missionEnergyStone,
|
|
|
+ conversation: conversationEnergyStone,
|
|
|
+ };
|
|
|
+ const energyStoneHintAssets: Record<'mission' | 'conversation', string> = {
|
|
|
+ mission: missionEnergyStoneHint,
|
|
|
+ conversation: conversationEnergyStoneHint,
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleEnergyStoneSpawn = useCallback(
|
|
|
+ (payload: EnergyStoneSpawnMessage) => {
|
|
|
+ spawnStone(payload);
|
|
|
+ },
|
|
|
+ [spawnStone]
|
|
|
+ );
|
|
|
+
|
|
|
+
|
|
|
+ // 获取用户名的首字母(英文大写,汉字保持原样)
|
|
|
+ const getUserInitial = (): string => {
|
|
|
+ const userName = getStorage(STORAGE_KEYS.USER_NAME);
|
|
|
+ if (!userName || userName.length === 0) {
|
|
|
+ return '';
|
|
|
+ }
|
|
|
+ const firstChar = userName[0];
|
|
|
+ // 判断是否为英文字母
|
|
|
+ if (/[a-zA-Z]/.test(firstChar)) {
|
|
|
+ return firstChar.toUpperCase();
|
|
|
+ }
|
|
|
+ // 汉字或其他字符保持原样
|
|
|
+ return firstChar;
|
|
|
+ };
|
|
|
+
|
|
|
+ // 根据背景图片获取对应的树木装饰图片URL
|
|
|
+ const getTreesImage = (): string | null => {
|
|
|
+ // 实时从 localStorage 读取最新的背景图片URL,使用背景图片作为树木装饰
|
|
|
+ const currentBgUrl = getCurrentBackgroundImageUrl() || backgroundImageUrl;
|
|
|
+ return currentBgUrl;
|
|
|
+ };
|
|
|
+
|
|
|
+ // 根据背景图片ID获取树木装饰图片的样式配置
|
|
|
+ const getTreesImageStyle = (): React.CSSProperties => {
|
|
|
+ const baseStyle: React.CSSProperties = {
|
|
|
+ width: '100%',
|
|
|
+ height: '100%',
|
|
|
+ top: 0,
|
|
|
+ left: 0,
|
|
|
+ objectFit: 'cover',
|
|
|
+ zIndex: 1,
|
|
|
+ };
|
|
|
+
|
|
|
+ // 从 localStorage 读取透明度配置(从数据库metadata中获取)
|
|
|
+ const opacity = getCurrentBackgroundImageOpacity();
|
|
|
+ return { ...baseStyle, opacity };
|
|
|
+ };
|
|
|
+
|
|
|
+ const userInitial = getUserInitial();
|
|
|
+
|
|
|
+ // 计算 Avatar-C 头像的显示逻辑
|
|
|
+ const isLoggedIn = getStorage(STORAGE_KEYS.IS_LOGGED_IN) === 'true';
|
|
|
+ // 图片选择:未登录或已登录且已分享 → avatarC,已登录且未分享 → avatarGift
|
|
|
+ const avatarSrc = (!isLoggedIn || hasSharedAvatar) ? avatarC : avatarGift;
|
|
|
+ // 首字母显示:未登录或已登录且已分享 → 显示,已登录且未分享 → 不显示
|
|
|
+ const shouldShowInitial = userInitial && (!isLoggedIn || hasSharedAvatar);
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ position: 'relative',
|
|
|
+ width: '100%',
|
|
|
+ minHeight: '100dvh',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {/* 树木装饰 - 根据背景图片调整图片和样式 */}
|
|
|
+ {getTreesImage() && (
|
|
|
+ <img
|
|
|
+ src={getTreesImage() || undefined}
|
|
|
+ alt="Trees"
|
|
|
+ className="chatbot-trees absolute pointer-events-none"
|
|
|
+ style={getTreesImageStyle()}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* Avatar-C 头像 - 设计稿:top 63px, right 33px, 尺寸 40×40,使用 px2vmin 等比缩放 */}
|
|
|
+ <div
|
|
|
+ className="chatbot-avatar-c absolute"
|
|
|
+ data-avatar-state={isAvatarCUnlocked ? 'unlocked' : 'locked'}
|
|
|
+ role={isAvatarCUnlocked ? 'button' : undefined}
|
|
|
+ tabIndex={isAvatarCUnlocked ? 0 : -1}
|
|
|
+ aria-label={isAvatarCUnlocked ? 'Avatar C' : undefined}
|
|
|
+ onClick={isAvatarCUnlocked ? handleAvatarClick : undefined}
|
|
|
+ onKeyDown={isAvatarCUnlocked ? handleAvatarKeyDown : undefined}
|
|
|
+ style={{
|
|
|
+ width: px2vmin(40),
|
|
|
+ height: px2vmin(40),
|
|
|
+ top: px2vmin(63),
|
|
|
+ right: px2vmin(33),
|
|
|
+ zIndex: hasSharedAvatar ? 5 : 30,
|
|
|
+ pointerEvents: isAvatarCUnlocked ? 'auto' : 'none',
|
|
|
+ cursor: isAvatarCUnlocked ? 'pointer' : 'default',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {isAvatarCUnlocked && (
|
|
|
+ <img
|
|
|
+ src={avatarHint}
|
|
|
+ alt="Avatar Hint"
|
|
|
+ className="chatbot-avatar-hint"
|
|
|
+ style={{
|
|
|
+ width: `${avatarHintSize}px`,
|
|
|
+ height: `${avatarHintSize}px`,
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ <img
|
|
|
+ src={avatarSrc}
|
|
|
+ alt="Avatar C"
|
|
|
+ className="rounded-full"
|
|
|
+ style={{
|
|
|
+ width: '100%',
|
|
|
+ height: '100%',
|
|
|
+ opacity: isAvatarCUnlocked ? 1 : 0.2,
|
|
|
+ transition: 'opacity 0.4s ease',
|
|
|
+ position: 'relative',
|
|
|
+ zIndex: hasSharedAvatar ? 1 : 30,
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ {shouldShowInitial && (
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ position: 'absolute',
|
|
|
+ top: '50%',
|
|
|
+ left: '50%',
|
|
|
+ transform: 'translate(-50%, -50%)',
|
|
|
+ backgroundImage: 'linear-gradient(180deg, #25232E 0%, #2B2A2F 100%)',
|
|
|
+ WebkitBackgroundClip: 'text',
|
|
|
+ backgroundClip: 'text',
|
|
|
+ WebkitTextFillColor: 'transparent',
|
|
|
+ color: 'transparent',
|
|
|
+ fontSize: px2vmin(16),
|
|
|
+ fontWeight: 600,
|
|
|
+ opacity: 0.9,
|
|
|
+ pointerEvents: 'none',
|
|
|
+ zIndex: 31,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {userInitial}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 隐藏的能量石容器 - 用于 useEnergyStones hook 计算位置 */}
|
|
|
+ <div
|
|
|
+ ref={hiddenEnergyStoneZoneRef}
|
|
|
+ style={{
|
|
|
+ position: 'absolute',
|
|
|
+ width: px2vmin(343), // 设计稿 343px,基于 vw/vh 最小值
|
|
|
+ height: px2vmin(172), // 设计稿 172px,基于 vw/vh 最小值
|
|
|
+ top: px2vmin(126), // 设计稿 top 126px,基于 vw/vh 最小值
|
|
|
+ left: '50%',
|
|
|
+ transform: 'translateX(-50%)',
|
|
|
+ visibility: 'hidden',
|
|
|
+ pointerEvents: 'none',
|
|
|
+ zIndex: -1,
|
|
|
+ }}
|
|
|
+ />
|
|
|
+
|
|
|
+ {/* 能量石展示区域 - 已废弃,能量石现在独立定位,不再嵌套在此容器中 */}
|
|
|
+ {/* <div
|
|
|
+ className="chatbot-energy-zone absolute"
|
|
|
+ style={{
|
|
|
+ width: px2vmin(343), // 设计稿 343px,基于 vw/vh 最小值
|
|
|
+ height: px2vmin(172), // 设计稿 172px,基于 vw/vh 最小值
|
|
|
+ top: px2vmin(126), // 设计稿 top 126px,基于 vw/vh 最小值
|
|
|
+ left: '50%',
|
|
|
+ transform: 'translateX(-50%)',
|
|
|
+ zIndex: 4,
|
|
|
+ pointerEvents: 'none',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <div className="energy-stone-layer" />
|
|
|
+ </div> */}
|
|
|
+
|
|
|
+ {/* 正式能量石 - 独立绝对定位,限制在能量石区域内 */}
|
|
|
+ {energyStones.map((stone) => {
|
|
|
+ const asset = energyStoneAssets[stone.stoneType];
|
|
|
+ const hintAsset = energyStoneHintAssets[stone.stoneType];
|
|
|
+ const stoneSize = 48; // 设计稿 48px,基于 vw/vh 最小值
|
|
|
+ const hintSize = 92; // 设计稿 92px,基于 vw/vh 最小值
|
|
|
+ const shouldShowHint = stone.stoneType === 'mission'
|
|
|
+ ? !hasClickedMissionStone
|
|
|
+ : !hasClickedConversationStone;
|
|
|
+
|
|
|
+ // 计算绝对位置:基于能量石区域的边界
|
|
|
+ // 区域:left 50% translateX(-50%), top: px2vmin(126), width: px2vmin(343), height: px2vmin(172)
|
|
|
+ // 能量石在区域内的百分比位置转换为绝对位置
|
|
|
+ // left: 50% + (xRatio - 0.5) * zoneWidth
|
|
|
+ // top: zoneTop + yRatio * zoneHeight
|
|
|
+ const zoneTop = px2vmin(126);
|
|
|
+ const zoneWidth = px2vmin(343);
|
|
|
+ const zoneHeight = px2vmin(172);
|
|
|
+ const absoluteLeft = `calc(50% + (${stone.position.xRatio} - 0.5) * ${zoneWidth})`;
|
|
|
+ const absoluteTop = `calc(${zoneTop} + ${stone.position.yRatio} * ${zoneHeight})`;
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div
|
|
|
+ key={stone.stoneId}
|
|
|
+ data-stone-id={stone.stoneId}
|
|
|
+ data-fading={stone.fading ? 'true' : 'false'}
|
|
|
+ className={`energy-stone-container ${stone.stoneType === 'mission' ? 'energy-stone-mission' : ''}`}
|
|
|
+ style={{
|
|
|
+ position: 'absolute',
|
|
|
+ left: absoluteLeft,
|
|
|
+ top: absoluteTop,
|
|
|
+ transform: 'translate(-50%, -50%)',
|
|
|
+ width: px2vmin(stoneSize),
|
|
|
+ height: px2vmin(stoneSize),
|
|
|
+ pointerEvents: 'auto',
|
|
|
+ cursor: 'pointer',
|
|
|
+ zIndex: 5,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {/* 光晕层 - 只在未点击时显示,但在动画过程中也显示以跟随能量石 */}
|
|
|
+ {shouldShowHint && (
|
|
|
+ <img
|
|
|
+ src={hintAsset}
|
|
|
+ alt=""
|
|
|
+ className="energy-stone-hint"
|
|
|
+ style={{
|
|
|
+ position: 'absolute',
|
|
|
+ top: '50%',
|
|
|
+ left: '50%',
|
|
|
+ transform: 'translate(-50%, -50%)',
|
|
|
+ transformOrigin: 'center center',
|
|
|
+ width: px2vmin(hintSize),
|
|
|
+ height: px2vmin(hintSize),
|
|
|
+ pointerEvents: 'none',
|
|
|
+ zIndex: 0,
|
|
|
+ opacity: stone.fading ? 0 : undefined, // 动画过程中逐渐消失
|
|
|
+ maxWidth: 'none',
|
|
|
+ maxHeight: 'none',
|
|
|
+ objectFit: 'contain',
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ <img
|
|
|
+ src={asset}
|
|
|
+ alt={stone.stoneType === 'mission' ? '任务能量石' : '对话能量石'}
|
|
|
+ className="energy-stone"
|
|
|
+ data-type={stone.stoneType}
|
|
|
+ data-fading={stone.fading ? 'true' : 'false'}
|
|
|
+ style={{
|
|
|
+ position: 'absolute',
|
|
|
+ top: '50%',
|
|
|
+ left: '50%',
|
|
|
+ transform: 'translate(-50%, -50%)',
|
|
|
+ transformOrigin: 'center center',
|
|
|
+ width: px2vmin(stoneSize),
|
|
|
+ height: px2vmin(stoneSize),
|
|
|
+ zIndex: stone.fading ? 100 : 1,
|
|
|
+ pointerEvents: stone.fading ? 'none' : 'auto',
|
|
|
+ cursor: 'pointer',
|
|
|
+ }}
|
|
|
+ onClick={async (e) => {
|
|
|
+ const container = e.currentTarget.closest('[data-stone-id]') as HTMLElement;
|
|
|
+ if (container) {
|
|
|
+ const rect = container.getBoundingClientRect();
|
|
|
+ const startLeft = rect.left + rect.width / 2;
|
|
|
+ const startTop = rect.top + rect.height / 2;
|
|
|
+ const viewportWidth = window.innerWidth;
|
|
|
+ const viewportHeight = window.innerHeight;
|
|
|
+ const vmin = Math.min(viewportWidth, viewportHeight);
|
|
|
+ const endTopPx = Math.min(
|
|
|
+ (452 * Math.min(viewportWidth, 600) / 375),
|
|
|
+ (452 / 812) * viewportHeight
|
|
|
+ );
|
|
|
+ const endLeft = viewportWidth / 2;
|
|
|
+ const dx = endLeft - startLeft;
|
|
|
+ const dy = endTopPx - startTop;
|
|
|
+ container.style.setProperty('--animation-start-left', `${startLeft}px`);
|
|
|
+ container.style.setProperty('--animation-start-top', `${startTop}px`);
|
|
|
+ container.style.setProperty('--animation-end-left', `${endLeft}px`);
|
|
|
+ container.style.setProperty('--animation-end-top', `${endTopPx}px`);
|
|
|
+ container.style.setProperty('--animation-dx', `${dx}px`);
|
|
|
+ container.style.setProperty('--animation-dy', `${dy}px`);
|
|
|
+ createOrUpdateDebugLine(stone.stoneId, startLeft, startTop, endLeft, endTopPx);
|
|
|
+ container.style.left = `${startLeft}px`;
|
|
|
+ container.style.top = `${startTop}px`;
|
|
|
+ container.style.transform = 'translate(-50%, -50%) translate3d(0, 0, 0)';
|
|
|
+ container.style.animation = 'energyStoneMoveToAvatar 1s ease-in-out forwards';
|
|
|
+ container.style.willChange = 'transform, opacity';
|
|
|
+ container.style.position = 'fixed';
|
|
|
+ container.style.pointerEvents = 'none';
|
|
|
+
|
|
|
+ runStoneAnimation(container, dx, dy);
|
|
|
+ console.log('[EnergyStone][click] set anim vars', {
|
|
|
+ stoneId: stone.stoneId,
|
|
|
+ viewportWidth,
|
|
|
+ viewportHeight,
|
|
|
+ vmin,
|
|
|
+ rect,
|
|
|
+ startLeft,
|
|
|
+ startTop,
|
|
|
+ endLeft,
|
|
|
+ endTopPx,
|
|
|
+ dx,
|
|
|
+ dy,
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ handleStoneClick(stone.stoneId);
|
|
|
+
|
|
|
+ const clickedStorageKey = stone.stoneType === 'mission'
|
|
|
+ ? STORAGE_KEYS.HAS_CLICKED_MISSION_STONE
|
|
|
+ : STORAGE_KEYS.HAS_CLICKED_CONVERSATION_STONE;
|
|
|
+ const hasClicked = getStorage(clickedStorageKey);
|
|
|
+ const isFirstClick = hasClicked !== 'true';
|
|
|
+ const storageKey = stone.stoneType === 'mission'
|
|
|
+ ? STORAGE_KEYS.HAS_SHOWN_MISSION_STONE_MODAL
|
|
|
+ : STORAGE_KEYS.HAS_SHOWN_CONVERSATION_STONE_MODAL;
|
|
|
+ const hasShown = getStorage(storageKey);
|
|
|
+ const isFirstShowModal = hasShown !== 'true';
|
|
|
+
|
|
|
+ if (isFirstClick) {
|
|
|
+ setStorage(clickedStorageKey, 'true');
|
|
|
+ if (stone.stoneType === 'mission') {
|
|
|
+ setHasClickedMissionStone(true);
|
|
|
+ } else {
|
|
|
+ setHasClickedConversationStone(true);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const userId = getUserId();
|
|
|
+ const updatePayload: Record<string, boolean> = {};
|
|
|
+ if (isFirstClick) {
|
|
|
+ if (stone.stoneType === 'mission') {
|
|
|
+ updatePayload.has_clicked_mission_stone = true;
|
|
|
+ } else {
|
|
|
+ updatePayload.has_clicked_conversation_stone = true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (isFirstShowModal) {
|
|
|
+ setEnergyStoneModalType(stone.stoneType);
|
|
|
+ setShowMissionStoneModal(true);
|
|
|
+ if (stone.stoneType === 'mission') {
|
|
|
+ updatePayload.has_shown_mission_stone_modal = true;
|
|
|
+ } else {
|
|
|
+ updatePayload.has_shown_conversation_stone_modal = true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (userId && Object.keys(updatePayload).length > 0) {
|
|
|
+ try {
|
|
|
+ await updateUserUIState(userId, updatePayload);
|
|
|
+ if (isFirstShowModal) {
|
|
|
+ setStorage(storageKey, 'true');
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('同步能量石状态到数据库失败:', error);
|
|
|
+ if (isFirstShowModal) {
|
|
|
+ console.warn('[能量石] 弹窗状态同步失败,但弹窗已显示');
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } else if (!userId) {
|
|
|
+ if (isFirstShowModal) {
|
|
|
+ setStorage(storageKey, 'true');
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ if (isFirstShowModal) {
|
|
|
+ setStorage(storageKey, 'true');
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ })}
|
|
|
+
|
|
|
+ {/* AnimaZone 头像 - 使用设计稿 375×812 基准 + px2vmin 适配 */}
|
|
|
+ <div
|
|
|
+ className="chatbot-anima-zone-avatar absolute"
|
|
|
+ style={{
|
|
|
+ // 设计稿宽 343px,高 340px,基于 vw/vh 的最小值等比缩放
|
|
|
+ width: px2vmin(343),
|
|
|
+ height: px2vmin(340),
|
|
|
+ // 设计稿 top 248px,同样基于 vw/vh 的最小值
|
|
|
+ top: px2vmin(248),
|
|
|
+ left: '50%',
|
|
|
+ transform: 'translateX(-50%)',
|
|
|
+ opacity: 1,
|
|
|
+ zIndex: 2,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <img
|
|
|
+ src={currentAvatarGif}
|
|
|
+ alt={currentAvatarAlt}
|
|
|
+ loading="eager"
|
|
|
+ decoding="sync"
|
|
|
+ style={{
|
|
|
+ width: '100%',
|
|
|
+ height: '100%',
|
|
|
+ objectFit: 'contain',
|
|
|
+ pointerEvents: 'none',
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 音频波形 - 宽度 100% 自适应 */}
|
|
|
+ <div
|
|
|
+ className="chatbot-audio-wave overflow-hidden"
|
|
|
+ style={{
|
|
|
+ // 相对于父容器定位,贴底
|
|
|
+ position: 'absolute',
|
|
|
+ width: '100%',
|
|
|
+ // 录音/播放时展开波形高度
|
|
|
+ height: waveformHeightStyle,
|
|
|
+ bottom: 0,
|
|
|
+ left: 0,
|
|
|
+ transition: 'height 0.3s ease-in-out',
|
|
|
+ zIndex: 10,
|
|
|
+ }}
|
|
|
+ data-expanded={shouldExpandWaveform ? 'true' : 'false'}
|
|
|
+ data-waveform-bottom={0}
|
|
|
+ >
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ position: 'absolute',
|
|
|
+ bottom: 0,
|
|
|
+ left: 0,
|
|
|
+ width: '100%',
|
|
|
+ height: waveformHeightStyle, // 设计稿高度根据状态切换
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <AudioWave
|
|
|
+ ref={audioWaveRef}
|
|
|
+ isAnimating={currentState === 'recording'}
|
|
|
+ width={typeof window !== 'undefined' ? window.innerWidth : 375}
|
|
|
+ height={waveformHeightPx}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 进度条+录音按钮容器 - 设计稿:top 614px,尺寸 114×114, 左右居中,使用 px2vmin 适配(基于 vw/vh 最小值) */}
|
|
|
+ {/* 容器在非录音状态显示进度环,在录音状态显示按钮 */}
|
|
|
+ <div
|
|
|
+ className="chatbot-progress-container absolute"
|
|
|
+ style={{
|
|
|
+ width: px2vmin(114), // 设计稿 114px,基于 vw/vh 最小值
|
|
|
+ height: px2vmin(114), // 设计稿 114px,基于 vw/vh 最小值
|
|
|
+ top: px2vh(614), // 设计稿 top 614px,基于 vh
|
|
|
+ left: '50%',
|
|
|
+ transform: 'translateX(-50%)',
|
|
|
+ zIndex: 20,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {/* 进度环 - 背景层 - 只在非录音状态显示 */}
|
|
|
+ {currentState !== 'recording' && (
|
|
|
+ <div
|
|
|
+ className="chatbot-progress-ring absolute"
|
|
|
+ style={{
|
|
|
+ width: '100%',
|
|
|
+ height: '100%',
|
|
|
+ top: 0,
|
|
|
+ left: 0,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {/* 空状态背景 */}
|
|
|
+ <img
|
|
|
+ src={progressRing}
|
|
|
+ alt="Progress Ring"
|
|
|
+ className="pointer-events-none"
|
|
|
+ style={{
|
|
|
+ position: 'absolute',
|
|
|
+ top: px2vmin(0), // 设计稿 top 0px,基于 vw/vh 最小值
|
|
|
+ left: '-0.5PX', // 设计稿 left -0.5px,使用大写 PX 避免转换
|
|
|
+ width: '100%',
|
|
|
+ height: '100%',
|
|
|
+ opacity: 1,
|
|
|
+ }}
|
|
|
+ />
|
|
|
+
|
|
|
+ {/* 进度填充层 - 使用conic-gradient mask */}
|
|
|
+ <div
|
|
|
+ className="pointer-events-none"
|
|
|
+ style={{
|
|
|
+ position: 'absolute',
|
|
|
+ top: px2vmin(0), // 设计稿 top 0px,基于 vw/vh 最小值
|
|
|
+ left: '-0.5PX', // 设计稿 left -0.5px,使用大写 PX 避免转换
|
|
|
+ width: '100%',
|
|
|
+ height: '100%',
|
|
|
+ maskImage: `conic-gradient(from 215deg at 50% 50%, black 0deg, black ${displayProgress * 2.88}deg, transparent ${displayProgress * 2.88}deg, transparent 360deg)`,
|
|
|
+ WebkitMaskImage: `conic-gradient(from 215deg at 50% 50%, black 0deg, black ${displayProgress * 2.88}deg, transparent ${displayProgress * 2.88}deg, transparent 360deg)`,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <img
|
|
|
+ src={progressFull}
|
|
|
+ alt="Progress Full"
|
|
|
+ style={{
|
|
|
+ width: '100%',
|
|
|
+ height: '100%',
|
|
|
+ display: 'block',
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 进度条提示 - 相对于进度环定位 - 首次有进度时显示 */}
|
|
|
+ {showProgressBarTooltip && isConnected && (
|
|
|
+ <img
|
|
|
+ ref={progressBarTooltipRef}
|
|
|
+ src={tooltipsProgressBar}
|
|
|
+ alt="Progress Bar Tooltip"
|
|
|
+ className="chatbot-tooltips absolute pointer-events-none"
|
|
|
+ style={{
|
|
|
+ width: px2vmin(167), // 设计稿 width 167px,基于 vw/vh 最小值
|
|
|
+ height: px2vmin(130), // 设计稿 height 130px,基于 vw/vh 最小值
|
|
|
+ top: '-100%',
|
|
|
+ left: '-100%',
|
|
|
+ opacity: 1,
|
|
|
+ zIndex: 10,
|
|
|
+ maxWidth: 'none', // 确保不被限制
|
|
|
+ maxHeight: 'none', // 确保不被限制
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* 录音按钮 - 设计稿:尺寸 96×96,top 623px,左右居中 - 只在非录音状态显示 */}
|
|
|
+ {currentState !== 'recording' && (
|
|
|
+ <div
|
|
|
+ className={`chatbot-record-button absolute touch-manipulation ${isConnected ? 'cursor-pointer' : 'cursor-not-allowed'}`}
|
|
|
+ onClick={handleStartRecord}
|
|
|
+ style={{
|
|
|
+ width: px2vmin(96), // 设计稿 96px,基于 vw/vh 最小值
|
|
|
+ height: px2vmin(96), // 设计稿 96px,基于 vw/vh 最小值
|
|
|
+ top: '50%', // 在容器内垂直居中
|
|
|
+ left: '50%',
|
|
|
+ transform: 'translate(-50%, -50%)', // 水平和垂直居中
|
|
|
+ borderRadius: px2vw(100),
|
|
|
+ opacity: isConnected ? 1 : 0.55,
|
|
|
+ display: 'flex',
|
|
|
+ alignItems: 'center',
|
|
|
+ justifyContent: 'center',
|
|
|
+ userSelect: 'none',
|
|
|
+ WebkitUserSelect: 'none',
|
|
|
+ caretColor: 'transparent',
|
|
|
+ outline: 'none',
|
|
|
+ filter: isConnected ? 'none' : 'grayscale(0.25)',
|
|
|
+ overflow: 'visible', // 允许 tooltips 超出按钮边界
|
|
|
+ }}
|
|
|
+ contentEditable={false}
|
|
|
+ tabIndex={-1}
|
|
|
+ aria-disabled={!isConnected}
|
|
|
+ >
|
|
|
+ {/* 录音按钮背景 - PNG 图片 */}
|
|
|
+ <img
|
|
|
+ src={audioButton}
|
|
|
+ alt="Audio Button Background"
|
|
|
+ className="pointer-events-none"
|
|
|
+ style={{
|
|
|
+ width: '100%',
|
|
|
+ height: '100%',
|
|
|
+ position: 'absolute',
|
|
|
+ top: 0,
|
|
|
+ left: 0,
|
|
|
+ }}
|
|
|
+ />
|
|
|
+
|
|
|
+ {/* 麦克风图标 - 居中 - 响应式尺寸 */}
|
|
|
+ <img
|
|
|
+ src={microphoneIcon}
|
|
|
+ alt="Microphone"
|
|
|
+ className="pointer-events-none"
|
|
|
+ style={{
|
|
|
+ width: px2vmin(32),
|
|
|
+ height: px2vmin(32),
|
|
|
+ opacity: 1,
|
|
|
+ position: 'relative',
|
|
|
+ zIndex: 1,
|
|
|
+ }}
|
|
|
+ />
|
|
|
+
|
|
|
+ {/* Tap to Chat 提示 - 相对于录音按钮定位 - 只显示一次 - 在按钮内部使用绝对定位 */}
|
|
|
+ {showTapToChat && isConnected && (
|
|
|
+ <img
|
|
|
+ src={tapToChat}
|
|
|
+ alt="Tap to Chat"
|
|
|
+ className="chatbot-tooltips absolute pointer-events-none"
|
|
|
+ style={{
|
|
|
+ width: px2vmin(128), // 设计稿 width 128px,基于 vw/vh 最小值
|
|
|
+ height: px2vmin(100), // 设计稿 height 100px,基于 vw/vh 最小值
|
|
|
+ top: '-100%',
|
|
|
+ right: '-100%',
|
|
|
+ borderRadius: `${Math.round(8 * layout.avatarScale)}px`,
|
|
|
+ opacity: 1,
|
|
|
+ zIndex: 10,
|
|
|
+ maxWidth: 'none', // 确保不被限制
|
|
|
+ maxHeight: 'none', // 确保不被限制
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* 取消语音发送按钮 - 录音状态显示,与录音按钮同高度,左侧对齐 */}
|
|
|
+ {currentState === 'recording' && (
|
|
|
+ <div
|
|
|
+ className="chatbot-action-buttons absolute flex items-center justify-center cursor-pointer touch-manipulation"
|
|
|
+ onClick={handleCancel}
|
|
|
+ style={{
|
|
|
+ width: px2vmin(56), // 设计稿 width 56px,基于 vw/vh 最小值
|
|
|
+ height: px2vmin(56), // 设计稿 height 56px,基于 vw/vh 最小值
|
|
|
+ top: '50%',
|
|
|
+ left: `calc(50% - ${px2vmin(56)} / 2 - ${px2vmin(50)} / 2)`, // 按钮宽度 56px,间距 50px,基于 vw/vh 最小值
|
|
|
+ transform: 'translate(-50%, -50%)',
|
|
|
+ borderRadius: px2vw(100),
|
|
|
+ opacity: 1,
|
|
|
+ background: 'rgba(255, 255, 255, 0.1)',
|
|
|
+ backdropFilter: 'blur(15px)',
|
|
|
+ zIndex: 20,
|
|
|
+ userSelect: 'none',
|
|
|
+ WebkitUserSelect: 'none',
|
|
|
+ caretColor: 'transparent',
|
|
|
+ outline: 'none',
|
|
|
+ overflow: 'visible', // 允许 tooltip 超出按钮边界
|
|
|
+ }}
|
|
|
+ contentEditable={false}
|
|
|
+ tabIndex={-1}
|
|
|
+ >
|
|
|
+ {/* 按钮背景装饰图片 */}
|
|
|
+ <img
|
|
|
+ src={audioButton}
|
|
|
+ alt="Button Background"
|
|
|
+ className="pointer-events-none"
|
|
|
+ style={{
|
|
|
+ width: 'calc(100% + 1px)',
|
|
|
+ height: 'calc(100% + 1px)',
|
|
|
+ position: 'absolute',
|
|
|
+ top: '0',
|
|
|
+ left: '0',
|
|
|
+ }}
|
|
|
+ />
|
|
|
+
|
|
|
+ {/* Cancel 图标 - 居中 - 响应式尺寸 */}
|
|
|
+ <img
|
|
|
+ src={cancelIcon}
|
|
|
+ alt="Cancel"
|
|
|
+ className="pointer-events-none"
|
|
|
+ style={{
|
|
|
+ width: px2vmin(24),
|
|
|
+ height: px2vmin(24),
|
|
|
+ position: 'relative',
|
|
|
+ zIndex: 1,
|
|
|
+ }}
|
|
|
+ />
|
|
|
+
|
|
|
+ {/* 取消语音发送提示 - 相对于取消按钮定位 - 只在首次录音时显示 */}
|
|
|
+ {showCancelTooltip && (
|
|
|
+ <img
|
|
|
+ src={tooltipsCancel}
|
|
|
+ alt="Cancel Tooltip"
|
|
|
+ className="chatbot-tooltips absolute pointer-events-none"
|
|
|
+ style={{
|
|
|
+ width: px2vmin(109), // 设计稿 width 109px,基于 vw/vh 最小值
|
|
|
+ height: px2vmin(91), // 设计稿 height 91px,基于 vw/vh 最小值
|
|
|
+ bottom: '110%',
|
|
|
+ left: '-150%',
|
|
|
+ borderRadius: px2vmin(8),
|
|
|
+ opacity: 1,
|
|
|
+ zIndex: 10,
|
|
|
+ maxWidth: 'none', // 确保不被限制
|
|
|
+ maxHeight: 'none', // 确保不被限制
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* 语音发送按钮 - 录音状态显示,与录音按钮同高度,右侧对齐 */}
|
|
|
+ {currentState === 'recording' && (
|
|
|
+ <div
|
|
|
+ className="chatbot-action-buttons absolute flex items-center justify-center cursor-pointer touch-manipulation"
|
|
|
+ onClick={handleSend}
|
|
|
+ style={{
|
|
|
+ width: px2vmin(56), // 设计稿 width 56px,基于 vw/vh 最小值
|
|
|
+ height: px2vmin(56), // 设计稿 height 56px,基于 vw/vh 最小值
|
|
|
+ top: '50%',
|
|
|
+ left: `calc(50% + ${px2vmin(56)} / 2 + ${px2vmin(50)} / 2)`, // 按钮宽度 56px,间距 50px,基于 vw/vh 最小值
|
|
|
+ transform: 'translate(-50%, -50%)',
|
|
|
+ borderRadius: px2vw(100),
|
|
|
+ opacity: 1,
|
|
|
+ background: 'rgba(255, 255, 255, 0.1)',
|
|
|
+ backdropFilter: 'blur(15px)',
|
|
|
+ zIndex: 20,
|
|
|
+ userSelect: 'none',
|
|
|
+ WebkitUserSelect: 'none',
|
|
|
+ caretColor: 'transparent',
|
|
|
+ outline: 'none',
|
|
|
+ overflow: 'visible', // 允许 tooltip 超出按钮边界
|
|
|
+ }}
|
|
|
+ contentEditable={false}
|
|
|
+ tabIndex={-1}
|
|
|
+ >
|
|
|
+ {/* 按钮背景装饰图片 */}
|
|
|
+ <img
|
|
|
+ src={audioButton}
|
|
|
+ alt="Button Background"
|
|
|
+ className="pointer-events-none"
|
|
|
+ style={{
|
|
|
+ width: 'calc(100% + 1px)',
|
|
|
+ height: 'calc(100% + 1px)',
|
|
|
+ position: 'absolute',
|
|
|
+ top: '0',
|
|
|
+ left: '0',
|
|
|
+ }}
|
|
|
+ />
|
|
|
+
|
|
|
+ {/* Send 图标 - 居中 - 响应式尺寸 */}
|
|
|
+ <img
|
|
|
+ src={sendIcon}
|
|
|
+ alt="Send"
|
|
|
+ className="pointer-events-none"
|
|
|
+ style={{
|
|
|
+ width: px2vmin(24),
|
|
|
+ height: px2vmin(24),
|
|
|
+ position: 'relative',
|
|
|
+ zIndex: 1,
|
|
|
+ }}
|
|
|
+ />
|
|
|
+
|
|
|
+ {/* 语音发送提示 - 相对于发送按钮定位 - 只在首次录音时显示 */}
|
|
|
+ {showSendTooltip && (
|
|
|
+ <img
|
|
|
+ src={tooltipsSend}
|
|
|
+ alt="Send Tooltip"
|
|
|
+ className="chatbot-tooltips absolute pointer-events-none"
|
|
|
+ style={{
|
|
|
+ width: px2vmin(111),
|
|
|
+ height: 'auto',
|
|
|
+ bottom: '110%',
|
|
|
+ right: '-150%',
|
|
|
+ borderRadius: px2vmin(8),
|
|
|
+ opacity: 1,
|
|
|
+ zIndex: 10,
|
|
|
+ maxWidth: 'none', // 确保不被限制
|
|
|
+ maxHeight: 'none', // 确保不被限制
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* 倒计时组件 - 只在录音50秒后(最后10秒)显示 - 与按钮上下居中,位于界面最中间 */}
|
|
|
+ {currentState === 'recording' &&
|
|
|
+ remainingTimeMs !== null &&
|
|
|
+ remainingTimeMs <= COUNTDOWN_THRESHOLD_MS && (
|
|
|
+ <div
|
|
|
+ className="chatbot-countdown absolute font-semibold"
|
|
|
+ style={{
|
|
|
+ width: px2vmin(50), // 设计稿 width 50px,基于 vw/vh 最小值
|
|
|
+ height: px2vmin(20), // 设计稿 height 20px,基于 vw/vh 最小值
|
|
|
+ top: '50%', // 在容器内垂直居中,与按钮同一水平线
|
|
|
+ left: '50%', // 在容器内水平居中
|
|
|
+ transform: 'translate(-50%, -50%)', // 水平和垂直居中
|
|
|
+ zIndex: 21,
|
|
|
+ fontSize: px2vmin(12), // 设计稿 font-size 12px,基于 vw/vh 最小值
|
|
|
+ color: 'rgba(245, 243, 240, 1)',
|
|
|
+ textShadow: '0 2px 8px rgba(0,0,0,0.55)',
|
|
|
+ letterSpacing: '0.08em',
|
|
|
+ display: 'flex',
|
|
|
+ alignItems: 'center',
|
|
|
+ justifyContent: 'center',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {formatCountdown(remainingTimeMs)}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {!isConnected && currentState !== 'recording' && (
|
|
|
+ <div
|
|
|
+ className="absolute text-white/80 text-center"
|
|
|
+ style={{
|
|
|
+ top: px2vh(580), // 设计稿 top 580px,基于 vh
|
|
|
+ left: '50%',
|
|
|
+ transform: 'translateX(-50%)',
|
|
|
+ zIndex: 25,
|
|
|
+ fontSize: px2vmin(14),
|
|
|
+ textShadow: '0 2px 6px rgba(0,0,0,0.35)',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ 正在重连语音服务...
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* 能量石弹窗 */}
|
|
|
+ <MissionEnergyStoneModal
|
|
|
+ visible={showMissionStoneModal}
|
|
|
+ onClose={() => setShowMissionStoneModal(false)}
|
|
|
+ stoneType={energyStoneModalType}
|
|
|
+ />
|
|
|
+
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+};
|
|
|
+
|