更新了Desktop页

main
mula.liu 2026-01-13 21:21:47 +08:00
parent 0af0f2331d
commit 72000d5660
14 changed files with 656 additions and 165 deletions

View File

@ -1,16 +1,22 @@
"""
用户认证相关 API
"""
from fastapi import APIRouter, Depends, HTTPException, status, Request
from fastapi import APIRouter, Depends, HTTPException, status, Request, UploadFile, File
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from datetime import datetime
import logging
import os
import uuid
from pathlib import Path
import aiofiles
from app.core.database import get_db
from app.core.security import verify_password, get_password_hash, create_access_token
from app.core.deps import get_current_user
from app.core.redis_client import TokenCache
from app.core.config import settings
from app.models.user import User
from app.models.role import Role, UserRole
from app.schemas.user import UserCreate, UserLogin, UserResponse, Token, ChangePassword, UserUpdate
@ -207,3 +213,77 @@ async def logout(
)
return success_response(message="退出成功")
@router.post("/upload-avatar", response_model=dict)
async def upload_avatar(
file: UploadFile = File(...),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""上传用户头像"""
# 验证文件类型
allowed_types = ["image/jpeg", "image/jpg", "image/png"]
if file.content_type not in allowed_types:
raise HTTPException(status_code=400, detail="仅支持 JPG、PNG 格式的图片")
# 验证文件大小
file_content = await file.read()
if len(file_content) > settings.AVATAR_MAX_SIZE:
raise HTTPException(status_code=400, detail="文件大小不能超过 1MB")
# 重置文件指针
await file.seek(0)
# 创建用户头像目录
user_avatar_dir = Path(settings.USERS_PATH) / str(current_user.id) / "avatar"
user_avatar_dir.mkdir(parents=True, exist_ok=True)
# 生成唯一文件名(使用 UUID + 原始文件扩展名)
file_ext = Path(file.filename).suffix
unique_filename = f"{uuid.uuid4()}{file_ext}"
file_path = user_avatar_dir / unique_filename
# 删除旧头像文件(如果存在)
if current_user.avatar:
old_avatar_path = Path(settings.USERS_PATH) / current_user.avatar
if old_avatar_path.exists():
try:
old_avatar_path.unlink()
except Exception as e:
logger.warning(f"Failed to delete old avatar: {e}")
# 保存文件
async with aiofiles.open(file_path, 'wb') as f:
await f.write(file_content)
# 更新用户头像字段(存储相对于 USERS_PATH 的路径,便于前端访问)
relative_path = f"{current_user.id}/avatar/{unique_filename}"
current_user.avatar = relative_path
await db.commit()
await db.refresh(current_user)
user_data = UserResponse.from_orm(current_user)
return success_response(data=user_data.dict(), message="头像上传成功")
@router.get("/avatar/{user_id}/{filename}")
async def get_avatar(
user_id: int,
filename: str
):
"""获取用户头像"""
# 构建头像文件路径
avatar_path = Path(settings.USERS_PATH) / str(user_id) / "avatar" / filename
if not avatar_path.exists() or not avatar_path.is_file():
raise HTTPException(status_code=404, detail="头像不存在")
# 返回文件
return FileResponse(
path=str(avatar_path),
media_type="image/jpeg", # 根据文件扩展名自动判断
headers={
"Cache-Control": "public, max-age=31536000, immutable"
}
)

View File

@ -1,17 +1,22 @@
"""
管理员仪表盘相关 API
"""
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from sqlalchemy import select, func, and_, or_
from datetime import datetime, date
from typing import Optional
import os
import glob
import json
from app.core.database import get_db
from app.core.deps import get_current_user
from app.core.config import settings
from app.models.user import User
from app.models.project import Project, ProjectMember
from app.models.log import OperationLog
from app.core.enums import OperationType, ResourceType
from app.schemas.response import success_response
router = APIRouter()
@ -185,3 +190,145 @@ async def get_personal_stats(
"recent_shared_projects": recent_shared_projects_data,
}
)
@router.get("/document-activity-dates", response_model=dict)
async def get_document_activity_dates(
year: int = Query(..., description="年份"),
month: int = Query(..., description="月份"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""获取指定月份有文档操作的日期列表"""
# 计算月份的开始和结束日期
start_date = datetime(year, month, 1)
if month == 12:
end_date = datetime(year + 1, 1, 1)
else:
end_date = datetime(year, month + 1, 1)
# 查询该用户在指定月份内的文档操作日志
# 文档操作包括:创建文件、保存文件、删除文件、重命名文件、移动文件
document_operations = [
OperationType.CREATE_FILE,
OperationType.SAVE_FILE,
OperationType.DELETE_FILE,
OperationType.RENAME_FILE,
OperationType.MOVE_FILE,
]
result = await db.execute(
select(func.date(OperationLog.created_at).label('activity_date'), func.count(OperationLog.id).label('count'))
.where(
and_(
OperationLog.user_id == current_user.id,
OperationLog.operation_type.in_(document_operations),
OperationLog.created_at >= start_date,
OperationLog.created_at < end_date
)
)
.group_by(func.date(OperationLog.created_at))
.order_by(func.date(OperationLog.created_at))
)
activity_dates = result.all()
dates_data = [
{
"date": activity_date.isoformat(),
"count": count
}
for activity_date, count in activity_dates
]
return success_response(data={"dates": dates_data})
@router.get("/document-activity", response_model=dict)
async def get_document_activity(
date_str: str = Query(..., description="日期YYYY-MM-DD"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""获取指定日期的文档操作日志"""
# 解析日期
try:
target_date = datetime.strptime(date_str, "%Y-%m-%d").date()
except ValueError:
raise HTTPException(status_code=400, detail="日期格式错误,应为 YYYY-MM-DD")
# 计算日期范围
start_datetime = datetime.combine(target_date, datetime.min.time())
end_datetime = datetime.combine(target_date, datetime.max.time())
# 查询该用户在指定日期的文档操作日志
document_operations = [
OperationType.CREATE_FILE,
OperationType.SAVE_FILE,
OperationType.DELETE_FILE,
OperationType.RENAME_FILE,
OperationType.MOVE_FILE,
]
result = await db.execute(
select(OperationLog)
.where(
and_(
OperationLog.user_id == current_user.id,
OperationLog.operation_type.in_(document_operations),
OperationLog.created_at >= start_datetime,
OperationLog.created_at <= end_datetime
)
)
.order_by(OperationLog.created_at.desc())
)
logs = result.scalars().all()
# 构建返回数据,包含项目信息
logs_data = []
for log in logs:
# 解析 detail 字段获取文件路径和项目ID
detail = json.loads(log.detail) if log.detail else {}
project_id = detail.get('project_id')
file_path = detail.get('path') or detail.get('file_path') or detail.get('old_path')
# 获取项目信息
project_name = None
project_storage_key = None
if project_id:
project_result = await db.execute(
select(Project).where(Project.id == project_id)
)
project = project_result.scalar_one_or_none()
if project:
project_name = project.name
project_storage_key = project.storage_key
# 检查文件是否存在(仅针对非删除操作)
file_exists = False
if project_storage_key and file_path and log.operation_type != OperationType.DELETE_FILE:
full_path = os.path.join(settings.PROJECTS_PATH, project_storage_key, file_path)
file_exists = os.path.exists(full_path) and os.path.isfile(full_path)
# 操作类型中文映射
operation_map = {
OperationType.CREATE_FILE: "创建文件",
OperationType.SAVE_FILE: "保存文件",
OperationType.DELETE_FILE: "删除文件",
OperationType.RENAME_FILE: "重命名文件",
OperationType.MOVE_FILE: "移动文件",
}
logs_data.append({
"id": log.id,
"operation_type": operation_map.get(log.operation_type, log.operation_type),
"project_id": project_id,
"project_name": project_name or "未知项目",
"file_path": file_path or "未知文件",
"file_exists": file_exists,
"created_at": log.created_at.isoformat() if log.created_at else None,
"detail": detail,
})
return success_response(data={"logs": logs_data})

View File

@ -101,7 +101,12 @@ async def get_project_tree(
if member:
user_role = member.role
return success_response(data={"tree": tree, "user_role": user_role})
return success_response(data={
"tree": tree,
"user_role": user_role,
"project_name": project.name,
"project_description": project.description
})
@router.get("/{project_id}/file", response_model=dict)

View File

@ -63,8 +63,12 @@ class Settings(BaseSettings):
# 文件存储配置
STORAGE_ROOT: str = "/data/nex_docus_store"
PROJECTS_PATH: str = "/data/nex_docus_store/projects"
USERS_PATH: str = "/data/nex_docus_store/users"
TEMP_PATH: str = "/data/nex_docus_store/temp"
# 头像上传配置
AVATAR_MAX_SIZE: int = 1 * 1024 * 1024 # 1MB
# 跨域配置
CORS_ORIGINS: List[str] = ["http://localhost:5173", "http://localhost:3000"]

View File

@ -18,6 +18,7 @@
"@bytemd/plugin-highlight": "^1.22.0",
"@bytemd/react": "^1.22.0",
"antd": "^5.12.0",
"antd-img-crop": "^4.27.0",
"axios": "^1.6.2",
"bytemd": "^1.22.0",
"dayjs": "^1.11.10",

View File

@ -56,3 +56,19 @@ export function changePassword(data) {
data,
})
}
/**
* 上传用户头像
*/
export function uploadAvatar(file) {
const formData = new FormData()
formData.append('file', file)
return request({
url: '/auth/upload-avatar',
method: 'post',
data: formData,
headers: {
'Content-Type': 'multipart/form-data',
},
})
}

View File

@ -22,3 +22,25 @@ export function getPersonalStats() {
method: 'get',
})
}
/**
* 获取指定月份有文档操作的日期列表
*/
export function getDocumentActivityDates(year, month) {
return request({
url: '/dashboard/document-activity-dates',
method: 'get',
params: { year, month },
})
}
/**
* 获取指定日期的文档操作日志
*/
export function getDocumentActivity(date) {
return request({
url: '/dashboard/document-activity',
method: 'get',
params: { date_str: date },
})
}

View File

@ -188,6 +188,20 @@ function AppHeader({ collapsed, onToggle }) {
}
}
// URL
const getUserAvatarUrl = () => {
if (!user?.avatar) return null
// avatar 2/avatar/xxx.jpg
// API : /api/v1/auth/avatar/{user_id}/{filename}
const parts = user.avatar.split('/')
if (parts.length >= 3) {
const userId = parts[0]
const filename = parts[2]
return `/api/v1/auth/avatar/${userId}/${filename}`
}
return null
}
return (
<Header className="app-header">
{/* 左侧Logo + 折叠按钮 */}
@ -251,7 +265,7 @@ function AppHeader({ collapsed, onToggle }) {
placement="bottomRight"
>
<div className="user-info">
<Avatar size={32} icon={<UserOutlined />} />
<Avatar size={32} icon={<UserOutlined />} src={getUserAvatarUrl()} />
<span className="username">{user?.nickname || user?.username || 'User'}</span>
</div>
</Dropdown>

View File

@ -0,0 +1,80 @@
.desktop-page {
padding: 24px;
}
.page-title {
margin-bottom: 24px;
color: #333;
font-size: 24px;
font-weight: 600;
}
.calendar-card {
height: 100%;
min-height: 400px;
}
.calendar-card .ant-picker-calendar {
padding: 12px;
}
.activity-card {
height: 100%;
min-height: 400px;
}
.activity-card .ant-card-body {
max-height: 600px;
overflow-y: auto;
}
.activity-card .ant-list-item {
padding: 12px 0;
transition: all 0.3s;
}
/* 可点击的活动项 */
.activity-item-clickable:hover {
background-color: #e6f7ff;
padding-left: 12px;
padding-right: 12px;
border-radius: 4px;
transform: translateX(4px);
}
.activity-item-clickable:active {
background-color: #bae7ff;
}
/* 不可点击的活动项(已失效) */
.activity-item-disabled {
opacity: 0.6;
}
.activity-item-disabled:hover {
background-color: #f5f5f5;
padding-left: 12px;
padding-right: 12px;
border-radius: 4px;
}
/* 日历单元格样式 */
.ant-picker-calendar-date {
position: relative;
}
.ant-picker-calendar-date-today {
border-color: #1890ff;
}
.ant-picker-calendar-date-selected {
background-color: #e6f7ff;
}
/* 响应式调整 */
@media (max-width: 992px) {
.calendar-card,
.activity-card {
margin-bottom: 24px;
}
}

View File

@ -1,176 +1,201 @@
import { useState, useEffect } from 'react'
import { Card, Row, Col, Statistic, Table, Spin, Descriptions } from 'antd'
import { ProjectOutlined, FileTextOutlined, TeamOutlined } from '@ant-design/icons'
import { getPersonalStats } from '@/api/dashboard'
import { Card, Row, Col, Calendar, List, Badge, Empty, Typography, Spin } from 'antd'
import { FileTextOutlined, ClockCircleOutlined } from '@ant-design/icons'
import { useNavigate } from 'react-router-dom'
import { getDocumentActivityDates, getDocumentActivity } from '@/api/dashboard'
import Toast from '@/components/Toast/Toast'
import dayjs from 'dayjs'
import './Desktop.css'
const { Text } = Typography
function Desktop() {
const [loading, setLoading] = useState(true)
const [userInfo, setUserInfo] = useState({})
const [stats, setStats] = useState({
personal_projects_count: 0,
shared_projects_count: 0,
document_count: 0,
})
const [recentPersonalProjects, setRecentPersonalProjects] = useState([])
const [recentSharedProjects, setRecentSharedProjects] = useState([])
const navigate = useNavigate()
const [loading, setLoading] = useState(false)
const [activityDates, setActivityDates] = useState([])
const [selectedDate, setSelectedDate] = useState(dayjs())
const [activityLogs, setActivityLogs] = useState([])
const [currentMonth, setCurrentMonth] = useState(dayjs())
useEffect(() => {
loadPersonalData()
loadActivityDates(currentMonth.year(), currentMonth.month() + 1)
loadActivityLogs(selectedDate.format('YYYY-MM-DD'))
}, [])
const loadPersonalData = async () => {
//
const loadActivityDates = async (year, month) => {
try {
const res = await getPersonalStats()
if (res.data) {
setUserInfo(res.data.user_info)
setStats(res.data.stats)
setRecentPersonalProjects(res.data.recent_personal_projects)
setRecentSharedProjects(res.data.recent_shared_projects)
const res = await getDocumentActivityDates(year, month)
if (res.data && res.data.dates) {
setActivityDates(res.data.dates)
}
} catch (error) {
console.error('Load personal data error:', error)
Toast.error('加载个人桌面数据失败')
console.error('Load activity dates error:', error)
}
}
//
const loadActivityLogs = async (date) => {
setLoading(true)
try {
const res = await getDocumentActivity(date)
if (res.data && res.data.logs) {
setActivityLogs(res.data.logs)
} else {
setActivityLogs([])
}
} catch (error) {
console.error('Load activity logs error:', error)
Toast.error('加载失败', '获取文档活动记录失败')
} finally {
setLoading(false)
}
}
const personalProjectColumns = [
{
title: '项目名称',
dataIndex: 'name',
key: 'name',
},
{
title: '描述',
dataIndex: 'description',
key: 'description',
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
render: (text) => (text ? new Date(text).toLocaleString('zh-CN') : '-'),
},
]
//
const dateCellRender = (value) => {
const dateStr = value.format('YYYY-MM-DD')
const activity = activityDates.find(item => item.date === dateStr)
const sharedProjectColumns = [
{
title: '项目名称',
dataIndex: 'name',
key: 'name',
},
{
title: '描述',
dataIndex: 'description',
key: 'description',
},
{
title: '角色',
dataIndex: 'role',
key: 'role',
render: (role) => {
const roleMap = {
admin: '管理员',
editor: '编辑者',
viewer: '查看者',
}
return roleMap[role] || role
},
},
{
title: '加入时间',
dataIndex: 'joined_at',
key: 'joined_at',
render: (text) => (text ? new Date(text).toLocaleString('zh-CN') : '-'),
},
]
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '400px' }}>
<Spin size="large" />
if (activity && activity.count > 0) {
return (
<div style={{ textAlign: 'center' }}>
<Badge
count={activity.count}
style={{ backgroundColor: '#1890ff' }}
overflowCount={99}
/>
</div>
)
)
}
return null
}
//
const onSelect = (date) => {
setSelectedDate(date)
loadActivityLogs(date.format('YYYY-MM-DD'))
}
//
const onPanelChange = (date) => {
setCurrentMonth(date)
loadActivityDates(date.year(), date.month() + 1)
}
//
const handleDocumentClick = (log) => {
//
if (!log.file_exists) {
Toast.warning('无法打开', '文件不存在或已被删除/移动/重命名')
return
}
// ID
if (!log.project_id) {
Toast.error('无法打开', '找不到对应的项目')
return
}
//
navigate(`/projects/${log.project_id}/documents?path=${encodeURIComponent(log.file_path)}`)
}
return (
<div style={{ padding: '24px' }}>
<h1 style={{ marginBottom: '24px', color: '#333' }}>个人桌面</h1>
<div className="desktop-page">
<h1 className="page-title">个人桌面</h1>
{/* 个人信息 */}
<Card title="个人信息" style={{ marginBottom: '24px' }}>
<Descriptions column={2}>
<Descriptions.Item label="用户名">{userInfo.username}</Descriptions.Item>
<Descriptions.Item label="邮箱">{userInfo.email}</Descriptions.Item>
<Descriptions.Item label="用户ID">{userInfo.id}</Descriptions.Item>
<Descriptions.Item label="注册时间">
{userInfo.created_at ? new Date(userInfo.created_at).toLocaleString('zh-CN') : '-'}
</Descriptions.Item>
</Descriptions>
</Card>
<Row gutter={24}>
{/* 左侧日历 */}
<Col xs={24} lg={12}>
<Card className="calendar-card">
<Calendar
fullscreen={false}
value={selectedDate}
onSelect={onSelect}
onPanelChange={onPanelChange}
cellRender={dateCellRender}
/>
</Card>
</Col>
{/* 统计卡片 */}
<Row gutter={16} style={{ marginBottom: '24px' }}>
<Col span={8}>
<Card>
<Statistic
title="个人项目数"
value={stats.personal_projects_count}
prefix={<ProjectOutlined />}
valueStyle={{ color: '#1890ff' }}
/>
</Card>
</Col>
<Col span={8}>
<Card>
<Statistic
title="参加项目数"
value={stats.shared_projects_count}
prefix={<TeamOutlined />}
valueStyle={{ color: '#52c41a' }}
/>
</Card>
</Col>
<Col span={8}>
<Card>
<Statistic
title="文档总数"
value={stats.document_count}
prefix={<FileTextOutlined />}
valueStyle={{ color: '#cf1322' }}
/>
</Card>
</Col>
</Row>
{/* 最近的个人项目 */}
<Card title="最近的个人项目" style={{ marginBottom: '24px' }}>
<Table
columns={personalProjectColumns}
dataSource={recentPersonalProjects}
rowKey="id"
pagination={false}
locale={{ emptyText: '暂无个人项目' }}
/>
</Card>
{/* 最近的分享项目 */}
<Card title="最近的分享项目" style={{ marginBottom: '24px' }}>
<Table
columns={sharedProjectColumns}
dataSource={recentSharedProjects}
rowKey="id"
pagination={false}
locale={{ emptyText: '暂无分享项目' }}
/>
</Card>
</div>
{/* 右侧活动列表 */}
<Col xs={24} lg={12}>
<Card
className="activity-card"
title={
<div>
<FileTextOutlined style={{ marginRight: 8 }} />
{selectedDate.format('YYYY年MM月DD日')} 的文档活动
</div>
}
>
<Spin spinning={loading}>
{activityLogs.length > 0 ? (
<List
dataSource={activityLogs}
renderItem={(item) => (
<List.Item
key={item.id}
onClick={() => handleDocumentClick(item)}
className={item.file_exists ? 'activity-item-clickable' : 'activity-item-disabled'}
style={{ cursor: item.file_exists ? 'pointer' : 'default' }}
>
<List.Item.Meta
avatar={
<ClockCircleOutlined
style={{
fontSize: 20,
color: item.file_exists ? '#1890ff' : '#d9d9d9'
}}
/>
}
title={
<div>
<Text strong style={{ color: item.file_exists ? undefined : '#999' }}>
{item.project_name}
</Text>
<Text
type="secondary"
style={{ marginLeft: 8, color: item.file_exists ? undefined : '#bbb' }}
>
{item.operation_type}
</Text>
{!item.file_exists && (
<Text type="danger" style={{ marginLeft: 8, fontSize: 12 }}>
(已失效)
</Text>
)}
</div>
}
description={
<div>
<div style={{ marginBottom: 4 }}>
<Text type="secondary">文件</Text>
<Text code style={{ color: item.file_exists ? undefined : '#999' }}>
{item.file_path}
</Text>
</div>
<Text type="secondary" style={{ fontSize: 12 }}>
{new Date(item.created_at).toLocaleTimeString('zh-CN')}
</Text>
</div>
}
/>
</List.Item>
)}
/>
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="该日期暂无文档活动记录"
/>
)}
</Spin>
</Card>
</Col>
</Row>
</div>
)
}

View File

@ -70,6 +70,7 @@ function DocumentEditor() {
const [isPdfSelected, setIsPdfSelected] = useState(false) // PDF
const [linkModalVisible, setLinkModalVisible] = useState(false)
const [linkTarget, setLinkTarget] = useState(null)
const [projectName, setProjectName] = useState('项目文档') //
const editorCtxRef = useRef(null)
//
@ -123,7 +124,9 @@ function DocumentEditor() {
const res = await getProjectTree(projectId)
const data = res.data || {}
const tree = data.tree || data || [] //
const name = data.project_name || '项目文档'
setTreeData(tree)
setProjectName(name)
} catch (error) {
Toast.error('加载失败', '加载文件树失败')
}
@ -859,7 +862,7 @@ function DocumentEditor() {
className="document-sider"
>
<div className="sider-header">
<h2>项目文档</h2>
<h2>{projectName}</h2>
<div className="sider-actions">
<Button
type="primary"

View File

@ -37,6 +37,7 @@ function DocumentPage() {
const [pdfFilename, setPdfFilename] = useState('')
const [viewMode, setViewMode] = useState('markdown') // 'markdown' or 'pdf'
const [gitRepos, setGitRepos] = useState([])
const [projectName, setProjectName] = useState('项目文档')
const contentRef = useRef(null)
useEffect(() => {
@ -65,9 +66,11 @@ function DocumentPage() {
const data = res.data || {}
const tree = data.tree || data || [] //
const role = data.user_role || 'viewer'
const name = data.project_name || '项目文档'
setFileTree(tree)
setUserRole(role)
setProjectName(name)
// README.md
const readmeNode = findReadme(tree)
@ -540,7 +543,7 @@ function DocumentPage() {
{/* 左侧目录 */}
<Sider width={280} className="docs-sider" theme="light">
<div className="docs-sider-header">
<h2>项目文档</h2>
<h2>{projectName}</h2>
<div className="docs-sider-actions">
{/* 只有 owner/admin/editor 可以编辑和Git操作 */}
{userRole !== 'viewer' && (

View File

@ -1,7 +1,8 @@
import { useState, useEffect } from 'react'
import { Card, Tabs, Form, Input, Button, Avatar, Upload, message } from 'antd'
import { UserOutlined, LockOutlined, UploadOutlined } from '@ant-design/icons'
import { getCurrentUser, updateProfile, changePassword } from '@/api/auth'
import ImgCrop from 'antd-img-crop'
import { getCurrentUser, updateProfile, changePassword, uploadAvatar } from '@/api/auth'
import useUserStore from '@/stores/userStore'
import Toast from '@/components/Toast/Toast'
import './ProfilePage.css'
@ -76,6 +77,53 @@ function ProfilePage() {
}
}
//
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 tabItems = [
{
key: 'profile',
@ -88,13 +136,30 @@ function ProfilePage() {
children: (
<div className="profile-tab-content">
<div className="avatar-section">
<Avatar size={100} icon={<UserOutlined />} />
<Upload showUploadList={false}>
<Button icon={<UploadOutlined />} style={{ marginTop: 16 }}>
更换头像
</Button>
</Upload>
<p className="avatar-tip">支持 JPGPNG 格式文件小于 2MB</p>
<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

View File

@ -984,6 +984,14 @@ ansi-styles@^4.1.0:
dependencies:
color-convert "^2.0.1"
antd-img-crop@^4.27.0:
version "4.27.0"
resolved "https://registry.npmmirror.com/antd-img-crop/-/antd-img-crop-4.27.0.tgz#9531cf3c18f5268f04875dfca79b1b4327284d72"
integrity sha512-NcFxEBex/8HSs6I43mYHfwD7+l7XqGXR88oIhtO17abBEdmgDVdTNjApRJAez7HCBZeBhPIMQkA4HfFzR0ouGQ==
dependencies:
react-easy-crop "^5.5.3"
tslib "^2.8.1"
antd@^5.12.0:
version "5.29.3"
resolved "https://registry.npmmirror.com/antd/-/antd-5.29.3.tgz"
@ -3800,6 +3808,11 @@ normalize-path@^3.0.0, normalize-path@~3.0.0:
resolved "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz"
integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
normalize-wheel@^1.0.1:
version "1.0.1"
resolved "https://registry.npmmirror.com/normalize-wheel/-/normalize-wheel-1.0.1.tgz#aec886affdb045070d856447df62ecf86146ec45"
integrity sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==
object-assign@^4.0.1, object-assign@^4.1.1:
version "4.1.1"
resolved "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz"
@ -4443,6 +4456,14 @@ react-dom@^18.2.0:
loose-envify "^1.1.0"
scheduler "^0.23.2"
react-easy-crop@^5.5.3:
version "5.5.6"
resolved "https://registry.npmmirror.com/react-easy-crop/-/react-easy-crop-5.5.6.tgz#1a1ea3a90f83559d93b0636a5d984df143b40fe4"
integrity sha512-Jw3/ozs8uXj3NpL511Suc4AHY+mLRO23rUgipXvNYKqezcFSYHxe4QXibBymkOoY6oOtLVMPO2HNPRHYvMPyTw==
dependencies:
normalize-wheel "^1.0.1"
tslib "^2.0.1"
react-is@^16.13.1:
version "16.13.1"
resolved "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz"
@ -5184,6 +5205,11 @@ ts-interface-checker@^0.1.9:
resolved "https://registry.npmmirror.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz"
integrity sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==
tslib@^2.0.1, tslib@^2.8.1:
version "2.8.1"
resolved "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
type-check@^0.4.0, type-check@~0.4.0:
version "0.4.0"
resolved "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz"