|
|
@@ -1,4 +1,4 @@
|
|
|
-import { useCallback, useEffect, useRef, useState } from 'react';
|
|
|
+import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from 'react';
|
|
|
import { useNavigate } from 'react-router-dom';
|
|
|
import { message as antMessage } from 'antd';
|
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
|
@@ -7,7 +7,16 @@ 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, STORAGE_KEYS, markRouteVisited, getUserId, getVisitedRoutes } from '@/utils/storage';
|
|
|
+import {
|
|
|
+ setStorage,
|
|
|
+ getStorage,
|
|
|
+ getUserLevel,
|
|
|
+ STORAGE_KEYS,
|
|
|
+ markRouteVisited,
|
|
|
+ getUserId,
|
|
|
+ getVisitedRoutes,
|
|
|
+ setUserLevel,
|
|
|
+} from '@/utils/storage';
|
|
|
import { onScreenResize, calculateLayout, type CalculatedLayout, updateTooSmallFlag } from '@/utils/screenSize';
|
|
|
import { performanceLogger } from '@/utils/logger';
|
|
|
import { getUserInfo, updateUserUIState, getUserUIState } from '@/utils/api';
|
|
|
@@ -93,6 +102,8 @@ export const ChatBotPage = () => {
|
|
|
const sendTimeRef = useRef<number>(0);
|
|
|
const energyStoneZoneRef = useRef<HTMLDivElement | null>(null);
|
|
|
const audioWaveRef = useRef<AudioWaveHandle | null>(null);
|
|
|
+ const [userLevelValue, setUserLevelValue] = useState(() => getUserLevel());
|
|
|
+ const levelRedirectRef = useRef(false);
|
|
|
// 初始化时检查 localStorage,确保第一次打开时立即显示提示
|
|
|
const [showTapToChat, setShowTapToChat] = useState(() => {
|
|
|
const hasShown = getStorage(STORAGE_KEYS.HAS_SHOWN_TAP_TO_CHAT);
|
|
|
@@ -126,6 +137,13 @@ export const ChatBotPage = () => {
|
|
|
useEffect(() => {
|
|
|
activeRequestIdRef.current = currentRequestId;
|
|
|
}, [currentRequestId]);
|
|
|
+ useEffect(() => {
|
|
|
+ const isLoggedIn = getStorage(STORAGE_KEYS.IS_LOGGED_IN) === 'true';
|
|
|
+ if (!isLoggedIn && userLevelValue >= 5 && !levelRedirectRef.current) {
|
|
|
+ levelRedirectRef.current = true;
|
|
|
+ navigate('/level5-welcome');
|
|
|
+ }
|
|
|
+ }, [userLevelValue, navigate]);
|
|
|
const [showCancelTooltip, setShowCancelTooltip] = useState(false);
|
|
|
const [showSendTooltip, setShowSendTooltip] = useState(false);
|
|
|
const [showProgressBarTooltip, setShowProgressBarTooltip] = useState(false);
|
|
|
@@ -184,6 +202,27 @@ export const ChatBotPage = () => {
|
|
|
const [currentAvatarGif, setCurrentAvatarGif] = useState<string>(DEFAULT_AVATAR_GIF);
|
|
|
// GIF 切换定时器
|
|
|
const emotionGifTimerRef = useRef<number | null>(null);
|
|
|
+ const handleAvatarClick = useCallback(() => {
|
|
|
+ const isLoggedIn = getStorage(STORAGE_KEYS.IS_LOGGED_IN) === 'true';
|
|
|
+ if (isLoggedIn || !isAvatarCUnlocked) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (userLevelValue >= 5) {
|
|
|
+ levelRedirectRef.current = true;
|
|
|
+ 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,
|
|
|
@@ -191,6 +230,11 @@ export const ChatBotPage = () => {
|
|
|
firstAudioReceived: 0,
|
|
|
emotionReceived: 0,
|
|
|
});
|
|
|
+ const syncUserLevel = useCallback((level: number) => {
|
|
|
+ setUserLevel(level);
|
|
|
+ setUserLevelValue(level);
|
|
|
+ }, []);
|
|
|
+
|
|
|
const unlockAvatarC = useCallback(() => {
|
|
|
if (avatarCUnlockedRef.current) {
|
|
|
return;
|
|
|
@@ -638,22 +682,27 @@ export const ChatBotPage = () => {
|
|
|
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;
|
|
|
- totalSpeechDurationRef.current = Math.max(
|
|
|
- totalDurationMs,
|
|
|
- avatarCUnlockedRef.current ? AVATAR_C_UNLOCK_THRESHOLD_MS : 0
|
|
|
- );
|
|
|
- if (totalDurationMs >= AVATAR_C_UNLOCK_THRESHOLD_MS) {
|
|
|
- unlockAvatarC();
|
|
|
+ if (userInfo.success && userInfo.data) {
|
|
|
+ const totalDurationMs = userInfo.data.total_speech_duration_ms ?? 0;
|
|
|
+ if (typeof userInfo.data.user_level === 'number') {
|
|
|
+ syncUserLevel(userInfo.data.user_level);
|
|
|
+ }
|
|
|
+ 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 initialLoopCount = Math.floor(totalProgressPercent / 100);
|
|
|
+ const initialProgress = totalProgressPercent % 100;
|
|
|
+ shouldSkipProgressAnimationRef.current = true;
|
|
|
+ setProgress(initialProgress, initialLoopCount);
|
|
|
}
|
|
|
- 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) {
|
|
|
// 获取用户信息失败不影响主流程,静默处理
|
|
|
@@ -809,6 +858,11 @@ export const ChatBotPage = () => {
|
|
|
}
|
|
|
setRecognizedText(message.text);
|
|
|
break;
|
|
|
+ case 'user_level':
|
|
|
+ if (typeof message.level === 'number') {
|
|
|
+ syncUserLevel(message.level);
|
|
|
+ }
|
|
|
+ break;
|
|
|
|
|
|
case 'emotion':
|
|
|
// 如果当前状态是 recording,且 requestId 不匹配当前请求,说明这是旧请求的返回,直接丢弃
|
|
|
@@ -1247,14 +1301,21 @@ export const ChatBotPage = () => {
|
|
|
|
|
|
{/* Avatar-C 头像 - 设计稿:top 63px, right 33px, 尺寸 40×40,使用 px2vmin 等比缩放 */}
|
|
|
<div
|
|
|
- className="chatbot-avatar-c absolute pointer-events-none"
|
|
|
+ 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: isAvatarCUnlocked ? 30 : 5,
|
|
|
+ pointerEvents: isAvatarCUnlocked ? 'auto' : 'none',
|
|
|
+ cursor: isAvatarCUnlocked ? 'pointer' : 'default',
|
|
|
}}
|
|
|
>
|
|
|
{isAvatarCUnlocked && (
|