2025-12-20 11:18:59 +00:00
|
|
|
|
import { useState, useEffect } from 'react'
|
2026-08-25 02:17:40 +00:00
|
|
|
|
import { Card, Tabs, Form, Input, Button, Avatar, Upload, Space, Typography, Modal } from 'antd'
|
2026-03-11 07:27:52 +00:00
|
|
|
|
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)
|
2026-08-25 02:17:40 +00:00
|
|
|
|
Toast.error('加载用户信息失败')
|
2025-12-20 11:18:59 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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)
|
2026-08-25 02:17:40 +00:00
|
|
|
|
Toast.error(error.response?.data?.detail || '更新失败')
|
2025-12-20 11:18:59 +00:00
|
|
|
|
} 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)
|
2026-08-25 02:17:40 +00:00
|
|
|
|
Toast.error(error.response?.data?.detail || '修改密码失败')
|
2025-12-20 11:18:59 +00:00
|
|
|
|
} 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">支持 JPG、PNG 格式,文件小于 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
|