nex_docus/frontend/src/pages/Profile/ProfilePage.jsx

425 lines
13 KiB
React
Raw Normal View History

2025-12-20 11:18:59 +00:00
import { useState, useEffect } from 'react'
2026-03-11 07:27:52 +00:00
import { Card, Tabs, Form, Input, Button, Avatar, Upload, message, Space, Typography, Modal } from 'antd'
import { UserOutlined, LockOutlined, UploadOutlined, ApiOutlined, CopyOutlined, ReloadOutlined } from '@ant-design/icons'
2026-01-13 13:21:47 +00:00
import ImgCrop from 'antd-img-crop'
2026-03-11 07:27:52 +00:00
import { getCurrentUser, updateProfile, changePassword, uploadAvatar, getMcpCredentials, rotateMcpSecret } from '@/api/auth'
2025-12-20 11:18:59 +00:00
import useUserStore from '@/stores/userStore'
import Toast from '@/components/Toast/Toast'
import './ProfilePage.css'
2026-03-11 07:27:52 +00:00
const { Paragraph, Text } = Typography
2025-12-20 11:18:59 +00:00
function ProfilePage() {
const [loading, setLoading] = useState(false)
const [userInfo, setUserInfo] = useState(null)
2026-03-11 07:27:52 +00:00
const [mcpCredentials, setMcpCredentials] = useState(null)
2025-12-20 11:18:59 +00:00
const [profileForm] = Form.useForm()
const [passwordForm] = Form.useForm()
2026-03-11 07:27:52 +00:00
const { setUser } = useUserStore()
2025-12-20 11:18:59 +00:00
useEffect(() => {
loadUserInfo()
2026-03-11 07:27:52 +00:00
loadMcpCredentials()
2025-12-20 11:18:59 +00:00
}, [])
// 加载用户信息
const loadUserInfo = async () => {
try {
const res = await getCurrentUser()
setUserInfo(res.data)
profileForm.setFieldsValue({
username: res.data.username,
nickname: res.data.nickname,
email: res.data.email,
phone: res.data.phone,
})
} catch (error) {
console.error('Load user info error:', error)
message.error('加载用户信息失败')
}
}
2026-03-11 07:27:52 +00:00
const loadMcpCredentials = async () => {
try {
const res = await getMcpCredentials()
setMcpCredentials(res.data)
} catch (error) {
console.error('Load MCP credentials error:', error)
Toast.error('加载失败', '获取 MCP 凭证失败')
}
}
2025-12-20 11:18:59 +00:00
// 更新资料
const handleUpdateProfile = async (values) => {
setLoading(true)
try {
const res = await updateProfile({
nickname: values.nickname,
email: values.email,
phone: values.phone,
})
setUserInfo(res.data)
setUser(res.data) // 更新全局用户信息
Toast.success('更新成功', '个人资料已更新')
} catch (error) {
console.error('Update profile error:', error)
message.error(error.response?.data?.detail || '更新失败')
} finally {
setLoading(false)
}
}
// 修改密码
const handleChangePassword = async (values) => {
setLoading(true)
try {
await changePassword({
old_password: values.old_password,
new_password: values.new_password,
})
Toast.success('修改成功', '密码已修改,请重新登录')
passwordForm.resetFields()
// 2秒后跳转到登录页
setTimeout(() => {
window.location.href = '/login'
}, 2000)
} catch (error) {
console.error('Change password error:', error)
message.error(error.response?.data?.detail || '修改密码失败')
} finally {
setLoading(false)
}
}
2026-01-13 13:21:47 +00:00
// 上传头像前的验证
const beforeAvatarUpload = (file) => {
const isJpgOrPng = file.type === 'image/jpeg' || file.type === 'image/png'
if (!isJpgOrPng) {
Toast.error('格式错误', '仅支持 JPG、PNG 格式的图片')
return false
}
const isLt1M = file.size / 1024 / 1024 < 1
if (!isLt1M) {
Toast.error('文件过大', '图片大小不能超过 1MB')
return false
}
return true
}
// 处理头像上传
const handleAvatarUpload = async (info) => {
const { file } = info
setLoading(true)
try {
const res = await uploadAvatar(file)
setUserInfo(res.data)
setUser(res.data) // 更新全局用户信息
Toast.success('上传成功', '头像已更新')
} catch (error) {
console.error('Upload avatar error:', error)
Toast.error('上传失败', error.response?.data?.detail || '头像上传失败')
} finally {
setLoading(false)
}
}
// 获取头像URL
const getAvatarUrl = () => {
if (!userInfo?.avatar) return null
// avatar 字段存储的是相对路径2/avatar/xxx.jpg
// 需要转换为 API 端点: /api/v1/auth/avatar/{user_id}/{filename}
const parts = userInfo.avatar.split('/')
if (parts.length >= 3) {
const userId = parts[0]
const filename = parts[2]
return `/api/v1/auth/avatar/${userId}/${filename}`
}
return null
}
2026-03-11 07:27:52 +00:00
const handleCopy = async (value, label) => {
if (!value) return
try {
await navigator.clipboard.writeText(value)
Toast.success('复制成功', `${label} 已复制到剪贴板`)
} catch (error) {
console.error('Copy credential error:', error)
Toast.error('复制失败', `无法复制 ${label}`)
}
}
const handleRotateSecret = () => {
Modal.confirm({
title: '重新生成 MCP Secret',
content: '旧 Secret 会立即失效,依赖该凭证的远程客户端需要同步更新。确认继续?',
okText: '重新生成',
cancelText: '取消',
okButtonProps: { danger: true },
onOk: async () => {
setLoading(true)
try {
const res = await rotateMcpSecret()
setMcpCredentials(res.data)
Toast.success('已生成', '新的 MCP Secret 已生效')
} catch (error) {
console.error('Rotate MCP secret error:', error)
Toast.error('生成失败', error.response?.data?.detail || '重新生成 MCP Secret 失败')
} finally {
setLoading(false)
}
},
})
}
2025-12-20 11:18:59 +00:00
const tabItems = [
{
key: 'profile',
label: (
<span>
<UserOutlined />
个人资料
</span>
),
children: (
<div className="profile-tab-content">
<div className="avatar-section">
2026-01-13 13:21:47 +00:00
<Avatar
size={100}
icon={<UserOutlined />}
src={getAvatarUrl()}
/>
<ImgCrop
rotationSlider
aspect={1}
quality={1}
modalTitle="裁剪头像"
modalOk="确定"
modalCancel="取消"
>
<Upload
showUploadList={false}
beforeUpload={beforeAvatarUpload}
customRequest={({ file }) => handleAvatarUpload({ file })}
>
<Button icon={<UploadOutlined />} style={{ marginTop: 16 }} loading={loading}>
更换头像
</Button>
</Upload>
</ImgCrop>
<p className="avatar-tip">支持 JPGPNG 格式文件小于 1MB</p>
2025-12-20 11:18:59 +00:00
</div>
<Form
form={profileForm}
layout="vertical"
onFinish={handleUpdateProfile}
className="profile-form"
>
<Form.Item label="用户名" name="username">
<Input disabled />
</Form.Item>
<Form.Item
label="昵称"
name="nickname"
rules={[{ max: 50, message: '昵称最多50个字符' }]}
>
<Input placeholder="请输入昵称" />
</Form.Item>
<Form.Item
label="邮箱"
name="email"
rules={[
{ type: 'email', message: '请输入有效的邮箱地址' },
]}
>
<Input placeholder="请输入邮箱" />
</Form.Item>
<Form.Item
label="手机号"
name="phone"
rules={[
{ pattern: /^1[3-9]\d{9}$/, message: '请输入有效的手机号' },
]}
>
<Input placeholder="请输入手机号" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={loading}>
保存修改
</Button>
</Form.Item>
</Form>
</div>
),
},
{
key: 'password',
label: (
<span>
<LockOutlined />
修改密码
</span>
),
children: (
<div className="password-tab-content">
<Form
form={passwordForm}
layout="vertical"
onFinish={handleChangePassword}
className="password-form"
>
<Form.Item
label="当前密码"
name="old_password"
rules={[{ required: true, message: '请输入当前密码' }]}
>
<Input.Password placeholder="请输入当前密码" />
</Form.Item>
<Form.Item
label="新密码"
name="new_password"
rules={[
{ required: true, message: '请输入新密码' },
{ min: 6, message: '密码至少6个字符' },
{ max: 50, message: '密码最多50个字符' },
]}
>
<Input.Password placeholder="请输入新密码至少6个字符" />
</Form.Item>
<Form.Item
label="确认新密码"
name="confirm_password"
dependencies={['new_password']}
rules={[
{ required: true, message: '请确认新密码' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('new_password') === value) {
return Promise.resolve()
}
return Promise.reject(new Error('两次输入的密码不一致'))
},
}),
]}
>
<Input.Password placeholder="请再次输入新密码" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={loading}>
修改密码
</Button>
<Button
style={{ marginLeft: 8 }}
onClick={() => passwordForm.resetFields()}
>
重置
</Button>
</Form.Item>
<div className="password-tips">
<h4>密码安全建议</h4>
<ul>
<li>密码长度至少6个字符</li>
<li>包含字母数字和特殊字符的组合更安全</li>
<li>不要使用过于简单的密码"123456"</li>
<li>定期更换密码提高账户安全性</li>
</ul>
</div>
</Form>
</div>
),
},
2026-03-11 07:27:52 +00:00
{
key: 'mcp',
label: (
<span>
<ApiOutlined />
MCP 接入
</span>
),
children: (
<div className="mcp-tab-content">
<div className="mcp-panel">
<div className="mcp-panel-header">
<div>
<h3>MCP 访问凭证</h3>
<p>用于远程 MCP Client 通过 `X-Bot-Id` `X-Bot-Secret` 接入你的账号</p>
</div>
<Button
icon={<ReloadOutlined />}
onClick={handleRotateSecret}
loading={loading}
>
重新生成 Secret
</Button>
</div>
<div className="mcp-field-list">
<div className="mcp-field-card">
<label>X-Bot-Id</label>
<Paragraph copyable={false} className="mcp-value">
{mcpCredentials?.bot_id || '-'}
</Paragraph>
<Button
icon={<CopyOutlined />}
onClick={() => handleCopy(mcpCredentials?.bot_id, 'X-Bot-Id')}
disabled={!mcpCredentials?.bot_id}
>
复制 Bot ID
</Button>
</div>
<div className="mcp-field-card">
<label>X-Bot-Secret</label>
<Paragraph copyable={false} className="mcp-value mcp-secret">
{mcpCredentials?.bot_secret || '-'}
</Paragraph>
<Space>
<Button
icon={<CopyOutlined />}
onClick={() => handleCopy(mcpCredentials?.bot_secret, 'X-Bot-Secret')}
disabled={!mcpCredentials?.bot_secret}
>
复制 Secret
</Button>
<Text type="secondary">变更后旧 Secret 立即失效</Text>
</Space>
</div>
</div>
<div className="mcp-config-tip">
<h4>客户端请求头</h4>
<pre>{`X-Bot-Id: ${mcpCredentials?.bot_id || 'your_bot_id'}\nX-Bot-Secret: ${mcpCredentials?.bot_secret || 'your_bot_secret'}`}</pre>
</div>
</div>
</div>
),
},
2025-12-20 11:18:59 +00:00
]
return (
<div className="profile-page">
<Card className="profile-card">
<h2 className="profile-title">个人中心</h2>
2026-03-11 07:27:52 +00:00
<Tabs
items={tabItems}
defaultActiveKey="profile"
tabPosition="left"
className="profile-tabs"
/>
2025-12-20 11:18:59 +00:00
</Card>
</div>
)
}
export default ProfilePage