| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331 |
- 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,
- getUserToken,
- setUserToken,
- clearUserSessionAndRedirectToBegin,
- ROUTE_ORDER,
- } from '@/utils/storage';
- import { onScreenResize, calculateLayout, type CalculatedLayout, updateTooSmallFlag } from '@/utils/screenSize';
- import { performanceLogger } from '@/utils/logger';
- import { getUserInfo, updateUserUIState, getUserUIState, getAvatarGifs, type AvatarGifSet, ensureSession } 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 = getOssUrl('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');
- const YpImage = getOssUrl('yp.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';
- // 控制台调试开关:生产环境关闭,避免多余日志
- const shouldLogDebug = false;
- const debugLog = (...args: unknown[]) => {
- if (!shouldLogDebug) return;
- // 使用 console.debug 统一输出调试日志
- // eslint-disable-next-line no-console
- console.debug(...args);
- };
- // GIF URL 兜底常量(基础形象)
- const FALLBACK_DEFAULT_AVATAR_GIF = getOssUrl('01-Default.gif');
- const FALLBACK_POSITIVE_EMOTION_GIF = getOssUrl('04-Positive-emotion.gif');
- const FALLBACK_NEGATIVE_EMOTION_GIF = getOssUrl('05-Negative-emotion.gif');
- const FALLBACK_TALKING_GIF = getOssUrl('06-talking.gif');
- // GIF URL 兜底常量(注册赠送形象:带帽子)
- const FALLBACK_REGISTERED_DEFAULT_AVATAR_GIF = getOssUrl('01-Default-Registered.gif');
- const FALLBACK_REGISTERED_POSITIVE_EMOTION_GIF = getOssUrl('04-Positive-emotion-Registered.gif');
- const FALLBACK_REGISTERED_NEGATIVE_EMOTION_GIF = getOssUrl('05-Negative-emotion-Registered.gif');
- const FALLBACK_REGISTERED_TALKING_GIF = getOssUrl('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';
- };
- // 获取情绪 GIF 播放时长(毫秒)
- // 统一:正向 2050ms,负向 4290ms(与形象无关)
- const getEmotionGifDuration = (_avatarId: string | null, emotionCategory: 'positive' | 'negative'): number => {
- if (emotionCategory === 'positive') {
- return 2050;
- }
- if (emotionCategory === 'negative') {
- return 4290;
- }
- return 0;
- };
- export const ChatBotPage = () => {
- const navigate = useNavigate();
- const [searchParams] = useSearchParams();
- const location = useLocation();
- const {
- currentState,
- currentRequestId,
- conversationProgress,
- progressLoopCount,
- setState,
- setRequestId,
- setEmotion,
- setRecognizedText,
- setError,
- setConnected,
- 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);
- // 首句 greeting 请求 ID(来自后端的 greeting_xxx)
- const greetingRequestIdRef = useRef<string | null>(null);
- // 标记首句是否处于“可播放/可显示”状态;一旦用户开始录音则置为 true,后续 greeting 全部丢弃
- const greetingSuppressedRef = useRef(false);
- // 标记当前是否正在播放首句音频,便于在开始录音时精确打断
- const isGreetingActiveRef = 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);
- 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) {
- debugLog('[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();
- debugLog('[ChatBotPage] 音频播放完成');
- } catch (error) {
- console.warn('[ChatBotPage] 等待音频播放完成时出错:', error);
- }
- }
- }
-
- debugLog('[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 pendingStoneIdRef = useRef<string | 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 切换定时器(情绪 GIF 播放一次后切回 talking/default)
- const emotionGifTimerRef = useRef<number | null>(null);
- // 待处理情绪缓存:收到 emotion 消息时先缓存,等音频真正开始播放时再驱动情绪动画
- const pendingEmotionRef = useRef<string | 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()) {
- debugLog('[ChatBotPage] 检测到音频正在播放,等待播放完成后再跳转...');
- try {
- await playerRef.current.waitForPlaybackComplete();
- debugLog('[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) {
- debugLog(`[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,
- };
- debugLog(`[resolveAvatarGifSet] avatarId=${avatarId}, set存在=${!!set}, result=`, result);
- return result;
- },
- [avatarGifSets],
- );
- const avatarGifSet = useMemo(() => {
- const gifSet = resolveAvatarGifSet(currentAvatarId);
- debugLog(`[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;
- debugLog(`[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}`);
- };
- }
- });
-
- debugLog(`[预加载] 已开始预加载形象 ${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) {
- debugLog(`[ChatBotPage] 检测到 localStorage 中的形象ID变化: ${currentAvatarId} -> ${storedAvatarId}`);
- setCurrentAvatarId(storedAvatarId);
- // 立即更新显示的 GIF,使用最新的 avatarGifSets
- const newGifSet = resolveAvatarGifSet(storedAvatarId);
- debugLog(`[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 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);
- // 标记动画就绪,允许 CSS 启动
- target.setAttribute('data-anim-ready', 'true');
- // 重新启动动画,确保使用更新后的起点
- resetAnimation(target);
- resetAnimation(target.querySelector('.energy-stone') as HTMLElement | null);
- resetAnimation(target.querySelector('.energy-stone-hint') as HTMLElement | null);
- }, 0);
- timers.push(timer);
- });
- return () => {
- timers.forEach((timer) => {
- window.clearTimeout(timer);
- });
- };
- }, [energyStones, runStoneAnimation]);
- const startStoneAnimationAndRemove = useCallback(
- (stoneId: string) => {
- const container = document.querySelector(
- `[data-stone-id="${stoneId}"]`
- ) as HTMLElement | null;
- 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 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`);
- 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);
- }
- handleStoneClick(stoneId);
- },
- [handleStoneClick, runStoneAnimation]
- );
- const handleMissionStoneModalClose = useCallback(() => {
- setShowMissionStoneModal(false);
- const pendingStoneId = pendingStoneIdRef.current;
- if (pendingStoneId) {
- pendingStoneIdRef.current = null;
- startStoneAnimationAndRemove(pendingStoneId);
- }
- }, [startStoneAnimationAndRemove]);
- // 能量石展示区域:宽高 & 位置按设计稿 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 触发时的状态
- debugLog('[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) {
- debugLog('[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) {
- // 初始化场景:直接使用目标值作为起始值,避免播放动画
- debugLog('[Progress] [useEffect] 初始化场景,使用目标值作为起始值,跳过动画');
- prevTotal = targetTotal;
- prevProgressStateRef.current = currentState;
- totalProgressRef.current = targetTotal;
- } else {
- debugLog('[Progress] [useEffect] 检测到初始值情况,使用 displayProgress 或 totalProgressRef');
- // 如果 totalProgressRef 有值,使用它;否则使用 displayProgress
- if (totalProgressRef.current > 0) {
- prevTotal = totalProgressRef.current;
- debugLog('[Progress] [useEffect] 使用 totalProgressRef:', totalProgressRef.current);
- } else {
- prevTotal = displayProgress;
- debugLog('[Progress] [useEffect] 使用 displayProgress:', displayProgress);
- }
- // 更新 prevProgressStateRef 为当前显示的状态
- prevProgressStateRef.current = {
- progress: displayProgress,
- loopCount: Math.floor(totalProgressRef.current / 100),
- };
- debugLog('[Progress] [useEffect] 更新 prevProgressStateRef 为:', prevProgressStateRef.current);
- }
- }
-
- // 始终使用上一次保存的状态作为起始值,而不是 totalProgressRef.current
- // 因为 totalProgressRef.current 可能在动画过程中,不准确
- let startTotal = prevTotal;
-
- // 如果 totalProgressRef 与上一次状态不一致,同步它
- // 这样可以确保 totalProgressRef 与起始值一致,避免动画跳变
- if (!shouldSkipProgressAnimationRef.current) {
- const diffFromPrev = Math.abs(totalProgressRef.current - prevTotal);
-
- debugLog('[Progress] [useEffect] 检查 totalProgressRef 同步:', {
- diffFromPrev,
- totalProgressRef: totalProgressRef.current,
- prevTotal,
- needSync: diffFromPrev > 1,
- });
-
- // 如果差异较大(超过1%),说明可能有动画在进行,需要取消它并同步起始值
- if (diffFromPrev > 1) {
- debugLog('[Progress] [useEffect] 取消旧动画并同步 totalProgressRef');
- // 取消可能正在进行的动画
- if (animationFrameRef.current) {
- cancelAnimationFrame(animationFrameRef.current);
- animationFrameRef.current = undefined;
- }
- // 同步 totalProgressRef 到起始值
- totalProgressRef.current = startTotal;
- }
- }
-
- // 立即更新 prevProgressStateRef 为当前状态(供下次动画使用)
- // 注意:这里保存的是本次动画的目标值,下次动画会使用这个值作为起始值
- prevProgressStateRef.current = currentState;
-
- const diff = targetTotal - startTotal;
-
- debugLog('[Progress] [useEffect] 计算动画参数:', {
- startTotal,
- targetTotal,
- diff,
- willAnimate: Math.abs(diff) >= 0.01 && !shouldSkipProgressAnimationRef.current,
- });
- // 如果 diff 为 0 或非常小,直接同步,不播放动画
- if (Math.abs(diff) < 0.01) {
- debugLog('[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();
- debugLog('[Progress] [useEffect] 开始动画:', {
- startTotal,
- targetTotal,
- actualDiff,
- duration,
- startTime,
- });
- if (animationFrameRef.current) {
- debugLog('[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) {
- debugLog('[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 {
- // 动画完成后,同步到实际的目标值
- debugLog('[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]);
- // 监听录音状态,首次显示取消提示(延迟 2 秒出现)
- useEffect(() => {
- let timer: number | undefined;
- if (currentState === 'recording') {
- const hasShownCancel = getStorage(STORAGE_KEYS.HAS_SHOWN_CANCEL_TOOLTIP);
- if (!hasShownCancel) {
- timer = window.setTimeout(() => {
- setShowCancelTooltip(true);
- setStorage(STORAGE_KEYS.HAS_SHOWN_CANCEL_TOOLTIP, 'true');
- const userId = getUserId();
- if (userId) {
- updateUserUIState(userId, {
- has_shown_cancel_tooltip: true,
- }).catch(() => {
- // 同步失败不影响主流程
- });
- }
- }, 200); // 提示更快出现
- } else {
- // 已显示过,确保隐藏
- setShowCancelTooltip(false);
- }
- } else {
- // 非录音状态,隐藏提示
- setShowCancelTooltip(false);
- }
- return () => {
- if (timer !== undefined) {
- window.clearTimeout(timer);
- }
- };
- }, [currentState]);
- // 监听录音状态,首次显示发送提示(延迟 2 秒出现)
- useEffect(() => {
- let timer: number | undefined;
- if (currentState === 'recording') {
- const hasShownSend = getStorage(STORAGE_KEYS.HAS_SHOWN_SEND_TOOLTIP);
- if (!hasShownSend) {
- timer = window.setTimeout(() => {
- setShowSendTooltip(true);
- setStorage(STORAGE_KEYS.HAS_SHOWN_SEND_TOOLTIP, 'true');
- const userId = getUserId();
- if (userId) {
- updateUserUIState(userId, {
- has_shown_send_tooltip: true,
- }).catch(() => {
- // 同步失败不影响主流程
- });
- }
- }, 200); // 提示更快出现
- } else {
- // 已显示过,确保隐藏
- setShowSendTooltip(false);
- }
- } else {
- // 非录音状态,隐藏提示
- setShowSendTooltip(false);
- }
- return () => {
- if (timer !== undefined) {
- window.clearTimeout(timer);
- }
- };
- }, [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 token = searchParams.get('token');
-
- // 如果 URL 中有 OAuth 回调参数,说明是登录成功回调
- // ✅ 确保 user_id 和 token 已设置到 localStorage(双重保险,避免时序问题)
- if (oauth === 'qq' && userId) {
- console.log(`[ChatBotPage] 🔧 检测到 QQ OAuth 回调,确保参数已设置到 localStorage - user_id=${userId}, token=${token ? '已提供' : '未提供'}`);
-
- // 1. 确保用户 ID 已设置(同步操作)
- setStorage(STORAGE_KEYS.USER_ID, userId);
-
- // 2. 如果 URL 中有 token,确保已设置(同步操作)
- if (token) {
- setUserToken(token);
- console.log(`[ChatBotPage] ✅ 已设置 token 到 localStorage`);
- }
-
- // 3. 确保登录状态已设置(同步操作)
- setStorage(STORAGE_KEYS.IS_LOGGED_IN, 'true');
-
- console.log(`[ChatBotPage] ✅ QQ OAuth 回调参数已全部设置到 localStorage`);
- antMessage.success('QQ登录成功');
-
- // 清除URL参数(延迟清除,确保 UserGuard 能读取到参数)
- setTimeout(() => {
- navigate('/chatbot', { replace: true });
- }, 100);
- }
- }, [searchParams, navigate]);
- // 初始化
- useEffect(() => {
- // 🔧 立即设置 shouldSkipProgressAnimation = true,避免在异步加载完成前触发动画
- // 这可以防止从其他页面返回时播放历史动画
- shouldSkipProgressAnimationRef.current = true;
- debugLog('[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);
-
- debugLog('[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 (合并本地和数据库的路由,避免覆盖本地刚记录的路由)
- const localVisited = getVisitedRoutes();
- if (Array.isArray(state.visited_routes)) {
- // 合并本地和数据库的路由,去重并保持顺序
- const mergedRoutes = [...new Set([...localVisited, ...state.visited_routes])];
- // 按照 ROUTE_ORDER 的顺序排序
- const sortedRoutes = ROUTE_ORDER.filter(route => mergedRoutes.includes(route));
- setStorage(STORAGE_KEYS.VISITED_ROUTES, JSON.stringify(sortedRoutes));
- } else if (state.visited_routes === null || state.visited_routes === undefined) {
- // 如果数据库中是 null 或 undefined,保留本地已有的路由
- if (localVisited.length > 0) {
- setStorage(STORAGE_KEYS.VISITED_ROUTES, JSON.stringify(localVisited));
- } else {
- 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);
-
- debugLog('[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();
- // 音频即将结束前的回调:提前结束情绪/说话 GIF,避免尾部静音导致动画拖尾
- player.onBeforeEnd(() => {
- const snapshot = useAppStore.getState();
- debugLog('[AudioPlayer] onBeforeEnd: audio about to finish, early stop GIF', {
- currentState: snapshot.currentState,
- currentRequestId: snapshot.currentRequestId
- ? snapshot.currentRequestId.substring(0, 8)
- : null,
- });
- // 本次对话即将结束,提前清理情绪状态和 GIF 切换定时器
- pendingEmotionRef.current = null;
- if (emotionGifTimerRef.current) {
- clearTimeout(emotionGifTimerRef.current);
- emotionGifTimerRef.current = null;
- }
- // 提前切回默认头像 GIF,避免 talking/情绪 GIF 多播 1-2 秒
- showDefaultGif();
- });
- // 音频开始播放回调:统一驱动情绪 GIF + talking GIF
- player.onStart(() => {
- const appStateSnapshot = useAppStore.getState();
- const now = Date.now();
- debugLog('[AudioPlayer] 音频开始播放,准备切换情绪/说话 GIF', {
- currentState: appStateSnapshot.currentState,
- currentRequestId: appStateSnapshot.currentRequestId
- ? appStateSnapshot.currentRequestId.substring(0, 8)
- : null,
- currentEmotion: appStateSnapshot.currentEmotion,
- clientNow: now,
- });
- // 优先使用缓存的情绪;如果没有,则使用 store 中当前情绪
- const cachedEmotion = pendingEmotionRef.current;
- const latestEmotion = cachedEmotion || appStateSnapshot.currentEmotion;
- pendingEmotionRef.current = null;
- const emotionCategory = getEmotionCategory(latestEmotion);
- debugLog('[AudioPlayer] onStart emotion category resolved', {
- cachedEmotion,
- latestEmotion,
- emotionCategory,
- });
- // 任何新的播放开始都应清理旧的情绪定时器,避免串台
- if (emotionGifTimerRef.current) {
- clearTimeout(emotionGifTimerRef.current);
- emotionGifTimerRef.current = null;
- debugLog('[AudioPlayer] onStart: cleared previous emotionGifTimer');
- }
- if (emotionCategory === 'positive') {
- // 正向情绪:播放一次正向情绪 GIF,然后自动切回 talking
- showPositiveGif();
- debugLog('[AudioPlayer] onStart: showPositiveGif');
- const currentAvatarId = getStorage(STORAGE_KEYS.CURRENT_AVATAR_ID);
- const duration = getEmotionGifDuration(currentAvatarId, emotionCategory);
- if (duration > 0) {
- emotionGifTimerRef.current = window.setTimeout(() => {
- debugLog('[AudioPlayer] emotionGifTimer fired: positive -> talking', {
- duration,
- avatarId: currentAvatarId,
- });
- showTalkingGif();
- emotionGifTimerRef.current = null;
- }, duration);
- } else {
- // 理论上不会走到这里,兜底切 talking
- debugLog('[AudioPlayer] onStart: positive emotion duration unknown, fallback talking');
- showTalkingGif();
- }
- } else if (emotionCategory === 'negative') {
- // 负向情绪:播放一次负向情绪 GIF,然后自动切回 talking
- showNegativeGif();
- debugLog('[AudioPlayer] onStart: showNegativeGif');
- const currentAvatarId = getStorage(STORAGE_KEYS.CURRENT_AVATAR_ID);
- const duration = getEmotionGifDuration(currentAvatarId, emotionCategory);
- if (duration > 0) {
- emotionGifTimerRef.current = window.setTimeout(() => {
- debugLog('[AudioPlayer] emotionGifTimer fired: negative -> talking', {
- duration,
- avatarId: currentAvatarId,
- });
- showTalkingGif();
- emotionGifTimerRef.current = null;
- }, duration);
- } else {
- debugLog('[AudioPlayer] onStart: negative emotion duration unknown, fallback talking');
- showTalkingGif();
- }
- } else {
- // 中性或未知情绪:直接进入 talking 状态
- debugLog('[AudioPlayer] onStart: neutral/unknown emotion, showTalkingGif');
- showTalkingGif();
- }
- });
- // 音频播放结束回调:强制回归 default,本次对话收尾
- player.onEnd(() => {
- const snapshot = useAppStore.getState();
- const requestId = snapshot.currentRequestId;
- debugLog('[AudioPlayer] onEnd: audio playback finished', {
- currentState: snapshot.currentState,
- currentRequestId: requestId ? requestId.substring(0, 8) : null,
- });
- if (requestId) {
- performanceLogger.recordPlaybackEnd(requestId);
- }
- // 本次对话已结束,清理待处理情绪
- pendingEmotionRef.current = null;
- // 进度条已基于用户说话时长更新,无需在播放完成后再次更新
- // 清理情绪 GIF 切换定时器
- if (emotionGifTimerRef.current) {
- clearTimeout(emotionGifTimerRef.current);
- emotionGifTimerRef.current = null;
- debugLog('[AudioPlayer] onEnd: cleared emotionGifTimer and reset to default GIF');
- }
- // 恢复默认头像 GIF(无论当前处于何种 GIF 状态)
- showDefaultGif();
- setState('idle');
- reset();
- });
- playerRef.current = player;
- // 初始化WebSocket前确保会话已就绪(支持用户直接刷新/直达聊天页)
- // 🔧 如果 URL 中有 OAuth 回调参数,说明正在处理登录回调,不调用 ensureSession(避免创建新用户)
- const oauthFromUrl = searchParams.get('oauth');
- const userIdFromUrl = searchParams.get('user_id');
-
- if (!oauthFromUrl || !userIdFromUrl) {
- // 只有在非 OAuth 回调时才调用 ensureSession
- try {
- await ensureSession();
- } catch (error) {
- console.error('[ChatBotPage] ensureSession 失败,无法初始化 WebSocket:', error);
- clearUserSessionAndRedirectToBegin();
- return;
- }
- } else {
- console.log(`[ChatBotPage] 检测到 OAuth 回调,跳过 ensureSession - oauth=${oauthFromUrl}, user_id=${userIdFromUrl}`);
- }
- // 初始化WebSocket(添加 user_id + token 查询参数,单活)
- const ws = new WebSocketManager();
- const token = getUserToken();
- const params = new URLSearchParams();
- if (user_id) {
- params.append('user_id', user_id);
- }
- if (token) {
- params.append('token', token);
- }
- ws.setAuth(
- { userId: user_id || undefined, token: token || undefined },
- async () => {
- // 服务端明确标记会话失效时(如在其他设备登录),直接清理本地状态并跳转到 /begin
- clearUserSessionAndRedirectToBegin();
- return false;
- }
- );
- const wsUrl = params.toString() ? `${WS_URL}?${params.toString()}` : 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 切换定时器
- pendingEmotionRef.current = null;
- 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 切换定时器
- pendingEmotionRef.current = null;
- 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);
- }
- // 组件卸载时不再保留任何待处理情绪,避免内存中残留过期状态
- pendingEmotionRef.current = null;
- };
- }, [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);
-
- // 🔥 首句消息特殊处理(requestId 以 "greeting_" 开头)
- const isGreetingMessage = requestId.startsWith('greeting_');
- // 记录首句 requestId,便于后续在开始录音时发送 cancel
- if (isGreetingMessage && !greetingRequestIdRef.current) {
- greetingRequestIdRef.current = requestId;
- }
- // 如果用户已经开始录音且选择“以用户为准”,则直接丢弃所有首句相关消息
- if (isGreetingMessage && greetingSuppressedRef.current) {
- debugLog(`[首句] [${requestId.substring(0, 8)}] 已被用户录音打断,忽略首句消息: ${message.type}`);
- return;
- }
- if (isCancelledRequest && !isGreetingMessage) {
- if (message.type === 'audio_response' && message.isLast) {
- cancelledRequestIdsRef.current.delete(requestId);
- }
- debugLog(`[${message.type}] [${requestId.substring(0, 8)}] 已取消的请求,忽略响应`);
- return;
- }
-
- switch (message.type) {
- case 'text_result':
- // 🔥 首句消息:直接处理,不检查 activeRequestId
- if (isGreetingMessage) {
- // 首句文本:添加到消息列表(作为 assistant 消息)
- if (message.isFinal) {
- // 这里可以添加消息到聊天历史(如果需要显示在界面上)
- debugLog(`[首句文本] [${requestId.substring(0, 8)}] ${message.text}`);
- // 设置识别文本,让前端显示
- setRecognizedText(message.text);
- }
- break;
- }
-
- // 如果当前状态是 recording,且 requestId 不匹配当前请求,说明这是旧请求的返回,直接丢弃
- // 如果 activeRequestId 为 null,说明是新请求的开始,允许处理
- if (activeRequestId !== null && requestId !== activeRequestId) {
- debugLog(`[文本结果] [${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
- // 满足解锁阈值时实时解锁,避免必须刷新页面
- if (message.totalDurationMs >= AVATAR_C_UNLOCK_THRESHOLD_MS && !avatarCUnlockedRef.current) {
- unlockAvatarC();
- }
- // 更新本地累加器,确保与数据库一致
- 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;
-
- debugLog('[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) {
- debugLog('[Progress] [user_level] 初始化同步,跳过动画但同步进度');
- shouldSkipProgressAnimationRef.current = true;
- }
- // 无论是否初始化,都同步进度值,动画是否播放由 shouldSkipProgressAnimationRef 控制
- debugLog('[Progress] [user_level] 同步进度,设置进度值:', {
- progress,
- targetLoopCount,
- });
- // 保持当前的圈数,不重置为0
- setProgress(progress, targetLoopCount);
- } else {
- debugLog('[Progress] [user_level] 差异太小,跳过同步');
- }
- }
- break;
- case 'emotion':
- // 🔥 首句消息:直接处理,不检查 activeRequestId
- if (isGreetingMessage) {
- performanceLogger.recordEmotion(requestId, message.emotion, message.timestamp);
- setEmotion(message.emotion);
- // 🔥 仅缓存情绪,不立即切换 GIF,等待首句音频真正开始播放时再驱动动画
- pendingEmotionRef.current = message.emotion;
- break;
- }
-
- // 如果当前状态是 recording,且 requestId 不匹配当前请求,说明这是旧请求的返回,直接丢弃
- // 如果 activeRequestId 为 null,说明是新请求的开始,允许处理
- if (activeRequestId !== null && requestId !== activeRequestId) {
- debugLog(`[情绪] [${requestId.substring(0, 8)}] 请求ID不匹配,丢弃旧请求的情绪`);
- break;
- }
-
- performanceLogger.recordEmotion(requestId, message.emotion, message.timestamp);
- setEmotion(message.emotion);
- // 🔥 普通情绪消息同样只缓存,真正的情绪动画在音频开始播放时统一处理
- pendingEmotionRef.current = message.emotion;
- break;
- case 'audio_response':
- // 🔥 首句消息:直接处理,不检查状态和 activeRequestId
- if (isGreetingMessage) {
- // 首句音频:自动播放
- if (currentState !== 'playing') {
- const now = Date.now();
- debugLog(`[音频响应][首句] [${requestId.substring(0, 8)}] 首次音频块,进入 playing 状态`, {
- messageTimestamp: message.timestamp,
- clientNow: now,
- delayMs: typeof message.timestamp === 'number' ? now - message.timestamp : null,
- });
- performanceLogger.recordFirstAudio(requestId, message.timestamp);
- performanceLogger.recordPlaybackStart(requestId);
- setState('playing');
- }
- // 标记当前处于首句播放阶段,便于后续开始录音时精确打断
- isGreetingActiveRef.current = true;
-
- if (message.data && playerRef.current) {
- // 将 base64 字符串解码为 Uint8Array
- 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();
- // 首句自然播放结束
- isGreetingActiveRef.current = false;
- greetingRequestIdRef.current = null;
- }
- break;
- }
-
- // 如果当前状态是 recording,说明用户已经点击了录音按钮,忽略后续的音频响应
- if (currentState === 'recording') {
- debugLog(`[音频响应] [${requestId.substring(0, 8)}] 用户已开始录音,忽略音频响应`);
- break;
- }
-
- // 如果 currentRequestId 不为 null,且 requestId 不匹配当前请求,说明这是旧请求的返回,直接丢弃
- // 如果 activeRequestId 为 null,说明是新请求的开始,允许处理
- if (activeRequestId !== null && requestId !== activeRequestId) {
- debugLog(`[音频响应] [${requestId.substring(0, 8)}] 请求ID不匹配,丢弃旧请求的音频响应`, {
- activeRequestId: activeRequestId ? activeRequestId.substring(0, 8) : null,
- messageIsLast: message.isLast,
- });
- break;
- }
-
- // 清除发送超时
- if (sendingTimeoutRef.current) {
- clearTimeout(sendingTimeoutRef.current);
- sendingTimeoutRef.current = null;
- }
-
- if (currentState !== 'playing') {
- const now = Date.now();
- debugLog(`[音频响应] [${requestId.substring(0, 8)}] 首次音频块,进入 playing 状态`, {
- messageTimestamp: message.timestamp,
- clientNow: now,
- delayMs: typeof message.timestamp === 'number' ? now - message.timestamp : null,
- });
- performanceLogger.recordFirstAudio(requestId, message.timestamp);
- performanceLogger.recordPlaybackStart(requestId);
- setState('playing');
- }
-
- 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);
- }
-
- debugLog(`[音频响应] [${requestId.substring(0, 8)}] 收到音频数据块`, {
- bytes: audioBytes.length,
- sampleRate: message.sampleRate || 24000,
- isLast: message.isLast,
- });
- playerRef.current?.addChunk(audioBytes, message.sampleRate || 24000);
- }
-
- if (message.isLast) {
- debugLog(`[音频响应] [${requestId.substring(0, 8)}] 收到 isLast=true,标记音频流结束`);
- 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);
- // 静默处理后端错误提示,避免打扰用户
- debugLog('[ServiceError]', message.message, message.code);
- setError(message.message);
- if (message.code === 'SESSION_INVALIDATED') {
- // 会话在其他设备被顶掉:清理会话并统一跳转到 /begin
- wsRef.current?.disconnect();
- antMessage.error('会话已在其他设备登录,请重新进入');
- clearUserSessionAndRedirectToBegin();
- return;
- }
- // 清理情绪 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;
- }
-
- // 🔥 改进:允许在连接建立过程中开始录音
- // 1. 如果 WebSocket 不存在,说明还未初始化,提示等待
- // 2. 如果 WebSocket 存在,检查是否正在连接中或已就绪
- // 3. 如果正在连接中(CONNECTING)或已就绪(OPEN),允许开始录音,消息会被缓冲
- // 4. 这样用户无感知,业务也能正常运行
- if (!wsRef.current) {
- antMessage.warning('语音服务初始化中,请稍后重试');
- return;
- }
- // 检查 WebSocket 状态:如果正在连接中或已就绪,允许开始录音
- const isConnecting = wsRef.current.isConnecting();
- const isReady = wsRef.current.isReady();
-
- // 如果既不在连接中,也不已就绪,且 isConnected 为 false,说明连接失败
- if (!isConnecting && !isReady && !isConnected) {
- // 静默提示:语音服务重连中
- debugLog('[SendGuard] 语音服务正在重连');
- 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)都允许开始录音
- // 一旦用户主动开始录音,则以用户为准:后续所有首句 greeting 消息全部丢弃
- greetingSuppressedRef.current = true;
- // 如果此时已经拿到首句的 requestId,则主动向后端发送 cancel,节省资源
- if (greetingRequestIdRef.current) {
- const greetingId = greetingRequestIdRef.current;
- wsRef.current?.sendCancel(greetingId);
- cancelledRequestIdsRef.current.add(greetingId);
- debugLog(`[首句] [${greetingId.substring(0, 8)}] 用户开始录音,发送 cancel 终止首句`);
- }
- // 首次点击录音按钮,隐藏 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;
- // 移除提示,让用户无感知,业务功能正常(prepareMobileAudio 和 requestVolumeControl 仍会执行)
- })
- .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 切换定时器
- pendingEmotionRef.current = null;
- if (emotionGifTimerRef.current) {
- clearTimeout(emotionGifTimerRef.current);
- emotionGifTimerRef.current = null;
- }
- // 恢复默认头像 GIF
- showDefaultGif();
- setState('idle');
- reset();
- return;
- }
- };
- // 发送录音
- const handleSend = () => {
- if (currentState !== 'recording' || !currentRequestId) {
- return;
- }
- stopRecordingTimer();
-
- // 🔥 改进:允许在连接建立过程中发送录音
- // 1. 如果 WebSocket 不存在,说明还未初始化,提示错误
- // 2. 如果 WebSocket 存在,检查是否正在连接中或已就绪
- // 3. 如果正在连接中(CONNECTING)或已就绪(OPEN),允许发送,消息会被缓冲
- // 4. 这样用户无感知,业务也能正常运行
- if (!wsRef.current) {
- // 静默处理:语音服务初始化中
- debugLog('[SendGuard] 语音服务初始化中');
- recorderRef.current?.stop();
- resetFrequencyVisualization();
- // 清理解码中的情绪状态与 GIF 切换定时器
- pendingEmotionRef.current = null;
- if (emotionGifTimerRef.current) {
- clearTimeout(emotionGifTimerRef.current);
- emotionGifTimerRef.current = null;
- }
- // 恢复默认头像 GIF
- showDefaultGif();
- performanceLogger.clearRequest(currentRequestId, 'websocket_unready_before_send');
- reset();
- return;
- }
- // 检查 WebSocket 状态:如果正在连接中或已就绪,允许发送
- const isConnecting = wsRef.current.isConnecting();
- const isReady = wsRef.current.isReady();
-
- // 如果既不在连接中,也不已就绪,且 isConnected 为 false,说明连接失败
- if (!isConnecting && !isReady && !isConnected) {
- // 静默处理:语音服务未连接
- debugLog('[SendGuard] 语音服务未连接');
- recorderRef.current?.stop();
- resetFrequencyVisualization();
- // 清理解码中的情绪状态与 GIF 切换定时器
- pendingEmotionRef.current = null;
- if (emotionGifTimerRef.current) {
- clearTimeout(emotionGifTimerRef.current);
- emotionGifTimerRef.current = null;
- }
- // 恢复默认头像 GIF
- showDefaultGif();
- performanceLogger.clearRequest(currentRequestId, 'websocket_disconnected_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;
-
- // 进度条与累计说话时长以后端为准:
- // 这里只负责将本次说话时长发送给后端,不在前端进行乐观更新,
- // 后端在成功处理并持久化 total_speech_duration_ms 后,会通过 totalDurationMs 同步最新进度。
- if (speechDuration > 0 && shouldSkipProgressAnimationRef.current) {
- debugLog('[Progress] [handleSend] 初始化阶段结束,后续进度同步以后端为准', {
- speechDurationMs: speechDuration,
- });
- shouldSkipProgressAnimationRef.current = false;
- }
- 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}] 响应超时,自动重置`);
- // 静默处理超时提示
- debugLog('[Timeout] 响应超时,自动重置');
- // 清理解码中的情绪状态与 GIF 切换定时器
- pendingEmotionRef.current = null;
- 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);
- debugLog(`[取消] [${shortId}] 取消录音`);
- recorderRef.current?.stop();
- resetFrequencyVisualization();
- stopRecordingTimer();
- // 清理解码中的情绪状态与 GIF 切换定时器
- pendingEmotionRef.current = null;
- 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',
- }}
- />
- )}
- {/* 能量石按钮 */}
- <button
- type="button"
- 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',
- }}
- onClick={async () => {
- // 更新点击状态(控制光晕显示)
- 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);
- pendingStoneIdRef.current = stone.stoneId;
- if (stone.stoneType === 'mission') {
- updatePayload.has_shown_mission_stone_modal = true;
- } else {
- updatePayload.has_shown_conversation_stone_modal = true;
- }
- }
- // 非首次弹窗,直接播放动画并移除能量石
- if (!isFirstShowModal) {
- startStoneAnimationAndRemove(stone.stoneId);
- }
-
- // 合并同步到数据库(一次性更新所有需要更新的字段)
- if (userId && Object.keys(updatePayload).length > 0) {
- try {
- await updateUserUIState(userId, updatePayload);
- debugLog('[能量石] 能量石状态已同步到数据库:', updatePayload);
-
- // 同步成功后更新 localStorage
- if (isFirstShowModal) {
- setStorage(storageKey, 'true');
- }
- } catch (error) {
- console.error('同步能量石状态到数据库失败:', error);
- // 同步失败时,如果只是点击状态失败,不影响弹窗显示
- // 但如果是弹窗状态失败,需要回滚弹窗显示
- if (isFirstShowModal) {
- // 弹窗已经显示,但同步失败,记录错误以便后续补偿
- console.warn('[能量石] 弹窗状态同步失败,但弹窗已显示');
- }
- }
- } else if (!userId) {
- // 没有 userId(游客态),仅更新本地
- if (isFirstShowModal) {
- setStorage(storageKey, 'true');
- }
- debugLog('[能量石] 游客状态,仅更新本地,等待后续补偿同步');
- } else {
- // 没有需要更新的字段,直接更新 localStorage(如果显示弹窗)
- if (isFirstShowModal) {
- setStorage(storageKey, 'true');
- }
- }
- }}
- aria-label={stone.stoneType === 'mission' ? '任务能量石' : '对话能量石'}
- >
- <img src={asset} alt="" aria-hidden="true" />
- </button>
- </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>
- {/* Yp 图片 - 位于动画形象下方 */}
- <div
- className="absolute"
- style={{
- top: `calc(${px2vmin(248)} + ${px2vmin(302)})`,
- left: '50%',
- transform: 'translateX(-50%)',
- width: px2vmin(113),
- height: px2vmin(18),
- zIndex: 10,
- }}
- >
- <img
- src={YpImage}
- alt="Yp"
- className="w-full h-full object-contain"
- style={{ width: '100%', height: '100%' }}
- draggable={false}
- />
- </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: '1px',
- 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,
- zIndex: 1,
- }}
- />
-
- {/* 进度填充层 - 使用 SVG + CSS Mask */}
- {/* SVG Mask 显示完整进度(到 progress%) */}
- {displayProgress > 0 && (
- <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%',
- // CSS conic-gradient mask:从 215° 开始,每 1% = 2.88 度
- // 显示完整进度(到 progress%)
- 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)`,
- zIndex: 2,
- }}
- >
- <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>
- {/* 能量石弹窗 */}
- <MissionEnergyStoneModal
- visible={showMissionStoneModal}
- onClose={handleMissionStoneModalClose}
- stoneType={energyStoneModalType}
- />
- </div>
- );
- };
|