diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index 8150cdb..3877768 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -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" + } + ) diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py index b997a4b..b99b0f1 100644 --- a/backend/app/api/v1/dashboard.py +++ b/backend/app/api/v1/dashboard.py @@ -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}) + diff --git a/backend/app/api/v1/files.py b/backend/app/api/v1/files.py index dbdbdce..94e0071 100644 --- a/backend/app/api/v1/files.py +++ b/backend/app/api/v1/files.py @@ -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) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index ebe05e3..22bcd62 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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"] diff --git a/frontend/package.json b/frontend/package.json index 5d498a5..fd7d2d7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/api/auth.js b/frontend/src/api/auth.js index 601bfac..25a646f 100644 --- a/frontend/src/api/auth.js +++ b/frontend/src/api/auth.js @@ -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', + }, + }) +} diff --git a/frontend/src/api/dashboard.js b/frontend/src/api/dashboard.js index 5640897..35c592e 100644 --- a/frontend/src/api/dashboard.js +++ b/frontend/src/api/dashboard.js @@ -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 }, + }) +} diff --git a/frontend/src/components/MainLayout/AppHeader.jsx b/frontend/src/components/MainLayout/AppHeader.jsx index 0cff937..6df8599 100644 --- a/frontend/src/components/MainLayout/AppHeader.jsx +++ b/frontend/src/components/MainLayout/AppHeader.jsx @@ -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 (
{/* 左侧:Logo + 折叠按钮 */} @@ -251,7 +265,7 @@ function AppHeader({ collapsed, onToggle }) { placement="bottomRight" >
- } /> + } src={getUserAvatarUrl()} /> {user?.nickname || user?.username || 'User'}
diff --git a/frontend/src/pages/Desktop.css b/frontend/src/pages/Desktop.css new file mode 100644 index 0000000..f9e562a --- /dev/null +++ b/frontend/src/pages/Desktop.css @@ -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; + } +} diff --git a/frontend/src/pages/Desktop.jsx b/frontend/src/pages/Desktop.jsx index ec1d5b3..11aed7a 100644 --- a/frontend/src/pages/Desktop.jsx +++ b/frontend/src/pages/Desktop.jsx @@ -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 ( - -
- + if (activity && activity.count > 0) { + return ( +
+
- - ) + ) + } + 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 ( - -
-

个人桌面

+
+

个人桌面

- {/* 个人信息 */} - - - {userInfo.username} - {userInfo.email} - {userInfo.id} - - {userInfo.created_at ? new Date(userInfo.created_at).toLocaleString('zh-CN') : '-'} - - - + + {/* 左侧日历 */} + + + + + - {/* 统计卡片 */} - - - - } - valueStyle={{ color: '#1890ff' }} - /> - - - - - } - valueStyle={{ color: '#52c41a' }} - /> - - - - - } - valueStyle={{ color: '#cf1322' }} - /> - - - - - {/* 最近的个人项目 */} - - - - - {/* 最近的分享项目 */} - -
- - - + {/* 右侧活动列表 */} + + + + {selectedDate.format('YYYY年MM月DD日')} 的文档活动 + + } + > + + {activityLogs.length > 0 ? ( + ( + handleDocumentClick(item)} + className={item.file_exists ? 'activity-item-clickable' : 'activity-item-disabled'} + style={{ cursor: item.file_exists ? 'pointer' : 'default' }} + > + + } + title={ +
+ + {item.project_name} + + + {item.operation_type} + + {!item.file_exists && ( + + (已失效) + + )} +
+ } + description={ +
+
+ 文件: + + {item.file_path} + +
+ + {new Date(item.created_at).toLocaleTimeString('zh-CN')} + +
+ } + /> +
+ )} + /> + ) : ( + + )} +
+
+ + + ) } diff --git a/frontend/src/pages/Document/DocumentEditor.jsx b/frontend/src/pages/Document/DocumentEditor.jsx index c6a8549..f6b36b1 100644 --- a/frontend/src/pages/Document/DocumentEditor.jsx +++ b/frontend/src/pages/Document/DocumentEditor.jsx @@ -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" >
-

项目文档

+

{projectName}

- -

支持 JPG、PNG 格式,文件小于 2MB

+ } + src={getAvatarUrl()} + /> + + handleAvatarUpload({ file })} + > + + + +

支持 JPG、PNG 格式,文件小于 1MB