| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995 |
- import { useEffect, useRef, useState } from 'react';
- import { message as antMessage } from 'antd';
- import { v4 as uuidv4 } from 'uuid';
- import { useAppStore } from '@/store';
- import { AudioRecorder } from '@/utils/audioRecorder';
- import { AudioPlayer } from '@/utils/audioPlayer';
- import { WebSocketManager } from '@/utils/websocket';
- import { prepareMobileAudio, requestVolumeControl, isMobileDevice } from '@/utils/audioHelper';
- import { setStorage, getStorage, STORAGE_KEYS, markRouteVisited, getUserId } from '@/utils/storage';
- import { onScreenResize, calculateLayout, type CalculatedLayout, updateTooSmallFlag } from '@/utils/screenSize';
- import { performanceLogger } from '@/utils/logger';
- import { getUserInfo } from '@/utils/api';
- import type { Message } from '@/types';
- import '@/styles/chatbot-fix.css';
- import avatarC from '@/assets/Avatar-C.svg';
- import treesImage from '@/assets/Group-3465183.svg';
- import animaZoneAvatar from '@/assets/Avatar-anima-zone.png';
- import audioButton from '@/assets/audio-button.png';
- import { AudioWave } from '@/components/AudioWave';
- import microphoneIcon from '@/assets/microphone-icon.svg';
- import progressRing from '@/assets/progress-ring.svg';
- import progressFull from '@/assets/progress-full.svg';
- import tapToChat from '@/assets/TapToChat.svg';
- import cancelIcon from '@/assets/cancel-icon.svg';
- import sendIcon from '@/assets/send-icon.svg';
- import tooltipsCancel from '@/assets/tooltips-cancel.svg';
- import tooltipsSend from '@/assets/tooltips-send.svg';
- import tooltipsProgressBar from '@/assets/tooltips-progress-bar.svg';
- const WS_URL = import.meta.env.VITE_WS_URL || 'ws://localhost:8000/ws';
- export const ChatBotPage = () => {
- const {
- currentState,
- currentRequestId,
- currentEmotion,
- conversationProgress,
- progressLoopCount,
- setState,
- setRequestId,
- setEmotion,
- setRecognizedText,
- setError,
- setConnected,
- updateProgressByDuration,
- setProgress,
- reset,
- } = useAppStore();
- const recorderRef = useRef<AudioRecorder | null>(null);
- const playerRef = useRef<AudioPlayer | null>(null);
- const wsRef = useRef<WebSocketManager | null>(null);
- const chunkIndexRef = useRef(0);
- const mobileAudioPreparedRef = useRef(false);
- const sendTimeRef = useRef<number>(0);
- // 初始化时检查 localStorage,确保第一次打开时立即显示提示
- const [showTapToChat, setShowTapToChat] = useState(() => {
- const hasShown = getStorage(STORAGE_KEYS.HAS_SHOWN_TAP_TO_CHAT);
- if (!hasShown) {
- // 第一次打开,立即保存标记并显示
- setStorage(STORAGE_KEYS.HAS_SHOWN_TAP_TO_CHAT, 'true');
- return true;
- }
- return false;
- });
- const sendingTimeoutRef = useRef<number | null>(null);
- const [showCancelTooltip, setShowCancelTooltip] = useState(false);
- const [showSendTooltip, setShowSendTooltip] = useState(false);
- const [showProgressBarTooltip, setShowProgressBarTooltip] = useState(false);
- const progressBarTooltipTimerRef = useRef<number | null>(null);
-
- // 控制声纹容器高度(延迟降低,等待衰减动画完成)
- const [shouldExpandWaveform, setShouldExpandWaveform] = useState(false);
- const waveformCollapseTimerRef = useRef<number | null>(null);
-
- // 用于动画的显示进度(平滑过渡)
- const [displayProgress, setDisplayProgress] = useState(0);
- const animationFrameRef = useRef<number>();
- const totalProgressRef = useRef(0);
- const shouldSkipProgressAnimationRef = useRef(true);
-
- // V2: 完全计算式布局系统
- const [layout, setLayout] = useState<CalculatedLayout>(() => {
- const initialLayout = calculateLayout();
- return initialLayout;
- });
-
- const timingRef = useRef({
- recordStart: 0,
- recordEnd: 0,
- firstAudioReceived: 0,
- emotionReceived: 0,
- });
- // 进度动画效果(支持多圈连续动画)
- useEffect(() => {
- const targetTotal = progressLoopCount * 100 + conversationProgress;
- const startTotal = totalProgressRef.current;
- const diff = targetTotal - startTotal;
- if (shouldSkipProgressAnimationRef.current) {
- shouldSkipProgressAnimationRef.current = false;
- totalProgressRef.current = targetTotal;
- const normalized = ((targetTotal % 100) + 100) % 100;
- setDisplayProgress(normalized);
- return;
- }
- // 若变化极小,直接同步
- if (Math.abs(diff) < 0.1) {
- totalProgressRef.current = targetTotal;
- const normalized = ((targetTotal % 100) + 100) % 100;
- setDisplayProgress(normalized);
- return;
- }
- // 每 1% 的进度约 8ms,限制在 0.4s~2.4s 之间
- const durationPerPercent = 8;
- const duration = Math.max(400, Math.min(2400, Math.abs(diff) * durationPerPercent));
- const startTime = performance.now();
- if (animationFrameRef.current) {
- cancelAnimationFrame(animationFrameRef.current);
- }
- const animate = (currentTime: number) => {
- const elapsed = currentTime - startTime;
- const progress = Math.min(elapsed / duration, 1);
- // easeOutCubic 缓动
- const easeProgress = 1 - Math.pow(1 - progress, 3);
- const currentTotal = startTotal + diff * easeProgress;
- totalProgressRef.current = currentTotal;
- const normalized = ((currentTotal % 100) + 100) % 100;
- setDisplayProgress(normalized);
- if (progress < 1) {
- animationFrameRef.current = requestAnimationFrame(animate);
- } else {
- totalProgressRef.current = targetTotal;
- const finalNormalized = ((targetTotal % 100) + 100) % 100;
- setDisplayProgress(finalNormalized);
- }
- };
- animationFrameRef.current = requestAnimationFrame(animate);
- return () => {
- if (animationFrameRef.current) {
- cancelAnimationFrame(animationFrameRef.current);
- }
- };
- }, [conversationProgress, progressLoopCount]);
- // 控制声纹容器高度变化
- useEffect(() => {
- // 清除之前的定时器
- if (waveformCollapseTimerRef.current) {
- clearTimeout(waveformCollapseTimerRef.current);
- waveformCollapseTimerRef.current = null;
- }
- if (currentState === 'recording' || currentState === 'playing') {
- // 录音或播放时,立即展开
- setShouldExpandWaveform(true);
- } else {
- // 其他状态(sending/idle 等),延迟 3 秒后再收起(等待衰减动画完成)
- waveformCollapseTimerRef.current = window.setTimeout(() => {
- setShouldExpandWaveform(false);
- }, 3000); // 3秒足够让所有竖条衰减为静态高度
- }
- return () => {
- if (waveformCollapseTimerRef.current) {
- clearTimeout(waveformCollapseTimerRef.current);
- }
- };
- }, [currentState]);
- // 监听录音状态,首次显示取消提示(3秒后自动隐藏)
- useEffect(() => {
- if (currentState === 'recording') {
- const hasShownCancel = getStorage(STORAGE_KEYS.HAS_SHOWN_CANCEL_TOOLTIP);
- if (!hasShownCancel) {
- setShowCancelTooltip(true);
- setStorage(STORAGE_KEYS.HAS_SHOWN_CANCEL_TOOLTIP, 'true');
-
- // 3秒后自动隐藏
- const timer = setTimeout(() => {
- setShowCancelTooltip(false);
- }, 3000);
-
- return () => clearTimeout(timer);
- } else {
- // 已显示过,确保隐藏
- setShowCancelTooltip(false);
- }
- } else {
- // 非录音状态,隐藏提示
- setShowCancelTooltip(false);
- }
- }, [currentState]);
- // 监听录音状态,首次显示发送提示(3秒后自动隐藏)
- useEffect(() => {
- if (currentState === 'recording') {
- const hasShownSend = getStorage(STORAGE_KEYS.HAS_SHOWN_SEND_TOOLTIP);
- if (!hasShownSend) {
- setShowSendTooltip(true);
- setStorage(STORAGE_KEYS.HAS_SHOWN_SEND_TOOLTIP, 'true');
-
- // 3秒后自动隐藏
- const timer = setTimeout(() => {
- setShowSendTooltip(false);
- }, 3000);
-
- return () => clearTimeout(timer);
- } else {
- // 已显示过,确保隐藏
- setShowSendTooltip(false);
- }
- } else {
- // 非录音状态,隐藏提示
- setShowSendTooltip(false);
- }
- }, [currentState]);
- // 监听进度条变化,首次有进度时显示进度条提示(2秒后自动隐藏)
- // 🔧 临时修改:为了UI调试,进度更新时始终显示提示
- useEffect(() => {
- // const hasShownProgressBar = getStorage(STORAGE_KEYS.HAS_SHOWN_PROGRESS_BAR_TOOLTIP);
-
- // // 如果已经显示过,不再处理
- // if (hasShownProgressBar) {
- // return;
- // }
-
- // 当进度条第一次大于0时显示提示(只显示一次)
- const hasShownProgressBarTooltip = getStorage(STORAGE_KEYS.HAS_SHOWN_PROGRESS_BAR_TOOLTIP);
- if (displayProgress > 0 && !showProgressBarTooltip && !hasShownProgressBarTooltip) {
- setShowProgressBarTooltip(true);
- setStorage(STORAGE_KEYS.HAS_SHOWN_PROGRESS_BAR_TOOLTIP, 'true');
-
- // 2秒后自动隐藏
- progressBarTooltipTimerRef.current = window.setTimeout(() => {
- setShowProgressBarTooltip(false);
- progressBarTooltipTimerRef.current = null;
- }, 2000);
- }
-
- return () => {
- if (progressBarTooltipTimerRef.current) {
- clearTimeout(progressBarTooltipTimerRef.current);
- progressBarTooltipTimerRef.current = null;
- }
- };
- }, [displayProgress, showProgressBarTooltip]);
- // 监听屏幕尺寸变化
- useEffect(() => {
- // 初始化时更新标记
- updateTooSmallFlag();
-
- const cleanup = onScreenResize(() => {
- const newLayout = calculateLayout();
- setLayout(newLayout); // V2: 更新完全计算式布局
-
- // 更新 data-too-small 标记
- updateTooSmallFlag();
- });
-
- return cleanup;
- }, []);
- // 初始化
- useEffect(() => {
- // 记录用户当前所在页面
- setStorage(STORAGE_KEYS.CURRENT_PAGE, 'chatbot');
-
- // 记录路由访问
- markRouteVisited('/chatbot');
-
- const init = async () => {
- try {
- // 获取用户信息并初始化进度条
- const user_id = getUserId();
- if (user_id) {
- try {
- const userInfo = await getUserInfo(user_id);
- if (userInfo.success && userInfo.data.total_speech_duration_ms) {
- // 根据总说话时长计算初始进度(30秒=100%)
- const PROGRESS_FULL_DURATION_MS = 30 * 1000; // 30000ms
- const totalDurationMs = userInfo.data.total_speech_duration_ms || 0;
- const totalProgressPercent = (totalDurationMs / PROGRESS_FULL_DURATION_MS) * 100;
- const initialLoopCount = Math.floor(totalProgressPercent / 100);
- const initialProgress = totalProgressPercent % 100;
- shouldSkipProgressAnimationRef.current = true;
- setProgress(initialProgress, initialLoopCount);
- }
- } catch (error) {
- // 获取用户信息失败不影响主流程,静默处理
- console.warn('获取用户信息失败:', error);
- }
- }
- // 初始化录音器
- const recorder = new AudioRecorder();
- await recorder.initialize();
- recorderRef.current = recorder;
- // 初始化播放器
- const player = new AudioPlayer();
- await player.initialize();
- player.onEnd(() => {
- const requestId = useAppStore.getState().currentRequestId;
- if (requestId) {
- performanceLogger.recordPlaybackEnd(requestId);
- }
- // 进度条已基于用户说话时长更新,无需在播放完成后再次更新
- setState('idle');
- reset();
- });
- playerRef.current = player;
- // 初始化WebSocket(添加user_id查询参数)
- const ws = new WebSocketManager();
- const wsUrl = user_id ? `${WS_URL}?user_id=${encodeURIComponent(user_id)}` : WS_URL;
- ws.connect(wsUrl, (connected) => {
- setConnected(connected);
- if (connected) {
- // 记录WebSocket连接建立时间
- performanceLogger.recordWebSocketConnected();
- } else {
- setState('error');
- setError('WebSocket连接断开');
- }
- });
- ws.onMessage('app', handleMessage);
- wsRef.current = ws;
- } catch (error) {
- antMessage.error('初始化失败,请检查麦克风权限');
- setError('初始化失败');
- }
- };
- init();
- return () => {
- recorderRef.current?.release();
- playerRef.current?.release();
- wsRef.current?.disconnect();
- if (sendingTimeoutRef.current) {
- clearTimeout(sendingTimeoutRef.current);
- }
- if (animationFrameRef.current) {
- cancelAnimationFrame(animationFrameRef.current);
- }
- if (progressBarTooltipTimerRef.current) {
- clearTimeout(progressBarTooltipTimerRef.current);
- }
- };
- }, []);
- // 处理WebSocket消息
- const handleMessage = (message: Message) => {
- const requestId = message.requestId || currentRequestId;
-
- switch (message.type) {
- case 'text_result':
- 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 'emotion':
- performanceLogger.recordEmotion(requestId, message.emotion, message.timestamp);
- setEmotion(message.emotion);
- break;
- case 'audio_response':
- // 清除发送超时
- if (sendingTimeoutRef.current) {
- clearTimeout(sendingTimeoutRef.current);
- sendingTimeoutRef.current = null;
- }
-
- if (currentState !== 'playing') {
- 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);
- }
-
- playerRef.current?.addChunk(audioBytes, message.sampleRate || 24000);
- }
-
- if (message.isLast) {
- performanceLogger.recordLastAudio(requestId);
- playerRef.current?.markStreamEnd();
- // 进度已在发送时增加,这里不再重复增加
- }
- break;
- case 'error':
- console.error(`[错误] [${requestId?.substring(0, 8) || ''}]`, message.message, message.code);
- antMessage.error(message.message);
- setError(message.message);
- performanceLogger.clearRequest(requestId, `${message.code}: ${message.message}`);
- break;
- case 'pong':
- break;
- default:
- console.warn(`[未知消息] [${requestId?.substring(0, 8) || ''}]`, message.type);
- break;
- }
- };
- // 开始录音
- const handleStartRecord = async () => {
- // 如果正在录音,忽略
- if (currentState === 'recording') {
- return;
- }
-
- // 如果正在播放,先停止播放
- if (currentState === 'playing') {
- playerRef.current?.stop();
- }
-
- // 其他状态(idle/error/sending)都允许开始录音
- // 首次点击录音按钮,隐藏 TapToChat 提示
- if (showTapToChat) {
- setShowTapToChat(false);
- }
- // 点击录音按钮时,隐藏进度条提示
- if (showProgressBarTooltip) {
- setShowProgressBarTooltip(false);
- if (progressBarTooltipTimerRef.current) {
- clearTimeout(progressBarTooltipTimerRef.current);
- progressBarTooltipTimerRef.current = null;
- }
- }
- if (isMobileDevice() && !mobileAudioPreparedRef.current) {
- try {
- await prepareMobileAudio();
- requestVolumeControl();
- mobileAudioPreparedRef.current = true;
- antMessage.info('请确保设备音量已调高');
- } catch (error) {
- // 准备移动端音频失败,静默处理
- }
- }
- // 🔥 清理播放器状态,确保上一次对话的残留状态被重置
- playerRef.current?.stop();
- const requestId = uuidv4();
- performanceLogger.startRequest(requestId);
- setRequestId(requestId);
- setState('recording');
- chunkIndexRef.current = 0;
- setRecognizedText('');
-
- timingRef.current = {
- recordStart: Date.now(),
- recordEnd: 0,
- firstAudioReceived: 0,
- emotionReceived: 0,
- };
- recorderRef.current?.start(
- (pcmData) => {
- 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++;
- }
- );
- };
- // 发送录音
- const handleSend = () => {
- if (currentState !== 'recording' || !currentRequestId) {
- return;
- }
- timingRef.current.recordEnd = Date.now();
- sendTimeRef.current = Date.now();
- performanceLogger.recordEnd(currentRequestId, chunkIndexRef.current);
- // 计算说话时长(毫秒)
- const speechDuration = timingRef.current.recordEnd - timingRef.current.recordStart;
-
- // 更新进度条(基于说话时长,30秒=100%)
- if (speechDuration > 0) {
- updateProgressByDuration(speechDuration);
- }
- recorderRef.current?.stop();
- // 发送音频结束消息,包含说话时长信息
- 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') {
- console.warn(`[超时] [${currentRequestId.substring(0, 8)}] 响应超时,自动重置`);
- antMessage.warning('响应超时,请重试');
- setState('idle');
- reset();
- performanceLogger.clearRequest(currentRequestId, '响应超时');
- }
- }, 10000);
- };
- // 取消录音
- const handleCancel = () => {
- if (currentState !== 'recording' || !currentRequestId) return;
- console.log(`[取消] [${currentRequestId.substring(0, 8)}] 取消录音`);
- recorderRef.current?.stop();
- wsRef.current?.sendCancel(currentRequestId);
- performanceLogger.clearRequest(currentRequestId);
- reset();
- };
- return (
- <div
- className={`chatbot-container relative w-full ${
- layout.needsScroll ? 'h-auto overflow-y-auto overflow-x-hidden' : 'h-screen overflow-hidden'
- }`}
- style={{
- minHeight: layout.needsScroll ? `${layout.contentHeight}px` : '100vh',
- // ⚠️ 移除 safe-area-inset class,改用手动控制顶部安全区域
- // 底部不加 padding,让声波纹可以直接贴底定位
- paddingTop: 'env(safe-area-inset-top)',
- paddingLeft: 'env(safe-area-inset-left)',
- paddingRight: 'env(safe-area-inset-right)',
- }}
- >
- {/* 背景图片 */}
- <div
- className="chatbot-bg absolute inset-0 pointer-events-none"
- style={{
- backgroundImage: 'url(/tolan-bg.svg)',
- backgroundSize: 'cover',
- backgroundPosition: 'center',
- backgroundRepeat: 'no-repeat',
- zIndex: 0,
- }}
- />
- {/* 内容容器 - 自适应所有屏幕尺寸 */}
- <div
- className="relative w-full"
- style={{
- boxSizing: 'border-box',
- minHeight: layout.needsScroll ? `${layout.contentHeight}px` : '100vh',
- height: layout.needsScroll ? 'auto' : '100%',
- }}
- >
- {/* 树木装饰 - 设计稿:top 175px, 宽度 395.03px, 高度 220.66px, 左右居中 */}
- <img
- src={treesImage}
- alt="Trees"
- className="chatbot-trees absolute pointer-events-none"
- style={{
- width: `${layout.treesWidth}px`,
- height: 'auto',
- top: `${layout.treesTop}px`,
- left: '50%',
- transform: 'translateX(-50%)',
- opacity: 1,
- zIndex: 1,
- }}
- />
- {/* Avatar-C 头像 - 设计稿:top 63px, right 63px, 尺寸 40×40 */}
- <img
- src={avatarC}
- alt="Avatar C"
- className="chatbot-avatar-c absolute pointer-events-none rounded-full"
- style={{
- width: `${layout.avatarCSize}px`,
- height: `${layout.avatarCSize}px`,
- top: `${layout.avatarCTop}px`,
- right: `${layout.avatarCRight}px`,
- opacity: 0.2,
- zIndex: 5,
- }}
- />
- {/* AnimaZone 头像 - 设计稿:top 248px (树木175px + 73px), 尺寸 343×340, 左右居中 */}
- <img
- src={animaZoneAvatar}
- alt="AnimaZone Avatar"
- className="chatbot-anima-zone-avatar absolute"
- style={{
- width: `${layout.mainAvatarWidth}px`,
- height: `${layout.mainAvatarHeight}px`,
- top: `${layout.animaZoneTop}px`,
- left: '50%',
- transform: 'translateX(-50%)',
- opacity: 1,
- zIndex: 2,
- }}
- />
- {/* 情感展示 - 设计稿:距离界面顶部 50px,左右居中 */}
- <div
- className="chatbot-emotion absolute"
- style={{
- top: `${layout.emotionTop}px`,
- left: '50%',
- transform: 'translateX(-50%)',
- textAlign: 'center',
- zIndex: 3,
- }}
- >
- <div
- className="text-fluid-sm"
- style={{
- fontWeight: 500,
- color: 'rgba(255, 255, 255, 0.9)',
- textShadow: '0px 2px 4px rgba(0, 0, 0, 0.3)',
- padding: '6px 16px',
- borderRadius: '12px',
- background: 'rgba(255, 255, 255, 0.1)',
- backdropFilter: 'blur(10px)',
- border: '1px solid rgba(255, 255, 255, 0.2)',
- }}
- >
- {currentEmotion}
- </div>
- </div>
- {/* 音频波形 - 宽度 100% 自适应 */}
- <div
- className="chatbot-audio-wave overflow-hidden"
- style={{
- position: 'fixed', // 改为 fixed,直接相对于视口定位
- width: '100%',
- height: shouldExpandWaveform
- ? `${layout.waveformHeight}px`
- : '20px',
- bottom: `${layout.waveformBottom}px`,
- left: 0,
- transition: 'height 0.3s ease-in-out',
- zIndex: 10,
- }}
- data-expanded={shouldExpandWaveform ? 'true' : 'false'}
- data-waveform-bottom={layout.waveformBottom}
- >
- <div
- style={{
- position: 'absolute',
- bottom: 0,
- left: 0,
- width: '100%',
- height: `${layout.waveformHeight}px`,
- }}
- >
- <AudioWave
- isAnimating={currentState === 'recording' || currentState === 'playing'}
- width={typeof window !== 'undefined' ? window.innerWidth : 375}
- height={layout.waveformHeight}
- />
- </div>
- </div>
- {/* 进度条+录音按钮容器 - 设计稿:bottom 84px, 尺寸 114×114, 左右居中 */}
- {currentState !== 'recording' && (
- <div
- className="chatbot-progress-container absolute"
- style={{
- width: `${layout.progressRingSize}px`,
- height: `${layout.progressRingSize}px`,
- top: `${layout.progressRingTop}px`,
- left: '50%',
- transform: 'translateX(-50%)',
- zIndex: 20,
- }}
- >
- {/* 进度环 - 背景层 */}
- <div
- className="chatbot-progress-ring absolute"
- style={{
- width: '100%',
- height: '100%',
- top: 0,
- left: '-0.5px',
- }}
- >
- {/* 空状态背景 */}
- <img
- src={progressRing}
- alt="Progress Ring"
- className="pointer-events-none"
- style={{
- position: 'absolute',
- top: 0,
- left: 0,
- width: '100%',
- height: '100%',
- opacity: 1,
- }}
- />
-
- {/* 进度填充层 - 使用conic-gradient mask */}
- <div
- className="pointer-events-none"
- style={{
- position: 'absolute',
- top: '5px',
- left: 0,
- width: '100%',
- height: '100%',
- maskImage: `conic-gradient(from 215deg at 50% 50%, black 0deg, black ${displayProgress * 2.88}deg, transparent ${displayProgress * 2.88}deg, transparent 360deg)`,
- WebkitMaskImage: `conic-gradient(from 215deg at 50% 50%, black 0deg, black ${displayProgress * 2.88}deg, transparent ${displayProgress * 2.88}deg, transparent 360deg)`,
- }}
- >
- <img
- src={progressFull}
- alt="Progress Full"
- style={{
- width: '100%',
- height: '100%',
- display: 'block',
- }}
- />
- </div>
- </div>
- {/* 录音按钮 - 设计稿:尺寸 96×96,在进度环内居中,左右居中 */}
- <div
- className="chatbot-record-button absolute cursor-pointer touch-manipulation"
- onClick={handleStartRecord}
- style={{
- width: `${layout.recordButtonSize}px`,
- height: `${layout.recordButtonSize}px`,
- minWidth: '44px',
- minHeight: '44px',
- top: `${layout.recordButtonOffset}px`,
- left: '50%',
- transform: 'translateX(-50%)',
- borderRadius: '100px',
- opacity: 1,
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
- userSelect: 'none',
- WebkitUserSelect: 'none',
- caretColor: 'transparent',
- outline: 'none',
- }}
- contentEditable={false}
- tabIndex={-1}
- >
- {/* 录音按钮背景 - 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: `${layout.recordButtonSize * 0.33}px`,
- height: `${layout.recordButtonSize * 0.33}px`,
- opacity: 1,
- position: 'relative',
- zIndex: 1,
- }}
- />
- </div>
- </div>
- )}
- {/* 进度条提示 - 首次有进度时显示 */}
- {showProgressBarTooltip && (
- <img
- src={tooltipsProgressBar}
- alt="Progress Bar Tooltip"
- className="chatbot-tooltips absolute pointer-events-none"
- style={{
- width: `${layout.tooltips.progressBar.width}px`,
- height: `${layout.tooltips.progressBar.height}px`,
- top: `${layout.tooltips.progressBar.top}px`,
- left: `${layout.tooltips.progressBar.left}px`,
- opacity: 1,
- }}
- />
- )}
- {/* Tap to Chat 提示 - 只显示一次 - 等比例缩放 */}
- {showTapToChat && (
- <img
- src={tapToChat}
- alt="Tap to Chat"
- className="chatbot-tooltips absolute pointer-events-none"
- style={{
- width: `${layout.tooltips.tapToChat.width}px`,
- height: 'auto',
- top: `${layout.tooltips.tapToChat.top}px`,
- right: `${layout.tooltips.tapToChat.right}px`,
- borderRadius: `${Math.round(8 * layout.avatarScale)}px`,
- opacity: 1,
- }}
- />
- )}
- {/* 取消语音发送提示 - 只在首次录音时显示 - 等比例缩放 */}
- {currentState === 'recording' && showCancelTooltip && (
- <img
- src={tooltipsCancel}
- alt="Cancel Tooltip"
- className="chatbot-tooltips absolute pointer-events-none"
- style={{
- width: `${layout.tooltips.cancel.width}px`,
- height: 'auto',
- top: `${layout.tooltips.cancel.top}px`,
- left: `${layout.tooltips.cancel.left}px`,
- borderRadius: `${Math.round(8 * layout.avatarScale)}px`,
- opacity: 1,
- }}
- />
- )}
- {/* 取消语音发送按钮 - 录音状态显示,与录音按钮同高度,左侧对齐 */}
- {currentState === 'recording' && (
- <div
- className="chatbot-action-buttons absolute flex items-center justify-center cursor-pointer touch-manipulation"
- onClick={handleCancel}
- style={{
- width: `${Math.round(layout.recordButtonSize * 0.58)}px`,
- height: `${Math.round(layout.recordButtonSize * 0.58)}px`,
- minWidth: '44px',
- minHeight: '44px',
- top: `${layout.progressRingTop + layout.recordButtonOffset}px`,
- left: '50%',
- transform: `translateX(calc(-100% - ${layout.verticalGap}px))`,
- borderRadius: '100px',
- opacity: 1,
- background: 'rgba(255, 255, 255, 0.1)',
- backdropFilter: 'blur(15px)',
- zIndex: 20,
- userSelect: 'none',
- WebkitUserSelect: 'none',
- caretColor: 'transparent',
- outline: 'none',
- }}
- 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: `${layout.recordButtonSize * 0.25}px`,
- height: `${layout.recordButtonSize * 0.25}px`,
- position: 'relative',
- zIndex: 1,
- }}
- />
- </div>
- )}
- {/* 语音发送提示 - 只在首次录音时显示 - 等比例缩放 */}
- {currentState === 'recording' && showSendTooltip && (
- <img
- src={tooltipsSend}
- alt="Send Tooltip"
- className="chatbot-tooltips absolute pointer-events-none"
- style={{
- width: `${layout.tooltips.send.width}px`,
- height: 'auto',
- top: `${layout.tooltips.send.top}px`,
- left: `${layout.tooltips.send.left}px`,
- borderRadius: `${Math.round(8 * layout.avatarScale)}px`,
- opacity: 1,
- }}
- />
- )}
- {/* 语音发送按钮 - 录音状态显示,与录音按钮同高度,右侧对齐 */}
- {currentState === 'recording' && (
- <div
- className="chatbot-action-buttons absolute flex items-center justify-center cursor-pointer touch-manipulation"
- onClick={handleSend}
- style={{
- width: `${Math.round(layout.recordButtonSize * 0.58)}px`,
- height: `${Math.round(layout.recordButtonSize * 0.58)}px`,
- minWidth: '44px',
- minHeight: '44px',
- top: `${layout.progressRingTop + layout.recordButtonOffset}px`,
- left: '50%',
- transform: `translateX(${layout.verticalGap}px)`,
- borderRadius: '100px',
- opacity: 1,
- background: 'rgba(255, 255, 255, 0.1)',
- backdropFilter: 'blur(15px)',
- zIndex: 20,
- userSelect: 'none',
- WebkitUserSelect: 'none',
- caretColor: 'transparent',
- outline: 'none',
- }}
- 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: `${layout.recordButtonSize * 0.25}px`,
- height: `${layout.recordButtonSize * 0.25}px`,
- position: 'relative',
- zIndex: 1,
- }}
- />
- </div>
- )}
- </div>
- </div>
- );
- };
|