更新了Desktop页
parent
0af0f2331d
commit
72000d5660
|
|
@ -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"
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 className="desktop-page">
|
||||
<h1 className="page-title">个人桌面</h1>
|
||||
|
||||
<div style={{ padding: '24px' }}>
|
||||
<h1 style={{ marginBottom: '24px', color: '#333' }}>个人桌面</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={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>
|
||||
<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>
|
||||
|
||||
{/* 右侧活动列表 */}
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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' && (
|
||||
|
|
|
|||
|
|
@ -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">支持 JPG、PNG 格式,文件小于 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">支持 JPG、PNG 格式,文件小于 1MB</p>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue