0.9.2
parent
e1d5533461
commit
f5e1e8871a
|
|
@ -1,79 +1,144 @@
|
||||||
# NEX Docus 实施计划
|
# NexDocus 功能增强实现计划
|
||||||
|
|
||||||
## Stage 1: 基础架构与数据库设计
|
## Stage 1: 路由调整和项目成员管理
|
||||||
**Goal**: 完成数据库设计、后端项目初始化、核心数据模型创建
|
**Goal**: 调整路由结构,实现项目卡片上的成员管理功能
|
||||||
**Success Criteria**:
|
**Success Criteria**:
|
||||||
- DATABASE.md 文档完成
|
- /projects/my 路由正常工作
|
||||||
- 后端项目结构搭建完成
|
- /projects/share 路由正常工作
|
||||||
- 数据库连接测试通过
|
- 项目卡片上可以打开成员管理弹窗
|
||||||
- 所有数据表创建完成
|
- 可以添加/删除项目成员
|
||||||
**Tests**:
|
**Tests**:
|
||||||
- 数据库连接测试
|
- 访问 /projects/my 显示我创建的项目
|
||||||
- 表结构验证
|
- 访问 /projects/share 显示我参与的项目
|
||||||
- ORM 模型单元测试
|
- 点击项目卡片的成员图标打开成员管理弹窗
|
||||||
|
- 成功添加用户到项目
|
||||||
|
- 成功从项目删除用户
|
||||||
**Status**: ✅ Completed
|
**Status**: ✅ Completed
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Stage 2: 用户认证与权限系统
|
## Stage 2: 个人桌面统计增强
|
||||||
**Goal**: 实现完整的用户认证、角色权限、菜单管理系统
|
**Goal**: 在个人桌面增加"参加项目数"统计
|
||||||
**Success Criteria**:
|
**Success Criteria**:
|
||||||
- JWT 认证流程完整
|
- 个人桌面显示参加项目数
|
||||||
- 用户注册、登录接口正常工作
|
- 统计数据准确(我创建的项目 + 我参与的项目)
|
||||||
- RBAC 权限校验中间件实现
|
|
||||||
- 角色-权限-菜单关联关系正确
|
|
||||||
**Tests**:
|
**Tests**:
|
||||||
- 登录/注册接口测试
|
- 创建新项目后,个人项目数 +1
|
||||||
- Token 生成和验证测试
|
- 加入协作项目后,参加项目数 +1
|
||||||
- 权限校验测试
|
- 统计卡片正确显示两个数值
|
||||||
- 角色授权测试
|
|
||||||
**Status**: ✅ Completed
|
**Status**: ✅ Completed
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Stage 3: 文件存储核心服务
|
## Stage 3: 参与项目视图和权限控制
|
||||||
**Goal**: 实现安全的文件系统存储管理服务
|
**Goal**: 实现参与项目单独视图,区分所有者和成员权限
|
||||||
**Success Criteria**:
|
**Success Criteria**:
|
||||||
- 路径安全校验机制完成
|
- /projects/share 页面单独展示我参与的项目(非所有者)
|
||||||
- 文件读写、目录树生成功能正常
|
- 项目卡片根据角色显示不同操作选项
|
||||||
- 文件上传、下载流式传输实现
|
- 成员不能删除项目(删除按钮不显示)
|
||||||
- UUID 文件夹映射机制正常
|
- 成员可以编辑项目内文档
|
||||||
**Tests**:
|
**Tests**:
|
||||||
- 路径注入攻击防御测试
|
- 参与者打开 /projects/share 只看到协作项目
|
||||||
- 文件读写性能测试
|
- 参与者看不到删除项目按钮
|
||||||
- 大文件上传测试
|
- 参与者可以正常编辑文档
|
||||||
- 目录树生成正确性测试
|
|
||||||
**Status**: ✅ Completed
|
**Status**: ✅ Completed
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Stage 4: 项目与文档管理 API
|
## Stage 4: 操作日志功能
|
||||||
**Goal**: 实现项目管理、文档 CRUD、协作成员管理的完整 API
|
**Goal**: 为文档编辑操作记录日志
|
||||||
**Success Criteria**:
|
**Success Criteria**:
|
||||||
- 项目创建/列表/详情接口完成
|
- 创建者编辑文档时记录到 operation_logs 表
|
||||||
- 文档 CRUD 接口完成
|
- 参与者编辑文档时记录到 operation_logs 表
|
||||||
- 成员邀请/权限管理接口完成
|
- 日志包含用户ID、操作类型、资源信息
|
||||||
- 图片/附件上传接口完成
|
|
||||||
**Tests**:
|
**Tests**:
|
||||||
- 项目 CRUD 接口测试
|
- 编辑文档后,operation_logs 表有新记录
|
||||||
- 文档操作接口测试
|
- 日志包含正确的用户ID和文档路径
|
||||||
- 成员权限验证测试
|
- 不同用户编辑生成不同日志
|
||||||
- 文件上传接口测试
|
|
||||||
**Status**: ✅ Completed
|
**Status**: ✅ Completed
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Stage 5: 前端整合与联调
|
## Stage 5: 项目编辑功能
|
||||||
**Goal**: 整合现有前端代码,适配新后端 API,实现完整业务流程
|
**Goal**: 在我的项目卡片上增加编辑功能
|
||||||
**Success Criteria**:
|
**Success Criteria**:
|
||||||
- 前端路由和布局整合完成
|
- 项目卡片显示编辑按钮(仅所有者)
|
||||||
- API 请求封装完成
|
- 点击编辑弹出表单
|
||||||
- 项目列表页面实现
|
- 可以修改项目名称、描述、公开性
|
||||||
- 文档编辑页面实现
|
|
||||||
- 用户登录注册页面实现
|
|
||||||
**Tests**:
|
**Tests**:
|
||||||
- 端到端业务流程测试
|
- 项目所有者看到编辑按钮
|
||||||
- 前后端联调测试
|
- 编辑项目信息成功保存
|
||||||
- 用户体验测试
|
- 非所有者不显示编辑按钮
|
||||||
**Status**: ✅ Completed
|
**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 全文检索
|
||||||
|
- 添加文档内容索引到数据库
|
||||||
|
- 实现增量索引更新
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
API v1 路由汇总
|
API v1 路由汇总
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter
|
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()
|
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(role_permissions.router, prefix="/role-permissions", tags=["角色权限管理"])
|
||||||
api_router.include_router(users.router, prefix="/users", tags=["用户管理"])
|
api_router.include_router(users.router, prefix="/users", tags=["用户管理"])
|
||||||
api_router.include_router(roles.router, prefix="/roles", 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=["系统日志"])
|
||||||
|
|
|
||||||
|
|
@ -15,13 +15,19 @@ from app.models.user import User
|
||||||
from app.models.role import Role, UserRole
|
from app.models.role import Role, UserRole
|
||||||
from app.schemas.user import UserCreate, UserLogin, UserResponse, Token, ChangePassword, UserUpdate
|
from app.schemas.user import UserCreate, UserLogin, UserResponse, Token, ChangePassword, UserUpdate
|
||||||
from app.schemas.response import success_response, error_response
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/register", response_model=dict)
|
@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))
|
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)
|
db.add(user_role)
|
||||||
await db.commit()
|
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(
|
return success_response(
|
||||||
data={"user_id": db_user.id, "username": db_user.username},
|
data={"user_id": db_user.id, "username": db_user.username},
|
||||||
message="注册成功"
|
message="注册成功"
|
||||||
|
|
@ -64,7 +81,11 @@ async def register(user_in: UserCreate, db: AsyncSession = Depends(get_db)):
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=dict)
|
@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))
|
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 到 Redis(24小时过期)
|
# 保存 token 到 Redis(24小时过期)
|
||||||
await TokenCache.save_token(user.id, access_token, expire_seconds=86400)
|
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
|
# 返回用户信息和 Token
|
||||||
user_data = UserResponse.from_orm(user)
|
user_data = UserResponse.from_orm(user)
|
||||||
token_data = Token(access_token=access_token, user=user_data)
|
token_data = Token(access_token=access_token, user=user_data)
|
||||||
|
|
@ -152,7 +184,8 @@ async def change_password(
|
||||||
@router.post("/logout", response_model=dict)
|
@router.post("/logout", response_model=dict)
|
||||||
async def logout(
|
async def logout(
|
||||||
request: Request,
|
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 中保存)
|
# 从请求状态中获取 token(已在 get_current_user 中保存)
|
||||||
|
|
@ -162,4 +195,15 @@ async def logout(
|
||||||
await TokenCache.delete_token(token)
|
await TokenCache.delete_token(token)
|
||||||
logger.info(f"User {current_user.username} logged out")
|
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="退出成功")
|
return success_response(message="退出成功")
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,15 @@ async def get_personal_stats(
|
||||||
)
|
)
|
||||||
personal_projects_count = personal_projects_count_result.scalar()
|
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 文件)
|
# 统计个人文档数(个人项目中的 .md 文件)
|
||||||
document_count = 0
|
document_count = 0
|
||||||
personal_projects_result = await db.execute(
|
personal_projects_result = await db.execute(
|
||||||
|
|
@ -169,6 +178,7 @@ async def get_personal_stats(
|
||||||
},
|
},
|
||||||
"stats": {
|
"stats": {
|
||||||
"personal_projects_count": personal_projects_count,
|
"personal_projects_count": personal_projects_count,
|
||||||
|
"shared_projects_count": shared_projects_count,
|
||||||
"document_count": document_count,
|
"document_count": document_count,
|
||||||
},
|
},
|
||||||
"recent_personal_projects": recent_personal_projects_data,
|
"recent_personal_projects": recent_personal_projects_data,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"""
|
"""
|
||||||
文件系统操作相关 API
|
文件系统操作相关 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 fastapi.responses import StreamingResponse, FileResponse
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
@ -10,12 +10,14 @@ import os
|
||||||
import zipfile
|
import zipfile
|
||||||
import io
|
import io
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.deps import get_current_user, get_user_from_token_or_query
|
from app.core.deps import get_current_user, get_user_from_token_or_query
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.project import Project, ProjectMember
|
from app.models.project import Project, ProjectMember
|
||||||
|
from app.models.log import OperationLog
|
||||||
from app.schemas.file import (
|
from app.schemas.file import (
|
||||||
FileTreeNode,
|
FileTreeNode,
|
||||||
FileSaveRequest,
|
FileSaveRequest,
|
||||||
|
|
@ -24,6 +26,8 @@ from app.schemas.file import (
|
||||||
)
|
)
|
||||||
from app.schemas.response import success_response
|
from app.schemas.response import success_response
|
||||||
from app.services.storage import storage_service
|
from app.services.storage import storage_service
|
||||||
|
from app.services.log_service import log_service
|
||||||
|
from app.core.enums import OperationType
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
@ -82,7 +86,21 @@ async def get_project_tree(
|
||||||
# 生成目录树
|
# 生成目录树
|
||||||
tree = storage_service.generate_tree(project_root)
|
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)
|
@router.get("/{project_id}/file", response_model=dict)
|
||||||
|
|
@ -108,6 +126,7 @@ async def get_file_content(
|
||||||
async def save_file(
|
async def save_file(
|
||||||
project_id: int,
|
project_id: int,
|
||||||
file_data: FileSaveRequest,
|
file_data: FileSaveRequest,
|
||||||
|
request: Request,
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
|
|
@ -120,6 +139,17 @@ async def save_file(
|
||||||
# 写入文件内容
|
# 写入文件内容
|
||||||
await storage_service.write_file(file_path, file_data.content)
|
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="文件保存成功")
|
return success_response(message="文件保存成功")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -127,6 +157,7 @@ async def save_file(
|
||||||
async def operate_file(
|
async def operate_file(
|
||||||
project_id: int,
|
project_id: int,
|
||||||
operation: FileOperateRequest,
|
operation: FileOperateRequest,
|
||||||
|
request: Request,
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
|
|
@ -139,6 +170,15 @@ async def operate_file(
|
||||||
if operation.action == "delete":
|
if operation.action == "delete":
|
||||||
# 删除文件或文件夹
|
# 删除文件或文件夹
|
||||||
await storage_service.delete_file(current_path)
|
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="删除成功")
|
return success_response(message="删除成功")
|
||||||
|
|
||||||
elif operation.action == "rename":
|
elif operation.action == "rename":
|
||||||
|
|
@ -147,6 +187,16 @@ async def operate_file(
|
||||||
raise HTTPException(status_code=400, detail="缺少新路径参数")
|
raise HTTPException(status_code=400, detail="缺少新路径参数")
|
||||||
new_path = storage_service.get_secure_path(project.storage_key, operation.new_path)
|
new_path = storage_service.get_secure_path(project.storage_key, operation.new_path)
|
||||||
await storage_service.rename_file(current_path, 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="重命名成功")
|
return success_response(message="重命名成功")
|
||||||
|
|
||||||
elif operation.action == "move":
|
elif operation.action == "move":
|
||||||
|
|
@ -155,17 +205,45 @@ async def operate_file(
|
||||||
raise HTTPException(status_code=400, detail="缺少目标路径参数")
|
raise HTTPException(status_code=400, detail="缺少目标路径参数")
|
||||||
new_path = storage_service.get_secure_path(project.storage_key, operation.new_path)
|
new_path = storage_service.get_secure_path(project.storage_key, operation.new_path)
|
||||||
await storage_service.rename_file(current_path, 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="移动成功")
|
return success_response(message="移动成功")
|
||||||
|
|
||||||
elif operation.action == "create_dir":
|
elif operation.action == "create_dir":
|
||||||
# 创建目录
|
# 创建目录
|
||||||
await storage_service.create_directory(current_path)
|
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="目录创建成功")
|
return success_response(message="目录创建成功")
|
||||||
|
|
||||||
elif operation.action == "create_file":
|
elif operation.action == "create_file":
|
||||||
# 创建文件
|
# 创建文件
|
||||||
content = operation.content or ""
|
content = operation.content or ""
|
||||||
await storage_service.write_file(current_path, content)
|
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="文件创建成功")
|
return success_response(message="文件创建成功")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
|
@ -177,6 +255,7 @@ async def upload_file(
|
||||||
project_id: int,
|
project_id: int,
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
subfolder: str = "images",
|
subfolder: str = "images",
|
||||||
|
request: Request = None,
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
|
|
@ -193,6 +272,20 @@ async def upload_file(
|
||||||
# 构建访问 URL
|
# 构建访问 URL
|
||||||
file_info["url"] = f"/api/v1/files/{project_id}/assets/{subfolder}/{file_info['filename']}"
|
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="文件上传成功")
|
return success_response(data=file_info, message="文件上传成功")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -236,6 +329,7 @@ async def import_documents(
|
||||||
project_id: int,
|
project_id: int,
|
||||||
files: List[UploadFile] = File(...),
|
files: List[UploadFile] = File(...),
|
||||||
target_path: str = "",
|
target_path: str = "",
|
||||||
|
request: Request = None,
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db)
|
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
|
relative_path = f"{target_path}/{file.filename}" if target_path else file.filename
|
||||||
imported_files.append(relative_path)
|
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(
|
return success_response(
|
||||||
data={"imported_files": imported_files},
|
data={"imported_files": imported_files},
|
||||||
message=f"成功导入 {len(imported_files)} 个文档"
|
message=f"成功导入 {len(imported_files)} 个文档"
|
||||||
|
|
@ -278,6 +386,7 @@ async def import_documents(
|
||||||
async def export_directory(
|
async def export_directory(
|
||||||
project_id: int,
|
project_id: int,
|
||||||
directory_path: str = "",
|
directory_path: str = "",
|
||||||
|
request: Request = None,
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
|
|
@ -292,6 +401,7 @@ async def export_directory(
|
||||||
|
|
||||||
# 创建ZIP文件在内存中
|
# 创建ZIP文件在内存中
|
||||||
zip_buffer = io.BytesIO()
|
zip_buffer = io.BytesIO()
|
||||||
|
file_count = 0
|
||||||
|
|
||||||
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
|
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)
|
arcname = file_path.relative_to(source_dir)
|
||||||
zip_file.write(file_path, arcname)
|
zip_file.write(file_path, arcname)
|
||||||
|
file_count += 1
|
||||||
|
|
||||||
# 重置buffer位置
|
# 重置buffer位置
|
||||||
zip_buffer.seek(0)
|
zip_buffer.seek(0)
|
||||||
|
|
@ -307,6 +418,20 @@ async def export_directory(
|
||||||
# 生成ZIP文件名
|
# 生成ZIP文件名
|
||||||
zip_filename = f"{project.name}_{directory_path.replace('/', '_') if directory_path else 'root'}.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(
|
return StreamingResponse(
|
||||||
zip_buffer,
|
zip_buffer,
|
||||||
media_type="application/zip",
|
media_type="application/zip",
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
})
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
"""
|
"""
|
||||||
项目预览相关 API(公开访问,支持分享)
|
项目预览相关 API(支持公开和私密项目)
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Header
|
from fastapi import APIRouter, Depends, HTTPException, Header
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
@ -7,19 +7,55 @@ from sqlalchemy import select
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from app.core.database import get_db
|
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.schemas.response import success_response
|
||||||
from app.services.storage import storage_service
|
from app.services.storage import storage_service
|
||||||
|
|
||||||
router = APIRouter()
|
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)
|
@router.get("/{project_id}/info", response_model=dict)
|
||||||
async def get_preview_info(
|
async def get_preview_info(
|
||||||
project_id: int,
|
project_id: int,
|
||||||
|
current_user: Optional[User] = Depends(get_current_user_optional),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""获取预览项目基本信息(公开访问)"""
|
"""获取预览项目基本信息"""
|
||||||
# 查询项目
|
# 查询项目
|
||||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||||
project = result.scalar_one_or_none()
|
project = result.scalar_one_or_none()
|
||||||
|
|
@ -27,11 +63,15 @@ async def get_preview_info(
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="项目不存在")
|
raise HTTPException(status_code=404, detail="项目不存在")
|
||||||
|
|
||||||
|
# 检查访问权限
|
||||||
|
await check_preview_access(project, current_user, db)
|
||||||
|
|
||||||
# 返回基本信息
|
# 返回基本信息
|
||||||
info = {
|
info = {
|
||||||
"id": project.id,
|
"id": project.id,
|
||||||
"name": project.name,
|
"name": project.name,
|
||||||
"description": project.description,
|
"description": project.description,
|
||||||
|
"is_public": project.is_public,
|
||||||
"has_password": bool(project.access_pass),
|
"has_password": bool(project.access_pass),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,6 +82,7 @@ async def get_preview_info(
|
||||||
async def verify_access_password(
|
async def verify_access_password(
|
||||||
project_id: int,
|
project_id: int,
|
||||||
password: str = Header(..., alias="X-Access-Password"),
|
password: str = Header(..., alias="X-Access-Password"),
|
||||||
|
current_user: Optional[User] = Depends(get_current_user_optional),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""验证访问密码"""
|
"""验证访问密码"""
|
||||||
|
|
@ -52,6 +93,9 @@ async def verify_access_password(
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="项目不存在")
|
raise HTTPException(status_code=404, detail="项目不存在")
|
||||||
|
|
||||||
|
# 检查访问权限
|
||||||
|
await check_preview_access(project, current_user, db)
|
||||||
|
|
||||||
# 验证密码
|
# 验证密码
|
||||||
if not project.access_pass:
|
if not project.access_pass:
|
||||||
return success_response(message="该项目无需密码访问")
|
return success_response(message="该项目无需密码访问")
|
||||||
|
|
@ -66,9 +110,10 @@ async def verify_access_password(
|
||||||
async def get_preview_tree(
|
async def get_preview_tree(
|
||||||
project_id: int,
|
project_id: int,
|
||||||
password: Optional[str] = Header(None, alias="X-Access-Password"),
|
password: Optional[str] = Header(None, alias="X-Access-Password"),
|
||||||
|
current_user: Optional[User] = Depends(get_current_user_optional),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""获取预览项目的文档树(公开访问,需验证密码)"""
|
"""获取预览项目的文档树"""
|
||||||
# 查询项目
|
# 查询项目
|
||||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||||
project = result.scalar_one_or_none()
|
project = result.scalar_one_or_none()
|
||||||
|
|
@ -76,6 +121,9 @@ async def get_preview_tree(
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="项目不存在")
|
raise HTTPException(status_code=404, detail="项目不存在")
|
||||||
|
|
||||||
|
# 检查访问权限
|
||||||
|
await check_preview_access(project, current_user, db)
|
||||||
|
|
||||||
# 如果设置了密码,需要验证
|
# 如果设置了密码,需要验证
|
||||||
if project.access_pass:
|
if project.access_pass:
|
||||||
if not password or project.access_pass != password:
|
if not password or project.access_pass != password:
|
||||||
|
|
@ -93,9 +141,10 @@ async def get_preview_file(
|
||||||
project_id: int,
|
project_id: int,
|
||||||
path: str,
|
path: str,
|
||||||
password: Optional[str] = Header(None, alias="X-Access-Password"),
|
password: Optional[str] = Header(None, alias="X-Access-Password"),
|
||||||
|
current_user: Optional[User] = Depends(get_current_user_optional),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""获取预览项目的文件内容(公开访问,需验证密码)"""
|
"""获取预览项目的文件内容"""
|
||||||
# 查询项目
|
# 查询项目
|
||||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||||
project = result.scalar_one_or_none()
|
project = result.scalar_one_or_none()
|
||||||
|
|
@ -103,6 +152,9 @@ async def get_preview_file(
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="项目不存在")
|
raise HTTPException(status_code=404, detail="项目不存在")
|
||||||
|
|
||||||
|
# 检查访问权限
|
||||||
|
await check_preview_access(project, current_user, db)
|
||||||
|
|
||||||
# 如果设置了密码,需要验证
|
# 如果设置了密码,需要验证
|
||||||
if project.access_pass:
|
if project.access_pass:
|
||||||
if not password or project.access_pass != password:
|
if not password or project.access_pass != password:
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"""
|
"""
|
||||||
项目管理相关 API
|
项目管理相关 API
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, or_
|
from sqlalchemy import select, or_
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
@ -10,7 +10,7 @@ import uuid
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.deps import get_current_user
|
from app.core.deps import get_current_user
|
||||||
from app.models.user import 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 (
|
from app.schemas.project import (
|
||||||
ProjectCreate,
|
ProjectCreate,
|
||||||
ProjectUpdate,
|
ProjectUpdate,
|
||||||
|
|
@ -23,6 +23,8 @@ from app.schemas.project import (
|
||||||
)
|
)
|
||||||
from app.schemas.response import success_response
|
from app.schemas.response import success_response
|
||||||
from app.services.storage import storage_service
|
from app.services.storage import storage_service
|
||||||
|
from app.services.log_service import log_service
|
||||||
|
from app.core.enums import OperationType, ResourceType
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
@ -58,9 +60,53 @@ async def get_my_projects(
|
||||||
return success_response(data=projects_data)
|
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)
|
@router.post("/", response_model=dict)
|
||||||
async def create_project(
|
async def create_project(
|
||||||
project_in: ProjectCreate,
|
project_in: ProjectCreate,
|
||||||
|
request: Request,
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
|
|
@ -94,11 +140,21 @@ async def create_project(
|
||||||
db_member = ProjectMember(
|
db_member = ProjectMember(
|
||||||
project_id=db_project.id,
|
project_id=db_project.id,
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
role=ProjectMemberRole.ADMIN,
|
role="admin",
|
||||||
)
|
)
|
||||||
db.add(db_member)
|
db.add(db_member)
|
||||||
await db.commit()
|
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)
|
project_data = ProjectResponse.from_orm(db_project)
|
||||||
return success_response(data=project_data.dict(), message="项目创建成功")
|
return success_response(data=project_data.dict(), message="项目创建成功")
|
||||||
|
|
||||||
|
|
@ -141,6 +197,7 @@ async def get_project(
|
||||||
async def update_project(
|
async def update_project(
|
||||||
project_id: int,
|
project_id: int,
|
||||||
project_in: ProjectUpdate,
|
project_in: ProjectUpdate,
|
||||||
|
request: Request,
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
|
|
@ -164,6 +221,16 @@ async def update_project(
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(project)
|
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)
|
project_data = ProjectResponse.from_orm(project)
|
||||||
return success_response(data=project_data.dict(), message="项目更新成功")
|
return success_response(data=project_data.dict(), message="项目更新成功")
|
||||||
|
|
||||||
|
|
@ -171,10 +238,11 @@ async def update_project(
|
||||||
@router.delete("/{project_id}", response_model=dict)
|
@router.delete("/{project_id}", response_model=dict)
|
||||||
async def delete_project(
|
async def delete_project(
|
||||||
project_id: int,
|
project_id: int,
|
||||||
|
request: Request,
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""删除项目(归档)"""
|
"""删除项目"""
|
||||||
# 查询项目
|
# 查询项目
|
||||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||||
project = result.scalar_one_or_none()
|
project = result.scalar_one_or_none()
|
||||||
|
|
@ -186,11 +254,52 @@ async def delete_project(
|
||||||
if project.owner_id != current_user.id:
|
if project.owner_id != current_user.id:
|
||||||
raise HTTPException(status_code=403, detail="无权删除该项目")
|
raise HTTPException(status_code=403, detail="无权删除该项目")
|
||||||
|
|
||||||
# 软删除(归档)
|
# 检查项目目录下是否有文件(排除_assets目录和隐藏文件)
|
||||||
project.status = 0
|
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()
|
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)
|
@router.get("/{project_id}/members", response_model=dict)
|
||||||
|
|
@ -219,13 +328,27 @@ async def get_project_members(
|
||||||
if not member:
|
if not member:
|
||||||
raise HTTPException(status_code=403, detail="无权访问该项目")
|
raise HTTPException(status_code=403, detail="无权访问该项目")
|
||||||
|
|
||||||
# 查询成员列表
|
# 查询成员列表并关联用户信息
|
||||||
members_result = await db.execute(
|
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)
|
return success_response(data=members_data)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -233,6 +356,7 @@ async def get_project_members(
|
||||||
async def add_project_member(
|
async def add_project_member(
|
||||||
project_id: int,
|
project_id: int,
|
||||||
member_in: ProjectMemberAdd,
|
member_in: ProjectMemberAdd,
|
||||||
|
request: Request,
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
|
|
@ -250,7 +374,7 @@ async def add_project_member(
|
||||||
select(ProjectMember).where(
|
select(ProjectMember).where(
|
||||||
ProjectMember.project_id == project_id,
|
ProjectMember.project_id == project_id,
|
||||||
ProjectMember.user_id == current_user.id,
|
ProjectMember.user_id == current_user.id,
|
||||||
ProjectMember.role == ProjectMemberRole.ADMIN
|
ProjectMember.role == "admin"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
member = member_result.scalar_one_or_none()
|
member = member_result.scalar_one_or_none()
|
||||||
|
|
@ -279,10 +403,83 @@ async def add_project_member(
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(db_member)
|
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)
|
member_data = ProjectMemberResponse.from_orm(db_member)
|
||||||
return success_response(data=member_data.dict(), message="成员添加成功")
|
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)
|
@router.get("/{project_id}/share", response_model=dict)
|
||||||
async def get_project_share_info(
|
async def get_project_share_info(
|
||||||
project_id: int,
|
project_id: int,
|
||||||
|
|
@ -297,17 +494,28 @@ async def get_project_share_info(
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="项目不存在")
|
raise HTTPException(status_code=404, detail="项目不存在")
|
||||||
|
|
||||||
# 只有项目所有者可以获取分享信息
|
# 检查是否是项目所有者或成员
|
||||||
if project.owner_id != current_user.id:
|
is_owner = project.owner_id == current_user.id
|
||||||
raise HTTPException(status_code=403, detail="只有项目所有者可以查看分享信息")
|
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_url = f"/preview/{project_id}"
|
||||||
|
|
||||||
|
# 只有项目所有者可以看到实际密码,成员只能知道是否设置了密码
|
||||||
share_info = ProjectShareInfo(
|
share_info = ProjectShareInfo(
|
||||||
share_url=share_url,
|
share_url=share_url,
|
||||||
has_password=bool(project.access_pass),
|
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())
|
return success_response(data=share_info.dict())
|
||||||
|
|
@ -317,6 +525,7 @@ async def get_project_share_info(
|
||||||
async def update_share_settings(
|
async def update_share_settings(
|
||||||
project_id: int,
|
project_id: int,
|
||||||
settings: ProjectShareSettings,
|
settings: ProjectShareSettings,
|
||||||
|
request: Request,
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
|
|
@ -336,5 +545,19 @@ async def update_share_settings(
|
||||||
project.access_pass = settings.access_pass
|
project.access_pass = settings.access_pass
|
||||||
await db.commit()
|
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 "访问密码已设置"
|
message = "访问密码已取消" if not settings.access_pass else "访问密码已设置"
|
||||||
return success_response(message=message)
|
return success_response(message=message)
|
||||||
|
|
|
||||||
|
|
@ -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)} 个结果")
|
||||||
|
|
@ -51,6 +51,7 @@ async def get_users(
|
||||||
page_size: int = Query(10, ge=1, le=100),
|
page_size: int = Query(10, ge=1, le=100),
|
||||||
keyword: Optional[str] = Query(None, description="搜索关键词(用户名、昵称、邮箱)"),
|
keyword: Optional[str] = Query(None, description="搜索关键词(用户名、昵称、邮箱)"),
|
||||||
status: Optional[int] = Query(None, description="状态筛选:0-禁用 1-启用"),
|
status: Optional[int] = Query(None, description="状态筛选:0-禁用 1-启用"),
|
||||||
|
role_id: Optional[int] = Query(None, description="角色ID筛选"),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
|
|
@ -69,19 +70,42 @@ async def get_users(
|
||||||
if status is not None:
|
if status is not None:
|
||||||
conditions.append(User.status == status)
|
conditions.append(User.status == status)
|
||||||
|
|
||||||
# 查询总数
|
# 如果需要按角色筛选
|
||||||
count_query = select(func.count(User.id))
|
if role_id is not None:
|
||||||
if conditions:
|
# 查询用户列表时需要JOIN UserRole表
|
||||||
count_query = count_query.where(*conditions)
|
query = (
|
||||||
total_result = await db.execute(count_query)
|
select(User)
|
||||||
total = total_result.scalar()
|
.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:
|
||||||
|
count_query = count_query.where(*conditions)
|
||||||
|
total_result = await db.execute(count_query)
|
||||||
|
total = total_result.scalar()
|
||||||
|
|
||||||
|
# 查询用户列表
|
||||||
|
query = select(User).order_by(User.created_at.desc())
|
||||||
|
if conditions:
|
||||||
|
query = query.where(*conditions)
|
||||||
|
|
||||||
# 查询用户列表
|
|
||||||
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)
|
result = await db.execute(query)
|
||||||
users = result.scalars().all()
|
users = result.scalars().all()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,57 @@ async def get_current_user(
|
||||||
return 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(
|
async def get_current_active_user(
|
||||||
current_user: User = Depends(get_current_user)
|
current_user: User = Depends(get_current_user)
|
||||||
) -> User:
|
) -> User:
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -47,10 +47,10 @@ class ProjectMember(Base):
|
||||||
project_id = Column(BigInteger, nullable=False, index=True, comment="项目ID")
|
project_id = Column(BigInteger, nullable=False, index=True, comment="项目ID")
|
||||||
user_id = Column(BigInteger, nullable=False, index=True, comment="用户ID")
|
user_id = Column(BigInteger, nullable=False, index=True, comment="用户ID")
|
||||||
role = Column(
|
role = Column(
|
||||||
Enum(ProjectMemberRole),
|
String(20),
|
||||||
default=ProjectMemberRole.VIEWER,
|
default="viewer",
|
||||||
index=True,
|
index=True,
|
||||||
comment="项目角色"
|
comment="项目角色: admin/editor/viewer"
|
||||||
)
|
)
|
||||||
invited_by = Column(BigInteger, comment="邀请人ID")
|
invited_by = Column(BigInteger, comment="邀请人ID")
|
||||||
joined_at = Column(DateTime, server_default=func.now(), comment="加入时间")
|
joined_at = Column(DateTime, server_default=func.now(), comment="加入时间")
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
"""
|
"""
|
||||||
项目相关的 Pydantic Schema
|
项目相关的 Pydantic Schema
|
||||||
"""
|
"""
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field, field_validator
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from app.models.project import ProjectMemberRole
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectBase(BaseModel):
|
class ProjectBase(BaseModel):
|
||||||
|
|
@ -47,12 +46,26 @@ class ProjectResponse(ProjectBase):
|
||||||
class ProjectMemberAdd(BaseModel):
|
class ProjectMemberAdd(BaseModel):
|
||||||
"""添加项目成员 Schema"""
|
"""添加项目成员 Schema"""
|
||||||
user_id: int = Field(..., description="用户ID")
|
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):
|
class ProjectMemberUpdate(BaseModel):
|
||||||
"""更新项目成员 Schema"""
|
"""更新项目成员 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):
|
class ProjectMemberResponse(BaseModel):
|
||||||
|
|
@ -60,7 +73,7 @@ class ProjectMemberResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
project_id: int
|
project_id: int
|
||||||
user_id: int
|
user_id: int
|
||||||
role: ProjectMemberRole
|
role: str
|
||||||
joined_at: datetime
|
joined_at: datetime
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -16,8 +16,12 @@ class StorageService:
|
||||||
"""文件存储服务类"""
|
"""文件存储服务类"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.projects_root = Path(settings.PROJECTS_PATH)
|
# 获取 backend 目录的绝对路径(app/services 的父目录的父目录)
|
||||||
self.temp_root = Path(settings.TEMP_PATH)
|
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)
|
self.projects_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
|
||||||
|
|
@ -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 { ConfigProvider } from 'antd'
|
||||||
import zhCN from 'antd/locale/zh_CN'
|
import zhCN from 'antd/locale/zh_CN'
|
||||||
import Login from '@/pages/Login/Login'
|
import Login from '@/pages/Login/Login'
|
||||||
|
|
@ -13,7 +13,9 @@ import ProfilePage from '@/pages/Profile/ProfilePage'
|
||||||
import Permissions from '@/pages/System/Permissions'
|
import Permissions from '@/pages/System/Permissions'
|
||||||
import Users from '@/pages/System/Users'
|
import Users from '@/pages/System/Users'
|
||||||
import Roles from '@/pages/System/Roles'
|
import Roles from '@/pages/System/Roles'
|
||||||
|
import SystemLogs from '@/pages/SystemLogs/SystemLogs'
|
||||||
import ProtectedRoute from '@/components/ProtectedRoute'
|
import ProtectedRoute from '@/components/ProtectedRoute'
|
||||||
|
import MainLayout from '@/components/MainLayout/MainLayout'
|
||||||
import '@/App.css'
|
import '@/App.css'
|
||||||
|
|
||||||
// 重定向到文档页面的组件
|
// 重定向到文档页面的组件
|
||||||
|
|
@ -22,6 +24,15 @@ function RedirectToDocs() {
|
||||||
return <Navigate to={`/projects/${projectId}/docs`} replace />
|
return <Navigate to={`/projects/${projectId}/docs`} replace />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 共享布局的包装组件
|
||||||
|
function LayoutWrapper() {
|
||||||
|
return (
|
||||||
|
<MainLayout>
|
||||||
|
<Outlet />
|
||||||
|
</MainLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<ConfigProvider locale={zhCN}>
|
<ConfigProvider locale={zhCN}>
|
||||||
|
|
@ -30,95 +41,25 @@ function App() {
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
{/* 项目预览(公开访问,无需登录) */}
|
{/* 项目预览(公开访问,无需登录) */}
|
||||||
<Route path="/preview/:projectId" element={<PreviewPage />} />
|
<Route path="/preview/:projectId" element={<PreviewPage />} />
|
||||||
<Route
|
|
||||||
path="/dashboard"
|
{/* 使用共享布局的路由 */}
|
||||||
element={
|
<Route element={<ProtectedRoute><LayoutWrapper /></ProtectedRoute>}>
|
||||||
<ProtectedRoute>
|
<Route path="/dashboard" element={<Dashboard />} />
|
||||||
<Dashboard />
|
<Route path="/desktop" element={<Desktop />} />
|
||||||
</ProtectedRoute>
|
<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
|
<Route path="/projects/:projectId/docs" element={<DocumentPage />} />
|
||||||
path="/desktop"
|
<Route path="/projects/:projectId/editor" element={<DocumentEditor />} />
|
||||||
element={
|
<Route path="/projects/:projectId/*" element={<RedirectToDocs />} />
|
||||||
<ProtectedRoute>
|
<Route path="/constructing" element={<Constructing />} />
|
||||||
<Desktop />
|
<Route path="/profile" element={<ProfilePage />} />
|
||||||
</ProtectedRoute>
|
<Route path="/system/permissions" element={<Permissions />} />
|
||||||
}
|
<Route path="/system/users" element={<Users />} />
|
||||||
/>
|
<Route path="/system/roles" element={<Roles />} />
|
||||||
<Route
|
<Route path="/system/logs" element={<SystemLogs />} />
|
||||||
path="/projects"
|
</Route>
|
||||||
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 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="/" element={<Navigate to="/projects" replace />} />
|
<Route path="/" element={<Navigate to="/projects" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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,
|
data,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除项目成员
|
||||||
|
*/
|
||||||
|
export function removeProjectMember(projectId, userId) {
|
||||||
|
return request({
|
||||||
|
url: `/projects/${projectId}/members/${userId}`,
|
||||||
|
method: 'delete',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
/**
|
||||||
|
* 文档搜索相关 API
|
||||||
|
*/
|
||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 搜索文档
|
||||||
|
*/
|
||||||
|
export function searchDocuments(keyword) {
|
||||||
|
return request({
|
||||||
|
url: '/search/documents',
|
||||||
|
method: 'get',
|
||||||
|
params: { keyword },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -76,13 +76,13 @@ function AppSider({ collapsed, onToggle }) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听路径变化和收拢状态,自动打开父菜单
|
// 监听菜单数据加载完成,初始化打开的父菜单
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!collapsed && menuData.length > 0) {
|
if (menuData.length > 0 && openKeys.length === 0) {
|
||||||
const defaultKeys = getDefaultOpenKeys()
|
const defaultKeys = getDefaultOpenKeys()
|
||||||
setOpenKeys(defaultKeys)
|
setOpenKeys(defaultKeys)
|
||||||
}
|
}
|
||||||
}, [location.pathname, collapsed, menuData])
|
}, [menuData])
|
||||||
|
|
||||||
const handleMenuClick = ({ key }) => {
|
const handleMenuClick = ({ key }) => {
|
||||||
// 查找对应的路径
|
// 查找对应的路径
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
import { Result, Button } from 'antd'
|
import { Result, Button } from 'antd'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { ToolOutlined } from '@ant-design/icons'
|
import { ToolOutlined } from '@ant-design/icons'
|
||||||
import MainLayout from '@/components/MainLayout/MainLayout'
|
|
||||||
|
|
||||||
function Constructing() {
|
function Constructing() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MainLayout>
|
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
|
@ -26,7 +25,7 @@ function Constructing() {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</MainLayout>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { useState, useEffect } from 'react'
|
||||||
import { Card, Row, Col, Statistic, Table, Spin } from 'antd'
|
import { Card, Row, Col, Statistic, Table, Spin } from 'antd'
|
||||||
import { UserOutlined, ProjectOutlined, FileTextOutlined } from '@ant-design/icons'
|
import { UserOutlined, ProjectOutlined, FileTextOutlined } from '@ant-design/icons'
|
||||||
import { getDashboardStats } from '@/api/dashboard'
|
import { getDashboardStats } from '@/api/dashboard'
|
||||||
import MainLayout from '@/components/MainLayout/MainLayout'
|
|
||||||
import Toast from '@/components/Toast/Toast'
|
import Toast from '@/components/Toast/Toast'
|
||||||
|
|
||||||
function Dashboard() {
|
function Dashboard() {
|
||||||
|
|
@ -80,16 +79,16 @@ function Dashboard() {
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<MainLayout>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '400px' }}>
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '400px' }}>
|
||||||
<Spin size="large" />
|
<Spin size="large" />
|
||||||
</div>
|
</div>
|
||||||
</MainLayout>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MainLayout>
|
|
||||||
<div style={{ padding: '24px' }}>
|
<div style={{ padding: '24px' }}>
|
||||||
<h1 style={{ marginBottom: '24px', fontSize: '24px', fontWeight: 600 }}>管理员仪表盘</h1>
|
<h1 style={{ marginBottom: '24px', fontSize: '24px', fontWeight: 600 }}>管理员仪表盘</h1>
|
||||||
|
|
||||||
|
|
@ -147,7 +146,7 @@ function Dashboard() {
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</MainLayout>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Card, Row, Col, Statistic, Table, Spin, Descriptions } from 'antd'
|
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 { getPersonalStats } from '@/api/dashboard'
|
||||||
import MainLayout from '@/components/MainLayout/MainLayout'
|
|
||||||
import Toast from '@/components/Toast/Toast'
|
import Toast from '@/components/Toast/Toast'
|
||||||
|
|
||||||
function Desktop() {
|
function Desktop() {
|
||||||
|
|
@ -10,6 +9,7 @@ function Desktop() {
|
||||||
const [userInfo, setUserInfo] = useState({})
|
const [userInfo, setUserInfo] = useState({})
|
||||||
const [stats, setStats] = useState({
|
const [stats, setStats] = useState({
|
||||||
personal_projects_count: 0,
|
personal_projects_count: 0,
|
||||||
|
shared_projects_count: 0,
|
||||||
document_count: 0,
|
document_count: 0,
|
||||||
})
|
})
|
||||||
const [recentPersonalProjects, setRecentPersonalProjects] = useState([])
|
const [recentPersonalProjects, setRecentPersonalProjects] = useState([])
|
||||||
|
|
@ -89,16 +89,16 @@ function Desktop() {
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<MainLayout>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '400px' }}>
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '400px' }}>
|
||||||
<Spin size="large" />
|
<Spin size="large" />
|
||||||
</div>
|
</div>
|
||||||
</MainLayout>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MainLayout>
|
|
||||||
<div style={{ padding: '24px' }}>
|
<div style={{ padding: '24px' }}>
|
||||||
<h1 style={{ marginBottom: '24px', color: '#333' }}>个人桌面</h1>
|
<h1 style={{ marginBottom: '24px', color: '#333' }}>个人桌面</h1>
|
||||||
|
|
||||||
|
|
@ -116,7 +116,7 @@ function Desktop() {
|
||||||
|
|
||||||
{/* 统计卡片 */}
|
{/* 统计卡片 */}
|
||||||
<Row gutter={16} style={{ marginBottom: '24px' }}>
|
<Row gutter={16} style={{ marginBottom: '24px' }}>
|
||||||
<Col span={12}>
|
<Col span={8}>
|
||||||
<Card>
|
<Card>
|
||||||
<Statistic
|
<Statistic
|
||||||
title="个人项目数"
|
title="个人项目数"
|
||||||
|
|
@ -126,7 +126,17 @@ function Desktop() {
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</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>
|
<Card>
|
||||||
<Statistic
|
<Statistic
|
||||||
title="文档总数"
|
title="文档总数"
|
||||||
|
|
@ -160,7 +170,7 @@ function Desktop() {
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</MainLayout>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,6 @@ import {
|
||||||
importDocuments,
|
importDocuments,
|
||||||
exportDirectory,
|
exportDirectory,
|
||||||
} from '@/api/file'
|
} from '@/api/file'
|
||||||
import MainLayout from '@/components/MainLayout/MainLayout'
|
|
||||||
import './DocumentEditor.css'
|
import './DocumentEditor.css'
|
||||||
|
|
||||||
const { Sider, Content } = Layout
|
const { Sider, Content } = Layout
|
||||||
|
|
@ -80,7 +79,9 @@ function DocumentEditor() {
|
||||||
const fetchTree = async () => {
|
const fetchTree = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await getProjectTree(projectId)
|
const res = await getProjectTree(projectId)
|
||||||
setTreeData(res.data || [])
|
const data = res.data || {}
|
||||||
|
const tree = data.tree || data || [] // 兼容新旧格式
|
||||||
|
setTreeData(tree)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fetch tree error:', error)
|
console.error('Fetch tree error:', error)
|
||||||
}
|
}
|
||||||
|
|
@ -141,13 +142,6 @@ function DocumentEditor() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保护README.md
|
|
||||||
const fileName = path.split('/').pop()
|
|
||||||
if (fileName === 'README.md' && path.indexOf('/') === -1) {
|
|
||||||
message.error('根目录的README.md不允许删除')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: '确认删除',
|
title: '确认删除',
|
||||||
content: `确定要删除 ${path} 吗?`,
|
content: `确定要删除 ${path} 吗?`,
|
||||||
|
|
@ -603,8 +597,7 @@ function DocumentEditor() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MainLayout>
|
<div className="document-editor-page">
|
||||||
<div className="document-editor-page">
|
|
||||||
<Layout className="document-editor-container">
|
<Layout className="document-editor-container">
|
||||||
<Sider
|
<Sider
|
||||||
width={280}
|
width={280}
|
||||||
|
|
@ -726,7 +719,6 @@ function DocumentEditor() {
|
||||||
</div>
|
</div>
|
||||||
</Content>
|
</Content>
|
||||||
</Layout>
|
</Layout>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title={
|
title={
|
||||||
|
|
@ -774,7 +766,7 @@ function DocumentEditor() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
</MainLayout>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import rehypeHighlight from 'rehype-highlight'
|
||||||
import 'highlight.js/styles/github.css'
|
import 'highlight.js/styles/github.css'
|
||||||
import { getProjectTree, getFileContent } from '@/api/file'
|
import { getProjectTree, getFileContent } from '@/api/file'
|
||||||
import { getProjectShareInfo, updateShareSettings } from '@/api/share'
|
import { getProjectShareInfo, updateShareSettings } from '@/api/share'
|
||||||
import MainLayout from '@/components/MainLayout/MainLayout'
|
|
||||||
import './DocumentPage.css'
|
import './DocumentPage.css'
|
||||||
|
|
||||||
const { Sider, Content } = Layout
|
const { Sider, Content } = Layout
|
||||||
|
|
@ -29,6 +28,7 @@ function DocumentPage() {
|
||||||
const [shareInfo, setShareInfo] = useState(null)
|
const [shareInfo, setShareInfo] = useState(null)
|
||||||
const [hasPassword, setHasPassword] = useState(false)
|
const [hasPassword, setHasPassword] = useState(false)
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
|
const [userRole, setUserRole] = useState('viewer') // 用户角色:owner/admin/editor/viewer
|
||||||
const contentRef = useRef(null)
|
const contentRef = useRef(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -39,8 +39,12 @@ function DocumentPage() {
|
||||||
const loadFileTree = async () => {
|
const loadFileTree = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await getProjectTree(projectId)
|
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)
|
setFileTree(tree)
|
||||||
|
setUserRole(role)
|
||||||
|
|
||||||
// 默认打开 README.md
|
// 默认打开 README.md
|
||||||
const readmeNode = findReadme(tree)
|
const readmeNode = findReadme(tree)
|
||||||
|
|
@ -280,22 +284,24 @@ function DocumentPage() {
|
||||||
const menuItems = convertTreeToMenuItems(fileTree)
|
const menuItems = convertTreeToMenuItems(fileTree)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MainLayout>
|
<div className="project-docs-page">
|
||||||
<div className="project-docs-page">
|
<Layout className="docs-layout">
|
||||||
<Layout className="docs-layout">
|
|
||||||
{/* 左侧目录 */}
|
{/* 左侧目录 */}
|
||||||
<Sider width={280} className="docs-sider" theme="light">
|
<Sider width={280} className="docs-sider" theme="light">
|
||||||
<div className="docs-sider-header">
|
<div className="docs-sider-header">
|
||||||
<h2>项目文档</h2>
|
<h2>项目文档</h2>
|
||||||
<div className="docs-sider-actions">
|
<div className="docs-sider-actions">
|
||||||
<Tooltip title="编辑模式">
|
{/* 只有 owner/admin/editor 可以编辑 */}
|
||||||
<Button
|
{userRole !== 'viewer' && (
|
||||||
type="link"
|
<Tooltip title="编辑模式">
|
||||||
size="middle"
|
<Button
|
||||||
icon={<EditOutlined />}
|
type="link"
|
||||||
onClick={handleEdit}
|
size="middle"
|
||||||
/>
|
icon={<EditOutlined />}
|
||||||
</Tooltip>
|
onClick={handleEdit}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
<Tooltip title="分享">
|
<Tooltip title="分享">
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -468,7 +474,6 @@ function DocumentPage() {
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
</MainLayout>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import { Card, Tabs, Form, Input, Button, Avatar, Upload, message } from 'antd'
|
||||||
import { UserOutlined, LockOutlined, UploadOutlined } from '@ant-design/icons'
|
import { UserOutlined, LockOutlined, UploadOutlined } from '@ant-design/icons'
|
||||||
import { getCurrentUser, updateProfile, changePassword } from '@/api/auth'
|
import { getCurrentUser, updateProfile, changePassword } from '@/api/auth'
|
||||||
import useUserStore from '@/stores/userStore'
|
import useUserStore from '@/stores/userStore'
|
||||||
import MainLayout from '@/components/MainLayout/MainLayout'
|
|
||||||
import Toast from '@/components/Toast/Toast'
|
import Toast from '@/components/Toast/Toast'
|
||||||
import './ProfilePage.css'
|
import './ProfilePage.css'
|
||||||
|
|
||||||
|
|
@ -228,14 +227,14 @@ function ProfilePage() {
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MainLayout>
|
|
||||||
<div className="profile-page">
|
<div className="profile-page">
|
||||||
<Card className="profile-card">
|
<Card className="profile-card">
|
||||||
<h2 className="profile-title">个人中心</h2>
|
<h2 className="profile-title">个人中心</h2>
|
||||||
<Tabs items={tabItems} defaultActiveKey="profile" />
|
<Tabs items={tabItems} defaultActiveKey="profile" />
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</MainLayout>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@
|
||||||
text-align: center;
|
text-align: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.3s;
|
transition: all 0.3s;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-card:hover {
|
.project-card:hover {
|
||||||
|
|
@ -26,6 +28,20 @@
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
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 {
|
.project-card-icon {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,53 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Card, Empty, Modal, Form, Input, Row, Col, Space, Button, Switch, message } from 'antd'
|
import { Card, Empty, Modal, Form, Input, Row, Col, Space, Button, Switch, message, Select, Table, Tag } from 'antd'
|
||||||
import { PlusOutlined, FolderOutlined, TeamOutlined, EyeOutlined, ShareAltOutlined, CopyOutlined } from '@ant-design/icons'
|
import { PlusOutlined, FolderOutlined, TeamOutlined, EyeOutlined, ShareAltOutlined, CopyOutlined, DeleteOutlined, EditOutlined, FileOutlined } from '@ant-design/icons'
|
||||||
import { getMyProjects, createProject, deleteProject } from '@/api/project'
|
import { getMyProjects, getOwnedProjects, getSharedProjects, createProject, deleteProject, updateProject, getProjectMembers, addProjectMember, removeProjectMember } from '@/api/project'
|
||||||
import { getProjectShareInfo, updateShareSettings } from '@/api/share'
|
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 ListActionBar from '@/components/ListActionBar/ListActionBar'
|
||||||
import Toast from '@/components/Toast/Toast'
|
import Toast from '@/components/Toast/Toast'
|
||||||
import './ProjectList.css'
|
import './ProjectList.css'
|
||||||
|
|
||||||
function ProjectList() {
|
function ProjectList({ type = 'my' }) {
|
||||||
const [projects, setProjects] = useState([])
|
const [projects, setProjects] = useState([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [modalVisible, setModalVisible] = useState(false)
|
const [modalVisible, setModalVisible] = useState(false)
|
||||||
|
const [editModalVisible, setEditModalVisible] = useState(false)
|
||||||
const [shareModalVisible, setShareModalVisible] = useState(false)
|
const [shareModalVisible, setShareModalVisible] = useState(false)
|
||||||
|
const [membersModalVisible, setMembersModalVisible] = useState(false)
|
||||||
const [currentProject, setCurrentProject] = useState(null)
|
const [currentProject, setCurrentProject] = useState(null)
|
||||||
const [shareInfo, setShareInfo] = useState(null)
|
const [shareInfo, setShareInfo] = useState(null)
|
||||||
const [hasPassword, setHasPassword] = useState(false)
|
const [hasPassword, setHasPassword] = useState(false)
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [searchKeyword, setSearchKeyword] = 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 [form] = Form.useForm()
|
||||||
|
const [editForm] = Form.useForm()
|
||||||
|
const [memberForm] = Form.useForm()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchProjects()
|
fetchProjects()
|
||||||
}, [])
|
}, [type])
|
||||||
|
|
||||||
const fetchProjects = async () => {
|
const fetchProjects = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
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 || [])
|
setProjects(res.data || [])
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fetch projects error:', error)
|
console.error('Fetch projects error:', error)
|
||||||
|
|
@ -53,19 +71,53 @@ function ProjectList() {
|
||||||
const handleDeleteProject = async (projectId) => {
|
const handleDeleteProject = async (projectId) => {
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: '确认删除',
|
title: '确认删除',
|
||||||
content: '确定要删除这个项目吗?删除后可以在归档中找到',
|
content: '确定要删除这个项目吗?如果项目中存在文件,将无法删除。删除后将无法恢复!',
|
||||||
|
okText: '确定删除',
|
||||||
|
okType: 'danger',
|
||||||
|
cancelText: '取消',
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
try {
|
try {
|
||||||
await deleteProject(projectId)
|
await deleteProject(projectId)
|
||||||
Toast.success('归档成功', '项目已归档')
|
Toast.success('删除成功', '项目已删除')
|
||||||
fetchProjects()
|
fetchProjects()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Delete project error:', 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) => {
|
const handleOpenProject = (projectId) => {
|
||||||
navigate(`/projects/${projectId}/docs`)
|
navigate(`/projects/${projectId}/docs`)
|
||||||
}
|
}
|
||||||
|
|
@ -134,16 +186,136 @@ function ProjectList() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 过滤项目
|
// 打开成员管理
|
||||||
const filteredProjects = projects.filter((project) =>
|
const handleMembers = async (e, project) => {
|
||||||
project.name.toLowerCase().includes(searchKeyword.toLowerCase())
|
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 (
|
return (
|
||||||
<MainLayout>
|
<div className="project-list-container">
|
||||||
<div className="project-list-container">
|
|
||||||
<ListActionBar
|
<ListActionBar
|
||||||
actions={[
|
actions={type === 'my' ? [
|
||||||
{
|
{
|
||||||
key: 'create',
|
key: 'create',
|
||||||
label: '创建项目',
|
label: '创建项目',
|
||||||
|
|
@ -151,30 +323,88 @@ function ProjectList() {
|
||||||
icon: <PlusOutlined />,
|
icon: <PlusOutlined />,
|
||||||
onClick: () => setModalVisible(true),
|
onClick: () => setModalVisible(true),
|
||||||
},
|
},
|
||||||
]}
|
] : []}
|
||||||
search={{
|
search={{
|
||||||
placeholder: '搜索项目...',
|
placeholder: '搜索项目或文件...',
|
||||||
value: searchKeyword,
|
value: searchKeyword,
|
||||||
onChange: setSearchKeyword,
|
onChange: handleSearchChange,
|
||||||
onSearch: (value) => setSearchKeyword(value),
|
onSearch: handleSearch,
|
||||||
}}
|
}}
|
||||||
showRefresh
|
showRefresh
|
||||||
onRefresh={fetchProjects}
|
onRefresh={fetchProjects}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
|
{/* 搜索结果 */}
|
||||||
|
{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) => (
|
{filteredProjects.map((project) => (
|
||||||
<Col xs={24} sm={12} md={8} lg={6} key={project.id}>
|
<Col xs={24} sm={12} md={8} lg={6} key={project.id}>
|
||||||
<Card
|
<Card
|
||||||
hoverable
|
hoverable
|
||||||
className="project-card"
|
className="project-card"
|
||||||
onClick={() => handleOpenProject(project.id)}
|
onClick={() => handleOpenProject(project.id)}
|
||||||
actions={[
|
actions={type === 'my' ? [
|
||||||
|
<EditOutlined key="edit" onClick={(e) => handleEdit(e, project)} />,
|
||||||
<ShareAltOutlined key="share" onClick={(e) => handleShare(e, project)} />,
|
<ShareAltOutlined key="share" onClick={(e) => handleShare(e, project)} />,
|
||||||
|
<TeamOutlined key="members" onClick={(e) => handleMembers(e, project)} />,
|
||||||
|
] : [
|
||||||
<EyeOutlined key="view" />,
|
<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">
|
<div className="project-card-icon">
|
||||||
<FolderOutlined style={{ fontSize: 48, color: '#1890ff' }} />
|
<FolderOutlined style={{ fontSize: 48, color: '#1890ff' }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -182,17 +412,35 @@ function ProjectList() {
|
||||||
<p className="project-description">{project.description || '暂无描述'}</p>
|
<p className="project-description">{project.description || '暂无描述'}</p>
|
||||||
<div className="project-meta">
|
<div className="project-meta">
|
||||||
<span>访问: {project.visit_count}</span>
|
<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>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{projects.length === 0 && !loading && (
|
{filteredProjects.length === 0 && !loading && (
|
||||||
<Col span={24}>
|
<Col span={24}>
|
||||||
<Empty description="还没有项目,创建一个开始吧" />
|
<Empty description={type === 'my' ? "还没有项目,创建一个开始吧" : "还没有参与的项目"} />
|
||||||
</Col>
|
</Col>
|
||||||
)}
|
)}
|
||||||
</Row>
|
</Row>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 搜索无结果提示 */}
|
||||||
|
{hasSearched && !searching && searchResults.length === 0 && (
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<Empty description={`没有找到包含 "${searchKeyword}" 的项目或文件`} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title="创建新项目"
|
title="创建新项目"
|
||||||
|
|
@ -242,6 +490,75 @@ function ProjectList() {
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</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
|
<Modal
|
||||||
title="分享项目"
|
title="分享项目"
|
||||||
open={shareModalVisible}
|
open={shareModalVisible}
|
||||||
|
|
@ -262,34 +579,181 @@ function ProjectList() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{/* 只有在我的项目中才显示密码设置功能 */}
|
||||||
<Space>
|
{type === 'my' && (
|
||||||
<span style={{ fontWeight: 500 }}>访问密码保护</span>
|
<>
|
||||||
<Switch checked={hasPassword} onChange={handlePasswordToggle} />
|
<div>
|
||||||
</Space>
|
<Space>
|
||||||
</div>
|
<span style={{ fontWeight: 500 }}>访问密码保护</span>
|
||||||
|
<Switch checked={hasPassword} onChange={handlePasswordToggle} />
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
{hasPassword && (
|
{hasPassword && (
|
||||||
<div>
|
<div>
|
||||||
<Input.Password
|
<Input.Password
|
||||||
placeholder="请输入访问密码"
|
placeholder="请输入访问密码"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
onClick={handleSavePassword}
|
onClick={handleSavePassword}
|
||||||
style={{ marginTop: 8 }}
|
style={{ marginTop: 8 }}
|
||||||
>
|
>
|
||||||
保存密码
|
保存密码
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 参与项目显示提示 */}
|
||||||
|
{type === 'share' && shareInfo.has_password && (
|
||||||
|
<div style={{ color: '#8c8c8c', fontSize: 12 }}>
|
||||||
|
该项目已设置访问密码保护
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</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>
|
</div>
|
||||||
</MainLayout>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import {
|
||||||
getRolePermissions,
|
getRolePermissions,
|
||||||
updateRolePermissions,
|
updateRolePermissions,
|
||||||
} from '@/api/rolePermissions'
|
} from '@/api/rolePermissions'
|
||||||
import MainLayout from '@/components/MainLayout/MainLayout'
|
|
||||||
import ListTable from '@/components/ListTable/ListTable'
|
import ListTable from '@/components/ListTable/ListTable'
|
||||||
import Toast from '@/components/Toast/Toast'
|
import Toast from '@/components/Toast/Toast'
|
||||||
|
|
||||||
|
|
@ -153,7 +152,7 @@ function Permissions() {
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MainLayout>
|
|
||||||
<div style={{ padding: '24px' }}>
|
<div style={{ padding: '24px' }}>
|
||||||
<h1 style={{ marginBottom: '24px', fontSize: '24px', fontWeight: 600 }}>
|
<h1 style={{ marginBottom: '24px', fontSize: '24px', fontWeight: 600 }}>
|
||||||
<SafetyOutlined style={{ marginRight: '8px' }} />
|
<SafetyOutlined style={{ marginRight: '8px' }} />
|
||||||
|
|
@ -229,7 +228,7 @@ function Permissions() {
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
</div>
|
</div>
|
||||||
</MainLayout>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,6 @@ import {
|
||||||
deleteRole,
|
deleteRole,
|
||||||
getRoleUsers,
|
getRoleUsers,
|
||||||
} from '@/api/roles'
|
} from '@/api/roles'
|
||||||
import MainLayout from '@/components/MainLayout/MainLayout'
|
|
||||||
import ListTable from '@/components/ListTable/ListTable'
|
import ListTable from '@/components/ListTable/ListTable'
|
||||||
import Toast from '@/components/Toast/Toast'
|
import Toast from '@/components/Toast/Toast'
|
||||||
|
|
||||||
|
|
@ -314,7 +313,7 @@ function Roles() {
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MainLayout>
|
|
||||||
<div style={{ padding: '24px' }}>
|
<div style={{ padding: '24px' }}>
|
||||||
<h1 style={{ marginBottom: '24px', fontSize: '24px', fontWeight: 600 }}>角色管理</h1>
|
<h1 style={{ marginBottom: '24px', fontSize: '24px', fontWeight: 600 }}>角色管理</h1>
|
||||||
|
|
||||||
|
|
@ -485,7 +484,7 @@ function Roles() {
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
</MainLayout>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,6 @@ import {
|
||||||
resetUserPassword,
|
resetUserPassword,
|
||||||
} from '@/api/users'
|
} from '@/api/users'
|
||||||
import { getAllRoles } from '@/api/rolePermissions'
|
import { getAllRoles } from '@/api/rolePermissions'
|
||||||
import MainLayout from '@/components/MainLayout/MainLayout'
|
|
||||||
import ListTable from '@/components/ListTable/ListTable'
|
import ListTable from '@/components/ListTable/ListTable'
|
||||||
import Toast from '@/components/Toast/Toast'
|
import Toast from '@/components/Toast/Toast'
|
||||||
|
|
||||||
|
|
@ -299,7 +298,7 @@ function Users() {
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MainLayout>
|
|
||||||
<div style={{ padding: '24px' }}>
|
<div style={{ padding: '24px' }}>
|
||||||
<h1 style={{ marginBottom: '24px', fontSize: '24px', fontWeight: 600 }}>用户管理</h1>
|
<h1 style={{ marginBottom: '24px', fontSize: '24px', fontWeight: 600 }}>用户管理</h1>
|
||||||
|
|
||||||
|
|
@ -459,7 +458,7 @@ function Users() {
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
</MainLayout>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
.system-logs-container {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-logs-container h2 {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
1143
frontend/yarn.lock
1143
frontend/yarn.lock
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue