Files
happy-life-star/course-web/src/components/UserMenu.jsx
T
2025-12-22 14:50:14 +08:00

313 lines
11 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useRef, useEffect } from 'react';
import { useStoreData } from '../hooks/useStoreData';
import { Store } from '../utils/store';
import { userApi } from '../api/user';
import { User, Settings, LogOut, X, Edit2 } from 'lucide-react';
import { GlassCard } from './ui/GlassCard';
import { Button } from './ui/Button';
import clsx from 'clsx';
/**
* 用户资料菜单组件
* 显示用户信息、编辑资料和退出登录选项
*/
export function UserMenu({ isOpen, onClose, onLogout }) {
const data = useStoreData();
const [showEditModal, setShowEditModal] = useState(false);
const menuRef = useRef(null);
// 每次打开菜单时,重置编辑模态框状态(模仿 PncyssD 的逻辑)
// 确保每次打开菜单都显示主菜单界面,而不是编辑界面
useEffect(() => {
if (isOpen) {
// 菜单打开时,确保编辑模态框是关闭的
setShowEditModal(false);
}
}, [isOpen]);
// 点击外部关闭菜单
useEffect(() => {
if (!isOpen) return;
const handleClickOutside = (event) => {
if (menuRef.current && !menuRef.current.contains(event.target)) {
onClose();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isOpen, onClose]);
// 如果菜单未打开,不渲染任何内容(除非正在显示编辑模态框)
if (!isOpen && !showEditModal) return null;
// 如果正在显示编辑模态框,只渲染编辑模态框
if (showEditModal) {
return (
<EditProfileModal
onClose={() => {
setShowEditModal(false);
// 编辑模态框关闭后,如果菜单是打开的,会显示主菜单
}}
userProfile={data.userProfile}
/>
);
}
// 显示主菜单(isOpen 为 true 且 showEditModal 为 false
// 显示主菜单
return (
<>
{/* 菜单遮罩层 */}
<div
className="fixed inset-0 z-40"
onClick={onClose}
/>
{/* 用户菜单 */}
<div
ref={menuRef}
className="fixed top-24 md:top-4 left-4 md:left-[300px] z-50 w-[calc(100%-2rem)] md:w-80 animate-fade-in"
>
<div className="bg-[#1a1c2c]/95 border border-white/20 shadow-2xl rounded-2xl p-6 space-y-6 backdrop-blur-sm">
{/* 用户信息头部 */}
<div className="flex items-center gap-4 pb-4 border-b border-white/10">
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-primary/30 to-blue-600/30 flex items-center justify-center text-2xl font-bold text-white border border-white/20 shadow-lg">
{data.userProfile.nickname?.[0] || 'U'}
</div>
<div className="flex-1 overflow-hidden">
<div className="font-bold text-gray-100 text-lg truncate">
{data.userProfile.nickname || '旅人'}
</div>
<div className="text-xs text-primary flex items-center gap-1.5 mt-1">
<span className="w-1.5 h-1.5 rounded-full bg-primary shadow-[0_0_5px_rgba(205,133,63,0.5)] animate-pulse"></span>
{data.userProfile.mbti || '未知'} · {data.userProfile.zodiac || '未知'}
</div>
</div>
</div>
{/* 统计数据 */}
<div className="grid grid-cols-2 gap-3">
<div className="p-4 bg-white/5 rounded-xl border border-white/5 text-center">
<div className="text-2xl font-bold text-primary">
{data.lifeTimeline?.length || 0}
</div>
<div className="text-[10px] text-white/40 uppercase tracking-widest mt-1">
生命足迹
</div>
</div>
<div className="p-4 bg-white/5 rounded-xl border border-white/5 text-center">
<div className="text-2xl font-bold text-blue-400">
{data.generatedScripts?.length || 0}
</div>
<div className="text-[10px] text-white/40 uppercase tracking-widest mt-1">
剧本生成
</div>
</div>
</div>
{/* 操作按钮 */}
<div className="space-y-2 pt-2 border-t border-white/5">
<Button
variant="secondary"
size="md"
className="w-full justify-start"
onClick={() => {
setShowEditModal(true);
// 不关闭主菜单,让编辑模态框显示在主菜单之上
// 这样关闭编辑模态框后,主菜单仍然可见
}}
>
<Settings className="w-4 h-4 mr-2" />
编辑资料
</Button>
<Button
variant="ghost"
size="md"
className="w-full justify-start text-red-400 hover:text-red-300 hover:bg-red-500/10"
onClick={() => {
if (window.confirm('确定要退出登录并清除所有数据吗?此操作不可逆。')) {
Store.reset();
if (onLogout) onLogout();
}
}}
>
<LogOut className="w-4 h-4 mr-2" />
退出登录
</Button>
</div>
</div>
</div>
</>
);
}
/**
* 编辑资料模态框组件
*/
function EditProfileModal({ onClose, userProfile }) {
const [formData, setFormData] = useState({
nickname: userProfile.nickname || '',
mbti: userProfile.mbti || '',
zodiac: userProfile.zodiac || '',
hobbies: userProfile.hobbies?.join(', ') || '',
gender: userProfile.gender || 'secret'
});
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState('');
const handleSave = async () => {
setIsSaving(true);
setError('');
const updatedProfile = {
nickname: formData.nickname,
mbti: formData.mbti,
zodiac: formData.zodiac,
hobbies: formData.hobbies.split(',').map(s => s.trim()).filter(s => s),
gender: formData.gender
};
try {
// 1. 更新本地 Store
Store.updateProfile(updatedProfile);
// 2. 同步到后端
const currentProfile = await userApi.getCurrentUser();
if (currentProfile.data && currentProfile.data.id) {
await userApi.updateUserProfile({
id: currentProfile.data.id,
nickname: updatedProfile.nickname,
mbti: updatedProfile.mbti,
zodiac: updatedProfile.zodiac,
hobbies: JSON.stringify(updatedProfile.hobbies),
gender: updatedProfile.gender
});
}
onClose();
} catch (e) {
console.error('保存资料失败:', e);
setError(e.response?.data?.message || '保存失败,请重试');
} finally {
setIsSaving(false);
}
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-xl">
<GlassCard className="w-full max-w-lg p-8 relative max-h-[90vh] overflow-y-auto">
{/* 关闭按钮 */}
<button
onClick={onClose}
className="absolute top-6 right-6 text-white/40 hover:text-white transition-colors z-10"
>
<X className="w-5 h-5" />
</button>
{/* 标题 */}
<div className="flex items-center gap-4 mb-8">
<div className="w-12 h-12 rounded-xl bg-primary/20 flex items-center justify-center">
<Edit2 className="text-primary w-6 h-6" />
</div>
<div>
<h3 className="text-2xl font-bold text-gray-100">编辑资料</h3>
<p className="text-xs text-gray-400 mt-1">调整你的人生航向基础信息</p>
</div>
</div>
{/* 表单 */}
<div className="space-y-6">
<div>
<label className="text-sm font-medium text-gray-300 mb-2 block">昵称</label>
<input
type="text"
value={formData.nickname}
onChange={(e) => setFormData({ ...formData, nickname: e.target.value })}
placeholder="你想被如何称呼?"
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/30 focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary/50 transition-all"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-gray-300 mb-2 block">MBTI</label>
<input
type="text"
value={formData.mbti}
onChange={(e) => setFormData({ ...formData, mbti: e.target.value })}
placeholder="性格色彩"
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/30 focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary/50 transition-all"
/>
</div>
<div>
<label className="text-sm font-medium text-gray-300 mb-2 block">星座</label>
<input
type="text"
value={formData.zodiac}
onChange={(e) => setFormData({ ...formData, zodiac: e.target.value })}
placeholder="星辰指引"
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/30 focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary/50 transition-all"
/>
</div>
</div>
<div>
<label className="text-sm font-medium text-gray-300 mb-2 block">兴趣爱好</label>
<input
type="text"
value={formData.hobbies}
onChange={(e) => setFormData({ ...formData, hobbies: e.target.value })}
placeholder="让灵魂起舞的事物(用逗号分隔)"
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/30 focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary/50 transition-all"
/>
</div>
<div>
<label className="text-sm font-medium text-gray-300 mb-2 block">性别</label>
<select
value={formData.gender}
onChange={(e) => setFormData({ ...formData, gender: e.target.value })}
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary/50 transition-all"
>
<option value="secret">保密</option>
<option value="male"></option>
<option value="female"></option>
</select>
</div>
{/* 错误提示 */}
{error && (
<div className="p-3 rounded-xl bg-red-500/10 border border-red-500/20 text-red-200 text-sm">
{error}
</div>
)}
</div>
{/* 操作按钮 */}
<div className="flex gap-4 mt-8 pt-6 border-t border-white/5">
<Button
variant="primary"
size="md"
className="flex-1"
onClick={handleSave}
isLoading={isSaving}
>
保存修改
</Button>
<Button
variant="ghost"
size="md"
onClick={onClose}
>
取消
</Button>
</div>
</GlassCard>
</div>
);
}