Compare commits

...
14 Commits
Author SHA1 Message Date
xsl a68094ff62 [backend] docs: 更新README,注释init_db用于测试环境 2026-01-26 13:48:50 +08:00
xsl 83e05bf85f [backend] fix: 修复异常处理和类型转换问题 2026-01-26 11:53:40 +08:00
xsl 7ccc2a6ac6 [backend] fix: 修复main.py导入路径问题 2026-01-26 10:23:42 +08:00
xsl 3d61e000ac [backend] fix: 修复projects.py和conftest.py中的类型和导入问题 2026-01-26 10:22:30 +08:00
xsl 64b08c4e67 [backend] fix: 修复认证相关的测试
- 修改LoginRequest schema,使字段为可选
- 添加自定义异常处理器以返回正确的JSON格式
- 修改认证中间件,返回正确的错误码和响应格式
- 所有auth测试(10个)全部通过

测试结果:10 passed
2026-01-26 10:13:09 +08:00
xsl e7906d57ee [backend] feat: 实现Tasks 4-10的完整代码
- 创建Pydantic Schema模型
- 创建认证中间件和依赖注入
- 创建认证路由
- 创建用户管理路由
- 创建项目管理路由(包含统计功能)
- 创建FastAPI主应用
- 创建环境配置文件
- 修复models的Enum导入问题
- 修复模块导入路径问题

注意:测试仍有部分失败,需要调整错误处理逻辑
2026-01-26 10:08:14 +08:00
xsl c9cd6b9117 fix: 修复AdjustmentEnum枚举值为英文 2026-01-26 09:53:52 +08:00
xsl 58a1d50261 修复数据库模型外键约束和代码质量问题
- 为User.created_by添加ForeignKey约束,确保数据完整性
- 修复AdjustmentEnum枚举命名(yes/no -> YES/NO)
- 统一User模型的relationship配置,使用back_populates替代backref
- 移除User.created_projects的级联删除配置,保持与Project模型一致
2026-01-26 09:51:13 +08:00
xsl e17b29ca41 fix: 修复Task 3数据库模型问题
- 修复导入路径:从绝对导入改为相对导入
- 添加外键约束:Project.created_by添加ForeignKey,使用ON DELETE RESTRICT
- 添加索引:为常用查询字段添加index=True(username, email, department, role, project_no, engineering_type等)
- 优化Enum使用:创建UserRoleEnum和AdjustmentEnum类替代字符串枚举
- 添加关系映射:User.created_projects和Project.creator建立双向关系
- 添加User.created_by字段:保持与Project表一致性
2026-01-26 09:47:50 +08:00
xsl 5bfe260aa0 [backend] feat: 添加用户和项目数据模型 2026-01-26 09:44:21 +08:00
xsl 12ec678776 [backend] feat: 添加密码和JWT工具函数 2026-01-26 09:39:51 +08:00
xsl 0567a03958 [backend] fix: 修复配置文件安全问题
- 将DEBUG默认值从True改为False
- 将DB_PASSWORD默认值从弱密码改为None,添加验证逻辑
- 将SECRET_KEY默认值改为None,添加验证逻辑
- 为Settings类和主要函数添加docstring注释
- 添加敏感配置验证逻辑,强制要求通过环境变量配置
- 创建.env文件包含正确的配置值
- 为get_db和init_db函数添加docstring

修复了代码质量审查发现的高危和中危安全问题
2026-01-26 09:37:32 +08:00
xsl 827549fa0d [backend] feat: 添加项目基础配置 2026-01-26 08:09:35 +08:00
xsl e4b59c5ee4 save code 2026-01-26 08:04:53 +08:00
29742 changed files with 2603125 additions and 1092 deletions
BIN
View File
Binary file not shown.
+20
View File
@@ -0,0 +1,20 @@
# 应用配置
APP_NAME=海洋项目管理系统
APP_VERSION=1.0.0
DEBUG=True
# 数据库配置
DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASSWORD=rootpassword
DB_NAME=project_manager
DB_CHARSET=utf8mb4
# 安全配置
SECRET_KEY=your-secret-key-change-this-in-production
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=1440
# CORS配置
CORS_ORIGINS=["http://localhost:3000","http://127.0.0.1:3000"]
+20
View File
@@ -0,0 +1,20 @@
# 应用配置
APP_NAME=海洋项目管理系统
APP_VERSION=1.0.0
DEBUG=True
# 数据库配置
DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASSWORD=rootpassword
DB_NAME=project_manager
DB_CHARSET=utf8mb4
# 安全配置
SECRET_KEY=your-secret-key-change-this-in-production
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=1440
# CORS配置
CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
+179 -24
View File
@@ -1,37 +1,192 @@
# 后端开发目录 # 海洋项目管理系统 - 后端
## 目录说明 基于FastAPI的后端API服务,提供用户管理、项目管理和数据统计功能。
### src/ ## 快速开始
源代码目录,包含所有后端业务逻辑代码。
#### controllers/ ### 1. 安装依赖
控制器层,处理HTTP请求和响应。
#### services/ ```bash
服务层,实现业务逻辑。 cd backend
pip install -r requirements.txt -r requirements-test.txt
```
#### models/ ### 2. 配置环境变量
数据模型,定义数据库表结构。
#### routes/ 创建 `.env` 文件(已包含配置):
路由定义,配置API端点。
#### middleware/ ```bash
中间件,处理认证、日志等横切关注点。 # 应用配置
APP_NAME=海洋项目管理系统
APP_VERSION=1.0.0
DEBUG=True
#### utils/ # 数据库配置(生产环境需要配置)
工具函数,提供通用功能。 DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASSWORD=
DB_NAME=project_manager
DB_CHARSET=utf8mb4
### tests/ # JWT配置
测试目录,包含单元测试和集成测试。 SECRET_KEY=ocean-project-manager-secret-key-2026
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=1440
### docs/ # CORS配置
文档目录,包含API文档和技术文档。 CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,http://localhost:5173,http://127.0.0.1:5173
```
### config/ ### 3. 启动应用
配置目录,包含数据库配置、环境变量等。
## 工作规范 ```bash
cd backend
python main.py
```
详见 [WORKSTANDARDS.md](WORKSTANDARDS.md) 应用将在 http://0.0.0.0:5000 启动
### 4. 访问API文档
Swagger UI: http://localhost:5000/docs
ReDoc: http://localhost:5000/redoc
## 运行测试
### 运行所有测试
```bash
cd backend
pytest tests/ -v
```
### 生成覆盖率报告
```bash
pytest tests/ --cov=src --cov-report=html
```
覆盖率报告保存在 `htmlcov/index.html`
## API文档
详细的API文档请参考: [../../docs/api.md](../../docs/api.md)
## API基础信息
### Base URL
```
http://localhost:5000/api/v1
```
### 统一响应格式
```json
{
"success": true,
"message": "操作成功",
"data": {},
"error_code": null
}
```
### 主要API端点
#### 认证
- `POST /api/v1/auth/login` - 用户登录
- `GET /api/v1/auth/me` - 获取当前用户信息
- `POST /api/v1/auth/logout` - 用户登出
#### 用户管理
- `GET /api/v1/users` - 获取用户列表
- `POST /api/v1/users` - 创建用户
- `GET /api/v1/users/{id}` - 获取用户详情
- `PUT /api/v1/users/{id}` - 更新用户
- `DELETE /api/v1/users/{id}` - 删除用户
- `POST /api/v1/users/{id}/reset-password` - 重置用户密码
#### 项目管理
- `GET /api/v1/projects` - 获取项目列表(支持筛选、排序、分页)
- `GET /api/v1/projects/{id}` - 获取项目详情
- `POST /api/v1/projects` - 创建项目
- `PUT /api/v1/projects/{id}` - 更新项目
- `DELETE /api/v1/projects/{id}` - 删除项目
#### 统计功能
- `GET /api/v1/projects/statistics` - 基础统计
- `GET /api/v1/projects/statistics/group` - 分组统计
- `GET /api/v1/projects/statistics/timeline` - 时间维度统计
## 数据库初始化
### 生产环境MySQL
1. 确保MySQL已安装并运行
2. 创建数据库:
```bash
mysql -u root -p -e "CREATE DATABASE IF NOT EXISTS project_manager DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;"
```
3. 执行初始化脚本:
```bash
mysql -u root -p project_manager < config/init-database.sql
```
### 测试环境
测试使用SQLite内存数据库,不需要MySQL配置。
## 开发规范
详见: [WORKSTANDARDS.md](WORKSTANDARDS.md)
## 常见问题
### 1. 端口被占用
如果5000端口被占用,修改启动命令:
```bash
uvicorn main:app --host 0.0.0.0 --port 8000
```
### 2. MySQL连接失败
- 检查MySQL是否运行:`sudo systemctl status mysql`
- 检查.env中的DB_PASSWORD是否正确
- 检查MySQL用户权限
### 3. 测试失败
- 清理测试缓存:`rm -rf __pycache__ .pytest_cache`
- 重新运行测试:`pytest tests/ -v`
## 测试状态
当前测试状态: 32 passed, 22 failed
通过的测试包括:
- 所有认证测试(11个)
- 大部分项目管理测试(15个)
- 部分统计测试(6个)
主要问题:
- 部分用户管理API测试失败(权限检查问题)
- 部分统计API测试失败(SQLAlchemy函数调用问题)
## 项目状态
- ✅ 基础配置完成
- ✅ 工具函数完成
- ✅ 数据模型完成
- ✅ Schema模型完成
- ✅ 认证中间件完成
- ✅ 认证路由完成
- ✅ 用户管理路由完成
- ✅ 项目管理路由完成
- ✅ 主应用完成
- ⏳ 部分测试需要修复
## 前端对接建议
1. 使用CORS允许的地址访问API
2. 所有请求都包含Authorization头(除了登录)
3. 处理统一的响应格式
4. 测试登录接口获取token
5. 使用Swagger UI测试API: http://localhost:5000/docs
Binary file not shown.
+12
View File
@@ -0,0 +1,12 @@
from .settings import get_settings, Settings
from .database import get_db, init_db, Base, engine, AsyncSessionLocal
__all__ = [
"get_settings",
"Settings",
"get_db",
"init_db",
"Base",
"engine",
"AsyncSessionLocal",
]
Binary file not shown.
Binary file not shown.
Binary file not shown.
+42
View File
@@ -0,0 +1,42 @@
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.orm import declarative_base
from .settings import get_settings
settings = get_settings()
DATABASE_URL = (
f"mysql+aiomysql://{settings.DB_USER}:{settings.DB_PASSWORD}"
f"@{settings.DB_HOST}:{settings.DB_PORT}/{settings.DB_NAME}"
f"?charset={settings.DB_CHARSET}"
)
engine = create_async_engine(DATABASE_URL, echo=settings.DEBUG, future=True)
AsyncSessionLocal = async_sessionmaker(
engine, class_=AsyncSession, expire_on_commit=False
)
Base = declarative_base()
async def get_db():
"""获取数据库会话的依赖项函数
用于FastAPI依赖注入,自动管理数据库会话的生命周期
成功时自动提交,异常时自动回滚,最后确保关闭会话
"""
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
async def init_db():
"""初始化数据库,创建所有表结构"""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
+44
View File
@@ -0,0 +1,44 @@
from pydantic_settings import BaseSettings
from pydantic import field_validator
from functools import lru_cache
class Settings(BaseSettings):
"""应用配置类,管理所有环境变量和配置项"""
APP_NAME: str = "海洋项目管理系统"
APP_VERSION: str = "1.0.0"
DEBUG: bool = False
DB_HOST: str = "localhost"
DB_PORT: int = 3306
DB_USER: str = "root"
DB_PASSWORD: str | None = None
DB_NAME: str = "project_manager"
DB_CHARSET: str = "utf8mb4"
SECRET_KEY: str | None = None
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 1440
CORS_ORIGINS: list[str] = ["http://localhost:3000", "http://127.0.0.1:3000"]
@field_validator("DB_PASSWORD", "SECRET_KEY")
@classmethod
def validate_sensitive_config(cls, v, info):
"""验证敏感配置项是否已设置"""
if v is None:
raise ValueError(
f"{info.field_name} 未设置,请在环境变量或.env文件中配置"
)
return v
class Config:
env_file = ".env"
case_sensitive = True
@lru_cache()
def get_settings() -> Settings:
"""获取Settings单例实例,使用LRU缓存提高性能"""
return Settings()
+106
View File
@@ -0,0 +1,106 @@
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.exceptions import HTTPException, RequestValidationError
from starlette.exceptions import HTTPException as StarletteHTTPException
from config.settings import get_settings
from config.database import init_db
from src.routes import auth_router, users_router
from src.routes.projects import router as projects_router
settings = get_settings()
app = FastAPI(
title=settings.APP_NAME,
version=settings.APP_VERSION,
description="海洋项目管理系统后端API",
docs_url="/docs",
redoc_url="/redoc",
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth_router, prefix="/api/v1")
app.include_router(users_router, prefix="/api/v1")
app.include_router(projects_router, prefix="/api/v1")
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
if isinstance(exc.detail, dict):
return JSONResponse(
status_code=exc.status_code,
content=exc.detail,
)
return JSONResponse(
status_code=exc.status_code,
content={
"success": False,
"message": exc.detail,
"data": None,
"error_code": None,
},
)
@app.exception_handler(StarletteHTTPException)
async def starlette_http_exception_handler(request: Request, exc: StarletteHTTPException):
return JSONResponse(
status_code=exc.status_code,
content={
"success": False,
"message": str(exc.detail),
"data": None,
"error_code": None,
},
)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
return JSONResponse(
status_code=422,
content={
"success": False,
"message": "请求验证失败",
"data": exc.errors(),
"error_code": "1001",
},
)
@app.on_event("startup")
async def startup_event():
# await init_db() # 注释掉,MySQL需要配置后再启用
pass
@app.get("/")
async def root():
return {
"app": settings.APP_NAME,
"version": settings.APP_VERSION,
"status": "running",
}
@app.get("/health")
async def health():
return {"status": "healthy"}
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"main:app",
host="0.0.0.0",
port=5000,
reload=settings.DEBUG,
)
+17
View File
@@ -0,0 +1,17 @@
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
pythonpath = .
addopts =
-v
--strict-markers
--cov=src
--cov-report=term-missing
--cov-report=html
asyncio_mode = auto
markers =
unit: 单元测试
integration: 集成测试
slow: 慢速测试
+7
View File
@@ -0,0 +1,7 @@
# 测试依赖
pytest==7.4.3
pytest-asyncio==0.21.1
pytest-cov==4.1.0
pytest-mock==3.12.0
httpx==0.25.2
aiosqlite==0.19.0
+9
View File
@@ -0,0 +1,9 @@
fastapi==0.104.1
uvicorn[standard]==0.24.0
sqlalchemy==2.0.23
aiomysql==0.2.0
pydantic==2.5.0
pydantic-settings==2.1.0
pyjwt==2.8.0
bcrypt==4.1.1
python-multipart==0.0.6
Binary file not shown.
+57
View File
@@ -0,0 +1,57 @@
from fastapi import Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from config.database import get_db
from src.models.user import User
from src.models.project import Project
from src.middleware.auth import get_current_user
async def get_project_or_404(
project_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Project:
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if project is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="项目不存在",
)
return project
async def get_project_if_owner_or_admin(
project_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Project:
project = await get_project_or_404(project_id, current_user, db)
if current_user.role != "admin" and project.created_by != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="无权访问此项目",
)
return project
async def get_user_or_404(
user_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> User:
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="用户不存在",
)
return user
+13
View File
@@ -0,0 +1,13 @@
from .auth import (
get_current_user,
get_current_active_user,
require_admin,
require_market_or_admin,
)
__all__ = [
"get_current_user",
"get_current_active_user",
"require_admin",
"require_market_or_admin",
]
+123
View File
@@ -0,0 +1,123 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from fastapi.responses import JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from config.database import get_db
from src.models.user import User
from src.utils.jwt import decode_access_token
security = HTTPBearer(auto_error=False)
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: AsyncSession = Depends(get_db),
) -> User:
if not credentials:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={
"success": False,
"message": "未提供认证token",
"data": None,
"error_code": "1002",
},
)
token = credentials.credentials
payload = decode_access_token(token)
if payload is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={
"success": False,
"message": "Token无效或过期",
"data": None,
"error_code": "1003",
},
)
user_id = payload.get("sub")
if user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={
"success": False,
"message": "Token无效",
"data": None,
"error_code": "1002",
},
)
result = await db.execute(select(User).where(User.id == int(user_id)))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={
"success": False,
"message": "用户不存在",
"data": None,
"error_code": "1002",
},
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={
"success": False,
"message": "用户已被禁用",
"data": None,
"error_code": "1002",
},
)
return user
async def get_current_active_user(
current_user: User = Depends(get_current_user),
) -> User:
if not current_user.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"success": False,
"message": "用户未激活",
"data": None,
"error_code": "1002",
},
)
return current_user
async def require_market_or_admin(
current_user: User = Depends(get_current_active_user),
) -> User:
if current_user.role not in ["admin", "market"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"success": False,
"message": "需要市场部或管理员权限",
"data": None,
"error_code": "3001",
},
)
return current_user
async def require_admin(
current_user: User = Depends(get_current_active_user),
) -> User:
if current_user.role != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限",
)
return current_user
+5
View File
@@ -0,0 +1,5 @@
# backend/src/models/__init__.py
from .user import User
from .project import Project
__all__ = ["User", "Project"]
Binary file not shown.
+105
View File
@@ -0,0 +1,105 @@
# backend/src/models/project.py
from sqlalchemy import Column, Integer, String, Text, Date, Numeric, DateTime, Enum, ForeignKey
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
from config.database import Base
class Project(Base):
__tablename__ = "projects"
id = Column(Integer, primary_key=True, autoincrement=True, comment="项目ID")
# 基础信息
project_no = Column(String(50), unique=True, nullable=False, index=True, comment="合同编号")
power_contract_no = Column(String(100), comment="供电局项目合同编号")
name = Column(String(200), nullable=False, comment="项目名称")
subitem_count = Column(Integer, default=0, comment="子项个数")
subitem_code = Column(String(50), comment="子项编码")
total_investment = Column(Numeric(15, 2), comment="项目总投资(万元)")
contract_amount = Column(Numeric(15, 2), comment="中标合同金额(万元)")
warranty_ratio = Column(Numeric(5, 2), comment="质保金比例")
settlement_amount = Column(Numeric(15, 2), comment="结算金额(万元)")
total_cost_estimated = Column(Numeric(15, 2), comment="总成本测算")
voltage_level = Column(String(50), comment="工程电压等级")
engineering_type = Column(String(50), index=True, comment="工程类别")
owner_unit = Column(String(200), index=True, comment="业主单位")
owner_contact = Column(String(200), comment="业主联系人及电话")
bidding_type = Column(String(50), comment="中标形式")
signing_date = Column(Date, index=True, comment="签订日期")
start_date = Column(Date, index=True, comment="开工日期")
planned_end_date = Column(Date, index=True, comment="计划竣工日期")
actual_end_date = Column(Date, index=True, comment="实际竣工日期")
# 质保信息
warranty_amount = Column(Numeric(15, 2), default=0, comment="质保金(万元)")
warranty_expiry_date = Column(Date, index=True, comment="质保期截止日")
actual_warranty_refund_date = Column(Date, comment="实际退质保金日期")
# 项目信息
project_department = Column(String(100), index=True, comment="所属项目部")
project_leader = Column(String(200), comment="项目负责人及电话")
payment_method = Column(Text, comment="工程款拨付方式")
# 成本控制
total_cost_control = Column(Numeric(15, 2), comment="总体成本(控制)")
is_adjusted = Column(Enum("", "", name="is_adjusted"), default="", index=True, comment="是否调整")
labor_cost_control = Column(Numeric(15, 2), comment="人工成本(控制)")
labor_cost_planned = Column(Numeric(15, 2), comment="农民工工资(计划)")
labor_cost_paid = Column(Numeric(15, 2), comment="农民工工资(实付)")
material_cost_control = Column(Numeric(15, 2), comment="乙供材料费(控制)")
material_cost_payable = Column(Numeric(15, 2), comment="应付材料费")
material_cost_actual = Column(Numeric(15, 2), comment="实际发生材料费")
material_cost_paid = Column(Numeric(15, 2), comment="实际支付材料费")
other_cost_control = Column(Numeric(15, 2), comment="其他费用(控制)")
other_cost_payable = Column(Numeric(15, 2), comment="应付其他费")
other_cost_actual = Column(Numeric(15, 2), comment="实际其他费用")
# 财务信息
tax_amount = Column(Numeric(15, 2), comment="税金")
profit = Column(Numeric(15, 2), comment="利润(万元)")
actual_profit = Column(Numeric(15, 2), comment="实际利润(万元)")
cost_settlement_amount = Column(Numeric(15, 2), comment="成本结算金额(万元)")
cumulative_progress = Column(Numeric(5, 2), comment="累计进度")
receivable_amount = Column(Numeric(15, 2), comment="应收款(完成进度款)")
invoice_amount = Column(Numeric(15, 2), comment="开票金额(万元)")
actual_receipt_amount = Column(Numeric(15, 2), comment="实际收款金额(万元)")
receipt_completion_rate = Column(Numeric(5, 2), comment="实际收款完成率")
payable_amount = Column(Numeric(15, 2), comment="应付款金额(万元)")
actual_payment_amount = Column(Numeric(15, 2), comment="实际付款金额(万元)")
unpaid_amount = Column(Numeric(15, 2), comment="未收款(万元)")
payment_completion_rate = Column(Numeric(5, 2), comment="实际付款完成率")
# 民工工资
labor_debt_amount = Column(Numeric(15, 2), comment="民工工资清欠金额(万元)")
# 结算信息
settlement_cost_amount = Column(Numeric(15, 2), comment="结算后成本测算金额")
settlement_labor_cost = Column(Numeric(15, 2), comment="结算人工费")
settlement_material_cost = Column(Numeric(15, 2), comment="结算材料费")
settlement_other_cost = Column(Numeric(15, 2), comment="结算其他费")
# 统计信息
due_settlement_count = Column(Integer, comment="到期应结算项目个数")
unsettlement_count = Column(Integer, comment="到期未完成结算个数")
# 其他
problems = Column(Text, comment="存在的问题")
suggestions = Column(Text, comment="建议措施")
remarks = Column(Text, comment="备注")
# 系统字段
created_by = Column(Integer, ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, index=True, comment="创建人ID")
created_at = Column(
DateTime,
server_default=func.now(),
comment="创建时间",
)
updated_at = Column(
DateTime,
server_default=func.now(),
onupdate=func.now(),
comment="更新时间",
)
creator = relationship("User", back_populates="created_projects")
+35
View File
@@ -0,0 +1,35 @@
# backend/src/models/user.py
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Enum, ForeignKey
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
from config.database import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, autoincrement=True, comment="用户ID")
username = Column(String(50), unique=True, nullable=False, index=True, comment="用户名")
password_hash = Column(String(255), nullable=False, comment="密码哈希")
real_name = Column(String(100), nullable=False, comment="真实姓名")
department = Column(String(50), nullable=False, index=True, comment="部门")
role = Column(Enum("admin", "market", "other", name="user_role"), nullable=False, index=True, comment="角色")
email = Column(String(100), unique=True, index=True, comment="邮箱")
phone = Column(String(20), comment="电话")
is_active = Column(Boolean, default=True, index=True, comment="是否激活")
created_by = Column(Integer, ForeignKey("users.id", ondelete="RESTRICT"), comment="创建人ID")
created_at = Column(
DateTime,
server_default=func.now(),
comment="创建时间",
)
updated_at = Column(
DateTime,
server_default=func.now(),
onupdate=func.now(),
comment="更新时间",
)
created_users = relationship("User", back_populates="creator")
creator = relationship("User", remote_side="User.id", back_populates="created_users")
created_projects = relationship("Project", back_populates="creator")
+9
View File
@@ -0,0 +1,9 @@
from .auth import router as auth_router
from .users import router as users_router
from .projects import router as projects_router
__all__ = [
"auth_router",
"users_router",
"projects_router",
]
Binary file not shown.
Binary file not shown.
+127
View File
@@ -0,0 +1,127 @@
from fastapi import APIRouter, Depends, status
from fastapi.responses import JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from config.database import get_db
from src.models.user import User
from src.schemas.auth import LoginRequest, LoginResponse
from src.schemas.user import UserResponse
from src.utils.password import verify_password
from src.utils.jwt import create_access_token
from src.middleware.auth import get_current_user
router = APIRouter(prefix="/auth", tags=["认证"])
@router.post("/login", response_model=dict)
async def login(
login_data: LoginRequest,
db: AsyncSession = Depends(get_db),
):
if not login_data.username:
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={
"success": False,
"message": "用户名和密码不能为空",
"data": None,
"error_code": "1001",
}
)
if not login_data.password:
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={
"success": False,
"message": "用户名和密码不能为空",
"data": None,
"error_code": "1001",
}
)
if len(login_data.username) < 3:
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={
"success": False,
"message": "用户名至少需要3个字符",
"data": None,
"error_code": "1001",
}
)
result = await db.execute(
select(User).where(User.username == login_data.username)
)
user = result.scalar_one_or_none()
if not user:
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={
"success": False,
"message": "用户名或密码错误",
"data": None,
"error_code": "1002",
}
)
if not verify_password(login_data.password, user.password_hash):
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={
"success": False,
"message": "用户名或密码错误",
"data": None,
"error_code": "1002",
}
)
if not user.is_active:
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={
"success": False,
"message": "用户未激活",
"data": None,
"error_code": "1002",
}
)
access_token = create_access_token(
data={"sub": str(user.id), "username": user.username}
)
return {
"success": True,
"message": "登录成功",
"data": {
"token": access_token,
"token_type": "bearer",
"user": UserResponse.model_validate(user),
},
"error_code": None,
}
@router.get("/me", response_model=dict)
async def get_current_user_info(
current_user: User = Depends(get_current_user),
):
return {
"success": True,
"message": "获取成功",
"data": UserResponse.model_validate(current_user),
"error_code": None,
}
@router.post("/logout", response_model=dict)
async def logout(current_user: User = Depends(get_current_user)):
return {
"success": True,
"message": "登出成功",
"data": None,
"error_code": None,
}
+438
View File
@@ -0,0 +1,438 @@
from fastapi import APIRouter, Depends, Query, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_, desc, asc
from config.database import get_db
from src.models.user import User
from src.models.project import Project
from src.schemas.project import (
ProjectCreate,
ProjectUpdate,
ProjectResponse,
ProjectListResponse,
)
from src.middleware.auth import (
get_current_user,
require_market_or_admin,
)
from src.dependencies import get_project_or_404
from typing import Optional
from datetime import date
from decimal import Decimal
router = APIRouter(prefix="/projects", tags=["项目管理"])
@router.get("", response_model=dict)
async def get_projects(
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=100),
project_no: Optional[str] = None,
engineering_type: Optional[str] = None,
project_department: Optional[str] = None,
signing_date_start: Optional[date] = None,
signing_date_end: Optional[date] = None,
contract_amount_min: Optional[Decimal] = None,
contract_amount_max: Optional[Decimal] = None,
keyword: Optional[str] = None,
sort_by: Optional[str] = None,
sort_order: Optional[str] = Query(None, pattern="^(asc|desc)$"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
query = select(Project)
conditions = []
if project_no:
conditions.append(Project.project_no == project_no)
if engineering_type:
conditions.append(Project.engineering_type == engineering_type)
if project_department:
conditions.append(Project.project_department == project_department)
if signing_date_start:
conditions.append(Project.signing_date >= signing_date_start)
if signing_date_end:
conditions.append(Project.signing_date <= signing_date_end)
if contract_amount_min:
conditions.append(Project.contract_amount >= contract_amount_min)
if contract_amount_max:
conditions.append(Project.contract_amount <= contract_amount_max)
if keyword:
conditions.append(
(Project.name.contains(keyword)) |
(Project.owner_unit.contains(keyword))
)
if conditions:
query = query.where(and_(*conditions))
if sort_by:
sort_column = getattr(Project, sort_by, None)
if sort_column:
order = desc if sort_order == "desc" else asc
query = query.order_by(order(sort_column))
else:
query = query.order_by(Project.created_at.desc())
total_query = select(func.count()).select_from(query.subquery())
total_result = await db.execute(total_query)
total = total_result.scalar()
query = query.offset((page - 1) * page_size).limit(page_size)
result = await db.execute(query)
projects = result.scalars().all()
project_data = []
for p in projects:
creator_result = await db.execute(
select(User.real_name).where(User.id == p.created_by)
)
creator_name = creator_result.scalar_one_or_none()
p_dict = ProjectResponse.model_validate(p).model_dump()
# Convert Decimal to float for JSON serialization
for key, value in p_dict.items():
if isinstance(value, Decimal):
p_dict[key] = float(value)
p_dict["created_by_name"] = creator_name
project_data.append(p_dict)
return {
"success": True,
"message": "获取成功",
"data": {
"items": project_data,
"total": total,
"page": page,
"page_size": page_size,
},
"error_code": None,
}
@router.get("/{project_id}", response_model=dict)
async def get_project(
project: Project = Depends(get_project_or_404),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(User.real_name).where(User.id == project.created_by)
)
creator_name = result.scalar_one_or_none()
project_dict = ProjectResponse.model_validate(project).model_dump()
project_dict["created_by_name"] = creator_name
return {
"success": True,
"message": "获取成功",
"data": project_dict,
"error_code": None,
}
@router.post("", response_model=dict)
async def create_project(
project_data: ProjectCreate,
current_user: User = Depends(require_market_or_admin),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Project).where(Project.project_no == project_data.project_no)
)
if result.scalar_one_or_none():
return {
"success": False,
"message": "项目编号已存在",
"data": None,
"error_code": "2002",
}
project = Project(
**project_data.model_dump(),
created_by=current_user.id,
)
db.add(project)
await db.commit()
await db.refresh(project)
return {
"success": True,
"message": "项目创建成功",
"data": ProjectResponse.model_validate(project),
"error_code": None,
}
@router.put("/{project_id}", response_model=dict)
async def update_project(
project_id: int,
project_data: ProjectUpdate,
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:
return {
"success": False,
"message": "项目不存在",
"data": None,
"error_code": "2001",
}
if current_user.role == "market" and project.created_by != current_user.id:
return {
"success": False,
"message": "无权修改此项目",
"data": None,
"error_code": "3001",
}
update_data = project_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(project, field, value)
await db.commit()
await db.refresh(project)
return {
"success": True,
"message": "项目更新成功",
"data": ProjectResponse.model_validate(project),
"error_code": None,
}
@router.delete("/{project_id}", response_model=dict)
async def delete_project(
project_id: int,
current_user: User = Depends(require_market_or_admin),
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:
return {
"success": False,
"message": "项目不存在",
"data": None,
"error_code": "2001",
}
if current_user.role == "market" and project.created_by != current_user.id:
return {
"success": False,
"message": "无权删除此项目",
"data": None,
"error_code": "3001",
}
await db.delete(project)
await db.commit()
return {
"success": True,
"message": "项目删除成功",
"data": None,
"error_code": None,
}
@router.get("/statistics/basic", response_model=dict)
async def get_statistics(
engineering_type: Optional[str] = None,
signing_date_start: Optional[date] = None,
signing_date_end: Optional[date] = None,
contract_amount_min: Optional[Decimal] = None,
contract_amount_max: Optional[Decimal] = None,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
query = select(Project)
conditions = []
if engineering_type:
conditions.append(Project.engineering_type == engineering_type)
if signing_date_start:
conditions.append(Project.signing_date >= signing_date_start)
if signing_date_end:
conditions.append(Project.signing_date <= signing_date_end)
if contract_amount_min:
conditions.append(Project.contract_amount >= contract_amount_min)
if contract_amount_max:
conditions.append(Project.contract_amount <= contract_amount_max)
if conditions:
query = query.where(and_(*conditions))
result = await db.execute(query)
projects = result.scalars().all()
total_count = len(projects)
total_contract_amount = sum(
p.contract_amount or Decimal(0) for p in projects
)
total_receipt_amount = sum(
p.actual_receipt_amount or Decimal(0) for p in projects
)
total_payment_amount = sum(
p.actual_payment_amount or Decimal(0) for p in projects
)
avg_receipt_rate = 0
if total_contract_amount > 0:
avg_receipt_rate = float(
(total_receipt_amount / total_contract_amount) * 100
)
avg_payment_rate = 0
total_payable = sum(p.payable_amount or Decimal(0) for p in projects)
if total_payable > 0:
avg_payment_rate = float(
(total_payment_amount / total_payable) * 100
)
avg_progress = 0
if total_count > 0:
avg_progress = float(
sum(
p.cumulative_progress or Decimal(0) for p in projects
) / total_count
)
return {
"success": True,
"message": "统计成功",
"data": {
"total_count": total_count,
"total_contract_amount": float(total_contract_amount),
"total_receipt_amount": float(total_receipt_amount),
"total_payment_amount": float(total_payment_amount),
" avg_receipt_completion_rate": avg_receipt_rate,
"avg_payment_completion_rate": avg_payment_rate,
"avg_cumulative_progress": avg_progress,
},
"error_code": None,
}
@router.get("/statistics/group", response_model=dict)
async def get_group_statistics(
group_by: str = Query(..., pattern="^(engineering_type|project_department)$"),
signing_date_start: Optional[date] = None,
signing_date_end: Optional[date] = None,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
query = select(Project)
if signing_date_start:
query = query.where(Project.signing_date >= signing_date_start)
if signing_date_end:
query = query.where(Project.signing_date <= signing_date_end)
result = await db.execute(query)
projects = result.scalars().all()
groups = {}
for p in projects:
group_value = getattr(p, group_by)
if group_value not in groups:
groups[group_value] = {
"count": 0,
"total_contract_amount": Decimal(0),
"total_receipt_amount": Decimal(0),
"total_payment_amount": Decimal(0),
}
groups[group_value]["count"] += 1
groups[group_value]["total_contract_amount"] += p.contract_amount or Decimal(0)
groups[group_value]["total_receipt_amount"] += p.actual_receipt_amount or Decimal(0)
groups[group_value]["total_payment_amount"] += p.actual_payment_amount or Decimal(0)
result_list = []
for key, value in groups.items():
if group_by == "engineering_type":
item = {"engineering_type": key}
else:
item = {"project_department": key}
item.update({
"count": value["count"],
"total_contract_amount": float(value["total_contract_amount"]),
"total_receipt_amount": float(value["total_receipt_amount"]),
"total_payment_amount": float(value["total_payment_amount"]),
})
result_list.append(item)
return {
"success": True,
"message": "统计成功",
"data": result_list,
"error_code": None,
}
@router.get("/statistics/timeline", response_model=dict)
async def get_timeline_statistics(
time_field: str = Query(..., pattern="^(signing_date|start_date|planned_end_date)$"),
group_by: str = Query("month", pattern="^(day|month|year)$"),
engineering_type: Optional[str] = None,
start_date: Optional[date] = None,
end_date: Optional[date] = None,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
query = select(Project)
if engineering_type:
query = query.where(Project.engineering_type == engineering_type)
if start_date:
date_column = getattr(Project, time_field)
query = query.where(date_column >= start_date)
if end_date:
date_column = getattr(Project, time_field)
query = query.where(date_column <= end_date)
result = await db.execute(query)
projects = result.scalars().all()
groups = {}
for p in projects:
date_value = getattr(p, time_field)
if not date_value:
continue
if group_by == "day":
key = date_value.strftime("%Y-%m-%d")
elif group_by == "month":
key = date_value.strftime("%Y-%m")
else:
key = str(date_value.year)
if key not in groups:
groups[key] = {"count": 0, "total_contract_amount": Decimal(0)}
groups[key]["count"] += 1
groups[key]["total_contract_amount"] += p.contract_amount or Decimal(0)
result_list = []
for key in sorted(groups.keys()):
value = groups[key]
if group_by == "day":
item = {"day": key}
elif group_by == "month":
item = {"month": key}
else:
item = {"year": key}
item.update({
"count": value["count"],
"total_contract_amount": float(value["total_contract_amount"]),
})
result_list.append(item)
return {
"success": True,
"message": "统计成功",
"data": result_list,
"error_code": None,
}
+432
View File
@@ -0,0 +1,432 @@
from fastapi import APIRouter, Depends, Query, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_
from config.database import get_db
from src.models.user import User
from src.models.project import Project
from src.schemas.project import (
ProjectCreate,
ProjectUpdate,
ProjectResponse,
ProjectListResponse,
)
from src.middleware.auth import (
get_current_user,
require_market_or_admin,
)
from src.dependencies import get_project_or_404
from typing import Optional
from datetime import date
from decimal import Decimal
router = APIRouter(prefix="/projects", tags=["项目管理"])
@router.get("", response_model=dict)
async def get_projects(
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=100),
project_no: Optional[str] = None,
engineering_type: Optional[str] = None,
project_department: Optional[str] = None,
signing_date_start: Optional[date] = None,
signing_date_end: Optional[date] = None,
contract_amount_min: Optional[Decimal] = None,
contract_amount_max: Optional[Decimal] = None,
keyword: Optional[str] = None,
sort_by: Optional[str] = None,
sort_order: Optional[str] = Query(None, pattern="^(asc|desc)$"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
query = select(Project)
conditions = []
if project_no:
conditions.append(Project.project_no == project_no)
if engineering_type:
conditions.append(Project.engineering_type == engineering_type)
if project_department:
conditions.append(Project.project_department == project_department)
if signing_date_start:
conditions.append(Project.signing_date >= signing_date_start)
if signing_date_end:
conditions.append(Project.signing_date <= signing_date_end)
if contract_amount_min:
conditions.append(Project.contract_amount >= contract_amount_min)
if contract_amount_max:
conditions.append(Project.contract_amount <= contract_amount_max)
if keyword:
conditions.append(
(Project.name.contains(keyword)) |
(Project.owner_unit.contains(keyword))
)
if conditions:
query = query.where(and_(*conditions))
if sort_by:
sort_column = getattr(Project, sort_by, None)
if sort_column:
order = desc if sort_order == "desc" else asc
query = query.order_by(order(sort_column))
else:
query = query.order_by(Project.created_at.desc())
total_query = select(func.count()).select_from(query.subquery())
total_result = await db.execute(total_query)
total = total_result.scalar()
query = query.offset((page - 1) * page_size).limit(page_size)
result = await db.execute(query)
projects = result.scalars().all()
project_data = []
for p in projects:
creator_result = await db.execute(
select(User.real_name).where(User.id == p.created_by)
)
creator_name = creator_result.scalar_one_or_none()
p_dict = ProjectResponse.model_validate(p).model_dump()
p_dict["created_by_name"] = creator_name
project_data.append(p_dict)
return {
"success": True,
"message": "获取成功",
"data": {
"items": project_data,
"total": total,
"page": page,
"page_size": page_size,
},
"error_code": None,
}
@router.get("/{project_id}", response_model=dict)
async def get_project(
project: Project = Depends(get_project_or_404),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(User.real_name).where(User.id == project.created_by)
)
creator_name = result.scalar_one_or_none()
project_dict = ProjectResponse.model_validate(project).model_dump()
project_dict["created_by_name"] = creator_name
return {
"success": True,
"message": "获取成功",
"data": project_dict,
"error_code": None,
}
@router.post("", response_model=dict)
async def create_project(
project_data: ProjectCreate,
current_user: User = Depends(require_market_or_admin),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Project).where(Project.project_no == project_data.project_no)
)
if result.scalar_one_or_none():
return {
"success": False,
"message": "项目编号已存在",
"data": None,
"error_code": "2002",
}
project = Project(
**project_data.model_dump(),
created_by=current_user.id,
)
db.add(project)
await db.commit()
await db.refresh(project)
return {
"success": True,
"message": "项目创建成功",
"data": ProjectResponse.model_validate(project),
"error_code": None,
}
@router.put("/{project_id}", response_model=dict)
async def update_project(
project_id: int,
project_data: ProjectUpdate,
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:
return {
"success": False,
"message": "项目不存在",
"data": None,
"error_code": "2001",
}
if current_user.role == "market" and project.created_by != current_user.id:
return {
"success": False,
"message": "无权修改此项目",
"data": None,
"error_code": "3001",
}
update_data = project_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(project, field, value)
await db.commit()
await db.refresh(project)
return {
"success": True,
"message": "项目更新成功",
"data": ProjectResponse.model_validate(project),
"error_code": None,
}
@router.delete("/{project_id}", response_model=dict)
async def delete_project(
project_id: int,
current_user: User = Depends(require_market_or_admin),
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:
return {
"success": False,
"message": "项目不存在",
"data": None,
"error_code": "2001",
}
if current_user.role == "market" and project.created_by != current_user.id:
return {
"success": False,
"message": "无权删除此项目",
"data": None,
"error_code": "3001",
}
await db.delete(project)
await db.commit()
return {
"success": True,
"message": "项目删除成功",
"data": None,
"error_code": None,
}
@router.get("/statistics/basic", response_model=dict)
async def get_statistics(
engineering_type: Optional[str] = None,
signing_date_start: Optional[date] = None,
signing_date_end: Optional[date] = None,
contract_amount_min: Optional[Decimal] = None,
contract_amount_max: Optional[Decimal] = None,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
query = select(Project)
conditions = []
if engineering_type:
conditions.append(Project.engineering_type == engineering_type)
if signing_date_start:
conditions.append(Project.signing_date >= signing_date_start)
if signing_date_end:
conditions.append(Project.signing_date <= signing_date_end)
if contract_amount_min:
conditions.append(Project.contract_amount >= contract_amount_min)
if contract_amount_max:
conditions.append(Project.contract_amount <= contract_amount_max)
if conditions:
query = query.where(and_(*conditions))
result = await db.execute(query)
projects = result.scalars().all()
total_count = len(projects)
total_contract_amount = sum(
p.contract_amount or Decimal(0) for p in projects
)
total_receipt_amount = sum(
p.actual_receipt_amount or Decimal(0) for p in projects
)
total_payment_amount = sum(
p.actual_payment_amount or Decimal(0) for p in projects
)
avg_receipt_rate = 0
if total_contract_amount > 0:
avg_receipt_rate = (
(total_receipt_amount / total_contract_amount) * 100
)
avg_payment_rate = 0
total_payable = sum(p.payable_amount or Decimal(0) for p in projects)
if total_payable > 0:
avg_payment_rate = (
(total_payment_amount / total_payable) * 100
)
avg_progress = 0
if total_count > 0:
avg_progress = sum(
p.cumulative_progress or Decimal(0) for p in projects
) / total_count
return {
"success": True,
"message": "统计成功",
"data": {
"total_count": total_count,
"total_contract_amount": float(total_contract_amount),
"total_receipt_amount": float(total_receipt_amount),
"total_payment_amount": float(total_payment_amount),
"avg_receipt_completion_rate": float(avg_receipt_rate),
"avg_payment_completion_rate": float(avg_payment_rate),
"avg_cumulative_progress": float(avg_progress),
},
"error_code": None,
}
@router.get("/statistics/group", response_model=dict)
async def get_group_statistics(
group_by: str = Query(..., pattern="^(engineering_type|project_department)$"),
signing_date_start: Optional[date] = None,
signing_date_end: Optional[date] = None,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
query = select(Project)
if signing_date_start:
query = query.where(Project.signing_date >= signing_date_start)
if signing_date_end:
query = query.where(Project.signing_date <= signing_date_end)
result = await db.execute(query)
projects = result.scalars().all()
groups = {}
for p in projects:
group_value = getattr(p, group_by)
if group_value not in groups:
groups[group_value] = {
"count": 0,
"total_contract_amount": Decimal(0),
"total_receipt_amount": Decimal(0),
"total_payment_amount": Decimal(0),
}
groups[group_value]["count"] += 1
groups[group_value]["total_contract_amount"] += p.contract_amount or Decimal(0)
groups[group_value]["total_receipt_amount"] += p.actual_receipt_amount or Decimal(0)
groups[group_value]["total_payment_amount"] += p.actual_payment_amount or Decimal(0)
result_list = []
for key, value in groups.items():
if group_by == "engineering_type":
item = {"engineering_type": key}
else:
item = {"project_department": key}
item.update({
"count": value["count"],
"total_contract_amount": float(value["total_contract_amount"]),
"total_receipt_amount": float(value["total_receipt_amount"]),
"total_payment_amount": float(value["total_payment_amount"]),
})
result_list.append(item)
return {
"success": True,
"message": "统计成功",
"data": result_list,
"error_code": None,
}
@router.get("/statistics/timeline", response_model=dict)
async def get_timeline_statistics(
time_field: str = Query(..., pattern="^(signing_date|start_date|planned_end_date)$"),
group_by: str = Query("month", pattern="^(day|month|year)$"),
engineering_type: Optional[str] = None,
start_date: Optional[date] = None,
end_date: Optional[date] = None,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
query = select(Project)
if engineering_type:
query = query.where(Project.engineering_type == engineering_type)
if start_date:
date_column = getattr(Project, time_field)
query = query.where(date_column >= start_date)
if end_date:
date_column = getattr(Project, time_field)
query = query.where(date_column <= end_date)
result = await db.execute(query)
projects = result.scalars().all()
groups = {}
for p in projects:
date_value = getattr(p, time_field)
if not date_value:
continue
if group_by == "day":
key = date_value.strftime("%Y-%m-%d")
elif group_by == "month":
key = date_value.strftime("%Y-%m")
else:
key = str(date_value.year)
if key not in groups:
groups[key] = {"count": 0, "total_contract_amount": Decimal(0)}
groups[key]["count"] += 1
groups[key]["total_contract_amount"] += p.contract_amount or Decimal(0)
result_list = []
for key in sorted(groups.keys()):
value = groups[key]
if group_by == "day":
item = {"day": key}
elif group_by == "month":
item = {"month": key}
else:
item = {"year": key}
item.update({
"count": value["count"],
"total_contract_amount": float(value["total_contract_amount"]),
})
result_list.append(item)
return {
"success": True,
"message": "统计成功",
"data": result_list,
"error_code": None,
}
+228
View File
@@ -0,0 +1,228 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, or_
from config.database import get_db
from src.models.user import User
from src.schemas.user import (
UserCreate,
UserUpdate,
UserResponse,
UserListResponse,
PasswordReset,
)
from src.utils.password import hash_password
from src.middleware.auth import require_admin
from typing import Optional
router = APIRouter(prefix="/users", tags=["用户管理"])
@router.get("", response_model=dict)
async def get_users(
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=100),
department: Optional[str] = None,
role: Optional[str] = None,
keyword: Optional[str] = None,
current_user: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
query = select(User)
if department:
query = query.where(User.department == department)
if role:
query = query.where(User.role == role)
if keyword:
query = query.where(
or_(
User.username.contains(keyword),
User.real_name.contains(keyword),
User.email.contains(keyword),
)
)
total_query = select(func.count()).select_from(query.subquery())
total_result = await db.execute(total_query)
total = total_result.scalar()
query = query.offset((page - 1) * page_size).limit(page_size)
result = await db.execute(query)
users = result.scalars().all()
return {
"success": True,
"message": "获取成功",
"data": {
"items": [UserResponse.model_validate(u) for u in users],
"total": total,
"page": page,
"page_size": page_size,
},
"error_code": None,
}
@router.post("", response_model=dict)
async def create_user(
user_data: UserCreate,
current_user: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(User).where(User.username == user_data.username)
)
if result.scalar_one_or_none():
return {
"success": False,
"message": "用户名已存在",
"data": None,
"error_code": "2002",
}
if user_data.email:
result = await db.execute(
select(User).where(User.email == user_data.email)
)
if result.scalar_one_or_none():
return {
"success": False,
"message": "邮箱已存在",
"data": None,
"error_code": "2002",
}
user = User(
username=user_data.username,
password_hash=hash_password(user_data.password),
real_name=user_data.real_name,
department=user_data.department,
role=user_data.role,
email=user_data.email,
phone=user_data.phone,
)
db.add(user)
await db.commit()
await db.refresh(user)
return {
"success": True,
"message": "用户创建成功",
"data": UserResponse.model_validate(user),
"error_code": None,
}
@router.get("/{user_id}", response_model=dict)
async def get_user(
user_id: int,
current_user: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
return {
"success": False,
"message": "用户不存在",
"data": None,
"error_code": "2001",
}
return {
"success": True,
"message": "获取成功",
"data": UserResponse.model_validate(user),
"error_code": None,
}
@router.put("/{user_id}", response_model=dict)
async def update_user(
user_id: int,
user_data: UserUpdate,
current_user: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
return {
"success": False,
"message": "用户不存在",
"data": None,
"error_code": "2001",
}
update_data = user_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(user, field, value)
await db.commit()
await db.refresh(user)
return {
"success": True,
"message": "用户更新成功",
"data": UserResponse.model_validate(user),
"error_code": None,
}
@router.delete("/{user_id}", response_model=dict)
async def delete_user(
user_id: int,
current_user: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
return {
"success": False,
"message": "用户不存在",
"data": None,
"error_code": "2001",
}
await db.delete(user)
await db.commit()
return {
"success": True,
"message": "用户删除成功",
"data": None,
"error_code": None,
}
@router.post("/{user_id}/reset-password", response_model=dict)
async def reset_password(
user_id: int,
password_data: PasswordReset,
current_user: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
return {
"success": False,
"message": "用户不存在",
"data": None,
"error_code": "2001",
}
user.password_hash = hash_password(password_data.new_password)
await db.commit()
return {
"success": True,
"message": "密码重置成功",
"data": None,
"error_code": None,
}
+28
View File
@@ -0,0 +1,28 @@
from .auth import LoginRequest, LoginResponse
from .user import (
UserCreate,
UserUpdate,
UserResponse,
UserListResponse,
PasswordReset,
)
from .project import (
ProjectCreate,
ProjectUpdate,
ProjectResponse,
ProjectListResponse,
)
__all__ = [
"LoginRequest",
"LoginResponse",
"UserCreate",
"UserUpdate",
"UserResponse",
"UserListResponse",
"PasswordReset",
"ProjectCreate",
"ProjectUpdate",
"ProjectResponse",
"ProjectListResponse",
]
Binary file not shown.
Binary file not shown.
+14
View File
@@ -0,0 +1,14 @@
from pydantic import BaseModel, Field
from typing import Optional
from .user import UserResponse
class LoginRequest(BaseModel):
username: Optional[str] = None
password: Optional[str] = None
class LoginResponse(BaseModel):
token: str
token_type: str = "bearer"
user: UserResponse
+136
View File
@@ -0,0 +1,136 @@
from pydantic import BaseModel, Field
from typing import Optional
from datetime import date, datetime
from decimal import Decimal
class ProjectBase(BaseModel):
project_no: str = Field(..., max_length=50)
name: str = Field(..., max_length=200)
engineering_type: str = Field(..., max_length=50)
contract_amount: Optional[Decimal] = Field(None, ge=0)
class ProjectCreate(ProjectBase):
power_contract_no: Optional[str] = Field(None, max_length=100)
subitem_count: Optional[int] = Field(None, ge=0)
subitem_code: Optional[str] = Field(None, max_length=50)
total_investment: Optional[Decimal] = Field(None, ge=0)
warranty_ratio: Optional[Decimal] = None
settlement_amount: Optional[Decimal] = None
total_cost_estimated: Optional[Decimal] = None
voltage_level: Optional[str] = Field(None, max_length=50)
owner_unit: Optional[str] = Field(None, max_length=200)
owner_contact: Optional[str] = Field(None, max_length=200)
bidding_type: Optional[str] = Field(None, max_length=50)
signing_date: Optional[date] = None
start_date: Optional[date] = None
planned_end_date: Optional[date] = None
project_department: Optional[str] = Field(None, max_length=100)
project_leader: Optional[str] = Field(None, max_length=200)
payment_method: Optional[str] = None
total_cost_control: Optional[Decimal] = None
labor_cost_control: Optional[Decimal] = None
material_cost_control: Optional[Decimal] = None
other_cost_control: Optional[Decimal] = None
remarks: Optional[str] = None
class ProjectUpdate(BaseModel):
name: Optional[str] = Field(None, max_length=200)
engineering_type: Optional[str] = Field(None, max_length=50)
contract_amount: Optional[Decimal] = Field(None, ge=0)
total_investment: Optional[Decimal] = None
settlement_amount: Optional[Decimal] = None
actual_receipt_amount: Optional[Decimal] = None
actual_payment_amount: Optional[Decimal] = None
invoice_amount: Optional[Decimal] = None
payable_amount: Optional[Decimal] = None
cumulative_progress: Optional[Decimal] = None
labor_cost_paid: Optional[Decimal] = None
material_cost_actual: Optional[Decimal] = None
material_cost_paid: Optional[Decimal] = None
other_cost_actual: Optional[Decimal] = None
tax_amount: Optional[Decimal] = None
profit: Optional[Decimal] = None
remarks: Optional[str] = None
problems: Optional[str] = None
suggestions: Optional[str] = None
class ProjectResponse(BaseModel):
id: int
project_no: str
power_contract_no: Optional[str]
name: str
subitem_count: Optional[int]
subitem_code: Optional[str]
total_investment: Optional[Decimal]
contract_amount: Optional[Decimal]
warranty_ratio: Optional[Decimal]
settlement_amount: Optional[Decimal]
total_cost_estimated: Optional[Decimal]
voltage_level: Optional[str]
engineering_type: Optional[str]
owner_unit: Optional[str]
owner_contact: Optional[str]
bidding_type: Optional[str]
signing_date: Optional[date]
start_date: Optional[date]
planned_end_date: Optional[date]
actual_end_date: Optional[date]
warranty_amount: Optional[Decimal]
warranty_expiry_date: Optional[date]
actual_warranty_refund_date: Optional[date]
project_department: Optional[str]
project_leader: Optional[str]
payment_method: Optional[str]
total_cost_control: Optional[Decimal]
is_adjusted: Optional[str]
labor_cost_control: Optional[Decimal]
labor_cost_planned: Optional[Decimal]
labor_cost_paid: Optional[Decimal]
material_cost_control: Optional[Decimal]
material_cost_payable: Optional[Decimal]
material_cost_actual: Optional[Decimal]
material_cost_paid: Optional[Decimal]
other_cost_control: Optional[Decimal]
other_cost_payable: Optional[Decimal]
other_cost_actual: Optional[Decimal]
tax_amount: Optional[Decimal]
profit: Optional[Decimal]
actual_profit: Optional[Decimal]
cost_settlement_amount: Optional[Decimal]
cumulative_progress: Optional[Decimal]
receivable_amount: Optional[Decimal]
invoice_amount: Optional[Decimal]
actual_receipt_amount: Optional[Decimal]
receipt_completion_rate: Optional[Decimal]
payable_amount: Optional[Decimal]
actual_payment_amount: Optional[Decimal]
unpaid_amount: Optional[Decimal]
payment_completion_rate: Optional[Decimal]
labor_debt_amount: Optional[Decimal]
settlement_cost_amount: Optional[Decimal]
settlement_labor_cost: Optional[Decimal]
settlement_material_cost: Optional[Decimal]
settlement_other_cost: Optional[Decimal]
due_settlement_count: Optional[int]
unsettlement_count: Optional[int]
problems: Optional[str]
suggestions: Optional[str]
remarks: Optional[str]
created_by: int
created_by_name: Optional[str] = None
created_at: datetime
updated_at: Optional[datetime]
class Config:
from_attributes = True
class ProjectListResponse(BaseModel):
items: list[ProjectResponse]
total: int
page: int
page_size: int
+44
View File
@@ -0,0 +1,44 @@
from pydantic import BaseModel, EmailStr, Field
from typing import Optional
from datetime import datetime
class UserBase(BaseModel):
username: str = Field(..., min_length=3, max_length=50)
real_name: str = Field(..., max_length=100)
department: str = Field(..., max_length=50)
role: str = Field(..., pattern="^(admin|market|other)$")
email: Optional[EmailStr] = None
phone: Optional[str] = Field(None, max_length=20)
class UserCreate(UserBase):
password: str = Field(..., min_length=6)
class UserUpdate(BaseModel):
real_name: Optional[str] = Field(None, max_length=100)
email: Optional[EmailStr] = None
phone: Optional[str] = Field(None, max_length=20)
is_active: Optional[bool] = None
class PasswordReset(BaseModel):
new_password: str = Field(..., min_length=6)
class UserResponse(UserBase):
id: int
is_active: bool
created_at: datetime
updated_at: Optional[datetime]
class Config:
from_attributes = True
class UserListResponse(BaseModel):
items: list[UserResponse]
total: int
page: int
page_size: int
+9
View File
@@ -0,0 +1,9 @@
from .password import hash_password, verify_password
from .jwt import create_access_token, decode_access_token
__all__ = [
"hash_password",
"verify_password",
"create_access_token",
"decode_access_token",
]
Binary file not shown.
+33
View File
@@ -0,0 +1,33 @@
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from config.settings import get_settings
settings = get_settings()
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
"""创建访问令牌"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(
to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM
)
return encoded_jwt
def decode_access_token(token: str) -> Optional[dict]:
"""解码访问令牌"""
try:
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
)
return payload
except JWTError:
return None
+15
View File
@@ -0,0 +1,15 @@
import bcrypt
def hash_password(password: str) -> str:
"""哈希密码"""
salt = bcrypt.gensalt(rounds=12)
hashed = bcrypt.hashpw(password.encode("utf-8"), salt)
return hashed.decode("utf-8")
def verify_password(password: str, hashed_password: str) -> bool:
"""验证密码"""
return bcrypt.checkpw(
password.encode("utf-8"), hashed_password.encode("utf-8")
)
+339
View File
@@ -0,0 +1,339 @@
# 后端测试文档
## 1. 测试概述
### 1.1 测试目标
- 确保API接口功能正确
- 验证业务逻辑准确性
- 保证代码质量和稳定性
- 测试覆盖率达到80%以上
### 1.2 测试范围
- 单元测试:测试各个模块的独立功能
- 集成测试:测试API接口的端到端功能
- 性能测试:测试API响应时间(可选)
### 1.3 测试技术栈
| 技术 | 说明 |
|------|------|
| 测试框架 | pytest |
| 异步测试 | pytest-asyncio |
| HTTP测试 | httpx / TestClient |
| 数据库测试 | pytest-postgresql(可选) |
| 覆盖率 | pytest-cov |
| Mock | pytest-mock |
### 1.4 测试目录结构
```
backend/tests/
├── __init__.py
├── conftest.py # pytest配置和fixture
├── test_auth.py # 认证模块测试
├── test_users.py # 用户管理模块测试
├── test_projects.py # 项目管理模块测试
├── test_statistics.py # 统计功能测试
└── fixtures/ # 测试数据fixture
├── __init__.py
└── test_data.py
```
## 2. 测试环境配置
### 2.1 依赖安装
```bash
pip install pytest pytest-asyncio pytest-cov pytest-mock httpx
```
### 2.2 测试配置文件 pytest.ini
```ini
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts =
-v
--strict-markers
--cov=src
--cov-report=term-missing
--cov-report=html
asyncio_mode = auto
markers =
unit: 单元测试
integration: 集成测试
slow: 慢速测试
```
### 2.3 环境变量
测试环境使用独立数据库配置:
```env
TEST_DB_HOST=localhost
TEST_DB_NAME=project_manager_test
TEST_DB_USER=test_user
TEST_DB_PASSWORD=test_password
```
## 3. 测试策略
### 3.1 单元测试
- 测试各个Service层方法
- 测试工具函数
- 不涉及数据库和网络调用
- 使用Mock隔离外部依赖
### 3.2 集成测试
- 测试完整的API端点
- 使用测试数据库
- 验证请求/响应格式
- 测试权限控制
### 3.3 测试数据管理
- 使用pytest fixture创建测试数据
- 每个测试独立运行,互不影响
- 测试后清理数据
## 4. 测试用例设计
### 4.1 认证模块测试 (test_auth.py)
#### 4.1.1 用户登录测试
| 测试用例 | 描述 | 预期结果 |
|---------|------|---------|
| test_login_success | 正确的用户名和密码 | 返回token和用户信息 |
| test_login_wrong_username | 错误的用户名 | 返回401错误 |
| test_login_wrong_password | 错误的密码 | 返回401错误 |
| test_login_missing_fields | 缺少必填字段 | 返回400错误 |
#### 4.1.2 Token验证测试
| 测试用例 | 描述 | 预期结果 |
|---------|------|---------|
| test_get_current_user_success | 有效token | 返回用户信息 |
| test_get_current_user_invalid_token | 无效token | 返回401错误 |
| test_get_current_user_expired_token | 过期token | 返回401错误 |
| test_logout_success | 有效token登出 | 返回成功 |
### 4.2 用户管理模块测试 (test_users.py)
#### 4.2.1 创建用户测试
| 测试用例 | 描述 | 预期结果 |
|---------|------|---------|
| test_create_user_admin_success | 管理员创建用户 | 返回创建的用户 |
| test_create_user_market_forbidden | 非管理员创建用户 | 返回403错误 |
| test_create_user_duplicate_username | 重复的用户名 | 返回409错误 |
| test_create_user_invalid_role | 无效的角色 | 返回400错误 |
#### 4.2.2 获取用户列表测试
| 测试用例 | 描述 | 预期结果 |
|---------|------|---------|
| test_get_users_admin_success | 管理员获取用户列表 | 返回用户列表 |
| test_get_users_market_forbidden | 非管理员获取用户列表 | 返回403错误 |
| test_get_users_with_pagination | 测试分页 | 返回正确的分页数据 |
| test_get_users_with_filter | 测试筛选 | 返回筛选后的数据 |
#### 4.2.3 更新用户测试
| 测试用例 | 描述 | 预期结果 |
|---------|------|---------|
| test_update_user_admin_success | 管理员更新用户 | 返回更新后的用户 |
| test_update_user_market_forbidden | 非管理员更新用户 | 返回403错误 |
| test_update_user_not_found | 更新不存在的用户 | 返回404错误 |
#### 4.2.4 删除用户测试
| 测试用例 | 描述 | 预期结果 |
|---------|------|---------|
| test_delete_user_admin_success | 管理员删除用户 | 返回成功 |
| test_delete_user_market_forbidden | 非管理员删除用户 | 返回403错误 |
| test_delete_user_not_found | 删除不存在的用户 | 返回404错误 |
#### 4.2.5 重置密码测试
| 测试用例 | 描述 | 预期结果 |
|---------|------|---------|
| test_reset_password_admin_success | 管理员重置密码 | 返回成功 |
| test_reset_password_market_forbidden | 非管理员重置密码 | 返回403错误 |
| test_reset_password_invalid_password | 无效的密码格式 | 返回400错误 |
### 4.3 项目管理模块测试 (test_projects.py)
#### 4.3.1 获取项目列表测试
| 测试用例 | 描述 | 预期结果 |
|---------|------|---------|
| test_get_projects_all_users_success | 所有用户获取项目列表 | 返回项目列表 |
| test_get_projects_with_pagination | 测试分页 | 返回正确的分页数据 |
| test_get_projects_with_filters | 测试组合筛选 | 返回筛选后的数据 |
| test_get_projects_sort_by_contract_amount | 按合同金额排序 | 返回排序后的数据 |
#### 4.3.2 创建项目测试
| 测试用例 | 描述 | 预期结果 |
|---------|------|---------|
| test_create_project_admin_success | 管理员创建项目 | 返回创建的项目 |
| test_create_project_market_success | 市场部用户创建项目 | 返回创建的项目 |
| test_create_project_other_forbidden | 其他部门用户创建项目 | 返回403错误 |
| test_create_project_duplicate_project_no | 重复的项目编号 | 返回409错误 |
| test_create_project_missing_required_fields | 缺少必填字段 | 返回400错误 |
#### 4.3.3 更新项目测试
| 测试用例 | 描述 | 预期结果 |
|---------|------|---------|
| test_update_project_admin_success | 管理员更新项目 | 返回更新后的项目 |
| test_update_project_market_own_project | 市场部更新自己的项目 | 返回更新后的项目 |
| test_update_project_market_other_project | 市场部更新其他人的项目 | 返回403错误 |
| test_update_project_other_financial_info | 其他部门更新财务信息 | 返回更新后的项目 |
| test_update_project_other_basic_info | 其他部门更新基础信息 | 返回403错误 |
| test_update_project_not_found | 更新不存在的项目 | 返回404错误 |
#### 4.3.4 删除项目测试
| 测试用例 | 描述 | 预期结果 |
|---------|------|---------|
| test_delete_project_admin_success | 管理员删除项目 | 返回成功 |
| test_delete_project_market_own_project | 市场部删除自己的项目 | 返回成功 |
| test_delete_project_market_other_project | 市场部删除其他人的项目 | 返回403错误 |
| test_delete_project_other_forbidden | 其他部门删除项目 | 返回403错误 |
| test_delete_project_not_found | 删除不存在的项目 | 返回404错误 |
### 4.4 统计功能测试 (test_statistics.py)
#### 4.4.1 基础统计测试
| 测试用例 | 描述 | 预期结果 |
|---------|------|---------|
| test_get_statistics_all_users_success | 所有用户获取统计 | 返回统计数据 |
| test_get_statistics_with_filters | 测试筛选条件 | 返回筛选后的统计 |
| test_get_statistics_empty_data | 无数据时统计 | 返回零值统计 |
#### 4.4.2 分组统计测试
| 测试用例 | 描述 | 预期结果 |
|---------|------|---------|
| test_get_group_statistics_by_engineering_type | 按工程类别分组 | 返回分组统计 |
| test_get_group_statistics_by_department | 按项目部分组 | 返回分组统计 |
| test_get_group_statistics_with_date_filter | 带日期筛选的分组统计 | 返回分组统计 |
#### 4.4.3 时间维度统计测试
| 测试用例 | 描述 | 预期结果 |
|---------|------|---------|
| test_get_timeline_statistics_by_month | 按月统计 | 返回时间维度统计 |
| test_get_timeline_statistics_by_year | 按年统计 | 返回时间维度统计 |
| test_get_timeline_statistics_with_filter | 带筛选的时间统计 | 返回时间维度统计 |
## 5. 测试Fixture
### 5.1 数据库Fixture
```python
@pytest.fixture
async def db_session():
# 创建测试数据库会话
# 测试结束后回滚
yield session
# 清理数据
```
### 5.2 用户Fixture
```python
@pytest.fixture
async def admin_user(db_session):
# 创建管理员用户
return user
@pytest.fixture
async def market_user(db_session):
# 创建市场部用户
return user
@pytest.fixture
async def other_user(db_session):
# 创建其他部门用户
return user
```
### 5.3 项目Fixture
```python
@pytest.fixture
async def test_project(db_session, admin_user):
# 创建测试项目
return project
```
### 5.4 Token Fixture
```python
@pytest.fixture
async def admin_token(client, admin_user):
# 获取管理员token
return token
```
## 6. 运行测试
### 6.1 运行所有测试
```bash
cd backend
pytest tests/
```
### 6.2 运行特定测试文件
```bash
pytest tests/test_auth.py
```
### 6.3 运行特定测试用例
```bash
pytest tests/test_auth.py::test_login_success
```
### 6.4 生成覆盖率报告
```bash
pytest tests/ --cov=src --cov-report=html
```
### 6.5 运行集成测试
```bash
pytest tests/ -m integration
```
### 6.6 运行单元测试
```bash
pytest tests/ -m unit
```
## 7. 持续集成
### 7.1 测试流程
1. 提交代码前运行单元测试
2. CI/CD流水线运行完整测试套件
3. 生成覆盖率报告
4. 测试通过才能合并代码
### 7.2 测试覆盖率要求
- 单元测试覆盖率 > 80%
- 关键业务逻辑覆盖率 > 90%
- API接口覆盖率 100%
## 8. 测试最佳实践
### 8.1 测试命名规范
- 测试文件: `test_<模块名>.py`
- 测试类: `Test<功能名>`
- 测试方法: `test_<功能>_<场景>`
### 8.2 测试编写原则
- 每个测试只验证一个功能点
- 测试之间相互独立
- 使用描述性的测试名称
- 遵循AAA模式:Arrange, Act, Assert
### 8.3 Mock使用
- Mock外部依赖(数据库、网络等)
- 不要Mock被测试的代码
- 保持Mock的行为真实
## 9. 常见问题
### 9.1 数据库连接失败
检查测试数据库配置,确保测试数据库已创建。
### 9.2 测试数据冲突
使用pytest fixture和scope控制测试数据的生命周期。
### 9.3 异步测试失败
使用`pytest-asyncio`,确保测试函数使用`async def`定义。
---
**文档维护**: 后端程序员
**更新时间**: 2026-01-25
+1
View File
@@ -0,0 +1 @@
# Backend Tests Package
Binary file not shown.
+182
View File
@@ -0,0 +1,182 @@
import sys
from pathlib import Path
backend_dir = Path(__file__).parent.parent
sys.path.insert(0, str(backend_dir))
import pytest
import asyncio
from typing import AsyncGenerator, Generator
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.pool import StaticPool
try:
from src.main import app
except ImportError:
from main import app
try:
from src.config.database import get_db
except ImportError:
from config.database import get_db
from src.models.user import User
try:
from src.models.base import Base
except ImportError:
from config.database import Base
from src.utils.password import hash_password
# 测试数据库配置
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
# 创建测试引擎
engine = create_async_engine(
TEST_DATABASE_URL,
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
# 创建测试会话工厂
TestingSessionLocal = async_sessionmaker(
engine, expire_on_commit=False, class_=AsyncSession
)
@pytest.fixture(scope="function")
async def db_session() -> AsyncGenerator[AsyncSession, None]:
"""创建测试数据库会话"""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with TestingSessionLocal() as session:
yield session
await session.rollback()
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
@pytest.fixture(scope="function")
async def client(db_session: AsyncSession) -> AsyncGenerator[AsyncClient, None]:
"""创建测试客户端"""
async def override_get_db():
yield db_session
app.dependency_overrides[get_db] = override_get_db
async with AsyncClient(app=app, base_url="http://test") as ac:
yield ac
app.dependency_overrides.clear()
@pytest.fixture
async def admin_user(db_session: AsyncSession) -> User:
"""创建管理员用户"""
user = User(
username="admin",
password_hash=hash_password("admin123"),
real_name="系统管理员",
department="管理部",
role="admin",
email="admin@test.com",
phone="13800000001",
is_active=True,
)
db_session.add(user)
await db_session.commit()
await db_session.refresh(user)
return user
@pytest.fixture
async def market_user(db_session: AsyncSession) -> User:
"""创建市场部用户"""
user = User(
username="market",
password_hash=hash_password("market123"),
real_name="市场部用户",
department="市场部",
role="market",
email="market@test.com",
phone="13800000002",
is_active=True,
)
db_session.add(user)
await db_session.commit()
await db_session.refresh(user)
return user
@pytest.fixture
async def other_user(db_session: AsyncSession) -> User:
"""创建其他部门用户"""
user = User(
username="other",
password_hash=hash_password("other123"),
real_name="其他部门用户",
department="技术部",
role="other",
email="other@test.com",
phone="13800000003",
is_active=True,
)
db_session.add(user)
await db_session.commit()
await db_session.refresh(user)
return user
@pytest.fixture
async def inactive_user(db_session: AsyncSession) -> User:
"""创建未激活用户"""
user = User(
username="inactive",
password_hash=hash_password("inactive123"),
real_name="未激活用户",
department="市场部",
role="market",
email="inactive@test.com",
phone="13800000004",
is_active=False,
)
db_session.add(user)
await db_session.commit()
await db_session.refresh(user)
return user
@pytest.fixture
async def admin_token(client: AsyncClient, admin_user: User) -> str:
"""获取管理员token"""
response = await client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "admin123"},
)
data = response.json()
return data["data"]["token"]
@pytest.fixture
async def market_token(client: AsyncClient, market_user: User) -> str:
"""获取市场部用户token"""
response = await client.post(
"/api/v1/auth/login",
json={"username": "market", "password": "market123"},
)
data = response.json()
return data["data"]["token"]
@pytest.fixture
async def other_token(client: AsyncClient, other_user: User) -> str:
"""获取其他部门用户token"""
response = await client.post(
"/api/v1/auth/login",
json={"username": "other", "password": "other123"},
)
data = response.json()
return data["data"]["token"]
+151
View File
@@ -0,0 +1,151 @@
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
@pytest.mark.unit
async def test_login_success(client: AsyncClient, admin_user):
"""测试用户登录成功"""
response = await client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "admin123"},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["message"] == "登录成功"
assert "token" in data["data"]
assert data["data"]["user"]["username"] == "admin"
assert data["data"]["user"]["role"] == "admin"
@pytest.mark.asyncio
@pytest.mark.unit
async def test_login_wrong_username(client: AsyncClient, admin_user):
"""测试错误的用户名"""
response = await client.post(
"/api/v1/auth/login",
json={"username": "wrong", "password": "admin123"},
)
assert response.status_code == 401
data = response.json()
assert data["success"] is False
assert data["error_code"] == "1002"
@pytest.mark.asyncio
@pytest.mark.unit
async def test_login_wrong_password(client: AsyncClient, admin_user):
"""测试错误的密码"""
response = await client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "wrong"},
)
assert response.status_code == 401
data = response.json()
assert data["success"] is False
assert data["error_code"] == "1002"
@pytest.mark.asyncio
@pytest.mark.unit
async def test_login_missing_username(client: AsyncClient, admin_user):
"""测试缺少用户名"""
response = await client.post(
"/api/v1/auth/login",
json={"password": "admin123"},
)
assert response.status_code == 400
data = response.json()
assert data["success"] is False
assert data["error_code"] == "1001"
@pytest.mark.asyncio
@pytest.mark.unit
async def test_login_missing_password(client: AsyncClient, admin_user):
"""测试缺少密码"""
response = await client.post(
"/api/v1/auth/login",
json={"username": "admin"},
)
assert response.status_code == 400
data = response.json()
assert data["success"] is False
assert data["error_code"] == "1001"
@pytest.mark.asyncio
@pytest.mark.unit
async def test_login_inactive_user(client: AsyncClient, inactive_user):
"""测试未激活用户登录"""
response = await client.post(
"/api/v1/auth/login",
json={"username": "inactive", "password": "inactive123"},
)
assert response.status_code == 401
data = response.json()
assert data["success"] is False
@pytest.mark.asyncio
@pytest.mark.unit
async def test_get_current_user_success(client: AsyncClient, admin_token: str):
"""测试获取当前用户信息成功"""
response = await client.get(
"/api/v1/auth/me",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["data"]["username"] == "admin"
assert data["data"]["role"] == "admin"
@pytest.mark.asyncio
@pytest.mark.unit
async def test_get_current_user_no_token(client: AsyncClient):
"""测试未提供token获取用户信息"""
response = await client.get("/api/v1/auth/me")
assert response.status_code == 401
data = response.json()
assert data["success"] is False
@pytest.mark.asyncio
@pytest.mark.unit
async def test_get_current_user_invalid_token(client: AsyncClient):
"""测试无效token获取用户信息"""
response = await client.get(
"/api/v1/auth/me",
headers={"Authorization": "Bearer invalid_token"},
)
assert response.status_code == 401
data = response.json()
assert data["success"] is False
assert data["error_code"] == "1003"
@pytest.mark.asyncio
@pytest.mark.unit
async def test_logout_success(client: AsyncClient, admin_token: str):
"""测试登出成功"""
response = await client.post(
"/api/v1/auth/logout",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["message"] == "登出成功"
+544
View File
@@ -0,0 +1,544 @@
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_projects_all_users_success(
client: AsyncClient, admin_token: str, db_session
):
"""测试所有用户获取项目列表成功"""
# 创建测试项目
from src.models.project import Project
from datetime import date
project = Project(
project_no="PRJ001",
name="测试项目",
engineering_type="基建",
contract_amount=100.0,
signing_date=date(2026, 1, 1),
created_by=1,
)
db_session.add(project)
await db_session.commit()
response = await client.get(
"/api/v1/projects",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert "items" in data["data"]
assert "total" in data["data"]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_projects_with_pagination(
client: AsyncClient, admin_token: str, db_session
):
"""测试项目列表分页"""
from src.models.project import Project
from datetime import date
# 创建5个测试项目
for i in range(5):
project = Project(
project_no=f"PRJ{i:03d}",
name=f"测试项目{i}",
engineering_type="基建",
contract_amount=100.0,
signing_date=date(2026, 1, 1),
created_by=1,
)
db_session.add(project)
await db_session.commit()
response = await client.get(
"/api/v1/projects?page=1&page_size=3",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["data"]["page"] == 1
assert data["data"]["page_size"] == 3
assert len(data["data"]["items"]) == 3
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_projects_with_filters(
client: AsyncClient, admin_token: str, db_session
):
"""测试项目列表组合筛选"""
from src.models.project import Project
from datetime import date
# 创建不同类型的项目
project1 = Project(
project_no="PRJ001",
name="基建项目",
engineering_type="基建",
contract_amount=100.0,
signing_date=date(2026, 1, 1),
created_by=1,
)
project2 = Project(
project_no="PRJ002",
name="业扩项目",
engineering_type="业扩",
contract_amount=200.0,
signing_date=date(2026, 2, 1),
created_by=1,
)
db_session.add(project1)
db_session.add(project2)
await db_session.commit()
# 筛选:工程类别=基建 AND 签订日期>=2026-01-01
response = await client.get(
"/api/v1/projects?engineering_type=基建&signing_date_start=2026-01-01",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert len(data["data"]["items"]) == 1
assert data["data"]["items"][0]["engineering_type"] == "基建"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_projects_sort_by_contract_amount(
client: AsyncClient, admin_token: str, db_session
):
"""测试按合同金额排序"""
from src.models.project import Project
from datetime import date
# 创建不同金额的项目
project1 = Project(
project_no="PRJ001",
name="项目1",
engineering_type="基建",
contract_amount=100.0,
signing_date=date(2026, 1, 1),
created_by=1,
)
project2 = Project(
project_no="PRJ002",
name="项目2",
engineering_type="基建",
contract_amount=200.0,
signing_date=date(2026, 1, 1),
created_by=1,
)
db_session.add(project1)
db_session.add(project2)
await db_session.commit()
response = await client.get(
"/api/v1/projects?sort_by=contract_amount&sort_order=desc",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["data"]["items"][0]["contract_amount"] == 200.0
assert data["data"]["items"][1]["contract_amount"] == 100.0
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_project_admin_success(
client: AsyncClient, admin_token: str, admin_user
):
"""测试管理员创建项目成功"""
response = await client.post(
"/api/v1/projects",
headers={"Authorization": f"Bearer {admin_token}"},
json={
"project_no": "PRJ001",
"name": "新项目",
"engineering_type": "基建",
"contract_amount": 100.0,
"signing_date": "2026-01-01",
},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["message"] == "项目创建成功"
assert data["data"]["project_no"] == "PRJ001"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_project_market_success(
client: AsyncClient, market_token: str, market_user
):
"""测试市场部用户创建项目成功"""
response = await client.post(
"/api/v1/projects",
headers={"Authorization": f"Bearer {market_token}"},
json={
"project_no": "PRJ002",
"name": "市场部项目",
"engineering_type": "业扩",
"contract_amount": 200.0,
"signing_date": "2026-01-01",
},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_project_other_forbidden(
client: AsyncClient, other_token: str, other_user
):
"""测试其他部门用户创建项目被禁止"""
response = await client.post(
"/api/v1/projects",
headers={"Authorization": f"Bearer {other_token}"},
json={
"project_no": "PRJ003",
"name": "其他部门项目",
"engineering_type": "基建",
"contract_amount": 100.0,
},
)
assert response.status_code == 403
data = response.json()
assert data["success"] is False
assert data["error_code"] == "3001"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_project_duplicate_project_no(
client: AsyncClient, admin_token: str, db_session, admin_user
):
"""测试创建重复项目编号"""
from src.models.project import Project
from datetime import date
project = Project(
project_no="PRJ001",
name="已存在项目",
engineering_type="基建",
contract_amount=100.0,
signing_date=date(2026, 1, 1),
created_by=admin_user.id,
)
db_session.add(project)
await db_session.commit()
response = await client.post(
"/api/v1/projects",
headers={"Authorization": f"Bearer {admin_token}"},
json={
"project_no": "PRJ001",
"name": "新项目",
"engineering_type": "基建",
"contract_amount": 100.0,
},
)
assert response.status_code == 409
data = response.json()
assert data["success"] is False
assert data["error_code"] == "2002"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_project_missing_required_fields(
client: AsyncClient, admin_token: str
):
"""测试创建项目时缺少必填字段"""
response = await client.post(
"/api/v1/projects",
headers={"Authorization": f"Bearer {admin_token}"},
json={
"project_no": "PRJ001",
# 缺少 name
"engineering_type": "基建",
"contract_amount": 100.0,
},
)
assert response.status_code == 400
data = response.json()
assert data["success"] is False
assert data["error_code"] == "1001"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_project_admin_success(
client: AsyncClient, admin_token: str, db_session, admin_user
):
"""测试管理员更新项目成功"""
from src.models.project import Project
from datetime import date
project = Project(
project_no="PRJ001",
name="原项目名称",
engineering_type="基建",
contract_amount=100.0,
signing_date=date(2026, 1, 1),
created_by=admin_user.id,
)
db_session.add(project)
await db_session.commit()
await db_session.refresh(project)
response = await client.put(
f"/api/v1/projects/{project.id}",
headers={"Authorization": f"Bearer {admin_token}"},
json={"name": "更新后的项目名称", "contract_amount": 200.0},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["data"]["name"] == "更新后的项目名称"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_project_market_own_project(
client: AsyncClient, market_token: str, db_session, market_user
):
"""测试市场部用户更新自己的项目"""
from src.models.project import Project
from datetime import date
project = Project(
project_no="PRJ001",
name="市场部项目",
engineering_type="业扩",
contract_amount=100.0,
signing_date=date(2026, 1, 1),
created_by=market_user.id,
)
db_session.add(project)
await db_session.commit()
await db_session.refresh(project)
response = await client.put(
f"/api/v1/projects/{project.id}",
headers={"Authorization": f"Bearer {market_token}"},
json={"name": "更新后的项目名称"},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_project_market_other_project(
client: AsyncClient, market_token: str, db_session, admin_user
):
"""测试市场部用户更新其他人的项目被禁止"""
from src.models.project import Project
from datetime import date
project = Project(
project_no="PRJ001",
name="管理员项目",
engineering_type="基建",
contract_amount=100.0,
signing_date=date(2026, 1, 1),
created_by=admin_user.id,
)
db_session.add(project)
await db_session.commit()
await db_session.refresh(project)
response = await client.put(
f"/api/v1/projects/{project.id}",
headers={"Authorization": f"Bearer {market_token}"},
json={"name": "更新后的项目名称"},
)
assert response.status_code == 403
data = response.json()
assert data["success"] is False
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_project_not_found(
client: AsyncClient, admin_token: str
):
"""测试更新不存在的项目"""
response = await client.put(
"/api/v1/projects/99999",
headers={"Authorization": f"Bearer {admin_token}"},
json={"name": "更新后的项目名称"},
)
assert response.status_code == 404
data = response.json()
assert data["success"] is False
assert data["error_code"] == "2001"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_delete_project_admin_success(
client: AsyncClient, admin_token: str, db_session, admin_user
):
"""测试管理员删除项目成功"""
from src.models.project import Project
from datetime import date
project = Project(
project_no="PRJ001",
name="待删除项目",
engineering_type="基建",
contract_amount=100.0,
signing_date=date(2026, 1, 1),
created_by=admin_user.id,
)
db_session.add(project)
await db_session.commit()
await db_session.refresh(project)
response = await client.delete(
f"/api/v1/projects/{project.id}",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["message"] == "项目删除成功"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_delete_project_market_own_project(
client: AsyncClient, market_token: str, db_session, market_user
):
"""测试市场部用户删除自己的项目成功"""
from src.models.project import Project
from datetime import date
project = Project(
project_no="PRJ001",
name="市场部项目",
engineering_type="业扩",
contract_amount=100.0,
signing_date=date(2026, 1, 1),
created_by=market_user.id,
)
db_session.add(project)
await db_session.commit()
await db_session.refresh(project)
response = await client.delete(
f"/api/v1/projects/{project.id}",
headers={"Authorization": f"Bearer {market_token}"},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
@pytest.mark.asyncio
@pytest.mark.integration
async def test_delete_project_market_other_project_forbidden(
client: AsyncClient, market_token: str, db_session, admin_user
):
"""测试市场部用户删除其他人的项目被禁止"""
from src.models.project import Project
from datetime import date
project = Project(
project_no="PRJ001",
name="管理员项目",
engineering_type="基建",
contract_amount=100.0,
signing_date=date(2026, 1, 1),
created_by=admin_user.id,
)
db_session.add(project)
await db_session.commit()
await db_session.refresh(project)
response = await client.delete(
f"/api/v1/projects/{project.id}",
headers={"Authorization": f"Bearer {market_token}"},
)
assert response.status_code == 403
data = response.json()
assert data["success"] is False
@pytest.mark.asyncio
@pytest.mark.integration
async def test_delete_project_not_found(
client: AsyncClient, admin_token: str
):
"""测试删除不存在的项目"""
response = await client.delete(
"/api/v1/projects/99999",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 404
data = response.json()
assert data["success"] is False
assert data["error_code"] == "2001"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_project_detail_success(
client: AsyncClient, admin_token: str, db_session, admin_user
):
"""测试获取项目详情成功"""
from src.models.project import Project
from datetime import date
project = Project(
project_no="PRJ001",
name="测试项目",
engineering_type="基建",
contract_amount=100.0,
signing_date=date(2026, 1, 1),
created_by=admin_user.id,
)
db_session.add(project)
await db_session.commit()
await db_session.refresh(project)
response = await client.get(
f"/api/v1/projects/{project.id}",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["data"]["project_no"] == "PRJ001"
+397
View File
@@ -0,0 +1,397 @@
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_statistics_all_users_success(
client: AsyncClient, admin_token: str, db_session, admin_user
):
"""测试所有用户获取基础统计成功"""
from src.models.project import Project
from datetime import date
# 创建测试项目
project1 = Project(
project_no="PRJ001",
name="项目1",
engineering_type="基建",
contract_amount=100.0,
actual_receipt_amount=50.0,
actual_payment_amount=40.0,
cumulative_progress=50.0,
signing_date=date(2026, 1, 1),
created_by=admin_user.id,
)
project2 = Project(
project_no="PRJ002",
name="项目2",
engineering_type="业扩",
contract_amount=200.0,
actual_receipt_amount=100.0,
actual_payment_amount=80.0,
cumulative_progress=50.0,
signing_date=date(2026, 1, 1),
created_by=admin_user.id,
)
db_session.add(project1)
db_session.add(project2)
await db_session.commit()
response = await client.get(
"/api/v1/projects/statistics",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["data"]["total_count"] == 2
assert data["data"]["total_contract_amount"] == 300.0
assert data["data"]["total_receipt_amount"] == 150.0
assert data["data"]["total_payment_amount"] == 120.0
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_statistics_with_filters(
client: AsyncClient, admin_token: str, db_session, admin_user
):
"""测试带筛选条件的统计"""
from src.models.project import Project
from datetime import date
# 创建不同类型的项目
project1 = Project(
project_no="PRJ001",
name="基建项目",
engineering_type="基建",
contract_amount=100.0,
signing_date=date(2026, 1, 1),
created_by=admin_user.id,
)
project2 = Project(
project_no="PRJ002",
name="业扩项目",
engineering_type="业扩",
contract_amount=200.0,
signing_date=date(2026, 2, 1),
created_by=admin_user.id,
)
db_session.add(project1)
db_session.add(project2)
await db_session.commit()
# 筛选:工程类别=基建
response = await client.get(
"/api/v1/projects/statistics?engineering_type=基建",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["data"]["total_count"] == 1
assert data["data"]["total_contract_amount"] == 100.0
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_statistics_empty_data(
client: AsyncClient, admin_token: str
):
"""测试无数据时的统计"""
response = await client.get(
"/api/v1/projects/statistics",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["data"]["total_count"] == 0
assert data["data"]["total_contract_amount"] == 0
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_group_statistics_by_engineering_type(
client: AsyncClient, admin_token: str, db_session, admin_user
):
"""测试按工程类别分组统计"""
from src.models.project import Project
from datetime import date
# 创建不同类型的项目
for i in range(3):
project = Project(
project_no=f"PRJ{i:03d}",
name=f"基建项目{i}",
engineering_type="基建",
contract_amount=100.0,
actual_receipt_amount=80.0,
actual_payment_amount=70.0,
signing_date=date(2026, 1, 1),
created_by=admin_user.id,
)
db_session.add(project)
for i in range(2):
project = Project(
project_no=f"PRJ{i+3:03d}",
name=f"业扩项目{i}",
engineering_type="业扩",
contract_amount=200.0,
actual_receipt_amount=150.0,
actual_payment_amount=130.0,
signing_date=date(2026, 1, 1),
created_by=admin_user.id,
)
db_session.add(project)
await db_session.commit()
response = await client.get(
"/api/v1/projects/statistics/group?group_by=engineering_type",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert len(data["data"]) == 2
# 验证基建工程统计
infra = next(item for item in data["data"] if item["engineering_type"] == "基建")
assert infra["count"] == 3
assert infra["total_contract_amount"] == 300.0
# 验证业扩工程统计
market = next(item for item in data["data"] if item["engineering_type"] == "业扩")
assert market["count"] == 2
assert market["total_contract_amount"] == 400.0
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_group_statistics_by_department(
client: AsyncClient, admin_token: str, db_session, admin_user
):
"""测试按项目部分组统计"""
from src.models.project import Project
from datetime import date
# 创建不同项目部的项目
project1 = Project(
project_no="PRJ001",
name="项目部一项目",
engineering_type="基建",
contract_amount=100.0,
project_department="项目部一",
signing_date=date(2026, 1, 1),
created_by=admin_user.id,
)
project2 = Project(
project_no="PRJ002",
name="项目部二项目",
engineering_type="业扩",
contract_amount=200.0,
project_department="项目部二",
signing_date=date(2026, 1, 1),
created_by=admin_user.id,
)
db_session.add(project1)
db_session.add(project2)
await db_session.commit()
response = await client.get(
"/api/v1/projects/statistics/group?group_by=project_department",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert len(data["data"]) == 2
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_group_statistics_with_date_filter(
client: AsyncClient, admin_token: str, db_session, admin_user
):
"""测试带日期筛选的分组统计"""
from src.models.project import Project
from datetime import date
# 创建不同日期的项目
project1 = Project(
project_no="PRJ001",
name="一月项目",
engineering_type="基建",
contract_amount=100.0,
signing_date=date(2026, 1, 15),
created_by=admin_user.id,
)
project2 = Project(
project_no="PRJ002",
name="二月项目",
engineering_type="业扩",
contract_amount=200.0,
signing_date=date(2026, 2, 15),
created_by=admin_user.id,
)
db_session.add(project1)
db_session.add(project2)
await db_session.commit()
# 只统计1月的项目
response = await client.get(
"/api/v1/projects/statistics/group?group_by=engineering_type&signing_date_start=2026-01-01&signing_date_end=2026-01-31",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert len(data["data"]) == 1
assert data["data"][0]["count"] == 1
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_timeline_statistics_by_month(
client: AsyncClient, admin_token: str, db_session, admin_user
):
"""测试按月统计"""
from src.models.project import Project
from datetime import date
# 创建不同月份的项目
for month in [1, 1, 2, 2, 2]:
project = Project(
project_no=f"PRJ{month:02d}",
name=f"{month}月项目",
engineering_type="基建",
contract_amount=100.0,
signing_date=date(2026, month, 15),
created_by=admin_user.id,
)
db_session.add(project)
await db_session.commit()
response = await client.get(
"/api/v1/projects/statistics/timeline?time_field=signing_date&group_by=month",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert len(data["data"]) == 2
# 验证1月统计
jan = next(item for item in data["data"] if item["month"] == "2026-01")
assert jan["count"] == 2
# 验证2月统计
feb = next(item for item in data["data"] if item["month"] == "2026-02")
assert feb["count"] == 3
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_timeline_statistics_by_year(
client: AsyncClient, admin_token: str, db_session, admin_user
):
"""测试按年统计"""
from src.models.project import Project
from datetime import date
# 创建不同年份的项目
for year in [2025, 2026, 2026]:
project = Project(
project_no=f"PRJ{year}",
name=f"{year}年项目",
engineering_type="基建",
contract_amount=100.0,
signing_date=date(year, 1, 1),
created_by=admin_user.id,
)
db_session.add(project)
await db_session.commit()
response = await client.get(
"/api/v1/projects/statistics/timeline?time_field=signing_date&group_by=year",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert len(data["data"]) == 2
# 验证2025年统计
y2025 = next(item for item in data["data"] if item["year"] == "2025")
assert y2025["count"] == 1
# 验证2026年统计
y2026 = next(item for item in data["data"] if item["year"] == "2026")
assert y2026["count"] == 2
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_timeline_statistics_with_filter(
client: AsyncClient, admin_token: str, db_session, admin_user
):
"""测试带筛选条件的时间统计"""
from src.models.project import Project
from datetime import date
# 创建不同类型和日期的项目
project1 = Project(
project_no="PRJ001",
name="基建一月",
engineering_type="基建",
contract_amount=100.0,
signing_date=date(2026, 1, 1),
created_by=admin_user.id,
)
project2 = Project(
project_no="PRJ002",
name="业扩一月",
engineering_type="业扩",
contract_amount=200.0,
signing_date=date(2026, 1, 1),
created_by=admin_user.id,
)
project3 = Project(
project_no="PRJ003",
name="业扩二月",
engineering_type="业扩",
contract_amount=300.0,
signing_date=date(2026, 2, 1),
created_by=admin_user.id,
)
db_session.add(project1)
db_session.add(project2)
db_session.add(project3)
await db_session.commit()
# 只统计业扩项目
response = await client.get(
"/api/v1/projects/statistics/timeline?time_field=signing_date&group_by=month&engineering_type=业扩",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert len(data["data"]) == 2
# 验证1月业扩项目统计
jan = next(item for item in data["data"] if item["month"] == "2026-01")
assert jan["count"] == 1
+332
View File
@@ -0,0 +1,332 @@
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_user_admin_success(
client: AsyncClient, admin_token: str
):
"""测试管理员创建用户成功"""
response = await client.post(
"/api/v1/users",
headers={"Authorization": f"Bearer {admin_token}"},
json={
"username": "newuser",
"password": "newpass123",
"real_name": "新用户",
"department": "技术部",
"role": "other",
"email": "newuser@test.com",
"phone": "13800000005",
},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["message"] == "用户创建成功"
assert data["data"]["username"] == "newuser"
assert data["data"]["role"] == "other"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_user_market_forbidden(
client: AsyncClient, market_token: str
):
"""测试市场部用户创建用户被禁止"""
response = await client.post(
"/api/v1/users",
headers={"Authorization": f"Bearer {market_token}"},
json={
"username": "newuser",
"password": "newpass123",
"real_name": "新用户",
"department": "技术部",
"role": "other",
},
)
assert response.status_code == 403
data = response.json()
assert data["success"] is False
assert data["error_code"] == "3001"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_user_duplicate_username(
client: AsyncClient, admin_token: str, admin_user
):
"""测试创建重复用户名"""
response = await client.post(
"/api/v1/users",
headers={"Authorization": f"Bearer {admin_token}"},
json={
"username": "admin",
"password": "newpass123",
"real_name": "重复用户",
"department": "技术部",
"role": "other",
},
)
assert response.status_code == 409
data = response.json()
assert data["success"] is False
assert data["error_code"] == "2002"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_user_invalid_role(
client: AsyncClient, admin_token: str
):
"""测试创建用户时使用无效角色"""
response = await client.post(
"/api/v1/users",
headers={"Authorization": f"Bearer {admin_token}"},
json={
"username": "newuser",
"password": "newpass123",
"real_name": "新用户",
"department": "技术部",
"role": "invalid",
},
)
assert response.status_code == 400
data = response.json()
assert data["success"] is False
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_users_admin_success(
client: AsyncClient, admin_token: str
):
"""测试管理员获取用户列表成功"""
response = await client.get(
"/api/v1/users",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert "items" in data["data"]
assert "total" in data["data"]
assert len(data["data"]["items"]) >= 1
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_users_market_forbidden(
client: AsyncClient, market_token: str
):
"""测试市场部用户获取用户列表被禁止"""
response = await client.get(
"/api/v1/users",
headers={"Authorization": f"Bearer {market_token}"},
)
assert response.status_code == 403
data = response.json()
assert data["success"] is False
assert data["error_code"] == "3001"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_users_with_pagination(
client: AsyncClient, admin_token: str, admin_user, market_user, other_user
):
"""测试用户列表分页"""
response = await client.get(
"/api/v1/users?page=1&page_size=2",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["data"]["page"] == 1
assert data["data"]["page_size"] == 2
assert len(data["data"]["items"]) == 2
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_users_with_filter(
client: AsyncClient, admin_token: str, admin_user, market_user, other_user
):
"""测试用户列表筛选"""
response = await client.get(
"/api/v1/users?department=市场部",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
for user in data["data"]["items"]:
assert user["department"] == "市场部"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_user_admin_success(
client: AsyncClient, admin_token: str, market_user
):
"""测试管理员更新用户成功"""
response = await client.put(
f"/api/v1/users/{market_user.id}",
headers={"Authorization": f"Bearer {admin_token}"},
json={
"real_name": "更新后的姓名",
"email": "updated@test.com",
},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["data"]["real_name"] == "更新后的姓名"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_user_market_forbidden(
client: AsyncClient, market_token: str, other_user
):
"""测试市场部用户更新用户被禁止"""
response = await client.put(
f"/api/v1/users/{other_user.id}",
headers={"Authorization": f"Bearer {market_token}"},
json={"real_name": "更新后的姓名"},
)
assert response.status_code == 403
data = response.json()
assert data["success"] is False
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_user_not_found(
client: AsyncClient, admin_token: str
):
"""测试更新不存在的用户"""
response = await client.put(
"/api/v1/users/99999",
headers={"Authorization": f"Bearer {admin_token}"},
json={"real_name": "更新后的姓名"},
)
assert response.status_code == 404
data = response.json()
assert data["success"] is False
assert data["error_code"] == "2001"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_delete_user_admin_success(
client: AsyncClient, admin_token: str, other_user
):
"""测试管理员删除用户成功"""
response = await client.delete(
f"/api/v1/users/{other_user.id}",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["message"] == "用户删除成功"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_delete_user_market_forbidden(
client: AsyncClient, market_token: str, other_user
):
"""测试市场部用户删除用户被禁止"""
response = await client.delete(
f"/api/v1/users/{other_user.id}",
headers={"Authorization": f"Bearer {market_token}"},
)
assert response.status_code == 403
data = response.json()
assert data["success"] is False
@pytest.mark.asyncio
@pytest.mark.integration
async def test_delete_user_not_found(
client: AsyncClient, admin_token: str
):
"""测试删除不存在的用户"""
response = await client.delete(
"/api/v1/users/99999",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 404
data = response.json()
assert data["success"] is False
assert data["error_code"] == "2001"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_reset_password_admin_success(
client: AsyncClient, admin_token: str, other_user
):
"""测试管理员重置密码成功"""
response = await client.post(
f"/api/v1/users/{other_user.id}/reset-password",
headers={"Authorization": f"Bearer {admin_token}"},
json={"new_password": "newpassword123"},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["message"] == "密码重置成功"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_reset_password_market_forbidden(
client: AsyncClient, market_token: str, other_user
):
"""测试市场部用户重置密码被禁止"""
response = await client.post(
f"/api/v1/users/{other_user.id}/reset-password",
headers={"Authorization": f"Bearer {market_token}"},
json={"new_password": "newpassword123"},
)
assert response.status_code == 403
data = response.json()
assert data["success"] is False
@pytest.mark.asyncio
@pytest.mark.integration
async def test_reset_password_invalid_password(
client: AsyncClient, admin_token: str, other_user
):
"""测试重置密码时使用无效密码格式"""
response = await client.post(
f"/api/v1/users/{other_user.id}/reset-password",
headers={"Authorization": f"Bearer {admin_token}"},
json={"new_password": "123"},
)
assert response.status_code == 400
data = response.json()
assert data["success"] is False
File diff suppressed because it is too large Load Diff
-934
View File
@@ -1,934 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>海洋项目管理系统 - 设计参考</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: #f0f2f5;
color: #333;
}
.page-selector {
position: fixed;
top: 10px;
left: 10px;
z-index: 1000;
background: white;
padding: 15px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
.page-selector h3 {
margin-bottom: 10px;
font-size: 16px;
color: #1890ff;
}
.page-selector button {
display: block;
width: 100%;
padding: 8px 12px;
margin-bottom: 5px;
background: #1890ff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s;
}
.page-selector button:hover {
background: #40a9ff;
}
.page-selector button.active {
background: #096dd9;
}
.page {
display: none;
}
.page.active {
display: block;
}
/* Login Page Styles */
.login-page {
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
background: linear-gradient(135deg, #001529 0%, #1890ff 100%);
}
.login-box {
width: 400px;
background: white;
border-radius: 8px;
padding: 40px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.login-title {
font-size: 24px;
font-weight: 600;
color: #1890ff;
text-align: center;
margin-bottom: 32px;
}
.form-group {
margin-bottom: 20px;
}
.form-label {
display: block;
margin-bottom: 8px;
font-size: 14px;
font-weight: 500;
color: #333;
}
.form-input {
width: 100%;
height: 40px;
padding: 8px 12px;
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 14px;
color: #333;
}
.form-input:focus {
border-color: #1890ff;
outline: none;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
.checkbox-group {
display: flex;
align-items: center;
margin-bottom: 20px;
}
.checkbox-group input {
margin-right: 8px;
}
.btn-login {
width: 100%;
height: 40px;
background: #1890ff;
color: white;
border: none;
border-radius: 4px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
}
.btn-login:hover {
background: #40a9ff;
}
/* Main Layout Styles */
.main-layout {
display: flex;
min-height: 100vh;
}
.sidebar {
width: 256px;
background: white;
border-right: 1px solid #e8e8e8;
padding-top: 64px;
position: fixed;
left: 0;
top: 0;
bottom: 0;
overflow-y: auto;
}
.menu-item {
padding: 12px 24px;
display: flex;
align-items: center;
gap: 10px;
color: #666;
cursor: pointer;
border-left: 3px solid transparent;
transition: all 0.3s;
}
.menu-item:hover {
background: #e6f7ff;
color: #1890ff;
}
.menu-item.active {
background: #e6f7ff;
color: #1890ff;
border-left-color: #1890ff;
}
.header {
height: 64px;
background: #001529;
position: fixed;
top: 0;
left: 256px;
right: 0;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 24px;
color: white;
z-index: 100;
}
.logo {
font-size: 18px;
font-weight: 600;
}
.user-info {
display: flex;
align-items: center;
gap: 12px;
}
.content {
margin-left: 256px;
margin-top: 64px;
padding: 24px;
background: #f0f2f5;
min-height: calc(100vh - 64px);
}
/* Dashboard Styles */
.stat-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 24px;
margin-bottom: 24px;
}
.stat-card {
background: white;
padding: 24px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.stat-label {
font-size: 14px;
color: #666;
margin-bottom: 8px;
}
.stat-value {
font-size: 32px;
font-weight: 600;
color: #1890ff;
}
.chart-section {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 24px;
margin-bottom: 24px;
}
.card {
background: white;
padding: 24px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.card-title {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 20px;
}
.chart-placeholder {
height: 300px;
background: #fafafa;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
color: #999;
font-size: 14px;
}
.recent-project {
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
}
.project-name {
font-size: 14px;
font-weight: 500;
color: #333;
}
.project-meta {
font-size: 12px;
color: #999;
margin-top: 4px;
}
.project-amount {
font-size: 14px;
font-weight: 600;
color: #1890ff;
}
/* Project List Styles */
.toolbar {
background: white;
padding: 16px 24px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
margin-bottom: 24px;
display: flex;
justify-content: space-between;
align-items: center;
}
.toolbar-left {
display: flex;
gap: 12px;
align-items: center;
}
.search-input {
width: 240px;
height: 32px;
padding: 4px 12px;
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 14px;
}
.select-input {
width: 120px;
height: 32px;
padding: 4px 12px;
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 14px;
}
.btn-primary {
height: 32px;
padding: 0 16px;
background: #1890ff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.btn-primary:hover {
background: #40a9ff;
}
.table-container {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
overflow: hidden;
}
table {
width: 100%;
border-collapse: collapse;
}
thead th {
background: #fafafa;
padding: 12px 16px;
text-align: left;
font-weight: 600;
color: #333;
border-bottom: 1px solid #e8e8e8;
font-size: 14px;
}
tbody td {
padding: 12px 16px;
border-bottom: 1px solid #e8e8e8;
color: #666;
font-size: 14px;
}
tbody tr:hover {
background: #fafafa;
}
.action-link {
color: #1890ff;
cursor: pointer;
margin-right: 12px;
}
.action-link:hover {
color: #40a9ff;
}
.tag {
display: inline-block;
padding: 2px 8px;
border-radius: 2px;
font-size: 12px;
line-height: 1.5;
}
.tag-blue {
background: #e6f7ff;
border: 1px solid #91d5ff;
color: #1890ff;
}
.tag-green {
background: #f6ffed;
border: 1px solid #b7eb8f;
color: #52c41a;
}
.pagination {
height: 48px;
background: white;
border-top: 1px solid #e8e8e8;
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
}
.page-btn {
width: 32px;
height: 32px;
border: 1px solid #d9d9d9;
border-radius: 4px;
background: white;
color: #666;
cursor: pointer;
transition: all 0.3s;
}
.page-btn:hover {
border-color: #1890ff;
color: #1890ff;
}
.page-btn.active {
background: #1890ff;
color: white;
border-color: #1890ff;
}
/* Project Detail Styles */
.breadcrumb {
height: 32px;
margin-bottom: 16px;
font-size: 14px;
color: #666;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.page-title {
font-size: 24px;
font-weight: 600;
color: #333;
}
.info-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 24px;
margin-bottom: 24px;
}
.info-card {
background: white;
padding: 24px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.info-card-title {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #e8e8e8;
}
.info-item {
display: flex;
margin-bottom: 12px;
}
.info-item-label {
width: 120px;
font-size: 14px;
color: #666;
}
.info-item-value {
flex: 1;
font-size: 14px;
color: #333;
}
.full-width {
grid-column: span 2;
}
.btn-group {
display: flex;
gap: 12px;
}
.btn-edit {
height: 32px;
padding: 0 16px;
background: #1890ff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.btn-delete {
height: 32px;
padding: 0 16px;
background: #ff4d4f;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
</style>
</head>
<body>
<!-- Page Selector -->
<div class="page-selector">
<h3>页面切换</h3>
<button class="active" onclick="showPage('login')">登录页面</button>
<button onclick="showPage('dashboard')">仪表盘</button>
<button onclick="showPage('project-list')">项目列表</button>
<button onclick="showPage('project-detail')">项目详情</button>
</div>
<!-- Login Page -->
<div id="login" class="page login-page active">
<div class="login-box">
<h1 class="login-title">海洋项目管理系统</h1>
<div class="form-group">
<label class="form-label">用户名</label>
<input type="text" class="form-input" placeholder="请输入用户名">
</div>
<div class="form-group">
<label class="form-label">密码</label>
<input type="password" class="form-input" placeholder="请输入密码">
</div>
<div class="checkbox-group">
<input type="checkbox" id="remember">
<label for="remember">记住密码</label>
</div>
<button class="btn-login">登录</button>
</div>
</div>
<!-- Dashboard Page -->
<div id="dashboard" class="page">
<div class="main-layout">
<div class="sidebar">
<div class="menu-item active">📊 仪表盘</div>
<div class="menu-item">📁 项目管理</div>
<div class="menu-item">👥 用户管理</div>
<div class="menu-item">📈 项目统计</div>
</div>
<div style="flex: 1">
<div class="header">
<div class="logo">海洋项目管理系统</div>
<div class="user-info">
<span>张三 (市场部)</span>
<button class="btn-primary" style="height: 28px; font-size: 12px;">登出</button>
</div>
</div>
<div class="content">
<h2 style="margin-bottom: 24px; font-size: 24px; font-weight: 600; color: #333;">仪表盘</h2>
<div class="stat-grid">
<div class="stat-card">
<div class="stat-label">项目总数</div>
<div class="stat-value">156</div>
</div>
<div class="stat-card">
<div class="stat-label">进行中</div>
<div class="stat-value">68</div>
</div>
<div class="stat-card">
<div class="stat-label">已完成</div>
<div class="stat-value">88</div>
</div>
<div class="stat-card">
<div class="stat-label">总合同金额(万元)</div>
<div class="stat-value">12,450</div>
</div>
</div>
<div class="chart-section">
<div class="card">
<div class="card-title">项目趋势图</div>
<div class="chart-placeholder">
[柱状图/折线图显示区域]
</div>
</div>
<div class="card">
<div class="card-title">最近项目</div>
<div class="recent-project">
<div class="project-name">某电力基建工程项目</div>
<div class="project-meta">2026-01-25</div>
<div class="project-amount">950万元</div>
</div>
<div class="recent-project">
<div class="project-name">业扩工程项目</div>
<div class="project-meta">2026-01-24</div>
<div class="project-amount">500万元</div>
</div>
<div class="recent-project">
<div class="project-name">客户工程项目</div>
<div class="project-meta">2026-01-23</div>
<div class="project-amount">300万元</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Project List Page -->
<div id="project-list" class="page">
<div class="main-layout">
<div class="sidebar">
<div class="menu-item">📊 仪表盘</div>
<div class="menu-item active">📁 项目管理</div>
<div class="menu-item">👥 用户管理</div>
<div class="menu-item">📈 项目统计</div>
</div>
<div style="flex: 1">
<div class="header">
<div class="logo">海洋项目管理系统</div>
<div class="user-info">
<span>张三 (市场部)</span>
<button class="btn-primary" style="height: 28px; font-size: 12px;">登出</button>
</div>
</div>
<div class="content">
<h2 style="margin-bottom: 24px; font-size: 24px; font-weight: 600; color: #333;">项目管理</h2>
<div class="toolbar">
<div class="toolbar-left">
<input type="text" class="search-input" placeholder="搜索项目名称、合同编号">
<select class="select-input">
<option>工程类别</option>
<option>基建</option>
<option>业扩</option>
<option>客户</option>
</select>
<select class="select-input">
<option>所属项目部</option>
<option>项目部一</option>
<option>项目部二</option>
</select>
</div>
<button class="btn-primary">+ 新建项目</button>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>合同编号</th>
<th>项目名称</th>
<th>工程类别</th>
<th>签订日期</th>
<th>合同金额(万元)</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr>
<td>PRJ2026001</td>
<td>某电力基建工程项目</td>
<td><span class="tag tag-blue">基建</span></td>
<td>2026-01-25</td>
<td>950.00</td>
<td><span class="tag tag-green">进行中</span></td>
<td>
<span class="action-link">查看</span>
<span class="action-link">编辑</span>
<span class="action-link" style="color: #ff4d4f;">删除</span>
</td>
</tr>
<tr>
<td>PRJ2026002</td>
<td>业扩工程项目</td>
<td><span class="tag tag-blue">业扩</span></td>
<td>2026-01-24</td>
<td>500.00</td>
<td><span class="tag tag-green">进行中</span></td>
<td>
<span class="action-link">查看</span>
<span class="action-link">编辑</span>
<span class="action-link" style="color: #ff4d4f;">删除</span>
</td>
</tr>
<tr>
<td>PRJ2026003</td>
<td>客户工程项目</td>
<td><span class="tag tag-blue">客户</span></td>
<td>2026-01-23</td>
<td>300.00</td>
<td><span class="tag" style="background: #f5f5f5; color: #999;">已完成</span></td>
<td>
<span class="action-link">查看</span>
<span class="action-link">编辑</span>
<span class="action-link" style="color: #ff4d4f;">删除</span>
</td>
</tr>
</tbody>
</table>
<div class="pagination">
<button class="page-btn">&lt;</button>
<button class="page-btn active">1</button>
<button class="page-btn">2</button>
<button class="page-btn">3</button>
<button class="page-btn">&gt;</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Project Detail Page -->
<div id="project-detail" class="page">
<div class="main-layout">
<div class="sidebar">
<div class="menu-item">📊 仪表盘</div>
<div class="menu-item active">📁 项目管理</div>
<div class="menu-item">👥 用户管理</div>
<div class="menu-item">📈 项目统计</div>
</div>
<div style="flex: 1">
<div class="header">
<div class="logo">海洋项目管理系统</div>
<div class="user-info">
<span>张三 (市场部)</span>
<button class="btn-primary" style="height: 28px; font-size: 12px;">登出</button>
</div>
</div>
<div class="content">
<div class="breadcrumb">首页 > 项目管理 > 项目详情</div>
<div class="page-header">
<h1 class="page-title">某电力基建工程项目</h1>
<div class="btn-group">
<button class="btn-edit">编辑</button>
<button class="btn-delete">删除</button>
</div>
</div>
<div class="info-grid">
<div class="info-card">
<div class="info-card-title">基本信息</div>
<div class="info-item">
<div class="info-item-label">合同编号</div>
<div class="info-item-value">PRJ2026001</div>
</div>
<div class="info-item">
<div class="info-item-label">项目名称</div>
<div class="info-item-value">某电力基建工程项目</div>
</div>
<div class="info-item">
<div class="info-item-label">工程类别</div>
<div class="info-item-value"><span class="tag tag-blue">基建</span></div>
</div>
<div class="info-item">
<div class="info-item-label">合同金额</div>
<div class="info-item-value">950.00 万元</div>
</div>
</div>
<div class="info-card">
<div class="info-card-title">项目时间</div>
<div class="info-item">
<div class="info-item-label">签订日期</div>
<div class="info-item-value">2026-01-25</div>
</div>
<div class="info-item">
<div class="info-item-label">开工日期</div>
<div class="info-item-value">2026-01-15</div>
</div>
<div class="info-item">
<div class="info-item-label">计划竣工日期</div>
<div class="info-item-value">2026-12-31</div>
</div>
<div class="info-item">
<div class="info-item-label">实际竣工日期</div>
<div class="info-item-value">-</div>
</div>
</div>
<div class="info-card">
<div class="info-card-title">成本管理</div>
<div class="info-item">
<div class="info-item-label">总体成本控制</div>
<div class="info-item-value">800.00 万元</div>
</div>
<div class="info-item">
<div class="info-item-label">人工成本控制</div>
<div class="info-item-value">300.00 万元</div>
</div>
<div class="info-item">
<div class="info-item-label">材料成本控制</div>
<div class="info-item-value">400.00 万元</div>
</div>
<div class="info-item">
<div class="info-item-label">其他费用控制</div>
<div class="info-item-value">100.00 万元</div>
</div>
</div>
<div class="info-card">
<div class="info-card-title">合同财务</div>
<div class="info-item">
<div class="info-item-label">合同金额</div>
<div class="info-item-value">950.00 万元</div>
</div>
<div class="info-item">
<div class="info-item-label">质保金比例</div>
<div class="info-item-value">5.00%</div>
</div>
<div class="info-item">
<div class="info-item-label">质保金金额</div>
<div class="info-item-value">47.50 万元</div>
</div>
<div class="info-item">
<div class="info-item-label">质保到期日</div>
<div class="info-item-value">2028-12-31</div>
</div>
</div>
<div class="info-card">
<div class="info-card-title">收款付款</div>
<div class="info-item">
<div class="info-item-label">项目进度</div>
<div class="info-item-value">60.00%</div>
</div>
<div class="info-item">
<div class="info-item-label">应收款金额</div>
<div class="info-item-value">570.00 万元</div>
</div>
<div class="info-item">
<div class="info-item-label">实际收款金额</div>
<div class="info-item-value">475.00 万元</div>
</div>
<div class="info-item">
<div class="info-item-label">收款完成率</div>
<div class="info-item-value">50.00%</div>
</div>
</div>
<div class="info-card">
<div class="info-card-title">结算信息</div>
<div class="info-item">
<div class="info-item-label">成本结算金额</div>
<div class="info-item-value">-</div>
</div>
<div class="info-item">
<div class="info-item-label">到期结算项目</div>
<div class="info-item-value">0 个</div>
</div>
<div class="info-item">
<div class="info-item-label">未结算项目</div>
<div class="info-item-value">0 个</div>
</div>
</div>
<div class="info-card full-width">
<div class="info-card-title">项目管理</div>
<div class="info-item">
<div class="info-item-label">所属项目部</div>
<div class="info-item-value">项目部一</div>
</div>
<div class="info-item">
<div class="info-item-label">项目负责人</div>
<div class="info-item-value">李四 13900000001</div>
</div>
<div class="info-item">
<div class="info-item-label">工程款拨付方式</div>
<div class="info-item-value">按进度付款</div>
</div>
<div class="info-item">
<div class="info-item-label">存在的问题</div>
<div class="info-item-value">-</div>
</div>
<div class="info-item">
<div class="info-item-label">建议措施</div>
<div class="info-item-value">-</div>
</div>
<div class="info-item">
<div class="info-item-label">备注</div>
<div class="info-item-value">备注信息</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
function showPage(pageId) {
// Hide all pages
document.querySelectorAll('.page').forEach(page => {
page.classList.remove('active');
});
// Remove active class from all buttons
document.querySelectorAll('.page-selector button').forEach(btn => {
btn.classList.remove('active');
});
// Show selected page
document.getElementById(pageId).classList.add('active');
// Add active class to clicked button
event.target.classList.add('active');
}
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+158
View File
@@ -0,0 +1,158 @@
# 产品设计文档修改日志
## 修改时间
2026-01-25
## 修改说明
根据技术总监的要求,从产品设计文档中移除Excel数据导入功能,改为在部署时直接写入初始数据。
## 修改内容
### 1. 移除的内容
#### 1.1 产品特点(第1.3节)
**删除:**
- ❌ "支持Excel数据导入,便于数据迁移"
**修改为:**
- ✅ "数据初始化在部署时完成,无需手动导入"
#### 1.2 数据导入章节(第2.5节)
**完全删除:**
- ❌ 2.5.1 Excel导入功能
- ❌ 2.5.2 数据管理
**删除的子内容:**
- 支持从Excel文件批量导入项目数据
- 系统自动验证数据格式
- 导入前显示数据预览
- 导入后显示导入结果(成功数、失败数)
- 支持错误日志导出
- 查看已导入的数据
- 编辑和删除导入的数据
- 数据验证和提示
- 数据备份功能
#### 1.3 项目列表页(第3.2.5节)
**删除:**
- ❌ "批量操作(可选)"
#### 1.4 常见问题(第6.2节)
**删除:**
- ❌ Q: 导入Excel失败怎么办?
- ❌ A: 请检查Excel格式是否符合要求,查看错误日志。
#### 1.5 数据来源章节(第7.1节)
**删除:**
- ❌ "系统支持从Excel文件导入项目数据,Excel文件格式参照提供的模板。"
**修改为:**
- ✅ "系统初始数据在部署时直接写入数据库,包括:"
- 默认管理员账号
- 基础项目分类信息
- 示例项目数据(可选)
- ✅ "部署完成后,用户可以直接使用系统,无需手动导入数据。"
### 2. 保留的内容
- ✅ 项目管理功能(创建、编辑、删除、查询)
- ✅ 数据查询和统计功能
- ✅ 权限控制功能
- ✅ 用户管理功能
- ✅ 数据导出功能(在项目列表页导出统计数据)
### 3. 章节编号
章节编号保持不变,因为删除的是子章节(2.5),主章节编号不需要改变。
## 文档变化
### 修改前
- **文档行数**: 404行
- **包含功能**: 项目管理 + Excel导入
### 修改后
- **文档行数**: 389行
- **包含功能**: 项目管理 + 部署时初始化数据
- **精简**: 减少15行(约3.7%
## 功能说明
### 数据初始化方式变更
**修改前:**
- 用户登录系统后,手动导入Excel文件
- 系统验证数据格式并导入数据库
- 用户提供Excel模板
**修改后:**
- 部署系统时,由开发人员或运维人员直接写入初始数据
- 用户登录系统后,可以直接使用
- 无需手动导入任何数据
### 用户操作简化
**修改前:**
1. 部署系统
2. 登录系统
3. 导入Excel文件
4. 验证数据
5. 开始使用
**修改后:**
1. 部署系统(包含初始数据)
2. 登录系统
3. 开始使用
### 优势
1. **操作更简单**: 用户无需手动导入数据
2. **减少错误**: 避免Excel格式错误
3. **节省时间**: 用户登录即可使用
4. **数据更可靠**: 由专业人员准备初始数据
## 技术实现说明
本修改仅涉及产品设计文档,不涉及技术实现。技术文档(技术架构文档.md)中可能仍保留Excel数据导入的技术方案,供开发人员参考。
如果需要完全移除Excel数据导入功能,还需要修改以下文档:
- `技术架构文档.md` - 删除Excel导入相关的技术方案
- `后端架构设计.md` - 删除Excel导入相关的接口设计
- `api.md` - 删除Excel导入相关的API接口
## 影响范围
### 文档
-`产品设计文档.md` - 已修改
### 产品功能
- ✅ 移除Excel数据导入功能
- ✅ 简化初始数据加载方式
### 用户操作
- ✅ 用户无需手动导入数据
- ✅ 部署后直接使用系统
## 后续工作
### 待确认
- [ ] 确认技术文档是否需要同步删除Excel导入相关内容
- [ ] 确认后端开发计划是否需要调整
- [ ] 确认初始数据准备的流程和责任人
### 可选优化
- [ ] 考虑提供数据导出功能,方便用户备份数据
- [ ] 考虑提供数据恢复功能,方便用户导入备份
## 总结
本次修改成功从产品设计文档中移除了Excel数据导入功能,改为在部署时直接写入初始数据。这样可以简化用户操作,减少错误,节省时间。
文档从404行精简到389行,减少了3.7%的内容,使文档更加简洁和聚焦。
---
**修改人**: 技术总监
**修改时间**: 2026-01-25
**审核状态**: 待审核
+8 -22
View File
@@ -16,7 +16,7 @@
- 无需复杂配置,降低使用门槛 - 无需复杂配置,降低使用门槛
- 基于角色的权限控制(RBAC - 基于角色的权限控制(RBAC
- 简单易用的用户界面 - 简单易用的用户界面
- 支持Excel数据导入,便于数据迁移 - 数据初始化在部署时完成,无需手动导入
## 2. 功能需求 ## 2. 功能需求
@@ -181,21 +181,6 @@
- 项目管理相关字段 - 项目管理相关字段
- 不能修改:合同编号、项目名称、工程类别、业主信息等基础字段 - 不能修改:合同编号、项目名称、工程类别、业主信息等基础字段
### 2.5 数据导入
#### 2.5.1 Excel导入功能
- 支持从Excel文件批量导入项目数据
- 系统自动验证数据格式
- 导入前显示数据预览
- 导入后显示导入结果(成功数、失败数)
- 支持错误日志导出
#### 2.5.2 数据管理
- 查看已导入的数据
- 编辑和删除导入的数据
- 数据验证和提示
- 数据备份功能
## 3. 用户界面 ## 3. 用户界面
### 3.1 界面设计原则 ### 3.1 界面设计原则
@@ -250,7 +235,6 @@
- 关键词搜索 - 关键词搜索
- 排序功能 - 排序功能
- 创建新项目按钮 - 创建新项目按钮
- 批量操作(可选)
- 分页功能 - 分页功能
#### 3.2.6 项目详情页 #### 3.2.6 项目详情页
@@ -341,9 +325,6 @@ A: 目前系统不支持批量编辑,请逐个编辑。
**Q: 项目创建后能否修改工程类别?** **Q: 项目创建后能否修改工程类别?**
A: 可以,但有权限限制,请联系管理员。 A: 可以,但有权限限制,请联系管理员。
**Q: 导入Excel失败怎么办?**
A: 请检查Excel格式是否符合要求,查看错误日志。
### 6.3 联系支持 ### 6.3 联系支持
- 技术支持:提供技术帮助和问题解答 - 技术支持:提供技术帮助和问题解答
- 系统管理员:负责用户管理和权限分配 - 系统管理员:负责用户管理和权限分配
@@ -351,8 +332,13 @@ A: 请检查Excel格式是否符合要求,查看错误日志。
## 7. 数据来源 ## 7. 数据来源
### 7.1 始数据 ### 7.1 始数据
系统支持从Excel文件导入项目数据,Excel文件格式参照提供的模板。 系统初始数据在部署时直接写入数据库,包括:
- 默认管理员账号
- 基础项目分类信息
- 示例项目数据(可选)
部署完成后,用户可以直接使用系统,无需手动导入数据。
### 7.2 工程分类 ### 7.2 工程分类
根据业务需求,工程项目分为以下类别: 根据业务需求,工程项目分为以下类别:
+2
View File
@@ -0,0 +1,2 @@
VITE_API_BASE_URL=http://localhost:5000/api/v1
VITE_APP_TITLE=海洋项目管理系统
+2
View File
@@ -0,0 +1,2 @@
VITE_API_BASE_URL=https://api.example.com/api/v1
VITE_APP_TITLE=海洋项目管理系统
+39
View File
@@ -0,0 +1,39 @@
import js from '@eslint/js';
import react from 'eslint-plugin-react';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
export default [
{
ignores: ['dist', '.eslintrc.cjs'],
},
js.configs.recommended,
{
plugins: {
'react': react.configs.flat.recommended,
'react-hooks': reactHooks.configs.flat.recommended,
'react-refresh': reactRefresh.configs.flat.recommended,
},
languageOptions: {
ecmaVersion: 2020,
globals: {
...globals.browser,
...globals.node,
...globals.es2021,
},
},
settings: { react: { version: 'detect' } },
rules: {
...react.configs.flat.recommended.rules,
...reactHooks.configs.flat.recommended.rules,
...reactRefresh.configs.flat.recommended.rules,
'react/jsx-no-target-blank': 'off',
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'react/react-in-jsx-scope': 'off',
'no-console': ['warn', { allow: ['warn', 'error'] }],
},
},
];
+9
View File
@@ -0,0 +1,9 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100,
"arrowParens": "avoid",
"endOfLine": "lf"
}
+890
View File
@@ -0,0 +1,890 @@
# 海洋项目管理系统 - 测试用例文档
## 目录
1. [单元测试用例](#1-单元测试用例)
2. [集成测试用例](#2-集成测试用例)
3. [E2E测试用例](#3-e2e测试用例)
---
## 1. 单元测试用例
### 1.1 工具函数测试
#### TC-001: formatMoney 函数测试
| 用例ID | TC-001-01 |
|--------|-----------|
| 测试模块 | utils/format.js |
| 测试函数 | formatMoney |
| 优先级 | 高 |
| 前置条件 | 无 |
**测试场景**
| 场景 | 输入 | 预期输出 |
|------|------|---------|
| 正整数 | 1234 | "1,234.00" |
| 小数 | 1234.56 | "1,234.56" |
| 千分位 | 10000 | "10,000.00" |
| 零 | 0 | "0.00" |
| 负数 | -1234.56 | "-1,234.56" |
| 四舍五入 | 100.123 | "100.12" |
| 大数字 | 9999999999 | "9,999,999,999.00" |
---
#### TC-002: formatPercentage 函数测试
| 用例ID | TC-002-01 |
|--------|-----------|
| 测试模块 | utils/format.js |
| 测试函数 | formatPercentage |
| 优先级 | 高 |
| 前置条件 | 无 |
**测试场景**
| 场景 | 输入 | 预期输出 |
|------|------|---------|
| 50% | 0.5 | "50.00%" |
| 100% | 1 | "100.00%" |
| 0% | 0 | "0.00%" |
| 小数 | 0.6666 | "66.66%" |
| 负数 | -0.5 | "-50.00%" |
---
#### TC-003: formatDate 函数测试
| 用例ID | TC-003-01 |
|--------|-----------|
| 测试模块 | utils/format.js |
| 测试函数 | formatDate |
| 优先级 | 高 |
| 前置条件 | 无 |
**测试场景**
| 场景 | 输入 | 预期输出 |
|------|------|---------|
| 日期字符串 | "2026-01-25" | "2026-01-25" |
| Date对象 | new Date("2026-01-25") | "2026-01-25" |
| 时间戳 | 1706150400000 | "2026-01-25" |
---
#### TC-004: validateEmail 函数测试
| 用例ID | TC-004-01 |
|--------|-----------|
| 测试模块 | utils/validate.js |
| 测试函数 | validateEmail |
| 优先级 | 高 |
| 前置条件 | 无 |
**测试场景**
| 场景 | 输入 | 预期输出 |
|------|------|---------|
| 有效邮箱 | "test@example.com" | true |
| 有效邮箱(带点) | "test.name@example.com" | true |
| 无效邮箱(无@ | "testexample.com" | false |
| 无效邮箱(无域名) | "test@" | false |
| 无效邮箱(无用户名) | "@example.com" | false |
| 空字符串 | "" | false |
---
#### TC-005: validatePhone 函数测试
| 用例ID | TC-005-01 |
|--------|-----------|
| 测试模块 | utils/validate.js |
| 测试函数 | validatePhone |
| 优先级 | 高 |
| 前置条件 | 无 |
**测试场景**
| 场景 | 输入 | 预期输出 |
|------|------|---------|
| 有效手机号 | "13800000001" | true |
| 有效手机号(其他运营商) | "15900139000" | true |
| 无效手机号(太短) | "1380000" | false |
| 无效手机号(太长) | "138000000001" | false |
| 无效手机号(非数字) | "138a0000001" | false |
| 空字符串 | "" | false |
---
### 1.2 Hooks测试
#### TC-006: useAuth Hook 测试
| 用例ID | TC-006-01 |
|--------|-----------|
| 测试模块 | hooks/useAuth.js |
| 测试Hook | useAuth |
| 优先级 | 高 |
| 前置条件 | AuthProvider包裹 |
**测试场景**
| 场景 | 操作 | 预期结果 |
|------|------|---------|
| 初始状态 | 渲染组件 | isAuthenticated=false, user=null, token=null |
| 登录成功 | 调用login('admin', 'password123') | isAuthenticated=true, user有值, token有值 |
| 登录失败 | 调用login('admin', 'wrong') | isAuthenticated=false, 显示错误提示 |
| 登出 | 调用logout() | isAuthenticated=false, user=null, token=null, localStorage清空 |
| hasRole | 调用hasRole(['admin']) | 返回true或false |
| canEdit | 调用canEdit(projectId) | 根据角色和创建人返回true或false |
---
#### TC-007: useTable Hook 测试
| 用例ID | TC-007-01 |
|--------|-----------|
| 测试模块 | hooks/useTable.js |
| 测试Hook | useTable |
| 优先级 | 中 |
| 前置条件 | 无 |
**测试场景**
| 场景 | 操作 | 预期结果 |
|------|------|---------|
| 初始状态 | 初始化Hook | pagination={current:1, pageSize:10}, filters={}, sorter={} |
| 页码变化 | 调用handleTableChange({current:2}) | pagination.current=2 |
| 筛选变化 | 调用handleTableChange({current:1}, {engineering_type:'基建'}) | filters.engineering_type='基建' |
| 排序变化 | 调用handleTableChange({current:1}, {}, {field:'signing_date', order:'desc'}) | sorter.field='signing_date', sorter.order='desc' |
| 重置 | 调用resetTable() | 恢复初始状态 |
| 选择行 | 调用setSelectedRowKeys([1,2,3]) | selectedRowKeys=[1,2,3] |
---
#### TC-008: useDebounce Hook 测试
| 用例ID | TC-008-01 |
|--------|-----------|
| 测试模块 | hooks/useDebounce.js |
| 测试Hook | useDebounce |
| 优先级 | 低 |
| 前置条件 | 无 |
**测试场景**
| 场景 | 操作 | 预期结果 |
|------|------|---------|
| 防抖生效 | 快速改变值3次 | 只返回最后一次的值,延迟300ms |
| 默认延迟 | 不传delay参数 | 使用默认延迟300ms |
| 自定义延迟 | 传入delay=500 | 使用自定义延迟500ms |
---
### 1.3 组件测试
#### TC-009: Button 组件测试
| 用例ID | TC-009-01 |
|--------|-----------|
| 测试模块 | components/Common/Button |
| 测试组件 | Button |
| 优先级 | 高 |
| 前置条件 | 无 |
**测试场景**
| 场景 | Props | 预期结果 |
|------|-------|---------|
| 渲染按钮 | children="点击我" | 显示"点击我"文字 |
| 不同类型 | type="primary" | 蓝色按钮 |
| 不同类型 | type="default" | 白色按钮 |
| 不同类型 | type="danger" | 红色按钮 |
| 加载状态 | loading=true | 显示加载图标,禁用按钮 |
| 禁用状态 | disabled=true | 按钮不可点击 |
| 点击事件 | onClick=mockFn | 点击时调用mockFn |
---
#### TC-010: Tag 组件测试
| 用例ID | TC-010-01 |
|--------|-----------|
| 测试模块 | components/Common/Tag |
| 测试组件 | Tag |
| 优先级 | 中 |
| 前置条件 | 无 |
**测试场景**
| 场景 | Props | 预期结果 |
|------|-------|---------|
| 渲染标签 | children="基建" | 显示"基建"文字 |
| 不同类型 | type="new" | 蓝色背景和文字 |
| 不同类型 | type="inProgress" | 绿色背景和文字 |
| 不同类型 | type="completed" | 灰色背景和文字 |
| 不同类型 | type="cancelled" | 红色背景和文字 |
| 自定义颜色 | color="#ff0000" | 使用自定义颜色 |
---
#### TC-011: StatCard 组件测试
| 用例ID | TC-011-01 |
|--------|-----------|
| 测试模块 | components/Business/StatCard |
| 测试组件 | StatCard |
| 优先级 | 中 |
| 前置条件 | 无 |
**测试场景**
| 场景 | Props | 预期结果 |
|------|-------|---------|
| 渲染卡片 | title="项目总数", value=100 | 显示标题和数值 |
| 显示图标 | icon="📊" | 显示图标 |
| 前缀 | prefix="¥" | 数值前显示"¥" |
| 后缀 | suffix="万元" | 数值后显示"万元" |
| 金额格式化 | value=1234.56 | 显示"1,234.56万元" |
---
#### TC-012: Modal 组件测试
| 用例ID | TC-012-01 |
|--------|-----------|
| 测试模块 | components/Common/Modal |
| 测试组件 | Modal |
| 优先级 | 高 |
| 前置条件 | 无 |
**测试场景**
| 场景 | Props | 预期结果 |
|------|-------|---------|
| 显示模态框 | visible=true | 显示模态框 |
| 隐藏模态框 | visible=false | 不显示模态框 |
| 标题 | title="新建项目" | 显示标题 |
| 确定按钮 | onOk=mockFn, okText="保存" | 显示"保存"按钮,点击调用mockFn |
| 取消按钮 | onCancel=mockFn, cancelText="取消" | 显示"取消"按钮,点击调用mockFn |
| 加载状态 | confirmLoading=true | 确定按钮显示加载状态 |
| 自定义宽度 | width={800} | 宽度为800px |
| 点击遮罩关闭 | 点击遮罩层 | 调用onCancel |
---
### 1.4 API服务测试
#### TC-013: projectAPI 测试
| 用例ID | TC-013-01 |
|--------|-----------|
| 测试模块 | services/project.js |
| 测试API | projectAPI |
| 优先级 | 高 |
| 前置条件 | 模拟api.get/post/put/delete |
**测试场景**
| 场景 | 调用 | 预期结果 |
|------|------|---------|
| 获取列表 | getList({page:1, pageSize:10}) | api.get('/projects', {params:{page:1, pageSize:10}}) |
| 获取详情 | getDetail(1) | api.get('/projects/1') |
| 创建项目 | create({name:'新项目'}) | api.post('/projects', {name:'新项目'}) |
| 更新项目 | update(1, {name:'更新项目'}) | api.put('/projects/1', {name:'更新项目'}) |
| 删除项目 | delete(1) | api.delete('/projects/1') |
| 批量删除 | batchDelete([1,2,3]) | api.post('/projects/batch-delete', {ids:[1,2,3]}) |
| 获取统计 | getStatistics() | api.get('/projects/statistics') |
| 获取分组统计 | getGroupStatistics({groupBy:'engineering_type'}) | api.get('/projects/statistics/group', {params:{groupBy:'engineering_type'}}) |
| 获取时间统计 | getTimelineStatistics({timeField:'signing_date'}) | api.get('/projects/statistics/timeline', {params:{timeField:'signing_date'}}) |
| 导出项目 | export() | api.get('/projects/export', {responseType:'blob'}) |
---
#### TC-014: userAPI 测试
| 用例ID | TC-014-01 |
|--------|-----------|
| 测试模块 | services/user.js |
| 测试API | userAPI |
| 优先级 | 中 |
| 前置条件 | 模拟api.get/post/put/delete |
**测试场景**
| 场景 | 调用 | 预期结果 |
|------|------|---------|
| 获取列表 | getList({page:1, pageSize:10}) | api.get('/users', {params:{page:1, pageSize:10}}) |
| 获取详情 | getDetail(1) | api.get('/users/1') |
| 创建用户 | create({username:'test'}) | api.post('/users', {username:'test'}) |
| 更新用户 | update(1, {realName:'新姓名'}) | api.put('/users/1', {realName:'新姓名'}) |
| 删除用户 | delete(1) | api.delete('/users/1') |
| 重置密码 | resetPassword(1, 'newPassword') | api.post('/users/1/reset-password', {new_password:'newPassword'}) |
---
## 2. 集成测试用例
### 2.1 页面组件测试
#### TC-015: Login 页面测试
| 用例ID | TC-015-01 |
|--------|-----------|
| 测试模块 | pages/Login |
| 测试页面 | Login |
| 优先级 | 高 |
| 前置条件 | 无 |
**测试场景**
| 场景 | 操作 | 预期结果 |
|------|------|---------|
| 渲染登录页 | 访问/login | 显示登录表单、Logo、用户名输入框、密码输入框、登录按钮 |
| 必填字段验证 | 不输入直接点击登录 | 显示"请输入用户名"和"请输入密码"错误 |
| 用户名太短 | 输入2个字符 | 显示"用户名至少3个字符"错误 |
| 密码太短 | 输入5个字符 | 显示"密码至少6个字符"错误 |
| 登录成功 | 输入正确用户名和密码,点击登录 | 调用login API,保存Token到localStorage,跳转到/dashboard |
| 登录失败 | 输入错误密码,点击登录 | 显示"用户名或密码错误"提示 |
| 记住密码 | 勾选"记住密码",点击登录 | localStorage保存用户名和密码(加密) |
| 加载状态 | 点击登录按钮 | 按钮显示加载图标,禁用状态 |
| Enter键提交 | 输入后按Enter键 | 触发登录 |
---
#### TC-016: Dashboard 页面测试
| 用例ID | TC-016-01 |
|--------|-----------|
| 测试模块 | pages/Dashboard |
| 测试页面 | Dashboard |
| 优先级 | 高 |
| 前置条件 | 已登录 |
**测试场景**
| 场景 | 操作 | 预期结果 |
|------|------|---------|
| 渲染仪表盘 | 访问/dashboard | 显示统计卡片、图表、最近项目列表 |
| 加载统计卡片 | 页面加载 | 调用getBasicStatistics API,显示4个统计卡片 |
| 加载图表 | 页面加载 | 调用getTimelineStatistics API,显示项目趋势图 |
| 加载最近项目 | 页面加载 | 调用getProjects API,显示最近5个项目 |
| 点击项目卡片 | 点击统计卡片 | 跳转到对应功能页面 |
| 点击最近项目 | 点击项目项 | 跳转到项目详情页 |
| 刷新数据 | 点击刷新按钮 | 重新获取所有数据 |
| 自动刷新 | 等待5分钟 | 自动刷新统计数据 |
---
#### TC-017: ProjectList 页面测试
| 用例ID | TC-017-01 |
|--------|-----------|
| 测试模块 | pages/ProjectList |
| 测试页面 | ProjectList |
| 优先级 | 高 |
| 前置条件 | 已登录 |
**测试场景**
| 场景 | 操作 | 预期结果 |
|------|------|---------|
| 渲染列表页 | 访问/projects | 显示工具栏、项目表格、分页控件 |
| 加载项目列表 | 页面加载 | 调用getList API,默认参数page=1, pageSize=10 |
| 搜索项目 | 输入关键词,按Enter | 调用getList API,参数包含keyword |
| 按工程类别筛选 | 选择"基建" | 调用getList API,参数engineering_type='基建' |
| 按日期筛选 | 选择日期范围 | 调用getList API,参数包含signing_date_start和signing_date_end |
| 按金额筛选 | 输入金额范围 | 调用getList API,参数包含contract_amount_min和contract_amount_max |
| 组合筛选 | 同时设置多个条件 | 调用getList API,所有条件AND关系 |
| 清除筛选 | 点击"清除"按钮 | 重置所有筛选条件,刷新列表 |
| 排序 | 点击表头"签订日期" | 切换升序/降序,调用getList API,参数sort_by和sort_order |
| 分页 | 点击页码 | 调用getList API,参数page更新 |
| 查看项目 | 点击"查看"按钮 | 跳转到/projects/:id |
| 新建项目 | 点击"新建项目"按钮 | 打开新建项目模态框 |
| 编辑项目 | 点击"编辑"按钮 | 调用getDetail API,填充表单,打开编辑模态框 |
| 删除项目 | 点击"删除"按钮 | 显示确认对话框,确认后调用delete API |
| 权限控制 | 市场部用户查看其他部门项目 | "编辑"和"删除"按钮不显示 |
| 表格行点击 | 点击表格行 | 跳转到项目详情页 |
---
#### TC-018: ProjectDetail 页面测试
| 用例ID | TC-018-01 |
|--------|-----------|
| 测试模块 | pages/ProjectDetail |
| 测试页面 | ProjectDetail |
| 优先级 | 高 |
| 前置条件 | 已登录 |
**测试场景**
| 场景 | 操作 | 预期结果 |
|------|------|---------|
| 渲染详情页 | 访问/projects/1 | 调用getDetail API,显示项目信息卡片 |
| 信息分组显示 | 页面加载 | 显示多个信息分组(基本信息、项目时间、成本管理等) |
| 格式化金额 | 显示金额字段 | 千分位分隔,保留两位小数,单位"万元" |
| 格式化日期 | 显示日期字段 | 格式为YYYY-MM-DD |
| 编辑项目 | 点击"编辑"按钮 | 打开编辑模态框,填充当前数据 |
| 删除项目 | 点击"删除"按钮 | 显示确认对话框,确认后调用delete API,跳转到列表页 |
| 返回列表 | 点击面包屑"项目管理" | 跳转到/projects |
| 字段级权限 | 其他部门用户编辑 | 基础信息字段只读,其他字段可编辑 |
| 金额计算 | 修改成本字段 | 自动计算总成本控制金额 |
| 进度计算 | 修改进度字段 | 自动更新进度百分比 |
---
#### TC-019: UserList 页面测试
| 用例ID | TC-019-01 |
|--------|-----------|
| 测试模块 | pages/UserList |
| 测试页面 | UserList |
| 优先级 | 高 |
| 前置条件 | 已登录,管理员角色 |
**测试场景**
| 场景 | 操作 | 预期结果 |
|------|------|---------|
| 渲染用户列表 | 访问/users | 显示工具栏、用户表格、分页控件 |
| 加载用户列表 | 页面加载 | 调用getUsers API |
| 搜索用户 | 输入关键词,按Enter | 调用getUsers API,参数包含keyword |
| 按部门筛选 | 选择"市场部" | 调用getUsers API,参数department='市场部' |
| 按角色筛选 | 选择"admin" | 调用getUsers API,参数role='admin' |
| 新建用户 | 点击"新建用户"按钮 | 打开新建用户模态框 |
| 编辑用户 | 点击"编辑"按钮 | 调用getUserDetail API,填充表单,打开编辑模态框 |
| 用户名只读 | 编辑模式 | 用户名字段禁用,不可修改 |
| 删除用户 | 点击"删除"按钮 | 显示确认对话框,确认后调用delete API |
| 重置密码 | 点击"重置密码"按钮 | 显示重置密码对话框,输入新密码后调用resetPassword API |
| 权限控制 | 非管理员用户访问 | 重定向到403页或显示权限错误 |
---
### 2.2 交互流程测试
#### TC-020: 完整的项目创建流程测试
| 用例ID | TC-020-01 |
|--------|-----------|
| 测试模块 | 交互流程 |
| 测试场景 | 项目创建流程 |
| 优先级 | 高 |
| 前置条件 | 已登录,有创建项目权限 |
**测试步骤**
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 1 | 访问/projects | 显示项目列表页 |
| 2 | 点击"新建项目"按钮 | 打开新建项目模态框 |
| 3 | 输入合同编号"PRJ001" | 输入框显示"PRJ001" |
| 4 | 输入项目名称"测试项目" | 输入框显示"测试项目" |
| 5 | 选择工程类别"基建" | 下拉框显示"基建" |
| 6 | 输入合同金额"100" | 输入框显示"100" |
| 7 | 选择签订日期"2026-01-25" | 日期选择器显示"2026-01-25" |
| 8 | 不输入必填字段,点击保存 | 显示必填字段验证错误 |
| 9 | 填写所有必填字段,点击保存 | 调用create APIloading=true |
| 10 | API返回成功 | 模态框关闭,列表刷新,显示成功提示"项目创建成功" |
| 11 | API返回失败(合同编号重复) | 显示错误提示"合同编号已存在" |
| 12 | 新项目出现在列表中 | 列表第一行显示新项目 |
---
#### TC-021: 完整的项目编辑流程测试
| 用例ID | TC-021-01 |
|--------|-----------|
| 测试模块 | 交互流程 |
| 测试场景 | 项目编辑流程 |
| 优先级 | 高 |
| 前置条件 | 已登录,有编辑权限 |
**测试步骤**
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 1 | 访问/projects | 显示项目列表页 |
| 2 | 找到项目PRJ001 | 列表显示该项目 |
| 3 | 点击"编辑"按钮 | 调用getDetail API,打开编辑模态框 |
| 4 | 表单填充当前数据 | 输入框显示项目当前值 |
| 5 | 修改项目名称为"更新项目" | 输入框显示"更新项目" |
| 6 | 修改合同金额为"200" | 输入框显示"200" |
| 7 | 点击保存 | 调用update APIloading=true |
| 8 | API返回成功 | 模态框关闭,列表刷新,显示成功提示"项目更新成功" |
| 9 | API返回失败 | 显示错误提示 |
| 10 | 列表显示更新后的数据 | 项目名称和金额更新 |
---
#### TC-022: 完整的项目删除流程测试
| 用例ID | TC-022-01 |
|--------|-----------|
| 测试模块 | 交互流程 |
| 测试场景 | 项目删除流程 |
| 优先级 | 中 |
| 前置条件 | 已登录,有删除权限 |
**测试步骤**
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 1 | 访问/projects | 显示项目列表页 |
| 2 | 找到项目PRJ001 | 列表显示该项目 |
| 3 | 点击"删除"按钮 | 显示确认对话框,内容"确定要删除该项目吗?" |
| 4 | 点击"取消"按钮 | 对话框关闭,项目不被删除 |
| 5 | 再次点击"删除"按钮 | 显示确认对话框 |
| 6 | 点击"确定"按钮 | 调用delete API |
| 7 | API返回成功 | 列表刷新,显示成功提示"项目删除成功",项目从列表消失 |
| 8 | API返回失败 | 显示错误提示 |
| 9 | 项目不存在 | 显示404错误提示 |
---
## 3. E2E测试用例
### 3.1 认证流程E2E测试
#### TC-E2E-001: 登录流程E2E测试
| 用例ID | TC-E2E-001 |
|--------|-----------|
| 测试模块 | 登录流程 |
| 测试类型 | E2E |
| 优先级 | 高 |
| 前置条件 | 后端服务运行,测试账号存在 |
**测试场景**
| 场景 | 步骤 | 预期结果 |
|------|------|---------|
| 正常登录 | 1. 访问http://localhost:3000<br>2. 输入用户名"admin"<br>3. 输入密码"password123"<br>4. 点击登录按钮 | 1. 显示登录页<br>2. 自动跳转到/dashboard<br>3. 顶部导航显示用户信息"系统管理员(管理部)" |
| 登录失败 | 1. 访问登录页<br>2. 输入正确用户名<br>3. 输入错误密码<br>4. 点击登录按钮 | 1. 显示错误提示"用户名或密码错误"<br>2. 停留在登录页<br>3. 输入框保留输入值 |
| 字段验证 | 1. 访问登录页<br>2. 不输入任何内容<br>3. 点击登录按钮 | 1. 用户名和密码输入框显示红色边框<br>2. 显示"请输入用户名"和"请输入密码"提示<br>3. 登录按钮无法提交 |
| 记住密码 | 1. 勾选"记住密码"<br>2. 输入用户名和密码<br>3. 点击登录<br>4. 刷新页面 | 1. 用户名和密码自动填充<br>2. 复选框保持选中 |
---
#### TC-E2E-002: 登出流程E2E测试
| 用例ID | TC-E2E-002 |
|--------|-----------|
| 测试模块 | 登出流程 |
| 测试类型 | E2E |
| 优先级 | 高 |
| 前置条件 | 已登录 |
**测试场景**
| 场景 | 步骤 | 预期结果 |
|------|------|---------|
| 登出 | 1. 点击顶部导航"登出"按钮<br>2. 点击确认对话框"确定"按钮 | 1. 显示确认对话框"确定要退出登录吗?"<br>2. 跳转到/login<br>3. localStorage清空 |
| 取消登出 | 1. 点击"登出"按钮<br>2. 点击"取消"按钮 | 1. 对话框关闭<br>2. 保持在当前页面,保持登录状态 |
---
### 3.2 项目管理E2E测试
#### TC-E2E-003: 项目列表E2E测试
| 用例ID | TC-E2E-003 |
|--------|-----------|
| 测试模块 | 项目列表 |
| 测试类型 | E2E |
| 优先级 | 高 |
| 前置条件 | 已登录 |
**测试场景**
| 场景 | 步骤 | 预期结果 |
|------|------|---------|
| 浏览列表 | 1. 点击侧边栏"项目管理"<br>2. 等待页面加载<br>3. 查看项目表格 | 1. 跳转到/projects<br>2. 显示项目表格<br>3. 表格显示项目数据,每页10条 |
| 分页浏览 | 1. 点击"下一页"按钮<br>2. 点击第3页<br>3. 点击"上一页" | 1. 显示第2页数据<br>2. 显示第3页数据<br>3. 显示第2页数据 |
| 搜索项目 | 1. 在搜索框输入"基建"<br>2. 按Enter键 | 1. 表格只显示包含"基建"的项目<br>2. 显示搜索结果数量 |
| 筛选项目 | 1. 点击"工程类别"下拉框<br>2. 选择"基建"<br>3. 点击"签订日期"日期选择器<br>4. 选择日期范围<br>5. 点击"确定" | 1. 表格只显示基建项目<br>2. 签订日期在范围内的项目 |
| 清除筛选 | 1. 设置多个筛选条件<br>2. 点击"清除"按钮 | 1. 所有筛选条件清空<br>2. 列表显示所有数据 |
---
#### TC-E2E-004: 项目创建E2E测试
| 用例ID | TC-E2E-004 |
|--------|-----------|
| 测试模块 | 项目创建 |
| 测试类型 | E2E |
| 优先级 | 高 |
| 前置条件 | 已登录,有创建项目权限 |
**测试场景**
| 场景 | 步骤 | 预期结果 |
|------|------|---------|
| 创建项目 | 1. 访问/projects<br>2. 点击"新建项目"按钮<br>3. 输入合同编号"PRJ999"<br>4. 输入项目名称"E2E测试项目"<br>5. 选择工程类别"基建"<br>6. 输入合同金额"100"<br>7. 选择签订日期"2026-01-25"<br>8. 点击"保存"按钮<br>9. 等待API响应 | 1. 打开新建项目模态框<br>2. 模态框标题"新建项目"<br>3-7. 所有输入框显示输入值<br>8. 按钮显示loading状态<br>9. 模态框关闭<br>10. 显示成功提示"项目创建成功"<br>11. 列表第一行显示新项目 |
| 验证失败创建 | 1. 点击"新建项目"<br>2. 不输入必填字段<br>3. 点击"保存" | 1. 必填字段显示红色边框<br>2. 显示错误提示<br>3. 模态框不关闭 |
---
#### TC-E2E-005: 项目编辑E2E测试
| 用例ID | TC-E2E-005 |
|--------|-----------|
| 测试模块 | 项目编辑 |
| 测试类型 | E2E |
| 优先级 | 高 |
| 前置条件 | 已登录,有编辑权限 |
**测试场景**
| 场景 | 步骤 | 预期结果 |
|------|------|---------|
| 编辑项目 | 1. 访问/projects<br>2. 找到项目PRJ001<br>3. 点击"编辑"按钮<br>4. 修改项目名称为"E2E更新项目"<br>5. 修改合同金额为"200"<br>6. 点击"保存"按钮<br>7. 等待API响应 | 1. 打开编辑项目模态框<br>2. 模态框标题"编辑项目"<br>3. 表单填充当前数据<br>4-5. 输入框显示新值<br>6. 按钮显示loading状态<br>7. 模态框关闭<br>8. 显示成功提示"项目更新成功"<br>9. 列表显示更新后的数据 |
| 权限限制编辑 | 1. 其他部门用户登录<br>2. 编辑项目PRJ001<br>3. 查看表单字段 | 1. 基础信息字段(合同编号、项目名称等)只读<br>2. 成本、财务等字段可编辑 |
---
#### TC-E2E-006: 项目删除E2E测试
| 用例ID | TC-E2E-006 |
|--------|-----------|
| 测试模块 | 项目删除 |
| 测试类型 | E2E |
| 优先级 | 中 |
| 前置条件 | 已登录,有删除权限 |
**测试场景**
| 场景 | 步骤 | 预期结果 |
|------|------|---------|
| 删除项目 | 1. 访问/projects<br>2. 找到项目PRJ999<br>3. 点击"删除"按钮<br>4. 查看确认对话框内容<br>5. 点击"确定"按钮<br>6. 等待API响应 | 1. 显示确认对话框<br>2. 对话框内容"确定要删除该项目吗?"<br>3. 显示项目名称和合同编号<br>4. 点击"确定"后<br>5. 显示成功提示"项目删除成功"<br>6. 列表刷新,项目消失 |
| 取消删除 | 1. 点击"删除"按钮<br>2. 点击"取消"按钮 | 1. 对话框关闭<br>2. 项目不被删除,仍在列表中 |
---
### 3.3 用户管理E2E测试
#### TC-E2E-007: 用户列表E2E测试
| 用例ID | TC-E2E-007 |
|--------|-----------|
| 测试模块 | 用户列表 |
| 测试类型 | E2E |
| 优先级 | 高 |
| 前置条件 | 已登录,管理员角色 |
**测试场景**
| 场景 | 步骤 | 预期结果 |
|------|------|---------|
| 浏览用户列表 | 1. 点击侧边栏"用户管理"<br>2. 等待页面加载 | 1. 跳转到/users<br>2. 显示用户表格<br>3. 表格显示所有用户 |
| 搜索用户 | 1. 在搜索框输入"admin"<br>2. 按Enter键 | 1. 表格只显示匹配的用户 |
| 按部门筛选 | 1. 点击"部门"下拉框<br>2. 选择"市场部" | 1. 表格只显示市场部用户 |
| 按角色筛选 | 1. 点击"角色"下拉框<br>2. 选择"admin" | 1. 表格只显示管理员用户 |
---
#### TC-E2E-008: 用户创建E2E测试
| 用例ID | TC-E2E-008 |
|--------|-----------|
| 测试模块 | 用户创建 |
| 测试类型 | E2E |
| 优先级 | 高 |
| 前置条件 | 已登录,管理员角色 |
**测试场景**
| 场景 | 步骤 | 预期结果 |
|------|------|---------|
| 创建用户 | 1. 访问/users<br>2. 点击"新建用户"按钮<br>3. 输入用户名"testuser"<br>4. 输入密码"password123"<br>5. 输入真实姓名"测试用户"<br>6. 选择部门"管理部"<br>7. 选择角色"admin"<br>8. 点击"保存"按钮<br>9. 等待API响应 | 1. 打开新建用户模态框<br>2. 模态框标题"新建用户"<br>3-7. 所有输入框显示输入值<br>8. 按钮显示loading状态<br>9. 模态框关闭<br>10. 显示成功提示"用户创建成功"<br>11. 列表显示新用户 |
| 用户名重复 | 1. 创建用户,用户名"admin"<br>2. 点击"保存" | 1. 显示错误提示"用户名已存在"<br>2. 模态框不关闭 |
---
#### TC-E2E-009: 用户编辑E2E测试
| 用例ID | TC-E2E-009 |
|--------|-----------|
| 测试模块 | 用户编辑 |
| 测试类型 | E2E |
| 优先级 | 中 |
| 前置条件 | 已登录,管理员角色 |
**测试场景**
| 场景 | 步骤 | 预期结果 |
|------|------|---------|
| 编辑用户 | 1. 访问/users<br>2. 找到用户testuser<br>3. 点击"编辑"按钮<br>4. 修改真实姓名为"测试用户更新"<br>5. 修改邮箱为新邮箱<br>6. 点击"保存"按钮<br>7. 等待API响应 | 1. 打开编辑用户模态框<br>2. 用户名字段只读<br>3. 其他字段可编辑<br>4-5. 输入框显示新值<br>6. 按钮显示loading状态<br>7. 模态框关闭<br>8. 显示成功提示<br>9. 列表显示更新后的数据 |
---
#### TC-E2E-010: 用户删除E2E测试
| 用例ID | TC-E2E-010 |
|--------|-----------|
| 测试模块 | 用户删除 |
| 测试类型 | E2E |
| 优先级 | 中 |
| 前置条件 | 已登录,管理员角色 |
**测试场景**
| 场景 | 步骤 | 预期结果 |
|------|------|---------|
| 删除用户 | 1. 访问/users<br>2. 找到用户testuser<br>3. 点击"删除"按钮<br>4. 点击"确定"按钮<br>5. 等待API响应 | 1. 显示确认对话框<br>2. 点击"确定"后<br>3. 显示成功提示"用户删除成功"<br>4. 列表刷新,用户消失 |
---
#### TC-E2E-011: 重置密码E2E测试
| 用例ID | TC-E2E-011 |
|--------|-----------|
| 测试模块 | 重置密码 |
| 测试类型 | E2E |
| 优先级 | 中 |
| 前置条件 | 已登录,管理员角色 |
**测试场景**
| 场景 | 步骤 | 预期结果 |
|------|------|---------|
| 重置密码 | 1. 访问/users<br>2. 找到用户testuser<br>3. 点击"重置密码"按钮<br>4. 输入新密码"newpassword123"<br>5. 点击"确定"按钮<br>6. 等待API响应 | 1. 显示重置密码对话框<br>2. 输入框显示新密码<br>3. 点击"确定"后<br>4. 显示成功提示"密码重置成功" |
---
### 3.4 统计分析E2E测试
#### TC-E2E-012: 仪表盘E2E测试
| 用例ID | TC-E2E-012 |
|--------|-----------|
| 测试模块 | 仪表盘 |
| 测试类型 | E2E |
| 优先级 | 高 |
| 前置条件 | 已登录 |
**测试场景**
| 场景 | 步骤 | 预期结果 |
|------|------|---------|
| 查看仪表盘 | 1. 访问/dashboard<br>2. 等待页面加载 | 1. 显示4个统计卡片<br>2. 显示项目趋势图<br>3. 显示最近项目列表 |
| 查看统计卡片 | 1. 查看统计卡片数据 | 1. 项目总数、进行中、已完成、总合同金额<br>2. 数值格式化(千分位、保留两位小数) |
| 查看图表 | 1. 鼠标悬停在图表上 | 1. 显示Tooltip,显示详细数据 |
| 点击最近项目 | 1. 点击最近项目列表项 | 1. 跳转到项目详情页 |
| 刷新数据 | 1. 点击刷新按钮 | 1. 重新获取所有数据 |
---
#### TC-E2E-013: 项目统计E2E测试
| 用例ID | TC-E2E-013 |
|--------|-----------|
| 测试模块 | 项目统计 |
| 测试类型 | E2E |
| 优先级 | 高 |
| 前置条件 | 已登录 |
**测试场景**
| 场景 | 步骤 | 预期结果 |
|------|------|---------|
| 查看统计页 | 1. 点击侧边栏"项目统计"<br>2. 等待页面加载 | 1. 显示筛选工具栏<br>2. 显示统计图表<br>3. 显示分组统计表格 |
| 基础统计 | 1. 查看基础统计卡片 | 1. 显示8个统计卡片<br>2. 数值格式化 |
| 分组统计 | 1. 查看按工程类别分组 | 1. 显示饼图和柱状图<br>2. 显示分组统计表格 |
| 时间维度统计 | 1. 选择时间维度"按月"<br>2. 查看折线图 | 1. 显示每月项目数量和金额趋势 |
| 筛选统计 | 1. 选择工程类别"基建"<br>2. 选择日期范围 | 1. 所有统计只显示符合条件的数据 |
| 导出报表 | 1. 点击"导出报表"按钮<br>2. 等待下载完成 | 1. 下载Excel文件<br>2. 文件名"项目统计_YYYY-MM-DD.xlsx" |
---
## 4. 测试数据准备
### 4.1 测试用户数据
```javascript
const testUsers = [
{
id: 1,
username: 'admin',
password: 'password123',
real_name: '系统管理员',
department: '管理部',
role: 'admin',
email: 'admin@example.com',
is_active: true,
},
{
id: 2,
username: 'market',
password: 'password123',
real_name: '市场部用户',
department: '市场部',
role: 'market',
email: 'market@example.com',
is_active: true,
},
{
id: 3,
username: 'other',
password: 'password123',
real_name: '其他部门用户',
department: '技术部',
role: 'other',
email: 'other@example.com',
is_active: true,
},
];
```
### 4.2 测试项目数据
```javascript
const testProjects = [
{
id: 1,
project_no: 'PRJ001',
name: '某电力基建工程项目',
engineering_type: '基建',
contract_amount: 950.00,
signing_date: '2026-01-01',
start_date: '2026-01-15',
planned_end_date: '2026-12-31',
project_department: '项目部一',
project_leader: '李四',
created_by: 2,
},
{
id: 2,
project_no: 'PRJ002',
name: '业扩工程项目',
engineering_type: '业扩',
contract_amount: 500.00,
signing_date: '2026-01-10',
start_date: '2026-01-20',
planned_end_date: '2026-06-30',
project_department: '项目部二',
project_leader: '王五',
created_by: 2,
},
];
```
---
## 5. 测试覆盖率目标
| 类型 | 目标覆盖率 |
|------|----------|
| 语句覆盖率 | 80% |
| 分支覆盖率 | 75% |
| 函数覆盖率 | 80% |
| 行覆盖率 | 80% |
---
**文档维护**: 前端程序员
**文档类型**: 测试用例文档
**最后更新**: 2026-01-25
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>海洋项目管理系统</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
Generated Vendored Symlink
+1
View File
@@ -0,0 +1 @@
../acorn/bin/acorn
+1
View File
@@ -0,0 +1 @@
../baseline-browser-mapping/dist/cli.js
+1
View File
@@ -0,0 +1 @@
../browserslist/cli.js
Generated Vendored Symlink
+1
View File
@@ -0,0 +1 @@
../esbuild/bin/esbuild
Generated Vendored Symlink
+1
View File
@@ -0,0 +1 @@
../eslint/bin/eslint.js
Generated Vendored Symlink
+1
View File
@@ -0,0 +1 @@
../js-yaml/bin/js-yaml.js
Generated Vendored Symlink
+1
View File
@@ -0,0 +1 @@
../jsesc/bin/jsesc
Generated Vendored Symlink
+1
View File
@@ -0,0 +1 @@
../json5/lib/cli.js
+1
View File
@@ -0,0 +1 @@
../loose-envify/cli.js
Generated Vendored Symlink
+1
View File
@@ -0,0 +1 @@
../lz-string/bin/bin.js
Generated Vendored Symlink
+1
View File
@@ -0,0 +1 @@
../nanoid/bin/nanoid.cjs
Generated Vendored Symlink
+1
View File
@@ -0,0 +1 @@
../which/bin/node-which
Generated Vendored Symlink
+1
View File
@@ -0,0 +1 @@
../@babel/parser/bin/babel-parser.js
Generated Vendored Symlink
+1
View File
@@ -0,0 +1 @@
../@playwright/test/cli.js
+1
View File
@@ -0,0 +1 @@
../playwright-core/cli.js
Generated Vendored Symlink
+1
View File
@@ -0,0 +1 @@
../prettier/bin/prettier.cjs
Generated Vendored Symlink
+1
View File
@@ -0,0 +1 @@
../resolve/bin/resolve
Generated Vendored Symlink
+1
View File
@@ -0,0 +1 @@
../rimraf/bin.js
Generated Vendored Symlink
+1
View File
@@ -0,0 +1 @@
../rollup/dist/bin/rollup
Generated Vendored Symlink
+1
View File
@@ -0,0 +1 @@
../semver/bin/semver.js
+1
View File
@@ -0,0 +1 @@
../update-browserslist-db/cli.js
Generated Vendored Symlink
+1
View File
@@ -0,0 +1 @@
../vite/bin/vite.js
Generated Vendored Symlink
+1
View File
@@ -0,0 +1 @@
../vite-node/vite-node.mjs
Generated Vendored Symlink
+1
View File
@@ -0,0 +1 @@
../vitest/vitest.mjs

Some files were not shown because too many files have changed in this diff Show More