main
mula.liu 2025-12-29 20:53:50 +08:00
parent e1d5533461
commit f5e1e8871a
35 changed files with 3387 additions and 312 deletions

View File

@ -1,79 +1,144 @@
# NEX Docus 实施计划
# NexDocus 功能增强实现计划
## Stage 1: 基础架构与数据库设计
**Goal**: 完成数据库设计、后端项目初始化、核心数据模型创建
## Stage 1: 路由调整和项目成员管理
**Goal**: 调整路由结构,实现项目卡片上的成员管理功能
**Success Criteria**:
- DATABASE.md 文档完成
- 后端项目结构搭建完成
- 数据库连接测试通过
- 所有数据表创建完成
- /projects/my 路由正常工作
- /projects/share 路由正常工作
- 项目卡片上可以打开成员管理弹窗
- 可以添加/删除项目成员
**Tests**:
- 数据库连接测试
- 表结构验证
- ORM 模型单元测试
- 访问 /projects/my 显示我创建的项目
- 访问 /projects/share 显示我参与的项目
- 点击项目卡片的成员图标打开成员管理弹窗
- 成功添加用户到项目
- 成功从项目删除用户
**Status**: ✅ Completed
---
## Stage 2: 用户认证与权限系统
**Goal**: 实现完整的用户认证、角色权限、菜单管理系统
## Stage 2: 个人桌面统计增强
**Goal**: 在个人桌面增加"参加项目数"统计
**Success Criteria**:
- JWT 认证流程完整
- 用户注册、登录接口正常工作
- RBAC 权限校验中间件实现
- 角色-权限-菜单关联关系正确
- 个人桌面显示参加项目数
- 统计数据准确(我创建的项目 + 我参与的项目)
**Tests**:
- 登录/注册接口测试
- Token 生成和验证测试
- 权限校验测试
- 角色授权测试
- 创建新项目后,个人项目数 +1
- 加入协作项目后,参加项目数 +1
- 统计卡片正确显示两个数值
**Status**: ✅ Completed
---
## Stage 3: 文件存储核心服务
**Goal**: 实现安全的文件系统存储管理服务
## Stage 3: 参与项目视图和权限控制
**Goal**: 实现参与项目单独视图,区分所有者和成员权限
**Success Criteria**:
- 路径安全校验机制完成
- 文件读写、目录树生成功能正常
- 文件上传、下载流式传输实现
- UUID 文件夹映射机制正常
- /projects/share 页面单独展示我参与的项目(非所有者)
- 项目卡片根据角色显示不同操作选项
- 成员不能删除项目(删除按钮不显示)
- 成员可以编辑项目内文档
**Tests**:
- 路径注入攻击防御测试
- 文件读写性能测试
- 大文件上传测试
- 目录树生成正确性测试
- 参与者打开 /projects/share 只看到协作项目
- 参与者看不到删除项目按钮
- 参与者可以正常编辑文档
**Status**: ✅ Completed
---
## Stage 4: 项目与文档管理 API
**Goal**: 实现项目管理、文档 CRUD、协作成员管理的完整 API
## Stage 4: 操作日志功能
**Goal**: 为文档编辑操作记录日志
**Success Criteria**:
- 项目创建/列表/详情接口完成
- 文档 CRUD 接口完成
- 成员邀请/权限管理接口完成
- 图片/附件上传接口完成
- 创建者编辑文档时记录到 operation_logs 表
- 参与者编辑文档时记录到 operation_logs 表
- 日志包含用户ID、操作类型、资源信息
**Tests**:
- 项目 CRUD 接口测试
- 文档操作接口测试
- 成员权限验证测试
- 文件上传接口测试
- 编辑文档后operation_logs 表有新记录
- 日志包含正确的用户ID和文档路径
- 不同用户编辑生成不同日志
**Status**: ✅ Completed
---
## Stage 5: 前端整合与联调
**Goal**: 整合现有前端代码,适配新后端 API实现完整业务流程
## Stage 5: 项目编辑功能
**Goal**: 在我的项目卡片上增加编辑功能
**Success Criteria**:
- 前端路由和布局整合完成
- API 请求封装完成
- 项目列表页面实现
- 文档编辑页面实现
- 用户登录注册页面实现
- 项目卡片显示编辑按钮(仅所有者)
- 点击编辑弹出表单
- 可以修改项目名称、描述、公开性
**Tests**:
- 端到端业务流程测试
- 前后端联调测试
- 用户体验测试
- 项目所有者看到编辑按钮
- 编辑项目信息成功保存
- 非所有者不显示编辑按钮
**Status**: ✅ Completed
---
## Stage 6: 全局搜索功能
**Goal**: 实现全局搜索文档内容
**Success Criteria**:
- 后端提供搜索API搜索文档元数据+内容)
- 前端增加全局搜索框
- 搜索结果显示匹配的文档列表
- 点击结果跳转到对应文档
**Tests**:
- 搜索文档标题能找到对应文档
- 搜索文档内容能找到包含关键词的文档
- 搜索结果高亮显示关键词
- 点击搜索结果正确跳转
**Status**: ✅ Completed
---
## 技术决策
### 数据库变更
- 无需修改现有表结构project_members 和 operation_logs 已存在)
### 后端API新增
1. `GET /projects/my` - 获取我创建的项目
2. `GET /projects/shared` - 获取我参与的项目
3. `POST /projects/{project_id}/members` - 添加项目成员(已存在,需测试)
4. `DELETE /projects/{project_id}/members/{user_id}` - 删除项目成员(需新增)
5. `POST /search/documents` - 全局文档搜索(需新增)
6. 修改 `POST /files/{project_id}/file` - 增加操作日志记录
### 前端路由调整
- `/projects/my` - 我的项目(原 /projects
- `/projects/share` - 参与的项目(新增)
- 保持 `/projects` 作为默认,重定向到 `/projects/my`
### 权限控制策略
- 项目所有者:完全控制(增删改查、成员管理、删除项目)
- 项目管理员ADMIN文档管理、成员管理
- 项目编辑者EDITOR文档编辑
- 项目查看者VIEWER仅查看
### 操作日志设计
```json
{
"user_id": 1,
"username": "admin",
"operation_type": "update",
"resource_type": "document",
"resource_id": 123,
"detail": {
"project_id": 10,
"file_path": "/docs/readme.md",
"action": "edit",
"changes": "content_modified"
},
"ip_address": "192.168.1.1",
"status": 1
}
```
### 全局搜索实现方案
1. **基础版本(当前阶段)**:
- 搜索 document_meta 表的 title 字段
- 读取磁盘文件内容进行关键词匹配
- 适用于小规模项目
2. **未来优化(可选)**:
- 使用 Elasticsearch 全文检索
- 添加文档内容索引到数据库
- 实现增量索引更新

View File

@ -2,7 +2,7 @@
API v1 路由汇总
"""
from fastapi import APIRouter
from app.api.v1 import auth, projects, files, menu, dashboard, preview, role_permissions, users, roles
from app.api.v1 import auth, projects, files, menu, dashboard, preview, role_permissions, users, roles, search, logs
api_router = APIRouter()
@ -16,3 +16,5 @@ api_router.include_router(preview.router, prefix="/preview", tags=["项目预览
api_router.include_router(role_permissions.router, prefix="/role-permissions", tags=["角色权限管理"])
api_router.include_router(users.router, prefix="/users", tags=["用户管理"])
api_router.include_router(roles.router, prefix="/roles", tags=["角色管理"])
api_router.include_router(search.router, prefix="/search", tags=["文档搜索"])
api_router.include_router(logs.router, prefix="/logs", tags=["系统日志"])

View File

@ -15,13 +15,19 @@ from app.models.user import User
from app.models.role import Role, UserRole
from app.schemas.user import UserCreate, UserLogin, UserResponse, Token, ChangePassword, UserUpdate
from app.schemas.response import success_response, error_response
from app.services.log_service import log_service
from app.core.enums import OperationType, ResourceType
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post("/register", response_model=dict)
async def register(user_in: UserCreate, db: AsyncSession = Depends(get_db)):
async def register(
user_in: UserCreate,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""用户注册"""
# 检查用户名是否存在
result = await db.execute(select(User).where(User.username == user_in.username))
@ -57,6 +63,17 @@ async def register(user_in: UserCreate, db: AsyncSession = Depends(get_db)):
db.add(user_role)
await db.commit()
# 记录注册日志
await log_service.log_operation(
db=db,
operation_type=OperationType.USER_REGISTER,
resource_type=ResourceType.USER,
user=db_user,
resource_id=db_user.id,
detail={"username": db_user.username, "email": user_in.email},
request=request,
)
return success_response(
data={"user_id": db_user.id, "username": db_user.username},
message="注册成功"
@ -64,7 +81,11 @@ async def register(user_in: UserCreate, db: AsyncSession = Depends(get_db)):
@router.post("/login", response_model=dict)
async def login(user_in: UserLogin, db: AsyncSession = Depends(get_db)):
async def login(
user_in: UserLogin,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""用户登录"""
# 查询用户
result = await db.execute(select(User).where(User.username == user_in.username))
@ -86,6 +107,17 @@ async def login(user_in: UserLogin, db: AsyncSession = Depends(get_db)):
# 保存 token 到 Redis24小时过期
await TokenCache.save_token(user.id, access_token, expire_seconds=86400)
# 记录登录日志
await log_service.log_operation(
db=db,
operation_type=OperationType.USER_LOGIN,
resource_type=ResourceType.USER,
user=user,
resource_id=user.id,
detail={"username": user.username},
request=request,
)
# 返回用户信息和 Token
user_data = UserResponse.from_orm(user)
token_data = Token(access_token=access_token, user=user_data)
@ -152,7 +184,8 @@ async def change_password(
@router.post("/logout", response_model=dict)
async def logout(
request: Request,
current_user: User = Depends(get_current_user)
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""退出登录"""
# 从请求状态中获取 token已在 get_current_user 中保存)
@ -162,4 +195,15 @@ async def logout(
await TokenCache.delete_token(token)
logger.info(f"User {current_user.username} logged out")
# 记录登出日志
await log_service.log_operation(
db=db,
operation_type=OperationType.USER_LOGOUT,
resource_type=ResourceType.USER,
user=current_user,
resource_id=current_user.id,
detail={"username": current_user.username},
request=request,
)
return success_response(message="退出成功")

View File

@ -107,6 +107,15 @@ async def get_personal_stats(
)
personal_projects_count = personal_projects_count_result.scalar()
# 统计参加项目数(协作项目)
shared_projects_count_result = await db.execute(
select(func.count(Project.id))
.join(ProjectMember, Project.id == ProjectMember.project_id)
.where(ProjectMember.user_id == current_user.id)
.where(Project.owner_id != current_user.id)
)
shared_projects_count = shared_projects_count_result.scalar()
# 统计个人文档数(个人项目中的 .md 文件)
document_count = 0
personal_projects_result = await db.execute(
@ -169,6 +178,7 @@ async def get_personal_stats(
},
"stats": {
"personal_projects_count": personal_projects_count,
"shared_projects_count": shared_projects_count,
"document_count": document_count,
},
"recent_personal_projects": recent_personal_projects_data,

View File

@ -1,7 +1,7 @@
"""
文件系统操作相关 API
"""
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request
from fastapi.responses import StreamingResponse, FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
@ -10,12 +10,14 @@ import os
import zipfile
import io
import mimetypes
import json
from pathlib import Path
from app.core.database import get_db
from app.core.deps import get_current_user, get_user_from_token_or_query
from app.models.user import User
from app.models.project import Project, ProjectMember
from app.models.log import OperationLog
from app.schemas.file import (
FileTreeNode,
FileSaveRequest,
@ -24,6 +26,8 @@ from app.schemas.file import (
)
from app.schemas.response import success_response
from app.services.storage import storage_service
from app.services.log_service import log_service
from app.core.enums import OperationType
router = APIRouter()
@ -82,7 +86,21 @@ async def get_project_tree(
# 生成目录树
tree = storage_service.generate_tree(project_root)
return success_response(data=tree)
# 获取当前用户角色
user_role = "owner" # 默认是所有者
if project.owner_id != current_user.id:
# 查询成员角色
member_result = await db.execute(
select(ProjectMember).where(
ProjectMember.project_id == project_id,
ProjectMember.user_id == current_user.id
)
)
member = member_result.scalar_one_or_none()
if member:
user_role = member.role
return success_response(data={"tree": tree, "user_role": user_role})
@router.get("/{project_id}/file", response_model=dict)
@ -108,6 +126,7 @@ async def get_file_content(
async def save_file(
project_id: int,
file_data: FileSaveRequest,
request: Request,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
@ -120,6 +139,17 @@ async def save_file(
# 写入文件内容
await storage_service.write_file(file_path, file_data.content)
# 记录操作日志
await log_service.log_file_operation(
db=db,
operation_type=OperationType.SAVE_FILE,
project_id=project_id,
file_path=file_data.path,
user=current_user,
detail={"content_length": len(file_data.content)},
request=request,
)
return success_response(message="文件保存成功")
@ -127,6 +157,7 @@ async def save_file(
async def operate_file(
project_id: int,
operation: FileOperateRequest,
request: Request,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
@ -139,6 +170,15 @@ async def operate_file(
if operation.action == "delete":
# 删除文件或文件夹
await storage_service.delete_file(current_path)
# 记录日志
await log_service.log_file_operation(
db=db,
operation_type=OperationType.DELETE_FILE,
project_id=project_id,
file_path=operation.path,
user=current_user,
request=request,
)
return success_response(message="删除成功")
elif operation.action == "rename":
@ -147,6 +187,16 @@ async def operate_file(
raise HTTPException(status_code=400, detail="缺少新路径参数")
new_path = storage_service.get_secure_path(project.storage_key, operation.new_path)
await storage_service.rename_file(current_path, new_path)
# 记录日志
await log_service.log_file_operation(
db=db,
operation_type=OperationType.RENAME_FILE,
project_id=project_id,
file_path=operation.path,
user=current_user,
detail={"new_path": operation.new_path},
request=request,
)
return success_response(message="重命名成功")
elif operation.action == "move":
@ -155,17 +205,45 @@ async def operate_file(
raise HTTPException(status_code=400, detail="缺少目标路径参数")
new_path = storage_service.get_secure_path(project.storage_key, operation.new_path)
await storage_service.rename_file(current_path, new_path)
# 记录日志
await log_service.log_file_operation(
db=db,
operation_type=OperationType.MOVE_FILE,
project_id=project_id,
file_path=operation.path,
user=current_user,
detail={"new_path": operation.new_path},
request=request,
)
return success_response(message="移动成功")
elif operation.action == "create_dir":
# 创建目录
await storage_service.create_directory(current_path)
# 记录日志
await log_service.log_file_operation(
db=db,
operation_type=OperationType.CREATE_DIR,
project_id=project_id,
file_path=operation.path,
user=current_user,
request=request,
)
return success_response(message="目录创建成功")
elif operation.action == "create_file":
# 创建文件
content = operation.content or ""
await storage_service.write_file(current_path, content)
# 记录日志
await log_service.log_file_operation(
db=db,
operation_type=OperationType.CREATE_FILE,
project_id=project_id,
file_path=operation.path,
user=current_user,
request=request,
)
return success_response(message="文件创建成功")
else:
@ -177,6 +255,7 @@ async def upload_file(
project_id: int,
file: UploadFile = File(...),
subfolder: str = "images",
request: Request = None,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
@ -193,6 +272,20 @@ async def upload_file(
# 构建访问 URL
file_info["url"] = f"/api/v1/files/{project_id}/assets/{subfolder}/{file_info['filename']}"
# 记录日志
await log_service.log_file_operation(
db=db,
operation_type=OperationType.UPLOAD_IMAGE,
project_id=project_id,
file_path=f"_assets/{subfolder}/{file_info['filename']}",
user=current_user,
detail={
"original_filename": file.filename,
"file_size": file_info.get("size", 0),
},
request=request,
)
return success_response(data=file_info, message="文件上传成功")
@ -236,6 +329,7 @@ async def import_documents(
project_id: int,
files: List[UploadFile] = File(...),
target_path: str = "",
request: Request = None,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
@ -268,6 +362,20 @@ async def import_documents(
relative_path = f"{target_path}/{file.filename}" if target_path else file.filename
imported_files.append(relative_path)
# 记录日志
await log_service.log_file_operation(
db=db,
operation_type=OperationType.IMPORT_DOCUMENTS,
project_id=project_id,
file_path=target_path or "/",
user=current_user,
detail={
"file_count": len(imported_files),
"files": imported_files,
},
request=request,
)
return success_response(
data={"imported_files": imported_files},
message=f"成功导入 {len(imported_files)} 个文档"
@ -278,6 +386,7 @@ async def import_documents(
async def export_directory(
project_id: int,
directory_path: str = "",
request: Request = None,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
@ -292,6 +401,7 @@ async def export_directory(
# 创建ZIP文件在内存中
zip_buffer = io.BytesIO()
file_count = 0
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
# 遍历目录添加所有文件
@ -300,6 +410,7 @@ async def export_directory(
# 计算相对路径
arcname = file_path.relative_to(source_dir)
zip_file.write(file_path, arcname)
file_count += 1
# 重置buffer位置
zip_buffer.seek(0)
@ -307,6 +418,20 @@ async def export_directory(
# 生成ZIP文件名
zip_filename = f"{project.name}_{directory_path.replace('/', '_') if directory_path else 'root'}.zip"
# 记录日志
await log_service.log_file_operation(
db=db,
operation_type=OperationType.EXPORT_DOCUMENTS,
project_id=project_id,
file_path=directory_path or "/",
user=current_user,
detail={
"file_count": file_count,
"zip_filename": zip_filename,
},
request=request,
)
return StreamingResponse(
zip_buffer,
media_type="application/zip",

View File

@ -0,0 +1,164 @@
"""
系统日志相关 API
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_
from typing import Optional
from datetime import datetime
from app.core.database import get_db
from app.core.deps import get_current_user
from app.models.user import User
from app.models.log import OperationLog
from app.models.project import Project
from app.schemas.response import success_response
router = APIRouter()
@router.get("/", response_model=dict)
async def get_operation_logs(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
operation_type: Optional[str] = None,
resource_type: Optional[str] = None,
user_id: Optional[int] = None,
project_id: Optional[int] = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""
获取操作日志列表
支持过滤
- operation_type: 操作类型
- resource_type: 资源类型
- user_id: 用户ID
- project_id: 项目ID
- start_date: 开始日期 (YYYY-MM-DD)
- end_date: 结束日期 (YYYY-MM-DD)
"""
# 构建查询条件
conditions = []
if operation_type:
conditions.append(OperationLog.operation_type == operation_type)
if resource_type:
conditions.append(OperationLog.resource_type == resource_type)
if user_id:
conditions.append(OperationLog.user_id == user_id)
if project_id:
conditions.append(OperationLog.resource_id == project_id)
if start_date:
try:
start_datetime = datetime.strptime(start_date, "%Y-%m-%d")
conditions.append(OperationLog.created_at >= start_datetime)
except ValueError:
raise HTTPException(status_code=400, detail="开始日期格式错误")
if end_date:
try:
end_datetime = datetime.strptime(f"{end_date} 23:59:59", "%Y-%m-%d %H:%M:%S")
conditions.append(OperationLog.created_at <= end_datetime)
except ValueError:
raise HTTPException(status_code=400, detail="结束日期格式错误")
# 查询总数
count_query = select(func.count(OperationLog.id))
if conditions:
count_query = count_query.where(and_(*conditions))
total_result = await db.execute(count_query)
total = total_result.scalar()
# 查询日志列表
query = select(OperationLog).order_by(OperationLog.created_at.desc())
if conditions:
query = query.where(and_(*conditions))
# 分页
offset = (page - 1) * page_size
query = query.offset(offset).limit(page_size)
result = await db.execute(query)
logs = result.scalars().all()
# 转换为字典格式
logs_data = []
for log in logs:
log_dict = {
"id": log.id,
"user_id": log.user_id,
"username": log.username,
"operation_type": log.operation_type,
"resource_type": log.resource_type,
"resource_id": log.resource_id,
"detail": log.detail,
"ip_address": log.ip_address,
"user_agent": log.user_agent,
"status": log.status,
"error_message": log.error_message,
"created_at": log.created_at.isoformat() if log.created_at else None,
}
logs_data.append(log_dict)
return success_response(data={
"items": logs_data,
"total": total,
"page": page,
"page_size": page_size,
"total_pages": (total + page_size - 1) // page_size
})
@router.get("/stats", response_model=dict)
async def get_log_stats(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""获取日志统计信息"""
# 统计各类操作数量
operation_stats_query = select(
OperationLog.operation_type,
func.count(OperationLog.id).label('count')
).group_by(OperationLog.operation_type)
operation_result = await db.execute(operation_stats_query)
operation_stats = {row.operation_type: row.count for row in operation_result}
# 统计各资源类型数量
resource_stats_query = select(
OperationLog.resource_type,
func.count(OperationLog.id).label('count')
).group_by(OperationLog.resource_type)
resource_result = await db.execute(resource_stats_query)
resource_stats = {row.resource_type: row.count for row in resource_result}
# 统计今天的操作数量
today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
today_count_query = select(func.count(OperationLog.id)).where(
OperationLog.created_at >= today_start
)
today_result = await db.execute(today_count_query)
today_count = today_result.scalar()
# 总日志数量
total_query = select(func.count(OperationLog.id))
total_result = await db.execute(total_query)
total_count = total_result.scalar()
return success_response(data={
"operation_stats": operation_stats,
"resource_stats": resource_stats,
"today_count": today_count,
"total_count": total_count,
})

View File

@ -1,5 +1,5 @@
"""
项目预览相关 API公开访问支持分享
项目预览相关 API支持公开和私密项目
"""
from fastapi import APIRouter, Depends, HTTPException, Header
from sqlalchemy.ext.asyncio import AsyncSession
@ -7,19 +7,55 @@ from sqlalchemy import select
from typing import Optional
from app.core.database import get_db
from app.models.project import Project
from app.core.deps import get_current_user_optional
from app.models.project import Project, ProjectMember
from app.models.user import User
from app.schemas.response import success_response
from app.services.storage import storage_service
router = APIRouter()
async def check_preview_access(
project: Project,
current_user: Optional[User],
db: AsyncSession
):
"""检查预览访问权限"""
# 公开项目:任何人都可以访问
if project.is_public == 1:
return True
# 私密项目:必须是项目成员
if not current_user:
raise HTTPException(status_code=401, detail="私密项目需要登录才能访问")
# 检查是否是项目所有者
if project.owner_id == current_user.id:
return True
# 检查是否是项目成员
member_result = await db.execute(
select(ProjectMember).where(
ProjectMember.project_id == project.id,
ProjectMember.user_id == current_user.id
)
)
member = member_result.scalar_one_or_none()
if not member:
raise HTTPException(status_code=403, detail="无权访问该私密项目")
return True
@router.get("/{project_id}/info", response_model=dict)
async def get_preview_info(
project_id: int,
current_user: Optional[User] = Depends(get_current_user_optional),
db: AsyncSession = Depends(get_db)
):
"""获取预览项目基本信息(公开访问)"""
"""获取预览项目基本信息"""
# 查询项目
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
@ -27,11 +63,15 @@ async def get_preview_info(
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
# 检查访问权限
await check_preview_access(project, current_user, db)
# 返回基本信息
info = {
"id": project.id,
"name": project.name,
"description": project.description,
"is_public": project.is_public,
"has_password": bool(project.access_pass),
}
@ -42,6 +82,7 @@ async def get_preview_info(
async def verify_access_password(
project_id: int,
password: str = Header(..., alias="X-Access-Password"),
current_user: Optional[User] = Depends(get_current_user_optional),
db: AsyncSession = Depends(get_db)
):
"""验证访问密码"""
@ -52,6 +93,9 @@ async def verify_access_password(
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
# 检查访问权限
await check_preview_access(project, current_user, db)
# 验证密码
if not project.access_pass:
return success_response(message="该项目无需密码访问")
@ -66,9 +110,10 @@ async def verify_access_password(
async def get_preview_tree(
project_id: int,
password: Optional[str] = Header(None, alias="X-Access-Password"),
current_user: Optional[User] = Depends(get_current_user_optional),
db: AsyncSession = Depends(get_db)
):
"""获取预览项目的文档树(公开访问,需验证密码)"""
"""获取预览项目的文档树"""
# 查询项目
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
@ -76,6 +121,9 @@ async def get_preview_tree(
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
# 检查访问权限
await check_preview_access(project, current_user, db)
# 如果设置了密码,需要验证
if project.access_pass:
if not password or project.access_pass != password:
@ -93,9 +141,10 @@ async def get_preview_file(
project_id: int,
path: str,
password: Optional[str] = Header(None, alias="X-Access-Password"),
current_user: Optional[User] = Depends(get_current_user_optional),
db: AsyncSession = Depends(get_db)
):
"""获取预览项目的文件内容(公开访问,需验证密码)"""
"""获取预览项目的文件内容"""
# 查询项目
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
@ -103,6 +152,9 @@ async def get_preview_file(
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
# 检查访问权限
await check_preview_access(project, current_user, db)
# 如果设置了密码,需要验证
if project.access_pass:
if not password or project.access_pass != password:

View File

@ -1,7 +1,7 @@
"""
项目管理相关 API
"""
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, or_
from typing import List
@ -10,7 +10,7 @@ import uuid
from app.core.database import get_db
from app.core.deps import get_current_user
from app.models.user import User
from app.models.project import Project, ProjectMember, ProjectMemberRole
from app.models.project import Project, ProjectMember
from app.schemas.project import (
ProjectCreate,
ProjectUpdate,
@ -23,6 +23,8 @@ from app.schemas.project import (
)
from app.schemas.response import success_response
from app.services.storage import storage_service
from app.services.log_service import log_service
from app.core.enums import OperationType, ResourceType
router = APIRouter()
@ -58,9 +60,53 @@ async def get_my_projects(
return success_response(data=projects_data)
@router.get("/my", response_model=dict)
async def get_owned_projects(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""获取我创建的项目列表"""
result = await db.execute(
select(Project).where(Project.owner_id == current_user.id, Project.status == 1)
)
projects = result.scalars().all()
projects_data = [ProjectResponse.from_orm(p).dict() for p in projects]
return success_response(data=projects_data)
@router.get("/shared", response_model=dict)
async def get_shared_projects(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""获取我参与的项目列表(不包括我创建的)"""
result = await db.execute(
select(Project, User, ProjectMember)
.join(ProjectMember, ProjectMember.project_id == Project.id)
.join(User, User.id == Project.owner_id)
.where(
ProjectMember.user_id == current_user.id,
Project.owner_id != current_user.id,
Project.status == 1
)
)
projects_with_info = result.all()
projects_data = []
for project, owner, member in projects_with_info:
project_dict = ProjectResponse.from_orm(project).dict()
project_dict['owner_name'] = owner.username
project_dict['owner_nickname'] = owner.nickname
project_dict['user_role'] = member.role # 添加用户角色
projects_data.append(project_dict)
return success_response(data=projects_data)
@router.post("/", response_model=dict)
async def create_project(
project_in: ProjectCreate,
request: Request,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
@ -94,11 +140,21 @@ async def create_project(
db_member = ProjectMember(
project_id=db_project.id,
user_id=current_user.id,
role=ProjectMemberRole.ADMIN,
role="admin",
)
db.add(db_member)
await db.commit()
# 记录操作日志
await log_service.log_project_operation(
db=db,
operation_type=OperationType.CREATE_PROJECT,
project_id=db_project.id,
user=current_user,
detail={"project_name": project_in.name},
request=request,
)
project_data = ProjectResponse.from_orm(db_project)
return success_response(data=project_data.dict(), message="项目创建成功")
@ -141,6 +197,7 @@ async def get_project(
async def update_project(
project_id: int,
project_in: ProjectUpdate,
request: Request,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
@ -164,6 +221,16 @@ async def update_project(
await db.commit()
await db.refresh(project)
# 记录操作日志
await log_service.log_project_operation(
db=db,
operation_type=OperationType.UPDATE_PROJECT,
project_id=project_id,
user=current_user,
detail={"updated_fields": list(update_data.keys())},
request=request,
)
project_data = ProjectResponse.from_orm(project)
return success_response(data=project_data.dict(), message="项目更新成功")
@ -171,10 +238,11 @@ async def update_project(
@router.delete("/{project_id}", response_model=dict)
async def delete_project(
project_id: int,
request: Request,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""删除项目(归档)"""
"""删除项目"""
# 查询项目
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
@ -186,11 +254,52 @@ async def delete_project(
if project.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="无权删除该项目")
# 软删除(归档)
project.status = 0
# 检查项目目录下是否有文件排除_assets目录和隐藏文件
project_root = storage_service.get_secure_path(project.storage_key)
has_files = False
if project_root.exists() and project_root.is_dir():
for item in project_root.iterdir():
# 跳过 _assets 目录和以.开头的隐藏文件(如.DS_Store
if item.name == "_assets" or item.name.startswith("."):
continue
has_files = True
break
if has_files:
raise HTTPException(
status_code=400,
detail="项目目录下存在文件,无法删除。请先清空项目文件。"
)
# 删除物理目录
if project_root.exists():
import shutil
try:
shutil.rmtree(project_root)
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"删除项目目录失败: {str(e)}"
)
project_name = project.name
# 删除数据库记录
await db.delete(project)
await db.commit()
return success_response(message="项目已归档")
# 记录操作日志
await log_service.log_project_operation(
db=db,
operation_type=OperationType.DELETE_PROJECT,
project_id=project_id,
user=current_user,
detail={"project_name": project_name},
request=request,
)
return success_response(message="项目已删除")
@router.get("/{project_id}/members", response_model=dict)
@ -219,13 +328,27 @@ async def get_project_members(
if not member:
raise HTTPException(status_code=403, detail="无权访问该项目")
# 查询成员列表
# 查询成员列表并关联用户信息
members_result = await db.execute(
select(ProjectMember).where(ProjectMember.project_id == project_id)
select(ProjectMember, User)
.join(User, ProjectMember.user_id == User.id)
.where(ProjectMember.project_id == project_id)
)
members = members_result.scalars().all()
members_with_users = members_result.all()
# 构建返回数据,包含用户名信息
members_data = []
for member, user in members_with_users:
members_data.append({
"id": member.id,
"project_id": member.project_id,
"user_id": member.user_id,
"role": member.role,
"joined_at": member.joined_at.isoformat() if member.joined_at else None,
"username": user.username,
"nickname": user.nickname,
})
members_data = [ProjectMemberResponse.from_orm(m).dict() for m in members]
return success_response(data=members_data)
@ -233,6 +356,7 @@ async def get_project_members(
async def add_project_member(
project_id: int,
member_in: ProjectMemberAdd,
request: Request,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
@ -250,7 +374,7 @@ async def add_project_member(
select(ProjectMember).where(
ProjectMember.project_id == project_id,
ProjectMember.user_id == current_user.id,
ProjectMember.role == ProjectMemberRole.ADMIN
ProjectMember.role == "admin"
)
)
member = member_result.scalar_one_or_none()
@ -279,10 +403,83 @@ async def add_project_member(
await db.commit()
await db.refresh(db_member)
# 记录操作日志
await log_service.log_member_operation(
db=db,
operation_type=OperationType.ADD_MEMBER,
project_id=project_id,
target_user_id=member_in.user_id,
user=current_user,
detail={"role": member_in.role},
request=request,
)
member_data = ProjectMemberResponse.from_orm(db_member)
return success_response(data=member_data.dict(), message="成员添加成功")
@router.delete("/{project_id}/members/{user_id}", response_model=dict)
async def remove_project_member(
project_id: int,
user_id: int,
request: Request,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""删除项目成员"""
# 查询项目
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
# 只有项目所有者和管理员可以删除成员
if project.owner_id != current_user.id:
member_result = await db.execute(
select(ProjectMember).where(
ProjectMember.project_id == project_id,
ProjectMember.user_id == current_user.id,
ProjectMember.role == "admin"
)
)
member = member_result.scalar_one_or_none()
if not member:
raise HTTPException(status_code=403, detail="无权删除成员")
# 不能删除项目所有者
if user_id == project.owner_id:
raise HTTPException(status_code=400, detail="不能删除项目所有者")
# 查询要删除的成员
member_result = await db.execute(
select(ProjectMember).where(
ProjectMember.project_id == project_id,
ProjectMember.user_id == user_id
)
)
member = member_result.scalar_one_or_none()
if not member:
raise HTTPException(status_code=404, detail="成员不存在")
# 删除成员
await db.delete(member)
await db.commit()
# 记录操作日志
await log_service.log_member_operation(
db=db,
operation_type=OperationType.REMOVE_MEMBER,
project_id=project_id,
target_user_id=user_id,
user=current_user,
request=request,
)
return success_response(message="成员删除成功")
@router.get("/{project_id}/share", response_model=dict)
async def get_project_share_info(
project_id: int,
@ -297,17 +494,28 @@ async def get_project_share_info(
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
# 只有项目所有者可以获取分享信息
if project.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="只有项目所有者可以查看分享信息")
# 检查是否是项目所有者或成员
is_owner = project.owner_id == current_user.id
if not is_owner:
# 检查是否是项目成员
member_result = await db.execute(
select(ProjectMember).where(
ProjectMember.project_id == project_id,
ProjectMember.user_id == current_user.id
)
)
member = member_result.scalar_one_or_none()
if not member:
raise HTTPException(status_code=403, detail="无权访问该项目")
# 构建分享链接
share_url = f"/preview/{project_id}"
# 只有项目所有者可以看到实际密码,成员只能知道是否设置了密码
share_info = ProjectShareInfo(
share_url=share_url,
has_password=bool(project.access_pass),
access_pass=project.access_pass # 返回实际密码给项目所有者
access_pass=project.access_pass if is_owner else None
)
return success_response(data=share_info.dict())
@ -317,6 +525,7 @@ async def get_project_share_info(
async def update_share_settings(
project_id: int,
settings: ProjectShareSettings,
request: Request,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
@ -336,5 +545,19 @@ async def update_share_settings(
project.access_pass = settings.access_pass
await db.commit()
# 记录操作日志
await log_service.log_operation(
db=db,
operation_type=OperationType.UPDATE_SHARE_SETTINGS,
resource_type=ResourceType.SHARE,
user=current_user,
resource_id=project_id,
detail={
"has_password": bool(settings.access_pass),
"project_name": project.name,
},
request=request,
)
message = "访问密码已取消" if not settings.access_pass else "访问密码已设置"
return success_response(message=message)

View File

@ -0,0 +1,123 @@
"""
文档搜索相关 API
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, or_
import os
import glob
from app.core.database import get_db
from app.core.deps import get_current_user
from app.models.user import User
from app.models.project import Project, ProjectMember
from app.services.storage import storage_service
from app.schemas.response import success_response
router = APIRouter()
@router.get("/documents", response_model=dict)
async def search_documents(
keyword: str = Query(..., min_length=1, description="搜索关键词"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""
文档搜索简化版
搜索范围项目名称项目描述文件名
"""
if not keyword:
return success_response(data=[])
keyword_lower = keyword.lower()
# 获取用户有权限访问的项目
# 1. 用户创建的项目
owned_projects_result = await db.execute(
select(Project).where(Project.owner_id == current_user.id, Project.status == 1)
)
owned_projects = owned_projects_result.scalars().all()
# 2. 用户参与的项目
member_projects_result = await db.execute(
select(Project)
.join(ProjectMember, ProjectMember.project_id == Project.id)
.where(
ProjectMember.user_id == current_user.id,
Project.owner_id != current_user.id,
Project.status == 1
)
)
member_projects = member_projects_result.scalars().all()
# 合并所有可访问的项目
all_projects = owned_projects + member_projects
# 搜索结果列表
search_results = []
# 搜索项目和文件
for project in all_projects:
# 检查项目名称或描述是否匹配
project_matched = False
if keyword_lower in project.name.lower():
project_matched = True
elif project.description and keyword_lower in project.description.lower():
project_matched = True
# 如果项目本身匹配,添加到结果
if project_matched:
search_results.append({
"type": "project",
"project_id": project.id,
"project_name": project.name,
"project_description": project.description or "",
"match_type": "项目",
})
# 搜索项目中的文件名
try:
project_path = storage_service.get_secure_path(project.storage_key)
if not project_path.exists() or not project_path.is_dir():
continue
# 查找所有 .md 文件
md_files = list(project_path.rglob("*.md"))
for file_path in md_files:
# 跳过 _assets 目录中的文件
if "_assets" in file_path.parts:
continue
try:
# 获取相对路径
relative_path = str(file_path.relative_to(project_path))
# 获取文件名(不含扩展名)
file_name = file_path.stem
# 检查关键词是否在文件名或路径中
if keyword_lower in file_name.lower() or keyword_lower in relative_path.lower():
search_results.append({
"type": "file",
"project_id": project.id,
"project_name": project.name,
"file_path": relative_path,
"file_name": file_name,
"match_type": "文件",
})
except Exception:
# 忽略无法处理的文件
continue
except Exception:
# 忽略无法遍历的目录
continue
# 限制返回结果数量
search_results = search_results[:100]
return success_response(data=search_results, message=f"找到 {len(search_results)} 个结果")

View File

@ -51,6 +51,7 @@ async def get_users(
page_size: int = Query(10, ge=1, le=100),
keyword: Optional[str] = Query(None, description="搜索关键词(用户名、昵称、邮箱)"),
status: Optional[int] = Query(None, description="状态筛选0-禁用 1-启用"),
role_id: Optional[int] = Query(None, description="角色ID筛选"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
@ -69,6 +70,29 @@ async def get_users(
if status is not None:
conditions.append(User.status == status)
# 如果需要按角色筛选
if role_id is not None:
# 查询用户列表时需要JOIN UserRole表
query = (
select(User)
.join(UserRole, UserRole.user_id == User.id)
.where(UserRole.role_id == role_id)
)
if conditions:
query = query.where(*conditions)
query = query.order_by(User.created_at.desc())
# 查询总数
count_query = (
select(func.count(User.id.distinct()))
.join(UserRole, UserRole.user_id == User.id)
.where(UserRole.role_id == role_id)
)
if conditions:
count_query = count_query.where(*conditions)
total_result = await db.execute(count_query)
total = total_result.scalar()
else:
# 查询总数
count_query = select(func.count(User.id))
if conditions:
@ -80,8 +104,8 @@ async def get_users(
query = select(User).order_by(User.created_at.desc())
if conditions:
query = query.where(*conditions)
query = query.offset((page - 1) * page_size).limit(page_size)
query = query.offset((page - 1) * page_size).limit(page_size)
result = await db.execute(query)
users = result.scalars().all()

View File

@ -105,6 +105,57 @@ async def get_current_user(
return user
async def get_current_user_optional(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security_optional),
db: AsyncSession = Depends(get_db)
) -> Optional[User]:
"""
获取当前登录用户可选不强制登录
如果未提供token或token无效返回None
"""
if not credentials:
return None
token = credentials.credentials
try:
# 验证 Redis 中是否存在该 token
user_id_from_redis = await TokenCache.get_user_id(token)
if user_id_from_redis is None:
return None
# 解码 JWT 验证完整性
payload = decode_access_token(token)
if payload is None:
return None
user_id_str = payload.get("sub")
if user_id_str is None:
return None
# 将字符串转为整数
try:
user_id = int(user_id_str)
except (ValueError, TypeError):
return None
# 验证 Redis 中的 user_id 与 JWT 中的是否一致
if user_id != user_id_from_redis:
return None
# 查询用户
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None or user.status != 1:
return None
return user
except Exception as e:
logger.warning(f"Optional auth failed: {str(e)}")
return None
async def get_current_active_user(
current_user: User = Depends(get_current_user)
) -> User:

View File

@ -0,0 +1,48 @@
"""
系统枚举类
"""
from enum import Enum
class OperationType(str, Enum):
"""操作类型枚举"""
# 项目操作
CREATE_PROJECT = "create_project"
UPDATE_PROJECT = "update_project"
DELETE_PROJECT = "delete_project"
# 成员操作
ADD_MEMBER = "add_member"
REMOVE_MEMBER = "remove_member"
# 文件操作
CREATE_FILE = "create_file"
CREATE_DIR = "create_dir"
DELETE_FILE = "delete_file"
RENAME_FILE = "rename_file"
MOVE_FILE = "move_file"
SAVE_FILE = "save_file"
# 导入导出
UPLOAD_IMAGE = "upload_image"
IMPORT_DOCUMENTS = "import_documents"
EXPORT_DOCUMENTS = "export_documents"
# 分享操作
UPDATE_SHARE_SETTINGS = "update_share_settings"
# 用户操作
USER_LOGIN = "user_login"
USER_LOGOUT = "user_logout"
USER_REGISTER = "user_register"
class ResourceType(str, Enum):
"""资源类型枚举"""
PROJECT = "project"
FILE = "file"
MEMBER = "member"
USER = "user"
SHARE = "share"

View File

@ -47,10 +47,10 @@ class ProjectMember(Base):
project_id = Column(BigInteger, nullable=False, index=True, comment="项目ID")
user_id = Column(BigInteger, nullable=False, index=True, comment="用户ID")
role = Column(
Enum(ProjectMemberRole),
default=ProjectMemberRole.VIEWER,
String(20),
default="viewer",
index=True,
comment="项目角色"
comment="项目角色: admin/editor/viewer"
)
invited_by = Column(BigInteger, comment="邀请人ID")
joined_at = Column(DateTime, server_default=func.now(), comment="加入时间")

View File

@ -1,10 +1,9 @@
"""
项目相关的 Pydantic Schema
"""
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
from typing import Optional
from datetime import datetime
from app.models.project import ProjectMemberRole
class ProjectBase(BaseModel):
@ -47,12 +46,26 @@ class ProjectResponse(ProjectBase):
class ProjectMemberAdd(BaseModel):
"""添加项目成员 Schema"""
user_id: int = Field(..., description="用户ID")
role: ProjectMemberRole = Field(ProjectMemberRole.VIEWER, description="项目角色")
role: str = Field("viewer", description="项目角色: admin/editor/viewer")
@field_validator('role')
@classmethod
def validate_role(cls, v):
if v not in ['admin', 'editor', 'viewer']:
raise ValueError('role must be one of: admin, editor, viewer')
return v
class ProjectMemberUpdate(BaseModel):
"""更新项目成员 Schema"""
role: ProjectMemberRole = Field(..., description="项目角色")
role: str = Field(..., description="项目角色: admin/editor/viewer")
@field_validator('role')
@classmethod
def validate_role(cls, v):
if v not in ['admin', 'editor', 'viewer']:
raise ValueError('role must be one of: admin, editor, viewer')
return v
class ProjectMemberResponse(BaseModel):
@ -60,7 +73,7 @@ class ProjectMemberResponse(BaseModel):
id: int
project_id: int
user_id: int
role: ProjectMemberRole
role: str
joined_at: datetime
class Config:

View File

@ -0,0 +1,138 @@
"""
操作日志服务
"""
from sqlalchemy.ext.asyncio import AsyncSession
from fastapi import Request
from typing import Optional
import json
from app.models.log import OperationLog
from app.models.user import User
from app.core.enums import OperationType, ResourceType
class LogService:
"""操作日志服务"""
@staticmethod
async def log_operation(
db: AsyncSession,
operation_type: OperationType,
resource_type: ResourceType,
user: Optional[User] = None,
resource_id: Optional[int] = None,
detail: Optional[dict] = None,
request: Optional[Request] = None,
status: int = 1,
error_message: Optional[str] = None,
):
"""
记录操作日志
Args:
db: 数据库会话
operation_type: 操作类型
resource_type: 资源类型
user: 操作用户
resource_id: 资源ID
detail: 操作详情字典格式
request: FastAPI Request对象
status: 状态 0-失败 1-成功
error_message: 错误信息
"""
# 获取IP和User-Agent
ip_address = None
user_agent = None
if request:
ip_address = request.client.host if request.client else None
user_agent = request.headers.get("user-agent", "")
# 创建日志记录
log = OperationLog(
user_id=user.id if user else None,
username=user.username if user else "匿名",
operation_type=operation_type.value,
resource_type=resource_type.value,
resource_id=resource_id,
detail=json.dumps(detail, ensure_ascii=False) if detail else None,
ip_address=ip_address,
user_agent=user_agent,
status=status,
error_message=error_message,
)
db.add(log)
await db.commit()
@staticmethod
async def log_project_operation(
db: AsyncSession,
operation_type: OperationType,
project_id: int,
user: User,
detail: Optional[dict] = None,
request: Optional[Request] = None,
):
"""记录项目相关操作"""
await LogService.log_operation(
db=db,
operation_type=operation_type,
resource_type=ResourceType.PROJECT,
user=user,
resource_id=project_id,
detail=detail,
request=request,
)
@staticmethod
async def log_file_operation(
db: AsyncSession,
operation_type: OperationType,
project_id: int,
file_path: str,
user: User,
detail: Optional[dict] = None,
request: Optional[Request] = None,
):
"""记录文件相关操作"""
file_detail = {"project_id": project_id, "file_path": file_path}
if detail:
file_detail.update(detail)
await LogService.log_operation(
db=db,
operation_type=operation_type,
resource_type=ResourceType.FILE,
user=user,
resource_id=project_id, # 将project_id作为resource_id
detail=file_detail,
request=request,
)
@staticmethod
async def log_member_operation(
db: AsyncSession,
operation_type: OperationType,
project_id: int,
target_user_id: int,
user: User,
detail: Optional[dict] = None,
request: Optional[Request] = None,
):
"""记录成员相关操作"""
member_detail = {"project_id": project_id, "target_user_id": target_user_id}
if detail:
member_detail.update(detail)
await LogService.log_operation(
db=db,
operation_type=operation_type,
resource_type=ResourceType.MEMBER,
user=user,
resource_id=project_id,
detail=member_detail,
request=request,
)
log_service = LogService()

View File

@ -16,8 +16,12 @@ class StorageService:
"""文件存储服务类"""
def __init__(self):
self.projects_root = Path(settings.PROJECTS_PATH)
self.temp_root = Path(settings.TEMP_PATH)
# 获取 backend 目录的绝对路径app/services 的父目录的父目录)
backend_dir = Path(__file__).parent.parent.parent
# 将配置中的路径转换为绝对路径
self.projects_root = (backend_dir / settings.PROJECTS_PATH).resolve()
self.temp_root = (backend_dir / settings.TEMP_PATH).resolve()
# 确保根目录存在
self.projects_root.mkdir(parents=True, exist_ok=True)

View File

@ -1,4 +1,4 @@
import { BrowserRouter, Routes, Route, Navigate, useParams } from 'react-router-dom'
import { BrowserRouter, Routes, Route, Navigate, useParams, Outlet } from 'react-router-dom'
import { ConfigProvider } from 'antd'
import zhCN from 'antd/locale/zh_CN'
import Login from '@/pages/Login/Login'
@ -13,7 +13,9 @@ import ProfilePage from '@/pages/Profile/ProfilePage'
import Permissions from '@/pages/System/Permissions'
import Users from '@/pages/System/Users'
import Roles from '@/pages/System/Roles'
import SystemLogs from '@/pages/SystemLogs/SystemLogs'
import ProtectedRoute from '@/components/ProtectedRoute'
import MainLayout from '@/components/MainLayout/MainLayout'
import '@/App.css'
//
@ -22,6 +24,15 @@ function RedirectToDocs() {
return <Navigate to={`/projects/${projectId}/docs`} replace />
}
//
function LayoutWrapper() {
return (
<MainLayout>
<Outlet />
</MainLayout>
)
}
function App() {
return (
<ConfigProvider locale={zhCN}>
@ -30,95 +41,25 @@ function App() {
<Route path="/login" element={<Login />} />
{/* 项目预览(公开访问,无需登录) */}
<Route path="/preview/:projectId" element={<PreviewPage />} />
<Route
path="/dashboard"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
<Route
path="/desktop"
element={
<ProtectedRoute>
<Desktop />
</ProtectedRoute>
}
/>
<Route
path="/projects"
element={
<ProtectedRoute>
<ProjectList />
</ProtectedRoute>
}
/>
{/* 文档阅读模式 */}
<Route
path="/projects/:projectId/docs"
element={
<ProtectedRoute>
<DocumentPage />
</ProtectedRoute>
}
/>
{/* 文档编辑模式 */}
<Route
path="/projects/:projectId/editor"
element={
<ProtectedRoute>
<DocumentEditor />
</ProtectedRoute>
}
/>
{/* 捕获所有 /projects/:projectId/* 路径(包括中文路径),重定向到文档页面 */}
{/* 使用共享布局的路由 */}
<Route element={<ProtectedRoute><LayoutWrapper /></ProtectedRoute>}>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/desktop" element={<Desktop />} />
<Route path="/projects" element={<Navigate to="/projects/my" replace />} />
<Route path="/projects/my" element={<ProjectList type="my" />} />
<Route path="/projects/share" element={<ProjectList type="share" />} />
<Route path="/projects/:projectId/docs" element={<DocumentPage />} />
<Route path="/projects/:projectId/editor" element={<DocumentEditor />} />
<Route path="/projects/:projectId/*" element={<RedirectToDocs />} />
{/* 功能开发中页面 */}
<Route
path="/constructing"
element={
<ProtectedRoute>
<Constructing />
</ProtectedRoute>
}
/>
{/* 个人中心 */}
<Route
path="/profile"
element={
<ProtectedRoute>
<ProfilePage />
</ProtectedRoute>
}
/>
{/* 角色权限管理 */}
<Route
path="/system/permissions"
element={
<ProtectedRoute>
<Permissions />
</ProtectedRoute>
}
/>
{/* 用户管理 */}
<Route
path="/system/users"
element={
<ProtectedRoute>
<Users />
</ProtectedRoute>
}
/>
{/* 角色管理 */}
<Route
path="/system/roles"
element={
<ProtectedRoute>
<Roles />
</ProtectedRoute>
}
/>
<Route path="/constructing" element={<Constructing />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/system/permissions" element={<Permissions />} />
<Route path="/system/users" element={<Users />} />
<Route path="/system/roles" element={<Roles />} />
<Route path="/system/logs" element={<SystemLogs />} />
</Route>
<Route path="/" element={<Navigate to="/projects" replace />} />
</Routes>
</BrowserRouter>

View File

@ -0,0 +1,22 @@
import request from '@/utils/request'
/**
* 获取操作日志列表
*/
export const getOperationLogs = (params) => {
return request({
url: '/logs/',
method: 'get',
params,
})
}
/**
* 获取日志统计信息
*/
export const getLogStats = () => {
return request({
url: '/logs/stats',
method: 'get',
})
}

View File

@ -13,6 +13,26 @@ export function getMyProjects() {
})
}
/**
* 获取我创建的项目列表
*/
export function getOwnedProjects() {
return request({
url: '/projects/my',
method: 'get',
})
}
/**
* 获取我参与的项目列表
*/
export function getSharedProjects() {
return request({
url: '/projects/shared',
method: 'get',
})
}
/**
* 创建项目
*/
@ -75,3 +95,13 @@ export function addProjectMember(projectId, data) {
data,
})
}
/**
* 删除项目成员
*/
export function removeProjectMember(projectId, userId) {
return request({
url: `/projects/${projectId}/members/${userId}`,
method: 'delete',
})
}

View File

@ -0,0 +1,15 @@
/**
* 文档搜索相关 API
*/
import request from '@/utils/request'
/**
* 搜索文档
*/
export function searchDocuments(keyword) {
return request({
url: '/search/documents',
method: 'get',
params: { keyword },
})
}

View File

@ -76,13 +76,13 @@ function AppSider({ collapsed, onToggle }) {
return []
}
//
//
useEffect(() => {
if (!collapsed && menuData.length > 0) {
if (menuData.length > 0 && openKeys.length === 0) {
const defaultKeys = getDefaultOpenKeys()
setOpenKeys(defaultKeys)
}
}, [location.pathname, collapsed, menuData])
}, [menuData])
const handleMenuClick = ({ key }) => {
//

View File

@ -1,13 +1,12 @@
import { Result, Button } from 'antd'
import { useNavigate } from 'react-router-dom'
import { ToolOutlined } from '@ant-design/icons'
import MainLayout from '@/components/MainLayout/MainLayout'
function Constructing() {
const navigate = useNavigate()
return (
<MainLayout>
<div style={{
display: 'flex',
justifyContent: 'center',
@ -26,7 +25,7 @@ function Constructing() {
}
/>
</div>
</MainLayout>
)
}

View File

@ -2,7 +2,6 @@ import { useState, useEffect } from 'react'
import { Card, Row, Col, Statistic, Table, Spin } from 'antd'
import { UserOutlined, ProjectOutlined, FileTextOutlined } from '@ant-design/icons'
import { getDashboardStats } from '@/api/dashboard'
import MainLayout from '@/components/MainLayout/MainLayout'
import Toast from '@/components/Toast/Toast'
function Dashboard() {
@ -80,16 +79,16 @@ function Dashboard() {
if (loading) {
return (
<MainLayout>
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '400px' }}>
<Spin size="large" />
</div>
</MainLayout>
)
}
return (
<MainLayout>
<div style={{ padding: '24px' }}>
<h1 style={{ marginBottom: '24px', fontSize: '24px', fontWeight: 600 }}>管理员仪表盘</h1>
@ -147,7 +146,7 @@ function Dashboard() {
/>
</Card>
</div>
</MainLayout>
)
}

View File

@ -1,8 +1,7 @@
import { useState, useEffect } from 'react'
import { Card, Row, Col, Statistic, Table, Spin, Descriptions } from 'antd'
import { ProjectOutlined, FileTextOutlined } from '@ant-design/icons'
import { ProjectOutlined, FileTextOutlined, TeamOutlined } from '@ant-design/icons'
import { getPersonalStats } from '@/api/dashboard'
import MainLayout from '@/components/MainLayout/MainLayout'
import Toast from '@/components/Toast/Toast'
function Desktop() {
@ -10,6 +9,7 @@ function Desktop() {
const [userInfo, setUserInfo] = useState({})
const [stats, setStats] = useState({
personal_projects_count: 0,
shared_projects_count: 0,
document_count: 0,
})
const [recentPersonalProjects, setRecentPersonalProjects] = useState([])
@ -89,16 +89,16 @@ function Desktop() {
if (loading) {
return (
<MainLayout>
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '400px' }}>
<Spin size="large" />
</div>
</MainLayout>
)
}
return (
<MainLayout>
<div style={{ padding: '24px' }}>
<h1 style={{ marginBottom: '24px', color: '#333' }}>个人桌面</h1>
@ -116,7 +116,7 @@ function Desktop() {
{/* 统计卡片 */}
<Row gutter={16} style={{ marginBottom: '24px' }}>
<Col span={12}>
<Col span={8}>
<Card>
<Statistic
title="个人项目数"
@ -126,7 +126,17 @@ function Desktop() {
/>
</Card>
</Col>
<Col span={12}>
<Col span={8}>
<Card>
<Statistic
title="参加项目数"
value={stats.shared_projects_count}
prefix={<TeamOutlined />}
valueStyle={{ color: '#52c41a' }}
/>
</Card>
</Col>
<Col span={8}>
<Card>
<Statistic
title="文档总数"
@ -160,7 +170,7 @@ function Desktop() {
/>
</Card>
</div>
</MainLayout>
)
}

View File

@ -33,7 +33,6 @@ import {
importDocuments,
exportDirectory,
} from '@/api/file'
import MainLayout from '@/components/MainLayout/MainLayout'
import './DocumentEditor.css'
const { Sider, Content } = Layout
@ -80,7 +79,9 @@ function DocumentEditor() {
const fetchTree = async () => {
try {
const res = await getProjectTree(projectId)
setTreeData(res.data || [])
const data = res.data || {}
const tree = data.tree || data || [] //
setTreeData(tree)
} catch (error) {
console.error('Fetch tree error:', error)
}
@ -141,13 +142,6 @@ function DocumentEditor() {
return
}
// README.md
const fileName = path.split('/').pop()
if (fileName === 'README.md' && path.indexOf('/') === -1) {
message.error('根目录的README.md不允许删除')
return
}
Modal.confirm({
title: '确认删除',
content: `确定要删除 ${path} 吗?`,
@ -603,7 +597,6 @@ function DocumentEditor() {
}
return (
<MainLayout>
<div className="document-editor-page">
<Layout className="document-editor-container">
<Sider
@ -726,7 +719,6 @@ function DocumentEditor() {
</div>
</Content>
</Layout>
</div>
<Modal
title={
@ -774,7 +766,7 @@ function DocumentEditor() {
/>
</div>
</Modal>
</MainLayout>
</div>
)
}

View File

@ -10,7 +10,6 @@ import rehypeHighlight from 'rehype-highlight'
import 'highlight.js/styles/github.css'
import { getProjectTree, getFileContent } from '@/api/file'
import { getProjectShareInfo, updateShareSettings } from '@/api/share'
import MainLayout from '@/components/MainLayout/MainLayout'
import './DocumentPage.css'
const { Sider, Content } = Layout
@ -29,6 +28,7 @@ function DocumentPage() {
const [shareInfo, setShareInfo] = useState(null)
const [hasPassword, setHasPassword] = useState(false)
const [password, setPassword] = useState('')
const [userRole, setUserRole] = useState('viewer') // owner/admin/editor/viewer
const contentRef = useRef(null)
useEffect(() => {
@ -39,8 +39,12 @@ function DocumentPage() {
const loadFileTree = async () => {
try {
const res = await getProjectTree(projectId)
const tree = res.data || []
const data = res.data || {}
const tree = data.tree || data || [] //
const role = data.user_role || 'viewer'
setFileTree(tree)
setUserRole(role)
// README.md
const readmeNode = findReadme(tree)
@ -280,7 +284,6 @@ function DocumentPage() {
const menuItems = convertTreeToMenuItems(fileTree)
return (
<MainLayout>
<div className="project-docs-page">
<Layout className="docs-layout">
{/* 左侧目录 */}
@ -288,6 +291,8 @@ function DocumentPage() {
<div className="docs-sider-header">
<h2>项目文档</h2>
<div className="docs-sider-actions">
{/* 只有 owner/admin/editor 可以编辑 */}
{userRole !== 'viewer' && (
<Tooltip title="编辑模式">
<Button
type="link"
@ -296,6 +301,7 @@ function DocumentPage() {
onClick={handleEdit}
/>
</Tooltip>
)}
<Tooltip title="分享">
<Button
type="text"
@ -468,7 +474,6 @@ function DocumentPage() {
)}
</Modal>
</div>
</MainLayout>
)
}

View File

@ -3,7 +3,6 @@ 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 useUserStore from '@/stores/userStore'
import MainLayout from '@/components/MainLayout/MainLayout'
import Toast from '@/components/Toast/Toast'
import './ProfilePage.css'
@ -228,14 +227,14 @@ function ProfilePage() {
]
return (
<MainLayout>
<div className="profile-page">
<Card className="profile-card">
<h2 className="profile-title">个人中心</h2>
<Tabs items={tabItems} defaultActiveKey="profile" />
</Card>
</div>
</MainLayout>
)
}

View File

@ -19,6 +19,8 @@
text-align: center;
cursor: pointer;
transition: all 0.3s;
position: relative;
overflow: hidden;
}
.project-card:hover {
@ -26,6 +28,20 @@
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.project-card-public-badge {
position: absolute;
top: 8px;
right: -28px;
background: linear-gradient(135deg, #52c41a 0%, #389e0d 100%);
color: white;
padding: 2px 32px;
font-size: 11px;
font-weight: 500;
transform: rotate(45deg);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
z-index: 1;
}
.project-card-icon {
margin-bottom: 16px;
}

View File

@ -1,35 +1,53 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Card, Empty, Modal, Form, Input, Row, Col, Space, Button, Switch, message } from 'antd'
import { PlusOutlined, FolderOutlined, TeamOutlined, EyeOutlined, ShareAltOutlined, CopyOutlined } from '@ant-design/icons'
import { getMyProjects, createProject, deleteProject } from '@/api/project'
import { Card, Empty, Modal, Form, Input, Row, Col, Space, Button, Switch, message, Select, Table, Tag } from 'antd'
import { PlusOutlined, FolderOutlined, TeamOutlined, EyeOutlined, ShareAltOutlined, CopyOutlined, DeleteOutlined, EditOutlined, FileOutlined } from '@ant-design/icons'
import { getMyProjects, getOwnedProjects, getSharedProjects, createProject, deleteProject, updateProject, getProjectMembers, addProjectMember, removeProjectMember } from '@/api/project'
import { getProjectShareInfo, updateShareSettings } from '@/api/share'
import MainLayout from '@/components/MainLayout/MainLayout'
import { getUserList } from '@/api/users'
import { searchDocuments } from '@/api/search'
import ListActionBar from '@/components/ListActionBar/ListActionBar'
import Toast from '@/components/Toast/Toast'
import './ProjectList.css'
function ProjectList() {
function ProjectList({ type = 'my' }) {
const [projects, setProjects] = useState([])
const [loading, setLoading] = useState(false)
const [modalVisible, setModalVisible] = useState(false)
const [editModalVisible, setEditModalVisible] = useState(false)
const [shareModalVisible, setShareModalVisible] = useState(false)
const [membersModalVisible, setMembersModalVisible] = useState(false)
const [currentProject, setCurrentProject] = useState(null)
const [shareInfo, setShareInfo] = useState(null)
const [hasPassword, setHasPassword] = useState(false)
const [password, setPassword] = useState('')
const [searchKeyword, setSearchKeyword] = useState('')
const [searchResults, setSearchResults] = useState([])
const [searching, setSearching] = useState(false)
const [hasSearched, setHasSearched] = useState(false)
const [members, setMembers] = useState([])
const [users, setUsers] = useState([])
const [loadingMembers, setLoadingMembers] = useState(false)
const [form] = Form.useForm()
const [editForm] = Form.useForm()
const [memberForm] = Form.useForm()
const navigate = useNavigate()
useEffect(() => {
fetchProjects()
}, [])
}, [type])
const fetchProjects = async () => {
setLoading(true)
try {
const res = await getMyProjects()
let res
if (type === 'my') {
res = await getOwnedProjects()
} else if (type === 'share') {
res = await getSharedProjects()
} else {
res = await getMyProjects()
}
setProjects(res.data || [])
} catch (error) {
console.error('Fetch projects error:', error)
@ -53,19 +71,53 @@ function ProjectList() {
const handleDeleteProject = async (projectId) => {
Modal.confirm({
title: '确认删除',
content: '确定要删除这个项目吗?删除后可以在归档中找到',
content: '确定要删除这个项目吗?如果项目中存在文件,将无法删除。删除后将无法恢复!',
okText: '确定删除',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
try {
await deleteProject(projectId)
Toast.success('归档成功', '项目已归档')
Toast.success('删除成功', '项目已删除')
fetchProjects()
} catch (error) {
console.error('Delete project error:', error)
const errorMsg = error.response?.data?.detail || error.message || '删除失败'
Toast.error('删除失败', errorMsg)
}
},
})
}
//
const handleEdit = (e, project) => {
e.stopPropagation()
setCurrentProject(project)
editForm.setFieldsValue({
name: project.name,
description: project.description,
is_public: project.is_public === 1,
})
setEditModalVisible(true)
}
//
const handleUpdateProject = async (values) => {
try {
await updateProject(currentProject.id, {
...values,
is_public: values.is_public ? 1 : 0,
})
message.success('项目更新成功')
setEditModalVisible(false)
editForm.resetFields()
fetchProjects()
} catch (error) {
console.error('Update project error:', error)
message.error('项目更新失败')
}
}
const handleOpenProject = (projectId) => {
navigate(`/projects/${projectId}/docs`)
}
@ -134,16 +186,136 @@ function ProjectList() {
}
}
//
const filteredProjects = projects.filter((project) =>
project.name.toLowerCase().includes(searchKeyword.toLowerCase())
//
const handleMembers = async (e, project) => {
e.stopPropagation()
setCurrentProject(project)
setMembersModalVisible(true)
setLoadingMembers(true)
try {
// role_id=3
const [membersRes, usersRes] = await Promise.all([
getProjectMembers(project.id),
getUserList({ page: 1, page_size: 100, status: 1, role_id: 3 })
])
console.log('Members Response:', membersRes)
console.log('Users Response:', usersRes)
const membersData = membersRes.data || []
// : { code: 200, message: "success", data: [...], total, page, page_size }
const usersData = Array.isArray(usersRes.data) ? usersRes.data : []
console.log('Setting members:', membersData)
console.log('Setting users:', usersData)
setMembers(membersData)
setUsers(usersData)
} catch (error) {
console.error('Get members error:', error)
console.error('Error details:', error.response)
message.error('获取数据失败: ' + (error.response?.data?.detail || error.message))
} finally {
setLoadingMembers(false)
}
}
//
const handleAddMember = async (values) => {
try {
await addProjectMember(currentProject.id, values)
message.success('成员添加成功')
memberForm.resetFields()
//
const res = await getProjectMembers(currentProject.id)
setMembers(res.data || [])
} catch (error) {
console.error('Add member error:', error)
const errorMsg = error.response?.data?.detail || error.message || '添加成员失败'
message.error(errorMsg)
}
}
//
const handleRemoveMember = async (userId) => {
Modal.confirm({
title: '确认删除',
content: '确定要删除这个成员吗?',
onOk: async () => {
try {
await removeProjectMember(currentProject.id, userId)
message.success('成员删除成功')
//
const res = await getProjectMembers(currentProject.id)
setMembers(res.data || [])
} catch (error) {
console.error('Remove member error:', error)
const errorMsg = error.response?.data?.detail || error.message || '删除成员失败'
message.error(errorMsg)
}
},
})
}
//
const handleSearchChange = (value) => {
setSearchKeyword(value)
//
if (!value || !value.trim()) {
setSearchResults([])
setHasSearched(false)
}
}
//
const handleSearch = async (keyword) => {
setSearchKeyword(keyword)
if (!keyword || !keyword.trim()) {
//
setSearchResults([])
setSearching(false)
setHasSearched(false)
return
}
setSearching(true)
setHasSearched(true)
try {
const res = await searchDocuments(keyword.trim())
setSearchResults(res.data || [])
} catch (error) {
console.error('Search error:', error)
Toast.error('搜索失败', error.response?.data?.detail || error.message)
} finally {
setSearching(false)
}
}
//
const handleSearchResultClick = (item) => {
if (item.type === 'project') {
//
navigate(`/projects/${item.project_id}/docs`)
} else if (item.type === 'file') {
//
navigate(`/projects/${item.project_id}/docs?file=${encodeURIComponent(item.file_path)}`)
}
}
// 使
const filteredProjects = !hasSearched && searchKeyword
? projects.filter((project) =>
project.name.toLowerCase().includes(searchKeyword.toLowerCase()) ||
(project.description && project.description.toLowerCase().includes(searchKeyword.toLowerCase()))
)
: projects
return (
<MainLayout>
<div className="project-list-container">
<ListActionBar
actions={[
actions={type === 'my' ? [
{
key: 'create',
label: '创建项目',
@ -151,17 +323,68 @@ function ProjectList() {
icon: <PlusOutlined />,
onClick: () => setModalVisible(true),
},
]}
] : []}
search={{
placeholder: '搜索项目...',
placeholder: '搜索项目或文件...',
value: searchKeyword,
onChange: setSearchKeyword,
onSearch: (value) => setSearchKeyword(value),
onChange: handleSearchChange,
onSearch: handleSearch,
}}
showRefresh
onRefresh={fetchProjects}
/>
{/* 搜索结果 */}
{hasSearched && searchResults.length > 0 && (
<div style={{ marginTop: 16, marginBottom: 16 }}>
<div style={{ marginBottom: 8, color: '#666' }}>
找到 {searchResults.length} 个结果
</div>
<Row gutter={[16, 16]}>
{searchResults.map((item, index) => (
<Col xs={24} sm={12} md={8} lg={6} key={`${item.type}-${item.project_id}-${index}`}>
<Card
hoverable
className="project-card"
onClick={() => handleSearchResultClick(item)}
>
<div className="project-card-icon">
{item.type === 'project' ? (
<FolderOutlined style={{ fontSize: 48, color: '#1890ff' }} />
) : (
<FileOutlined style={{ fontSize: 48, color: '#52c41a' }} />
)}
</div>
<Space direction="vertical" style={{ width: '100%' }}>
<Space>
<h3 style={{ margin: 0 }}>
{item.type === 'project' ? item.project_name : item.file_name}
</h3>
<Tag color={item.type === 'project' ? 'blue' : 'green'}>
{item.match_type}
</Tag>
</Space>
{item.type === 'project' && (
<p className="project-description">
{item.project_description || '暂无描述'}
</p>
)}
{item.type === 'file' && (
<div style={{ fontSize: 12, color: '#666' }}>
<div>项目: {item.project_name}</div>
<div>路径: {item.file_path}</div>
</div>
)}
</Space>
</Card>
</Col>
))}
</Row>
</div>
)}
{/* 正常项目列表 */}
{!hasSearched && (
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
{filteredProjects.map((project) => (
<Col xs={24} sm={12} md={8} lg={6} key={project.id}>
@ -169,12 +392,19 @@ function ProjectList() {
hoverable
className="project-card"
onClick={() => handleOpenProject(project.id)}
actions={[
actions={type === 'my' ? [
<EditOutlined key="edit" onClick={(e) => handleEdit(e, project)} />,
<ShareAltOutlined key="share" onClick={(e) => handleShare(e, project)} />,
<TeamOutlined key="members" onClick={(e) => handleMembers(e, project)} />,
] : [
<EyeOutlined key="view" />,
<TeamOutlined key="members" />,
<ShareAltOutlined key="share" onClick={(e) => handleShare(e, project)} />,
]}
>
{/* 公开项目标识 */}
{project.is_public === 1 && (
<div className="project-card-public-badge">公开</div>
)}
<div className="project-card-icon">
<FolderOutlined style={{ fontSize: 48, color: '#1890ff' }} />
</div>
@ -182,17 +412,35 @@ function ProjectList() {
<p className="project-description">{project.description || '暂无描述'}</p>
<div className="project-meta">
<span>访问: {project.visit_count}</span>
{type === 'share' && project.owner_name && (
<span style={{ marginLeft: 12 }}>
所有者: {project.owner_nickname || project.owner_name}
</span>
)}
{type === 'share' && project.user_role && (
<span style={{ marginLeft: 12 }}>
角色: {project.user_role === 'admin' ? '管理者' : project.user_role === 'editor' ? '编辑者' : '查看者'}
</span>
)}
</div>
</Card>
</Col>
))}
{projects.length === 0 && !loading && (
{filteredProjects.length === 0 && !loading && (
<Col span={24}>
<Empty description="还没有项目,创建一个开始吧" />
<Empty description={type === 'my' ? "还没有项目,创建一个开始吧" : "还没有参与的项目"} />
</Col>
)}
</Row>
)}
{/* 搜索无结果提示 */}
{hasSearched && !searching && searchResults.length === 0 && (
<div style={{ marginTop: 16 }}>
<Empty description={`没有找到包含 "${searchKeyword}" 的项目或文件`} />
</div>
)}
<Modal
title="创建新项目"
@ -242,6 +490,75 @@ function ProjectList() {
</Form>
</Modal>
<Modal
title="编辑项目"
open={editModalVisible}
onCancel={() => {
setEditModalVisible(false)
editForm.resetFields()
}}
footer={null}
>
<Form
form={editForm}
layout="vertical"
onFinish={handleUpdateProject}
>
<Form.Item
label="项目名称"
name="name"
rules={[{ required: true, message: '请输入项目名称' }]}
>
<Input placeholder="请输入项目名称" />
</Form.Item>
<Form.Item
label="项目描述"
name="description"
>
<Input.TextArea
rows={4}
placeholder="请输入项目描述(选填)"
/>
</Form.Item>
<Form.Item
label="公开项目"
name="is_public"
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item>
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Button
danger
icon={<DeleteOutlined />}
onClick={() => {
setEditModalVisible(false)
editForm.resetFields()
handleDeleteProject(currentProject.id)
}}
>
删除项目
</Button>
<Space>
<Button type="primary" htmlType="submit">
更新
</Button>
<Button onClick={() => {
setEditModalVisible(false)
editForm.resetFields()
}}>
取消
</Button>
</Space>
</Space>
</Form.Item>
</Form>
</Modal>
<Modal
title="分享项目"
open={shareModalVisible}
@ -262,6 +579,9 @@ function ProjectList() {
/>
</div>
{/* 只有在我的项目中才显示密码设置功能 */}
{type === 'my' && (
<>
<div>
<Space>
<span style={{ fontWeight: 500 }}>访问密码保护</span>
@ -285,11 +605,155 @@ function ProjectList() {
</Button>
</div>
)}
</>
)}
{/* 参与项目显示提示 */}
{type === 'share' && shareInfo.has_password && (
<div style={{ color: '#8c8c8c', fontSize: 12 }}>
该项目已设置访问密码保护
</div>
)}
</Space>
)}
</Modal>
<Modal
title="成员管理"
open={membersModalVisible}
onCancel={() => {
setMembersModalVisible(false)
memberForm.resetFields()
setMembers([])
setUsers([])
}}
footer={null}
width={700}
>
<Space direction="vertical" style={{ width: '100%' }} size="large">
<div>
{users.length === 0 ? (
<div style={{ marginBottom: 16, color: '#999' }}>
{loadingMembers ? '正在加载用户列表...' : '没有可添加的用户'}
</div>) : null}
<Form
form={memberForm}
layout="inline"
onFinish={handleAddMember}
>
<Form.Item
name="user_id"
rules={[{ required: true, message: '请选择用户' }]}
style={{ width: 250 }}
>
<Select
placeholder={users.length > 0 ? "选择用户" : "没有可添加的用户"}
showSearch
loading={loadingMembers}
disabled={users.length === 0}
filterOption={(input, option) =>
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
}
options={users
.filter(user => !members.some(m => m.user_id === user.id))
.map(user => ({
value: user.id,
label: user.nickname ? `${user.username} (${user.nickname})` : user.username,
}))}
notFoundContent={loadingMembers ? "加载中..." : "没有找到用户"}
/>
</Form.Item>
<Form.Item
name="role"
rules={[{ required: true, message: '请选择角色' }]}
initialValue="viewer"
style={{ width: 150 }}
>
<Select placeholder="选择角色">
<Select.Option value="admin">管理员</Select.Option>
<Select.Option value="editor">编辑者</Select.Option>
<Select.Option value="viewer">查看者</Select.Option>
</Select>
</Form.Item>
<Form.Item>
<Button
type="primary"
htmlType="submit"
loading={loadingMembers}
disabled={users.length === 0}
>
添加
</Button>
</Form.Item>
</Form>
</div>
<div>
<h4>当前成员</h4>
{members.length === 0 ? (
<Empty description="暂无成员" />
) : (
<Table
dataSource={members}
rowKey="id"
pagination={false}
loading={loadingMembers}
columns={[
{
title: '用户',
dataIndex: 'username',
key: 'username',
render: (username, record) => {
if (record.nickname) {
return `${username} (${record.nickname})`
}
return username || `用户ID: ${record.user_id}`
},
},
{
title: '角色',
dataIndex: 'role',
key: 'role',
render: (role) => {
const roleMap = {
admin: '管理员',
editor: '编辑者',
viewer: '查看者',
}
return roleMap[role] || role
},
},
{
title: '加入时间',
dataIndex: 'joined_at',
key: 'joined_at',
render: (time) => time ? new Date(time).toLocaleString('zh-CN') : '-',
},
{
title: '操作',
key: 'action',
render: (_, record) => {
const isOwner = record.user_id === currentProject?.owner_id
return (
<Button
type="link"
danger
onClick={() => handleRemoveMember(record.user_id)}
disabled={isOwner}
title={isOwner ? '不能删除项目所有者' : '删除成员'}
>
{isOwner ? '所有者' : '删除'}
</Button>
)
},
},
]}
/>
)}
</div>
</Space>
</Modal>
</div>
</MainLayout>
)
}

View File

@ -7,7 +7,6 @@ import {
getRolePermissions,
updateRolePermissions,
} from '@/api/rolePermissions'
import MainLayout from '@/components/MainLayout/MainLayout'
import ListTable from '@/components/ListTable/ListTable'
import Toast from '@/components/Toast/Toast'
@ -153,7 +152,7 @@ function Permissions() {
]
return (
<MainLayout>
<div style={{ padding: '24px' }}>
<h1 style={{ marginBottom: '24px', fontSize: '24px', fontWeight: 600 }}>
<SafetyOutlined style={{ marginRight: '8px' }} />
@ -229,7 +228,7 @@ function Permissions() {
</Col>
</Row>
</div>
</MainLayout>
)
}

View File

@ -28,7 +28,6 @@ import {
deleteRole,
getRoleUsers,
} from '@/api/roles'
import MainLayout from '@/components/MainLayout/MainLayout'
import ListTable from '@/components/ListTable/ListTable'
import Toast from '@/components/Toast/Toast'
@ -314,7 +313,7 @@ function Roles() {
]
return (
<MainLayout>
<div style={{ padding: '24px' }}>
<h1 style={{ marginBottom: '24px', fontSize: '24px', fontWeight: 600 }}>角色管理</h1>
@ -485,7 +484,7 @@ function Roles() {
/>
</Modal>
</div>
</MainLayout>
)
}

View File

@ -33,7 +33,6 @@ import {
resetUserPassword,
} from '@/api/users'
import { getAllRoles } from '@/api/rolePermissions'
import MainLayout from '@/components/MainLayout/MainLayout'
import ListTable from '@/components/ListTable/ListTable'
import Toast from '@/components/Toast/Toast'
@ -299,7 +298,7 @@ function Users() {
]
return (
<MainLayout>
<div style={{ padding: '24px' }}>
<h1 style={{ marginBottom: '24px', fontSize: '24px', fontWeight: 600 }}>用户管理</h1>
@ -459,7 +458,7 @@ function Users() {
</Form>
</Modal>
</div>
</MainLayout>
)
}

View File

@ -0,0 +1,9 @@
.system-logs-container {
padding: 24px;
}
.system-logs-container h2 {
margin-bottom: 24px;
font-size: 24px;
font-weight: 600;
}

View File

@ -0,0 +1,388 @@
import { useState, useEffect } from 'react'
import { Table, Card, Select, DatePicker, Space, Button, Tag, Statistic, Row, Col, Input, message } from 'antd'
import { ReloadOutlined, SearchOutlined } from '@ant-design/icons'
import { getOperationLogs, getLogStats } from '@/api/logs'
import { getUserList } from '@/api/users'
import dayjs from 'dayjs'
import './SystemLogs.css'
const { RangePicker } = DatePicker
const { Option } = Select
//
const operationTypeMap = {
create_project: '创建项目',
update_project: '更新项目',
delete_project: '删除项目',
add_member: '添加成员',
remove_member: '删除成员',
create_file: '创建文件',
create_dir: '创建目录',
delete_file: '删除文件',
rename_file: '重命名文件',
move_file: '移动文件',
save_file: '保存文件',
upload_image: '上传图片',
import_documents: '导入文档',
export_documents: '导出文档',
update_share_settings: '更新分享设置',
user_login: '用户登录',
user_logout: '用户登出',
user_register: '用户注册',
}
//
const resourceTypeMap = {
project: '项目',
file: '文件',
member: '成员',
user: '用户',
share: '分享',
}
//
const operationTypeColors = {
create_project: 'green',
update_project: 'blue',
delete_project: 'red',
add_member: 'cyan',
remove_member: 'orange',
create_file: 'green',
create_dir: 'green',
delete_file: 'red',
rename_file: 'blue',
move_file: 'blue',
save_file: 'blue',
upload_image: 'purple',
import_documents: 'purple',
export_documents: 'purple',
update_share_settings: 'blue',
user_login: 'green',
user_logout: 'default',
user_register: 'green',
}
function SystemLogs() {
const [logs, setLogs] = useState([])
const [loading, setLoading] = useState(false)
const [pagination, setPagination] = useState({
current: 1,
pageSize: 20,
total: 0,
})
const [stats, setStats] = useState(null)
const [users, setUsers] = useState([])
//
const [filters, setFilters] = useState({
operation_type: undefined,
resource_type: undefined,
user_id: undefined,
project_id: undefined,
dateRange: null,
})
useEffect(() => {
fetchLogs()
fetchStats()
fetchUsers()
}, [])
const fetchLogs = async (page = 1, pageSize = 20) => {
setLoading(true)
try {
const params = {
page,
page_size: pageSize,
}
//
if (filters.operation_type) {
params.operation_type = filters.operation_type
}
if (filters.resource_type) {
params.resource_type = filters.resource_type
}
if (filters.user_id) {
params.user_id = filters.user_id
}
if (filters.project_id) {
params.project_id = filters.project_id
}
//
if (filters.dateRange && filters.dateRange.length === 2) {
params.start_date = filters.dateRange[0].format('YYYY-MM-DD')
params.end_date = filters.dateRange[1].format('YYYY-MM-DD')
}
const res = await getOperationLogs(params)
setLogs(res.data.items || [])
setPagination({
current: res.data.page,
pageSize: res.data.page_size,
total: res.data.total,
})
} catch (error) {
console.error('Fetch logs error:', error)
message.error('获取日志失败')
} finally {
setLoading(false)
}
}
const fetchStats = async () => {
try {
const res = await getLogStats()
setStats(res.data)
} catch (error) {
console.error('Fetch stats error:', error)
}
}
const fetchUsers = async () => {
try {
const res = await getUserList({ page: 1, page_size: 100, status: 1 })
console.log('Fetch users response:', res)
// : { code: 200, message: "success", data: [...], total, page, page_size }
const usersData = Array.isArray(res.data) ? res.data : []
console.log('Users data:', usersData)
setUsers(usersData)
} catch (error) {
console.error('Fetch users error:', error)
message.error('获取用户列表失败')
}
}
const handleTableChange = (newPagination) => {
fetchLogs(newPagination.current, newPagination.pageSize)
}
const handleSearch = () => {
//
const cleanFilters = { ...filters }
if (!cleanFilters.project_id || cleanFilters.project_id === '') {
delete cleanFilters.project_id
}
setFilters(cleanFilters)
fetchLogs(1, pagination.pageSize)
}
const handleReset = () => {
setFilters({
operation_type: undefined,
resource_type: undefined,
user_id: undefined,
project_id: undefined,
dateRange: null,
})
setTimeout(() => {
fetchLogs(1, pagination.pageSize)
}, 0)
}
const columns = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 80,
},
{
title: '操作用户',
dataIndex: 'username',
key: 'username',
width: 120,
},
{
title: '操作类型',
dataIndex: 'operation_type',
key: 'operation_type',
width: 140,
render: (type) => (
<Tag color={operationTypeColors[type] || 'default'}>
{operationTypeMap[type] || type}
</Tag>
),
},
{
title: '资源类型',
dataIndex: 'resource_type',
key: 'resource_type',
width: 100,
render: (type) => resourceTypeMap[type] || type,
},
{
title: '资源ID',
dataIndex: 'resource_id',
key: 'resource_id',
width: 100,
},
{
title: '详情',
dataIndex: 'detail',
key: 'detail',
ellipsis: true,
render: (detail) => {
if (!detail) return '-'
try {
const parsed = JSON.parse(detail)
return (
<div style={{ maxWidth: 300 }}>
{Object.entries(parsed).map(([key, value]) => (
<div key={key} style={{ fontSize: 12 }}>
<strong>{key}:</strong> {JSON.stringify(value)}
</div>
))}
</div>
)
} catch {
return detail
}
},
},
{
title: 'IP地址',
dataIndex: 'ip_address',
key: 'ip_address',
width: 140,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 80,
render: (status) => (
<Tag color={status === 1 ? 'success' : 'error'}>
{status === 1 ? '成功' : '失败'}
</Tag>
),
},
{
title: '操作时间',
dataIndex: 'created_at',
key: 'created_at',
width: 180,
render: (time) => time ? dayjs(time).format('YYYY-MM-DD HH:mm:ss') : '-',
},
]
return (
<div className="system-logs-container">
<h2>系统日志</h2>
{/* 统计信息 */}
{stats && (
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={6}>
<Card>
<Statistic title="总日志数" value={stats.total_count} />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic title="今日操作" value={stats.today_count} />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="项目操作"
value={stats.operation_stats?.create_project + stats.operation_stats?.update_project + stats.operation_stats?.delete_project || 0}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="文件操作"
value={stats.operation_stats?.create_file + stats.operation_stats?.save_file + stats.operation_stats?.delete_file || 0}
/>
</Card>
</Col>
</Row>
)}
{/* 筛选条件 */}
<Card style={{ marginBottom: 16 }}>
<Space wrap size="middle">
<Select
placeholder="操作类型"
style={{ width: 160 }}
allowClear
value={filters.operation_type}
onChange={(value) => setFilters({ ...filters, operation_type: value })}
>
{Object.entries(operationTypeMap).map(([key, label]) => (
<Option key={key} value={key}>{label}</Option>
))}
</Select>
<Select
placeholder="资源类型"
style={{ width: 120 }}
allowClear
value={filters.resource_type}
onChange={(value) => setFilters({ ...filters, resource_type: value })}
>
{Object.entries(resourceTypeMap).map(([key, label]) => (
<Option key={key} value={key}>{label}</Option>
))}
</Select>
<Select
placeholder="操作用户"
style={{ width: 160 }}
allowClear
showSearch
filterOption={(input, option) =>
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
}
value={filters.user_id}
onChange={(value) => setFilters({ ...filters, user_id: value })}
>
{users.map((user) => (
<Option key={user.id} value={user.id} label={user.username}>
{user.nickname ? `${user.username} (${user.nickname})` : user.username}
</Option>
))}
</Select>
<Input
placeholder="项目ID"
style={{ width: 120 }}
value={filters.project_id}
onChange={(e) => setFilters({ ...filters, project_id: e.target.value })}
/>
<RangePicker
value={filters.dateRange}
onChange={(dates) => setFilters({ ...filters, dateRange: dates })}
/>
<Button type="primary" icon={<SearchOutlined />} onClick={handleSearch}>
查询
</Button>
<Button icon={<ReloadOutlined />} onClick={handleReset}>
重置
</Button>
</Space>
</Card>
{/* 日志列表 */}
<Card>
<Table
columns={columns}
dataSource={logs}
rowKey="id"
loading={loading}
pagination={pagination}
onChange={handleTableChange}
scroll={{ x: 1400 }}
/>
</Card>
</div>
)
}
export default SystemLogs

File diff suppressed because it is too large Load Diff