|
|
@@ -1,7 +1,7 @@
|
|
|
import { useEffect, useState, useCallback, useRef } from 'react';
|
|
|
import { useNavigate } from 'react-router-dom';
|
|
|
import { px2vmin, px2vmax, px2vw } from '@/utils/viewport';
|
|
|
-import { getUserInfo, updateUsername, updateBotName } from '@/utils/api';
|
|
|
+import { getUserInfo, updateUsername, updateBotName, getUserUIState, updateUserUIState, updateBackgroundImageId } from '@/utils/api';
|
|
|
|
|
|
// 获取后端 API 基础 URL(与 api.ts 中的逻辑保持一致)
|
|
|
const getApiBaseUrl = (): string => {
|
|
|
@@ -21,7 +21,7 @@ const getApiBaseUrl = (): string => {
|
|
|
|
|
|
return apiUrl;
|
|
|
};
|
|
|
-import { getUserId, getStorage, setStorage, STORAGE_KEYS } from '@/utils/storage';
|
|
|
+import { getUserId, getStorage, setStorage, STORAGE_KEYS, setCurrentAvatarId as setAvatarIdToStorage, setCurrentBackgroundImageId, syncBackgroundImageToStorage } from '@/utils/storage';
|
|
|
import { getOssUrl } from '@/utils/oss';
|
|
|
import { MissionEnergyStoneModal } from '@/components/MissionEnergyStoneModal';
|
|
|
|
|
|
@@ -46,6 +46,8 @@ export const ProfilePage = () => {
|
|
|
const [conversationStoneCount, setConversationStoneCount] = useState<number>(0);
|
|
|
const [hasCap, setHasCap] = useState<boolean>(false);
|
|
|
const [hasBackgroundReward, setHasBackgroundReward] = useState<boolean>(false);
|
|
|
+ const [currentAvatarId, setCurrentAvatarId] = useState<string | null>(null);
|
|
|
+ const [currentBackgroundImageId, setCurrentBackgroundImageId] = useState<string | null>(null);
|
|
|
const [loading, setLoading] = useState<boolean>(true);
|
|
|
const [userLevel, setUserLevel] = useState<number>(0);
|
|
|
const [totalSpeechDurationMs, setTotalSpeechDurationMs] = useState<number>(0);
|
|
|
@@ -59,10 +61,15 @@ export const ProfilePage = () => {
|
|
|
const botNameInputRef = useRef<HTMLInputElement>(null);
|
|
|
const [showEnergyStoneModal, setShowEnergyStoneModal] = useState<boolean>(false);
|
|
|
const [energyStoneModalType, setEnergyStoneModalType] = useState<'mission' | 'conversation'>('mission');
|
|
|
+ const [showFeedbackModal, setShowFeedbackModal] = useState<boolean>(false);
|
|
|
+ const [feedbackText, setFeedbackText] = useState<string>('');
|
|
|
+ const [isSubmittingFeedback, setIsSubmittingFeedback] = useState<boolean>(false);
|
|
|
|
|
|
const cancelBtnImage = 'https://lyrria-pro.oss-cn-beijing.aliyuncs.com/cancel.png';
|
|
|
const changeBtnImage = 'https://lyrria-pro.oss-cn-beijing.aliyuncs.com/change.png';
|
|
|
const changeBtnActiveImage = 'https://lyrria-pro.oss-cn-beijing.aliyuncs.com/change-a.png';
|
|
|
+ const sendBtnImage = 'https://lyrria-pro.oss-cn-beijing.aliyuncs.com/send.png';
|
|
|
+ const sendBtnActiveImage = 'https://lyrria-pro.oss-cn-beijing.aliyuncs.com/send-a.png';
|
|
|
const focusGradient = 'linear-gradient(90deg, #FFC900 11%, #13A6FF 32%, #806EFF 50%, #FF8EEC 66%, #FE4F51 89%)';
|
|
|
|
|
|
// 获取能量石数量
|
|
|
@@ -180,6 +187,12 @@ export const ProfilePage = () => {
|
|
|
setTotalSpeechDurationMs(userInfo.data.total_speech_duration_ms || 0);
|
|
|
}
|
|
|
|
|
|
+ // 获取用户 UI 状态(包含 current_avatar_id)
|
|
|
+ const uiState = await getUserUIState(userId);
|
|
|
+ if (uiState.success && uiState.data) {
|
|
|
+ setCurrentAvatarId(uiState.data.current_avatar_id || null);
|
|
|
+ }
|
|
|
+
|
|
|
// 获取能量石数量
|
|
|
await fetchEnergyStoneCounts(userId);
|
|
|
|
|
|
@@ -200,7 +213,7 @@ export const ProfilePage = () => {
|
|
|
|
|
|
// 防止弹窗打开时背景滚动
|
|
|
useEffect(() => {
|
|
|
- if (showUsernameModal || showBotNameModal || showEnergyStoneModal) {
|
|
|
+ if (showUsernameModal || showBotNameModal || showEnergyStoneModal || showFeedbackModal) {
|
|
|
document.body.style.overflow = 'hidden';
|
|
|
} else {
|
|
|
document.body.style.overflow = '';
|
|
|
@@ -209,23 +222,62 @@ export const ProfilePage = () => {
|
|
|
return () => {
|
|
|
document.body.style.overflow = '';
|
|
|
};
|
|
|
- }, [showUsernameModal, showBotNameModal, showEnergyStoneModal]);
|
|
|
+ }, [showUsernameModal, showBotNameModal, showEnergyStoneModal, showFeedbackModal]);
|
|
|
|
|
|
const handleClose = useCallback(() => {
|
|
|
navigate(-1);
|
|
|
}, [navigate]);
|
|
|
|
|
|
- const handleHeadClick = useCallback(() => {
|
|
|
+ const handleHeadClick = useCallback(async () => {
|
|
|
+ const userId = getUserId();
|
|
|
+ if (!userId) return;
|
|
|
+
|
|
|
if (!hasCap) {
|
|
|
navigate('/first-login');
|
|
|
+ return;
|
|
|
}
|
|
|
- }, [hasCap, navigate]);
|
|
|
|
|
|
- const handleBackgroundClick = useCallback(() => {
|
|
|
+ // 已拥有情况下,切换头像
|
|
|
+ const newAvatarId = currentAvatarId === 'avatar_registered_cap'
|
|
|
+ ? 'avatar_default'
|
|
|
+ : 'avatar_registered_cap';
|
|
|
+
|
|
|
+ try {
|
|
|
+ const result = await updateUserUIState(userId, { current_avatar_id: newAvatarId });
|
|
|
+ if (result.success) {
|
|
|
+ setCurrentAvatarId(newAvatarId); // 更新 React state
|
|
|
+ setAvatarIdToStorage(newAvatarId); // 同步到 localStorage
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('切换头像失败:', error);
|
|
|
+ }
|
|
|
+ }, [hasCap, currentAvatarId, navigate]);
|
|
|
+
|
|
|
+ const handleBackgroundClick = useCallback(async () => {
|
|
|
+ const userId = getUserId();
|
|
|
+ if (!userId) return;
|
|
|
+
|
|
|
if (!hasBackgroundReward) {
|
|
|
navigate('/first-login-reward');
|
|
|
+ return;
|
|
|
}
|
|
|
- }, [hasBackgroundReward, navigate]);
|
|
|
+
|
|
|
+ // 已拥有情况下,切换背景图片
|
|
|
+ const newBackgroundImageId = currentBackgroundImageId === 'happynewyear'
|
|
|
+ ? 'default'
|
|
|
+ : 'happynewyear';
|
|
|
+
|
|
|
+ try {
|
|
|
+ const result = await updateBackgroundImageId(userId, newBackgroundImageId);
|
|
|
+ if (result.success) {
|
|
|
+ setCurrentBackgroundImageId(newBackgroundImageId); // 更新 React state
|
|
|
+ // 同步到 localStorage(包括 URL 和透明度)
|
|
|
+ syncBackgroundImageToStorage(newBackgroundImageId, null);
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('切换背景图片失败:', error);
|
|
|
+ }
|
|
|
+ }, [hasBackgroundReward, currentBackgroundImageId, navigate]);
|
|
|
|
|
|
// 打开编辑用户名弹窗
|
|
|
const handleEditUsername = useCallback(() => {
|
|
|
@@ -316,34 +368,65 @@ export const ProfilePage = () => {
|
|
|
setShowEnergyStoneModal(false);
|
|
|
}, []);
|
|
|
|
|
|
+ // 打开反馈弹窗
|
|
|
+ const handleOpenFeedbackModal = useCallback(() => {
|
|
|
+ setShowFeedbackModal(true);
|
|
|
+ setFeedbackText('');
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ // 关闭反馈弹窗
|
|
|
+ const handleCloseFeedbackModal = useCallback(() => {
|
|
|
+ setShowFeedbackModal(false);
|
|
|
+ setFeedbackText('');
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ // 提交反馈
|
|
|
+ const handleSubmitFeedback = useCallback(async () => {
|
|
|
+ const userId = getUserId();
|
|
|
+ if (!userId || !feedbackText.trim()) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ setIsSubmittingFeedback(true);
|
|
|
+ try {
|
|
|
+ const apiBaseUrl = getApiBaseUrl();
|
|
|
+ const response = await fetch(`${apiBaseUrl}/api/users/${userId}/feedback`, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: {
|
|
|
+ 'Content-Type': 'application/json',
|
|
|
+ },
|
|
|
+ body: JSON.stringify({ content: feedbackText.trim() }),
|
|
|
+ });
|
|
|
+
|
|
|
+ if (response.ok) {
|
|
|
+ setShowFeedbackModal(false);
|
|
|
+ setFeedbackText('');
|
|
|
+ // 可以添加成功提示
|
|
|
+ } else {
|
|
|
+ console.error('提交反馈失败:', response.statusText);
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('提交反馈异常:', error);
|
|
|
+ } finally {
|
|
|
+ setIsSubmittingFeedback(false);
|
|
|
+ }
|
|
|
+ }, [feedbackText]);
|
|
|
+
|
|
|
// 计算升级所需时间和当前进度
|
|
|
const getLevelProgress = useCallback(() => {
|
|
|
const level = userLevel;
|
|
|
const totalSeconds = Math.floor(totalSpeechDurationMs / 1000);
|
|
|
|
|
|
- // 定义各等级所需的累计时间(秒)
|
|
|
- const levelThresholds = [0, 10, 30, 60, 120, 240];
|
|
|
+ // 定义各等级升级需要的时长(秒)
|
|
|
+ const requiredTimesByLevel = [10, 30, 60, 120, 240, 240];
|
|
|
|
|
|
- // 如果已经是最高等级
|
|
|
- if (level >= 5) {
|
|
|
- const displaySeconds = Math.max(0, Math.floor(totalSeconds - 120));
|
|
|
- return {
|
|
|
- currentTime: 120,
|
|
|
- requiredTime: 120,
|
|
|
- progress: 100,
|
|
|
- displayText: `${displaySeconds}/120`,
|
|
|
- };
|
|
|
- }
|
|
|
-
|
|
|
- // 当前等级所需的累计时间
|
|
|
- const currentLevelThreshold = levelThresholds[level];
|
|
|
- // 下一等级所需的累计时间
|
|
|
- const nextLevelThreshold = levelThresholds[level + 1];
|
|
|
- // 升级所需的时间
|
|
|
- const requiredTime = nextLevelThreshold - currentLevelThreshold;
|
|
|
- // 当前等级内的已用时间
|
|
|
- const currentTime = Math.max(0, totalSeconds - currentLevelThreshold);
|
|
|
- // 进度百分比
|
|
|
+ // 获取当前等级升级需要的时长
|
|
|
+ const requiredTime = requiredTimesByLevel[level] || 240;
|
|
|
+
|
|
|
+ // 当前已沟通的时长(秒)= 总时长
|
|
|
+ const currentTime = Math.max(0, totalSeconds);
|
|
|
+
|
|
|
+ // 进度百分比 = 当前已沟通时长 / 升级需要的时长
|
|
|
const progress = Math.min(100, (currentTime / requiredTime) * 100);
|
|
|
|
|
|
return {
|
|
|
@@ -386,6 +469,9 @@ export const ProfilePage = () => {
|
|
|
.profile-modal-input::placeholder {
|
|
|
color: rgba(245, 243, 240, 0.45);
|
|
|
}
|
|
|
+ .feedback-textarea::placeholder {
|
|
|
+ color: rgba(245, 243, 240, 0.45);
|
|
|
+ }
|
|
|
`}</style>
|
|
|
<div
|
|
|
style={{
|
|
|
@@ -684,7 +770,7 @@ export const ProfilePage = () => {
|
|
|
>
|
|
|
{/* Lumi 头像 */}
|
|
|
<img
|
|
|
- src={hasCap ? lumiCap : lumiNoCap}
|
|
|
+ src={currentAvatarId === 'avatar_registered_cap' ? lumiCap : lumiNoCap}
|
|
|
alt="Lumi"
|
|
|
style={{
|
|
|
width: px2vmin(180),
|
|
|
@@ -767,7 +853,11 @@ export const ProfilePage = () => {
|
|
|
style={{
|
|
|
width: px2vmin(108),
|
|
|
height: px2vmin(70),
|
|
|
- border: `2px solid ${hasCap ? '#BF9DF8' : 'rgba(245,243,240, 0.3)'}`,
|
|
|
+ border: `2px solid ${
|
|
|
+ hasCap && currentAvatarId === 'avatar_registered_cap'
|
|
|
+ ? '#BF9DF8'
|
|
|
+ : 'rgba(245,243,240, 0.3)'
|
|
|
+ }`,
|
|
|
borderRadius: px2vmin(12),
|
|
|
marginLeft: '50%',
|
|
|
transform: 'translateX(-50%)',
|
|
|
@@ -775,7 +865,7 @@ export const ProfilePage = () => {
|
|
|
display: 'flex',
|
|
|
alignItems: 'center',
|
|
|
justifyContent: 'center',
|
|
|
- cursor: hasCap ? 'default' : 'pointer',
|
|
|
+ cursor: hasCap ? 'pointer' : 'pointer',
|
|
|
}}
|
|
|
>
|
|
|
<img
|
|
|
@@ -828,13 +918,17 @@ export const ProfilePage = () => {
|
|
|
style={{
|
|
|
width: px2vmin(108),
|
|
|
height: px2vmin(70),
|
|
|
- border: `2px solid ${hasBackgroundReward ? '#BF9DF8' : 'rgba(245,243,240, 0.3)'}`,
|
|
|
+ border: `2px solid ${
|
|
|
+ hasBackgroundReward && currentBackgroundImageId === 'happynewyear'
|
|
|
+ ? '#BF9DF8'
|
|
|
+ : 'rgba(245,243,240, 0.3)'
|
|
|
+ }`,
|
|
|
borderRadius: px2vmin(12),
|
|
|
marginLeft: '50%',
|
|
|
transform: 'translateX(-50%)',
|
|
|
position: 'relative',
|
|
|
overflow: 'hidden',
|
|
|
- cursor: hasBackgroundReward ? 'default' : 'pointer',
|
|
|
+ cursor: 'pointer',
|
|
|
}}
|
|
|
>
|
|
|
<img
|
|
|
@@ -846,6 +940,34 @@ export const ProfilePage = () => {
|
|
|
objectFit: 'cover',
|
|
|
}}
|
|
|
/>
|
|
|
+ {!hasBackgroundReward && (
|
|
|
+ <>
|
|
|
+ {/* 蒙层 */}
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ position: 'absolute',
|
|
|
+ top: 0,
|
|
|
+ left: 0,
|
|
|
+ right: 0,
|
|
|
+ bottom: 0,
|
|
|
+ backgroundColor: 'rgba(245, 243, 240, 0.3)',
|
|
|
+ display: 'flex',
|
|
|
+ alignItems: 'center',
|
|
|
+ justifyContent: 'center',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {/* 礼物图标 */}
|
|
|
+ <img
|
|
|
+ src="https://lyrria-pro.oss-cn-beijing.aliyuncs.com/gift.png"
|
|
|
+ alt="Gift"
|
|
|
+ style={{
|
|
|
+ width: px2vmin(40),
|
|
|
+ height: px2vmin(40),
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </>
|
|
|
+ )}
|
|
|
</div>
|
|
|
{/* Background 文字 */}
|
|
|
<div
|
|
|
@@ -877,6 +999,7 @@ export const ProfilePage = () => {
|
|
|
>
|
|
|
{/* 意见反馈 & 帮助 */}
|
|
|
<div
|
|
|
+ onClick={handleOpenFeedbackModal}
|
|
|
style={{
|
|
|
display: 'flex',
|
|
|
alignItems: 'center',
|
|
|
@@ -885,6 +1008,7 @@ export const ProfilePage = () => {
|
|
|
borderTop: '1px solid #34313D',
|
|
|
marginLeft: px2vmin(10),
|
|
|
marginRight: px2vmin(10),
|
|
|
+ cursor: 'pointer',
|
|
|
}}
|
|
|
>
|
|
|
<span
|
|
|
@@ -1385,6 +1509,146 @@ export const ProfilePage = () => {
|
|
|
onClose={handleCloseEnergyStoneModal}
|
|
|
stoneType={energyStoneModalType}
|
|
|
/>
|
|
|
+
|
|
|
+ {/* 意见反馈弹窗 */}
|
|
|
+ {showFeedbackModal && (
|
|
|
+ <>
|
|
|
+ {/* 遮罩层 */}
|
|
|
+ <div
|
|
|
+ onClick={handleCloseFeedbackModal}
|
|
|
+ style={{
|
|
|
+ position: 'fixed',
|
|
|
+ top: 0,
|
|
|
+ left: 0,
|
|
|
+ right: 0,
|
|
|
+ bottom: 0,
|
|
|
+ backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
|
|
+ zIndex: 9998,
|
|
|
+ animation: 'fadeIn 0.3s ease-out',
|
|
|
+ }}
|
|
|
+ />
|
|
|
+
|
|
|
+ {/* 弹窗内容 */}
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ position: 'fixed',
|
|
|
+ bottom: 0,
|
|
|
+ left: '50%',
|
|
|
+ transform: 'translateX(-50%)',
|
|
|
+ width: px2vw(375),
|
|
|
+ height: px2vmin(420),
|
|
|
+ backgroundColor: '#34313D',
|
|
|
+ borderTopLeftRadius: px2vmin(32),
|
|
|
+ borderTopRightRadius: px2vmin(32),
|
|
|
+ zIndex: 9999,
|
|
|
+ display: 'flex',
|
|
|
+ flexDirection: 'column',
|
|
|
+ alignItems: 'center',
|
|
|
+ paddingTop: px2vmin(12),
|
|
|
+ paddingBottom: px2vmin(32),
|
|
|
+ paddingLeft: px2vmin(32),
|
|
|
+ paddingRight: px2vmin(32),
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {/* 顶部拖拽指示条 */}
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ width: px2vmin(32),
|
|
|
+ height: px2vmin(4),
|
|
|
+ backgroundColor: '#79747E',
|
|
|
+ borderRadius: px2vmin(4),
|
|
|
+ }}
|
|
|
+ />
|
|
|
+
|
|
|
+ {/* 文字标签 */}
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ width: '100%',
|
|
|
+ fontSize: px2vmin(14),
|
|
|
+ color: '#fff',
|
|
|
+ fontWeight: 'bold',
|
|
|
+ marginTop: px2vmin(24),
|
|
|
+ marginBottom: px2vmin(16),
|
|
|
+ textAlign: 'left',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ 意见反馈 & 帮助
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 输入区域 */}
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ width: px2vmin(311),
|
|
|
+ height: px2vmin(224),
|
|
|
+ marginBottom: px2vmin(32),
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <textarea
|
|
|
+ value={feedbackText}
|
|
|
+ onChange={(e) => setFeedbackText(e.target.value)}
|
|
|
+ placeholder="在此输入您的反馈或遇到的问题。为了保护您的隐私,请不要包含任何个人信息,例如您的姓名和地址"
|
|
|
+ className="feedback-textarea"
|
|
|
+ style={{
|
|
|
+ width: '100%',
|
|
|
+ height: '100%',
|
|
|
+ backgroundColor: 'rgba(245, 243, 240, 0.1)',
|
|
|
+ border: 'none',
|
|
|
+ borderRadius: px2vmin(12),
|
|
|
+ padding: px2vmin(16),
|
|
|
+ fontSize: px2vmin(14),
|
|
|
+ color: 'rgba(245, 243, 240, 0.7)',
|
|
|
+ fontFamily: 'inherit',
|
|
|
+ resize: 'none',
|
|
|
+ outline: 'none',
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 按钮组 */}
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ display: 'flex',
|
|
|
+ gap: px2vmin(43),
|
|
|
+ justifyContent: 'center',
|
|
|
+ width: '100%',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <button
|
|
|
+ onClick={handleCloseFeedbackModal}
|
|
|
+ disabled={isSubmittingFeedback}
|
|
|
+ style={{
|
|
|
+ width: px2vmin(90),
|
|
|
+ height: px2vmin(46),
|
|
|
+ backgroundImage: `url(${cancelBtnImage})`,
|
|
|
+ backgroundSize: 'cover',
|
|
|
+ backgroundRepeat: 'no-repeat',
|
|
|
+ backgroundPosition: 'center',
|
|
|
+ border: 'none',
|
|
|
+ cursor: isSubmittingFeedback ? 'default' : 'pointer',
|
|
|
+ padding: 0,
|
|
|
+ opacity: isSubmittingFeedback ? 0.6 : 1,
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ <button
|
|
|
+ onClick={handleSubmitFeedback}
|
|
|
+ disabled={!feedbackText.trim() || isSubmittingFeedback}
|
|
|
+ style={{
|
|
|
+ width: px2vmin(76),
|
|
|
+ height: px2vmin(46),
|
|
|
+ backgroundImage: `url(${feedbackText.trim() ? sendBtnActiveImage : sendBtnImage})`,
|
|
|
+ backgroundSize: 'cover',
|
|
|
+ backgroundRepeat: 'no-repeat',
|
|
|
+ backgroundPosition: 'center',
|
|
|
+ border: 'none',
|
|
|
+ cursor: feedbackText.trim() && !isSubmittingFeedback ? 'pointer' : 'default',
|
|
|
+ padding: 0,
|
|
|
+ opacity: feedbackText.trim() && !isSubmittingFeedback ? 1 : 0.6,
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </>
|
|
|
+ )}
|
|
|
</div>
|
|
|
</>
|
|
|
);
|