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

425 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import { useState, useEffect } from 'react'
import { Card, Tabs, Form, Input, Button, Avatar, Upload, Space, Typography, Modal } from 'antd'
import { UserOutlined, LockOutlined, UploadOutlined, ApiOutlined, CopyOutlined, ReloadOutlined } from '@ant-design/icons'
import ImgCrop from 'antd-img-crop'
import { getCurrentUser, updateProfile, changePassword, uploadAvatar, getMcpCredentials, rotateMcpSecret } from '@/api/auth'
import useUserStore from '@/stores/userStore'
import Toast from '@/components/Toast/Toast'
import './ProfilePage.css'
const { Paragraph, Text } = Typography
function ProfilePage() {
const [loading, setLoading] = useState(false)
const [userInfo, setUserInfo] = useState(null)
const [mcpCredentials, setMcpCredentials] = useState(null)
const [profileForm] = Form.useForm()
const [passwordForm] = Form.useForm()
const { setUser } = useUserStore()
useEffect(() => {
loadUserInfo()
loadMcpCredentials()
}, [])
// 加载用户信息
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)
Toast.error('加载用户信息失败')
}
}
const loadMcpCredentials = async () => {
try {
const res = await getMcpCredentials()
setMcpCredentials(res.data)
} catch (error) {
console.error('Load MCP credentials error:', error)
Toast.error('加载失败', '获取 MCP 凭证失败')
}
}
// 更新资料
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)
Toast.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)
Toast.error(error.response?.data?.detail || '修改密码失败')
} finally {
setLoading(false)
}
}
// 上传头像前的验证
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
}
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)
}
},
})
}
const tabItems = [
{
key: 'profile',
label: (
<span>
<UserOutlined />
个人资料
</span>
),
children: (
<div className="profile-tab-content">
<div className="avatar-section">
<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>
</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>
),
},
{
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>
),
},
]
return (
<div className="profile-page">
<Card className="profile-card">
<h2 className="profile-title">个人中心</h2>
<Tabs
items={tabItems}
defaultActiveKey="profile"
tabPosition="left"
className="profile-tabs"
/>
</Card>
</div>
)
}
export default ProfilePage