|
|
@@ -599,17 +599,29 @@ export const ChatBotPage = () => {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
- // 若变化极小,直接同步
|
|
|
+ // 处理整数圈:如果 diff 接近 0 且是整数圈,播放完整一圈动画
|
|
|
+ let actualDiff = diff;
|
|
|
if (Math.abs(diff) < 0.1) {
|
|
|
- totalProgressRef.current = targetTotal;
|
|
|
- const normalized = ((targetTotal % 100) + 100) % 100;
|
|
|
- setDisplayProgress(normalized);
|
|
|
- return;
|
|
|
+ // 判断是否是整数圈(目标值是 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);
|
|
|
+ return;
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
// 每 1% 的进度约 8ms,限制在 0.4s~2.4s 之间
|
|
|
const durationPerPercent = 8;
|
|
|
- const duration = Math.max(400, Math.min(2400, Math.abs(diff) * durationPerPercent));
|
|
|
+ const duration = Math.max(400, Math.min(2400, Math.abs(actualDiff) * durationPerPercent));
|
|
|
const startTime = performance.now();
|
|
|
|
|
|
if (animationFrameRef.current) {
|
|
|
@@ -622,7 +634,7 @@ export const ChatBotPage = () => {
|
|
|
|
|
|
// easeOutCubic 缓动
|
|
|
const easeProgress = 1 - Math.pow(1 - progress, 3);
|
|
|
- const currentTotal = startTotal + diff * easeProgress;
|
|
|
+ const currentTotal = startTotal + actualDiff * easeProgress;
|
|
|
totalProgressRef.current = currentTotal;
|
|
|
|
|
|
const normalized = ((currentTotal % 100) + 100) % 100;
|
|
|
@@ -631,6 +643,7 @@ export const ChatBotPage = () => {
|
|
|
if (progress < 1) {
|
|
|
animationFrameRef.current = requestAnimationFrame(animate);
|
|
|
} else {
|
|
|
+ // 动画完成后,同步到实际的目标值
|
|
|
totalProgressRef.current = targetTotal;
|
|
|
const finalNormalized = ((targetTotal % 100) + 100) % 100;
|
|
|
setDisplayProgress(finalNormalized);
|
|
|
@@ -932,75 +945,162 @@ export const ChatBotPage = () => {
|
|
|
const visited = getVisitedRoutes();
|
|
|
const userId = getUserId();
|
|
|
if (userId) {
|
|
|
- // 先尝试从后端拉取 UI 状态用于恢复本地提示与 Avatar 状态
|
|
|
- getUserUIState(userId)
|
|
|
- .then((resp) => {
|
|
|
- if (!resp.success || !resp.data) {
|
|
|
- return;
|
|
|
+ // 从数据库加载所有用户信息到 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');
|
|
|
}
|
|
|
- const state = resp.data;
|
|
|
+
|
|
|
+ // 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;
|
|
|
+ shouldSkipProgressAnimationRef.current = true;
|
|
|
+ setProgress(initialProgress, 0);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 加载用户 UI 状态到 localStorage
|
|
|
+ if (uiStateResult?.success && uiStateResult.data) {
|
|
|
+ const state = uiStateResult.data;
|
|
|
+
|
|
|
+ // Lyrria_current_avatar_id
|
|
|
if (state.current_avatar_id) {
|
|
|
setCurrentAvatarId(state.current_avatar_id);
|
|
|
setStorage(STORAGE_KEYS.CURRENT_AVATAR_ID, state.current_avatar_id);
|
|
|
}
|
|
|
-
|
|
|
+
|
|
|
+ // Lyrria_current_page
|
|
|
if (state.current_page) {
|
|
|
setStorage(STORAGE_KEYS.CURRENT_PAGE, state.current_page);
|
|
|
}
|
|
|
+
|
|
|
+ // Lyrria_visited_routes (无论是否为空数组,都从数据库同步)
|
|
|
if (Array.isArray(state.visited_routes)) {
|
|
|
setStorage(STORAGE_KEYS.VISITED_ROUTES, JSON.stringify(state.visited_routes));
|
|
|
+ } else if (state.visited_routes === null || state.visited_routes === undefined) {
|
|
|
+ // 如果数据库中是 null 或 undefined,设置为空数组
|
|
|
+ setStorage(STORAGE_KEYS.VISITED_ROUTES, JSON.stringify([]));
|
|
|
}
|
|
|
+
|
|
|
+ // Lyrria_has_shown_tap_to_chat
|
|
|
if (state.has_shown_tap_to_chat) {
|
|
|
setStorage(STORAGE_KEYS.HAS_SHOWN_TAP_TO_CHAT, 'true');
|
|
|
- // 只有在 WebSocket 已连接时才隐藏提示(确保用户能看到提示)
|
|
|
- // 如果 WebSocket 还未连接,保持 showTapToChat 为 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');
|
|
|
}
|
|
|
- if (state.has_shared_avatar) {
|
|
|
- setStorage(STORAGE_KEYS.HAS_SHARED_AVATAR, 'true');
|
|
|
- setHasSharedAvatarState(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,尝试补偿同步
|
|
|
+
|
|
|
+ // 补偿同步:如果本地记录为 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(() => {
|
|
|
- // 静默处理,后续会继续尝试
|
|
|
+ // 静默处理
|
|
|
});
|
|
|
}
|
|
|
|
|
|
@@ -1014,7 +1114,6 @@ export const ChatBotPage = () => {
|
|
|
});
|
|
|
}
|
|
|
|
|
|
- // 补偿同步能量石点击状态
|
|
|
const localMissionClicked = getStorage(STORAGE_KEYS.HAS_CLICKED_MISSION_STONE) === 'true';
|
|
|
if (localMissionClicked && !state.has_clicked_mission_stone) {
|
|
|
updateUserUIState(userId, {
|
|
|
@@ -1032,10 +1131,11 @@ export const ChatBotPage = () => {
|
|
|
// 静默处理
|
|
|
});
|
|
|
}
|
|
|
- })
|
|
|
- .catch(() => {
|
|
|
- // 拉取失败不影响主流程
|
|
|
- });
|
|
|
+ }
|
|
|
+ }).catch(() => {
|
|
|
+ // 加载失败不影响主流程
|
|
|
+ console.warn('[ChatBotPage] 从数据库加载用户信息失败');
|
|
|
+ });
|
|
|
|
|
|
// 同步当前页面与已访问路由
|
|
|
updateUserUIState(userId, {
|
|
|
@@ -1048,7 +1148,7 @@ export const ChatBotPage = () => {
|
|
|
|
|
|
const init = async () => {
|
|
|
try {
|
|
|
- // 获取用户信息并初始化进度条
|
|
|
+ // 获取用户信息并初始化进度条(已在上面 Promise.all 中处理,这里保留作为兜底)
|
|
|
const user_id = getUserId();
|
|
|
if (user_id) {
|
|
|
try {
|
|
|
@@ -1092,11 +1192,12 @@ export const ChatBotPage = () => {
|
|
|
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;
|
|
|
+ // 重置圈数为 0,只显示当前周期的进度
|
|
|
shouldSkipProgressAnimationRef.current = true;
|
|
|
- setProgress(initialProgress, initialLoopCount);
|
|
|
+ setProgress(initialProgress, 0);
|
|
|
}
|
|
|
}
|
|
|
} catch (error) {
|
|
|
@@ -1294,13 +1395,12 @@ export const ChatBotPage = () => {
|
|
|
message.totalDurationMs,
|
|
|
avatarCUnlockedRef.current ? AVATAR_C_UNLOCK_THRESHOLD_MS : 0
|
|
|
);
|
|
|
- // 根据总时长计算进度条位置
|
|
|
+ // 根据总时长计算进度条位置(只显示非整数圈的进度)
|
|
|
const totalProgressPercent = (message.totalDurationMs / PROGRESS_FULL_DURATION_MS) * 100;
|
|
|
- const loopCount = Math.floor(totalProgressPercent / 100);
|
|
|
const progress = totalProgressPercent % 100;
|
|
|
- // 跳过动画,直接同步到后端返回的值
|
|
|
+ // 跳过动画,直接同步到后端返回的值,重置圈数为 0
|
|
|
shouldSkipProgressAnimationRef.current = true;
|
|
|
- setProgress(progress, loopCount);
|
|
|
+ setProgress(progress, 0);
|
|
|
}
|
|
|
break;
|
|
|
|
|
|
@@ -2056,47 +2156,6 @@ export const ChatBotPage = () => {
|
|
|
/>
|
|
|
</div>
|
|
|
|
|
|
- {/* 情感展示 - 设计稿:距离界面顶部 50px,左右居中,使用 px2vmin 等比缩放 */}
|
|
|
- <div
|
|
|
- className="chatbot-emotion absolute"
|
|
|
- style={{
|
|
|
- top: px2vmin(50),
|
|
|
- left: '50%',
|
|
|
- transform: 'translateX(-50%)',
|
|
|
- textAlign: 'center',
|
|
|
- zIndex: 3,
|
|
|
- }}
|
|
|
- >
|
|
|
- <div
|
|
|
- className="text-fluid-sm"
|
|
|
- onClick={() => navigate('/level5-welcome')}
|
|
|
- style={{
|
|
|
- fontWeight: 500,
|
|
|
- color: 'rgba(255, 255, 255, 0.9)',
|
|
|
- textShadow: '0px 2px 4px rgba(0, 0, 0, 0.3)',
|
|
|
- padding: `${px2vh(6)} ${px2vw(16)}`,
|
|
|
- borderRadius: px2vw(12),
|
|
|
- background: 'rgba(255, 255, 255, 0.1)',
|
|
|
- backdropFilter: 'blur(10px)',
|
|
|
- border: '1px solid rgba(255, 255, 255, 0.2)',
|
|
|
- cursor: 'pointer',
|
|
|
- userSelect: 'none',
|
|
|
- WebkitUserSelect: 'none',
|
|
|
- transition: 'all 0.2s ease',
|
|
|
- }}
|
|
|
- onMouseEnter={(e) => {
|
|
|
- e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
|
|
|
- e.currentTarget.style.transform = 'scale(1.05)';
|
|
|
- }}
|
|
|
- onMouseLeave={(e) => {
|
|
|
- e.currentTarget.style.background = 'rgba(255, 255, 255, 0.1)';
|
|
|
- e.currentTarget.style.transform = 'scale(1)';
|
|
|
- }}
|
|
|
- >
|
|
|
- {currentEmotion}
|
|
|
- </div>
|
|
|
- </div>
|
|
|
-
|
|
|
{/* 音频波形 - 宽度 100% 自适应 */}
|
|
|
<div
|
|
|
className="chatbot-audio-wave overflow-hidden"
|