- 将 Tooltip 改为 Popover 组件,支持鼠标悬停到弹出内容上 - 用户现在可以正常悬停到"点击头像管理订阅"提示 - 订阅信息新增到期日期显示,用户无需自己计算到期时间 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
632 lines
34 KiB
JavaScript
632 lines
34 KiB
JavaScript
// src/views/Profile/ProfilePage.js
|
|
import React, { useState, useRef } from 'react';
|
|
import {
|
|
Box,
|
|
Container,
|
|
VStack,
|
|
HStack,
|
|
Text,
|
|
Heading,
|
|
Avatar,
|
|
Button,
|
|
Input,
|
|
Textarea,
|
|
FormControl,
|
|
FormLabel,
|
|
SimpleGrid,
|
|
Card,
|
|
CardBody,
|
|
CardHeader,
|
|
Stat,
|
|
StatLabel,
|
|
StatNumber,
|
|
StatHelpText,
|
|
Badge,
|
|
Divider,
|
|
Select,
|
|
useToast,
|
|
IconButton,
|
|
Flex,
|
|
Progress,
|
|
Tag,
|
|
TagLabel,
|
|
TagCloseButton,
|
|
Wrap,
|
|
WrapItem,
|
|
Modal,
|
|
ModalOverlay,
|
|
ModalContent,
|
|
ModalHeader,
|
|
ModalBody,
|
|
ModalCloseButton,
|
|
useDisclosure
|
|
} from '@chakra-ui/react';
|
|
import { EditIcon, CheckIcon, CloseIcon, AddIcon } from '@chakra-ui/icons';
|
|
import { useAuth } from '../../contexts/AuthContext';
|
|
import { logger } from '../../utils/logger';
|
|
import { useProfileEvents } from '../../hooks/useProfileEvents';
|
|
|
|
export default function ProfilePage() {
|
|
const { user, updateUser } = useAuth();
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
|
|
// 🎯 初始化个人资料埋点Hook
|
|
const profileEvents = useProfileEvents({ pageType: 'profile' });
|
|
const [newTag, setNewTag] = useState('');
|
|
const { isOpen, onOpen, onClose } = useDisclosure();
|
|
const fileInputRef = useRef();
|
|
const toast = useToast();
|
|
|
|
// 表单数据状态
|
|
const [formData, setFormData] = useState({
|
|
nickname: user?.nickname || '',
|
|
bio: user?.bio || '',
|
|
location: user?.location || '',
|
|
gender: user?.gender || '',
|
|
birth_date: user?.birth_date || '',
|
|
trading_experience: user?.trading_experience || '',
|
|
investment_style: user?.investment_style || '',
|
|
risk_preference: user?.risk_preference || '',
|
|
investment_amount: user?.investment_amount || '',
|
|
preferred_markets: user?.preferred_markets ? user.preferred_markets.split(',') : [],
|
|
creator_tags: user?.creator_tags ? user.creator_tags.split(',') : []
|
|
});
|
|
|
|
const handleInputChange = (e) => {
|
|
const { name, value } = e.target;
|
|
setFormData(prev => ({
|
|
...prev,
|
|
[name]: value
|
|
}));
|
|
};
|
|
|
|
const handleSaveProfile = async () => {
|
|
setIsLoading(true);
|
|
try {
|
|
// 这里应该调用后端API更新用户信息
|
|
const updatedData = {
|
|
...formData,
|
|
preferred_markets: formData.preferred_markets.join(','),
|
|
creator_tags: formData.creator_tags.join(',')
|
|
};
|
|
|
|
logger.debug('ProfilePage', '保存个人资料', { userId: user?.id });
|
|
|
|
// 模拟API调用
|
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
|
|
updateUser(updatedData);
|
|
setIsEditing(false);
|
|
|
|
// 🎯 追踪个人资料更新成功
|
|
const updatedFields = Object.keys(formData).filter(
|
|
key => user?.[key] !== formData[key]
|
|
);
|
|
profileEvents.trackProfileUpdated(updatedFields, updatedData);
|
|
|
|
// ✅ 保留关键操作提示
|
|
toast({
|
|
title: "个人资料更新成功",
|
|
status: "success",
|
|
duration: 3000,
|
|
isClosable: true,
|
|
});
|
|
} catch (error) {
|
|
logger.error('ProfilePage', 'handleSaveProfile', error, { userId: user?.id });
|
|
|
|
// 🎯 追踪个人资料更新失败
|
|
const attemptedFields = Object.keys(formData);
|
|
profileEvents.trackProfileUpdateFailed(attemptedFields, error.message);
|
|
|
|
// ✅ 保留错误提示
|
|
toast({
|
|
title: "更新失败",
|
|
description: error.message,
|
|
status: "error",
|
|
duration: 3000,
|
|
isClosable: true,
|
|
});
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleAvatarUpload = (event) => {
|
|
const file = event.target.files[0];
|
|
if (file) {
|
|
logger.debug('ProfilePage', '上传头像', { fileName: file.name, fileSize: file.size });
|
|
|
|
// 这里应该上传文件到服务器
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => {
|
|
updateUser({ avatar_url: e.target.result });
|
|
|
|
// 🎯 追踪头像上传
|
|
profileEvents.trackAvatarUploaded('file_upload', file.size);
|
|
|
|
// ✅ 保留关键操作提示
|
|
toast({
|
|
title: "头像更新成功",
|
|
status: "success",
|
|
duration: 3000,
|
|
isClosable: true,
|
|
});
|
|
};
|
|
reader.readAsDataURL(file);
|
|
}
|
|
};
|
|
|
|
const addMarketTag = () => {
|
|
if (newTag && !formData.preferred_markets.includes(newTag)) {
|
|
setFormData(prev => ({
|
|
...prev,
|
|
preferred_markets: [...prev.preferred_markets, newTag]
|
|
}));
|
|
setNewTag('');
|
|
}
|
|
};
|
|
|
|
const removeMarketTag = (tagToRemove) => {
|
|
setFormData(prev => ({
|
|
...prev,
|
|
preferred_markets: prev.preferred_markets.filter(tag => tag !== tagToRemove)
|
|
}));
|
|
};
|
|
|
|
const getProgressColor = (score) => {
|
|
if (score >= 800) return 'green';
|
|
if (score >= 500) return 'blue';
|
|
if (score >= 200) return 'yellow';
|
|
return 'red';
|
|
};
|
|
|
|
return (
|
|
<Container maxW="container.xl" py={8}>
|
|
<VStack spacing={8} align="stretch">
|
|
{/* 页面标题 */}
|
|
<HStack justify="space-between">
|
|
<Heading size="lg" color="gray.800">个人资料</Heading>
|
|
{!isEditing ? (
|
|
<Button
|
|
leftIcon={<EditIcon />}
|
|
colorScheme="blue"
|
|
onClick={() => setIsEditing(true)}
|
|
>
|
|
编辑资料
|
|
</Button>
|
|
) : (
|
|
<HStack>
|
|
<Button
|
|
leftIcon={<CheckIcon />}
|
|
colorScheme="green"
|
|
onClick={handleSaveProfile}
|
|
isLoading={isLoading}
|
|
>
|
|
保存
|
|
</Button>
|
|
<Button
|
|
leftIcon={<CloseIcon />}
|
|
variant="outline"
|
|
colorScheme="gray"
|
|
color="gray.300"
|
|
borderColor="gray.500"
|
|
_hover={{ bg: 'gray.700', borderColor: 'gray.400' }}
|
|
onClick={() => {
|
|
setIsEditing(false);
|
|
setFormData({
|
|
nickname: user?.nickname || '',
|
|
bio: user?.bio || '',
|
|
location: user?.location || '',
|
|
gender: user?.gender || '',
|
|
birth_date: user?.birth_date || '',
|
|
trading_experience: user?.trading_experience || '',
|
|
investment_style: user?.investment_style || '',
|
|
risk_preference: user?.risk_preference || '',
|
|
investment_amount: user?.investment_amount || '',
|
|
preferred_markets: user?.preferred_markets ? user.preferred_markets.split(',') : [],
|
|
creator_tags: user?.creator_tags ? user.creator_tags.split(',') : []
|
|
});
|
|
}}
|
|
>
|
|
取消
|
|
</Button>
|
|
</HStack>
|
|
)}
|
|
</HStack>
|
|
|
|
<SimpleGrid columns={{ base: 1, lg: 3 }} spacing={8}>
|
|
{/* 左侧:基本信息 */}
|
|
<Box gridColumn={{ base: "1", lg: "1 / 3" }}>
|
|
<Card>
|
|
<CardHeader>
|
|
<Heading size="md">基本信息</Heading>
|
|
</CardHeader>
|
|
<CardBody>
|
|
<VStack spacing={6}>
|
|
{/* 头像和基本信息 */}
|
|
<HStack spacing={6} align="start" w="full">
|
|
<VStack>
|
|
<Avatar
|
|
size="2xl"
|
|
src={user?.avatar_url}
|
|
name={user?.nickname || user?.username}
|
|
/>
|
|
{isEditing && (
|
|
<Button
|
|
size="sm"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
>
|
|
更换头像
|
|
</Button>
|
|
)}
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
style={{ display: 'none' }}
|
|
onChange={handleAvatarUpload}
|
|
/>
|
|
</VStack>
|
|
|
|
<VStack flex="1" align="start" spacing={4}>
|
|
<HStack w="full">
|
|
<Badge colorScheme="blue" variant="subtle">
|
|
用户名: {user?.username}
|
|
</Badge>
|
|
{user?.has_wechat && (
|
|
<Badge colorScheme="green">微信已绑定</Badge>
|
|
)}
|
|
{user?.is_creator && (
|
|
<Badge colorScheme="purple">创作者</Badge>
|
|
)}
|
|
</HStack>
|
|
|
|
<SimpleGrid columns={2} spacing={4} w="full">
|
|
<FormControl>
|
|
<FormLabel>昵称</FormLabel>
|
|
{isEditing ? (
|
|
<Input
|
|
name="nickname"
|
|
value={formData.nickname}
|
|
onChange={handleInputChange}
|
|
placeholder="请输入昵称"
|
|
/>
|
|
) : (
|
|
<Text>{user?.nickname || '未设置'}</Text>
|
|
)}
|
|
</FormControl>
|
|
|
|
<FormControl>
|
|
<FormLabel>性别</FormLabel>
|
|
{isEditing ? (
|
|
<Select
|
|
name="gender"
|
|
value={formData.gender}
|
|
onChange={handleInputChange}
|
|
>
|
|
<option value="">请选择</option>
|
|
<option value="male">男</option>
|
|
<option value="female">女</option>
|
|
<option value="other">其他</option>
|
|
</Select>
|
|
) : (
|
|
<Text>
|
|
{user?.gender === 'male' ? '男' :
|
|
user?.gender === 'female' ? '女' :
|
|
user?.gender === 'other' ? '其他' : '未设置'}
|
|
</Text>
|
|
)}
|
|
</FormControl>
|
|
|
|
<FormControl>
|
|
<FormLabel>所在地</FormLabel>
|
|
{isEditing ? (
|
|
<Input
|
|
name="location"
|
|
value={formData.location}
|
|
onChange={handleInputChange}
|
|
placeholder="请输入所在地"
|
|
/>
|
|
) : (
|
|
<Text>{user?.location || '未设置'}</Text>
|
|
)}
|
|
</FormControl>
|
|
|
|
<FormControl>
|
|
<FormLabel>生日</FormLabel>
|
|
{isEditing ? (
|
|
<Input
|
|
name="birth_date"
|
|
type="date"
|
|
value={formData.birth_date}
|
|
onChange={handleInputChange}
|
|
/>
|
|
) : (
|
|
<Text>{user?.birth_date || '未设置'}</Text>
|
|
)}
|
|
</FormControl>
|
|
</SimpleGrid>
|
|
|
|
<FormControl>
|
|
<FormLabel>个人简介</FormLabel>
|
|
{isEditing ? (
|
|
<Textarea
|
|
name="bio"
|
|
value={formData.bio}
|
|
onChange={handleInputChange}
|
|
placeholder="介绍一下自己..."
|
|
rows={3}
|
|
/>
|
|
) : (
|
|
<Text color="gray.600">
|
|
{user?.bio || '这个人很懒,什么都没留下...'}
|
|
</Text>
|
|
)}
|
|
</FormControl>
|
|
</VStack>
|
|
</HStack>
|
|
|
|
<Divider />
|
|
|
|
{/* 投资偏好 */}
|
|
<Box w="full">
|
|
<Heading size="sm" mb={4}>投资偏好</Heading>
|
|
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
|
|
<FormControl>
|
|
<FormLabel>交易经验</FormLabel>
|
|
{isEditing ? (
|
|
<Select
|
|
name="trading_experience"
|
|
value={formData.trading_experience}
|
|
onChange={handleInputChange}
|
|
>
|
|
<option value="">请选择</option>
|
|
<option value="beginner">新手 (0-1年)</option>
|
|
<option value="intermediate">中级 (1-3年)</option>
|
|
<option value="advanced">高级 (3-5年)</option>
|
|
<option value="expert">专家 (5年以上)</option>
|
|
</Select>
|
|
) : (
|
|
<Text>
|
|
{user?.trading_experience === 'beginner' ? '新手 (0-1年)' :
|
|
user?.trading_experience === 'intermediate' ? '中级 (1-3年)' :
|
|
user?.trading_experience === 'advanced' ? '高级 (3-5年)' :
|
|
user?.trading_experience === 'expert' ? '专家 (5年以上)' : '未设置'}
|
|
</Text>
|
|
)}
|
|
</FormControl>
|
|
|
|
<FormControl>
|
|
<FormLabel>投资风格</FormLabel>
|
|
{isEditing ? (
|
|
<Select
|
|
name="investment_style"
|
|
value={formData.investment_style}
|
|
onChange={handleInputChange}
|
|
>
|
|
<option value="">请选择</option>
|
|
<option value="conservative">保守型</option>
|
|
<option value="moderate">稳健型</option>
|
|
<option value="aggressive">积极型</option>
|
|
<option value="speculative">投机型</option>
|
|
</Select>
|
|
) : (
|
|
<Text>
|
|
{user?.investment_style === 'conservative' ? '保守型' :
|
|
user?.investment_style === 'moderate' ? '稳健型' :
|
|
user?.investment_style === 'aggressive' ? '积极型' :
|
|
user?.investment_style === 'speculative' ? '投机型' : '未设置'}
|
|
</Text>
|
|
)}
|
|
</FormControl>
|
|
|
|
<FormControl>
|
|
<FormLabel>风险偏好</FormLabel>
|
|
{isEditing ? (
|
|
<Select
|
|
name="risk_preference"
|
|
value={formData.risk_preference}
|
|
onChange={handleInputChange}
|
|
>
|
|
<option value="">请选择</option>
|
|
<option value="low">低风险</option>
|
|
<option value="medium">中等风险</option>
|
|
<option value="high">高风险</option>
|
|
</Select>
|
|
) : (
|
|
<Text>
|
|
{user?.risk_preference === 'low' ? '低风险' :
|
|
user?.risk_preference === 'medium' ? '中等风险' :
|
|
user?.risk_preference === 'high' ? '高风险' : '未设置'}
|
|
</Text>
|
|
)}
|
|
</FormControl>
|
|
|
|
<FormControl>
|
|
<FormLabel>投资金额</FormLabel>
|
|
{isEditing ? (
|
|
<Select
|
|
name="investment_amount"
|
|
value={formData.investment_amount}
|
|
onChange={handleInputChange}
|
|
>
|
|
<option value="">请选择</option>
|
|
<option value="under_10k">1万以下</option>
|
|
<option value="10k_50k">1-5万</option>
|
|
<option value="50k_100k">5-10万</option>
|
|
<option value="100k_500k">10-50万</option>
|
|
<option value="over_500k">50万以上</option>
|
|
</Select>
|
|
) : (
|
|
<Text>
|
|
{user?.investment_amount === 'under_10k' ? '1万以下' :
|
|
user?.investment_amount === '10k_50k' ? '1-5万' :
|
|
user?.investment_amount === '50k_100k' ? '5-10万' :
|
|
user?.investment_amount === '100k_500k' ? '10-50万' :
|
|
user?.investment_amount === 'over_500k' ? '50万以上' : '未设置'}
|
|
</Text>
|
|
)}
|
|
</FormControl>
|
|
</SimpleGrid>
|
|
|
|
{/* 偏好市场标签 */}
|
|
<FormControl mt={4}>
|
|
<FormLabel>偏好市场</FormLabel>
|
|
<Wrap>
|
|
{formData.preferred_markets.map((market, index) => (
|
|
<WrapItem key={index}>
|
|
<Tag size="md" variant="solid" colorScheme="blue">
|
|
<TagLabel>{market}</TagLabel>
|
|
{isEditing && (
|
|
<TagCloseButton onClick={() => removeMarketTag(market)} />
|
|
)}
|
|
</Tag>
|
|
</WrapItem>
|
|
))}
|
|
{isEditing && (
|
|
<WrapItem>
|
|
<HStack>
|
|
<Input
|
|
size="sm"
|
|
placeholder="添加市场"
|
|
value={newTag}
|
|
onChange={(e) => setNewTag(e.target.value)}
|
|
onKeyPress={(e) => e.key === 'Enter' && addMarketTag()}
|
|
/>
|
|
<IconButton
|
|
size="sm"
|
|
icon={<AddIcon />}
|
|
onClick={addMarketTag}
|
|
/>
|
|
</HStack>
|
|
</WrapItem>
|
|
)}
|
|
</Wrap>
|
|
{formData.preferred_markets.length === 0 && !isEditing && (
|
|
<Text color="gray.500" fontSize="sm">暂未设置偏好市场</Text>
|
|
)}
|
|
</FormControl>
|
|
</Box>
|
|
</VStack>
|
|
</CardBody>
|
|
</Card>
|
|
</Box>
|
|
|
|
{/* 右侧:统计数据 */}
|
|
<VStack spacing={6}>
|
|
{/* 社区统计 */}
|
|
<Card w="full">
|
|
<CardHeader>
|
|
<Heading size="md">社区统计</Heading>
|
|
</CardHeader>
|
|
<CardBody>
|
|
<VStack spacing={4}>
|
|
<Stat textAlign="center">
|
|
<StatLabel>用户等级</StatLabel>
|
|
<StatNumber color="blue.500">Lv.{user?.user_level || 1}</StatNumber>
|
|
</Stat>
|
|
|
|
<SimpleGrid columns={2} spacing={4} w="full">
|
|
<Stat textAlign="center">
|
|
<StatLabel>声誉分数</StatLabel>
|
|
<StatNumber fontSize="lg">{user?.reputation_score || 0}</StatNumber>
|
|
</Stat>
|
|
<Stat textAlign="center">
|
|
<StatLabel>贡献点数</StatLabel>
|
|
<StatNumber fontSize="lg">{user?.contribution_point || 0}</StatNumber>
|
|
</Stat>
|
|
<Stat textAlign="center">
|
|
<StatLabel>发帖数</StatLabel>
|
|
<StatNumber fontSize="lg">{user?.post_count || 0}</StatNumber>
|
|
</Stat>
|
|
<Stat textAlign="center">
|
|
<StatLabel>评论数</StatLabel>
|
|
<StatNumber fontSize="lg">{user?.comment_count || 0}</StatNumber>
|
|
</Stat>
|
|
<Stat textAlign="center">
|
|
<StatLabel>关注者</StatLabel>
|
|
<StatNumber fontSize="lg">{user?.follower_count || 0}</StatNumber>
|
|
</Stat>
|
|
<Stat textAlign="center">
|
|
<StatLabel>关注中</StatLabel>
|
|
<StatNumber fontSize="lg">{user?.following_count || 0}</StatNumber>
|
|
</Stat>
|
|
</SimpleGrid>
|
|
|
|
<Box w="full">
|
|
<Text fontSize="sm" color="gray.600" mb={2}>声誉等级</Text>
|
|
<Progress
|
|
value={(user?.reputation_score || 0) / 10}
|
|
colorScheme={getProgressColor(user?.reputation_score || 0)}
|
|
borderRadius="md"
|
|
/>
|
|
<Text fontSize="xs" color="gray.500" mt={1}>
|
|
{user?.reputation_score || 0} / 1000
|
|
</Text>
|
|
</Box>
|
|
</VStack>
|
|
</CardBody>
|
|
</Card>
|
|
|
|
{/* 账户信息 */}
|
|
<Card w="full">
|
|
<CardHeader>
|
|
<Heading size="md">账户信息</Heading>
|
|
</CardHeader>
|
|
<CardBody>
|
|
<VStack spacing={3} align="start">
|
|
<HStack justify="space-between" w="full">
|
|
<Text fontSize="sm" color="gray.600">邮箱</Text>
|
|
<VStack align="end" spacing={0}>
|
|
<Text fontSize="sm">{user?.email}</Text>
|
|
{user?.email_confirmed && (
|
|
<Badge size="xs" colorScheme="green">已验证</Badge>
|
|
)}
|
|
</VStack>
|
|
</HStack>
|
|
|
|
{user?.phone && (
|
|
<HStack justify="space-between" w="full">
|
|
<Text fontSize="sm" color="gray.600">手机号</Text>
|
|
<VStack align="end" spacing={0}>
|
|
<Text fontSize="sm">{user.phone}</Text>
|
|
{user?.phone_confirmed && (
|
|
<Badge size="xs" colorScheme="green">已验证</Badge>
|
|
)}
|
|
</VStack>
|
|
</HStack>
|
|
)}
|
|
|
|
<HStack justify="space-between" w="full">
|
|
<Text fontSize="sm" color="gray.600">微信</Text>
|
|
{user?.has_wechat ? (
|
|
<Badge size="xs" colorScheme="green">已绑定</Badge>
|
|
) : (
|
|
<Badge size="xs" colorScheme="gray">未绑定</Badge>
|
|
)}
|
|
</HStack>
|
|
|
|
<HStack justify="space-between" w="full">
|
|
<Text fontSize="sm" color="gray.600">注册时间</Text>
|
|
<Text fontSize="sm">
|
|
{user?.created_at ? new Date(user.created_at).toLocaleDateString() : '未知'}
|
|
</Text>
|
|
</HStack>
|
|
|
|
<HStack justify="space-between" w="full">
|
|
<Text fontSize="sm" color="gray.600">最后活跃</Text>
|
|
<Text fontSize="sm">
|
|
{user?.last_seen ? new Date(user.last_seen).toLocaleDateString() : '未知'}
|
|
</Text>
|
|
</HStack>
|
|
</VStack>
|
|
</CardBody>
|
|
</Card>
|
|
|
|
</VStack>
|
|
</SimpleGrid>
|
|
</VStack>
|
|
</Container>
|
|
);
|
|
} |