Commit ccdb2e26 by Performance System

update

parent e0413fc4
# 前端环境变量配置
# API 服务地址
VITE_API_BASE_URL=http://localhost:8000
# 应用配置
VITE_APP_TITLE=绩效计分系统
VITE_APP_VERSION=8.8.0
# 开发模式配置
VITE_DEV_MODE=true
...@@ -8,8 +8,14 @@ WORKDIR /app ...@@ -8,8 +8,14 @@ WORKDIR /app
COPY package.json package-lock.json* ./ COPY package.json package-lock.json* ./
RUN npm ci --silent || npm install --legacy-peer-deps RUN npm ci --silent || npm install --legacy-peer-deps
# 拷贝源码并构建 # 拷贝源码
COPY . . COPY . .
# 设置构建时环境变量
ARG VITE_API_BASE_URL=http://localhost:8000
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
# 构建应用
RUN npm run build RUN npm run build
# 2) Runtime stage (Nginx) # 2) Runtime stage (Nginx)
......
...@@ -106,9 +106,7 @@ npm run preview ...@@ -106,9 +106,7 @@ npm run preview
│ │ └── theme.css │ │ └── theme.css
│ ├── utils/ # 工具函数 │ ├── utils/ # 工具函数
│ │ ├── dataCleanup.js │ │ ├── dataCleanup.js
│ │ ├── index.js │ │ └── index.js
│ │ ├── serverDataSync.js
│ │ └── simpleCrossBrowserSync.js
│ ├── App.vue # 根组件 │ ├── App.vue # 根组件
│ └── main.js # 入口文件 │ └── main.js # 入口文件
├── docs/ # 项目文档 ├── docs/ # 项目文档
......
# 数据库配置
DATABASE_URL=postgresql://performance_user:performance_pass@localhost:5432/performance_db
# API 服务配置
API_HOST=0.0.0.0
API_PORT=8000
# CORS 配置(多个域名用逗号分隔)
CORS_ORIGINS=http://localhost:5173,http://localhost:8080,http://localhost:4001
# 安全配置
SECRET_KEY=your-secret-key-here-change-in-production
# 日志配置
LOG_LEVEL=INFO
LOG_FILE=logs/api.log
# 数据迁移配置
MIGRATION_BATCH_SIZE=100
# FastAPI 后端服务 Dockerfile
FROM python:3.11-slim
# 设置工作目录
WORKDIR /app
# 安装系统依赖
RUN apt-get update && apt-get install -y \
curl \
gcc \
&& rm -rf /var/lib/apt/lists/*
# 复制依赖文件
COPY requirements.txt .
# 安装Python依赖
RUN pip install --no-cache-dir -r requirements.txt
# 复制应用代码
COPY . .
# 创建日志目录
RUN mkdir -p logs
# 暴露端口
EXPOSE 8000
# 健康检查
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1
# 启动命令
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
"""
JWT 认证工具模块
提供 JWT token 生成、验证、刷新等功能
以及密码加密和验证功能
"""
from datetime import datetime, timedelta, timezone
from typing import Optional, Dict, Any
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import HTTPException, status
from loguru import logger
from config import settings
# 密码加密上下文
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
class JWTManager:
"""JWT 管理器"""
def __init__(self):
self.secret_key = settings.JWT_SECRET_KEY
self.algorithm = settings.JWT_ALGORITHM
self.access_token_expire_hours = settings.JWT_EXPIRE_HOURS
self.refresh_token_expire_days = 7 # 刷新token有效期7天
def create_access_token(self, data: Dict[str, Any]) -> str:
"""
创建访问token
Args:
data: 要编码到token中的数据
Returns:
JWT access token字符串
"""
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(hours=self.access_token_expire_hours)
to_encode.update({
"exp": expire,
"type": "access"
})
try:
encoded_jwt = jwt.encode(to_encode, self.secret_key, algorithm=self.algorithm)
logger.info(f"创建访问token成功,用户: {data.get('sub')}")
return encoded_jwt
except Exception as e:
logger.error(f"创建访问token失败: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Token创建失败"
)
def create_refresh_token(self, data: Dict[str, Any]) -> str:
"""
创建刷新token
Args:
data: 要编码到token中的数据
Returns:
JWT refresh token字符串
"""
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(days=self.refresh_token_expire_days)
to_encode.update({
"exp": expire,
"type": "refresh"
})
try:
encoded_jwt = jwt.encode(to_encode, self.secret_key, algorithm=self.algorithm)
logger.info(f"创建刷新token成功,用户: {data.get('sub')}")
return encoded_jwt
except Exception as e:
logger.error(f"创建刷新token失败: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="刷新Token创建失败"
)
def verify_token(self, token: str, token_type: str = "access") -> Dict[str, Any]:
"""
验证token
Args:
token: JWT token字符串
token_type: token类型 ("access" 或 "refresh")
Returns:
解码后的token数据
Raises:
HTTPException: token无效时抛出异常
"""
try:
payload = jwt.decode(token, self.secret_key, algorithms=[self.algorithm])
# 检查token类型
if payload.get("type") != token_type:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"无效的token类型,期望: {token_type}"
)
# 检查过期时间
exp = payload.get("exp")
if exp is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token缺少过期时间"
)
if datetime.now(timezone.utc) > datetime.fromtimestamp(exp, timezone.utc):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token已过期"
)
return payload
except JWTError as e:
logger.warning(f"Token验证失败: {e}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的token"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Token验证异常: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Token验证失败"
)
def refresh_access_token(self, refresh_token: str) -> str:
"""
使用刷新token生成新的访问token
Args:
refresh_token: 刷新token字符串
Returns:
新的访问token
"""
# 验证刷新token
payload = self.verify_token(refresh_token, "refresh")
# 创建新的访问token数据
new_data = {
"sub": payload.get("sub"),
"user_id": payload.get("user_id"),
"role": payload.get("role"),
"phone": payload.get("phone")
}
return self.create_access_token(new_data)
class PasswordManager:
"""密码管理器"""
@staticmethod
def hash_password(password: str) -> str:
"""
加密密码
Args:
password: 明文密码
Returns:
加密后的密码哈希
"""
try:
hashed = pwd_context.hash(password)
logger.info("密码加密成功")
return hashed
except Exception as e:
logger.error(f"密码加密失败: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="密码加密失败"
)
@staticmethod
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""
验证密码
Args:
plain_password: 明文密码
hashed_password: 加密后的密码哈希
Returns:
密码是否匹配
"""
try:
result = pwd_context.verify(plain_password, hashed_password)
logger.info(f"密码验证结果: {'成功' if result else '失败'}")
return result
except Exception as e:
logger.error(f"密码验证异常: {e}")
return False
@staticmethod
def is_hashed_password(password: str) -> bool:
"""
检查密码是否已经是哈希格式
Args:
password: 密码字符串
Returns:
是否为哈希密码
"""
# bcrypt哈希通常以$2b$开头,长度为60字符
return password.startswith("$2b$") and len(password) == 60
# 创建全局实例
jwt_manager = JWTManager()
password_manager = PasswordManager()
"""
应用配置模块
使用 Pydantic Settings 管理配置参数
"""
from pydantic_settings import BaseSettings
from typing import List
import os
class Settings(BaseSettings):
"""应用配置类"""
# 应用基本信息
APP_NAME: str = "绩效计分系统API"
APP_VERSION: str = "1.0.0"
DEBUG: bool = True
# 服务器配置
HOST: str = "0.0.0.0"
PORT: int = 8000
API_HOST: str = "0.0.0.0" # 兼容性字段
API_PORT: int = 8000 # 兼容性字段
# 数据库配置
DATABASE_URL: str = "postgresql://performance_user:performance_pass@localhost:5432/performance_db"
# JWT 配置
JWT_SECRET_KEY: str = "local-development-secret-key-not-for-production"
JWT_ALGORITHM: str = "HS256"
JWT_EXPIRE_HOURS: int = 24
SECRET_KEY: str = "your-secret-key-here-change-in-production" # 兼容性字段
# 文件上传配置
UPLOAD_DIR: str = "uploads"
MAX_FILE_SIZE: int = 5242880 # 5MB
MAX_IMAGES_PER_INSTITUTION: int = 10
# CORS 配置
CORS_ORIGINS: List[str] = [
"http://localhost:5173", # Vite 开发服务器
"http://localhost:3000", # React 开发服务器
"http://localhost:4001", # Docker 前端
"http://localhost:4003", # 其他前端
"http://localhost:8080", # 生产环境前端
"http://127.0.0.1:3000",
"http://127.0.0.1:4001",
"http://127.0.0.1:4003",
"http://127.0.0.1:8080",
]
# WebSocket 配置
WEBSOCKET_HEARTBEAT_INTERVAL: int = 30
WEBSOCKET_TIMEOUT: int = 60
# 系统配置
ENABLE_REAL_TIME_SYNC: bool = True
SYNC_EVENT_RETENTION_DAYS: int = 30
SESSION_CLEANUP_INTERVAL: int = 3600
# 日志配置
LOG_LEVEL: str = "INFO"
LOG_FILE: str = "logs/api.log"
# 数据迁移配置
MIGRATION_BATCH_SIZE: int = 100
class Config:
env_file = ".env"
case_sensitive = True
extra = "ignore" # 忽略额外的环境变量
# 创建全局配置实例
settings = Settings()
"""
数据库连接和操作层
使用 databases 和 SQLAlchemy 进行异步数据库操作
"""
import databases
import sqlalchemy
from sqlalchemy import create_engine, MetaData, Table, Column, String, Integer, Text, TIMESTAMP, Boolean, ForeignKey
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.sql import func
from config import settings
from loguru import logger
# 创建数据库连接
database = databases.Database(settings.DATABASE_URL)
# 创建 SQLAlchemy 引擎
engine = create_engine(settings.DATABASE_URL)
# 创建元数据对象
metadata = MetaData()
# 定义用户表
users_table = Table(
"users",
metadata,
Column("id", String(50), primary_key=True),
Column("phone", String(20), unique=True, nullable=False),
Column("password", String(255), nullable=False),
Column("name", String(100), nullable=False),
Column("role", String(20), default="user"),
Column("institutions", JSONB, default="[]"),
Column("created_at", TIMESTAMP, server_default=func.now()),
Column("updated_at", TIMESTAMP, server_default=func.now(), onupdate=func.now()),
)
# 定义机构表
institutions_table = Table(
"institutions",
metadata,
Column("id", String(50), primary_key=True),
Column("institution_id", String(50), unique=True),
Column("name", String(200), nullable=False),
Column("owner_id", String(50), ForeignKey("users.id", ondelete="SET NULL")),
Column("created_at", TIMESTAMP, server_default=func.now()),
Column("updated_at", TIMESTAMP, server_default=func.now(), onupdate=func.now()),
)
# 定义机构图片表
institution_images_table = Table(
"institution_images",
metadata,
Column("id", String(50), primary_key=True),
Column("institution_id", String(50), ForeignKey("institutions.id", ondelete="CASCADE"), nullable=False),
Column("url", Text, nullable=False),
Column("upload_time", TIMESTAMP, nullable=False),
Column("created_at", TIMESTAMP, server_default=func.now()),
)
# 定义系统配置表
system_config_table = Table(
"system_config",
metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("config_key", String(100), unique=True, nullable=False),
Column("config_value", JSONB),
Column("description", Text),
Column("created_at", TIMESTAMP, server_default=func.now()),
Column("updated_at", TIMESTAMP, server_default=func.now(), onupdate=func.now()),
)
# 定义月度历史统计表
monthly_history_table = Table(
"monthly_history",
metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("month", String(7), unique=True, nullable=False),
Column("save_time", TIMESTAMP(timezone=True), nullable=False),
Column("total_users", Integer, nullable=False),
Column("total_institutions", Integer, nullable=False),
Column("total_images", Integer, nullable=False),
Column("user_stats", JSONB, nullable=False),
Column("created_at", TIMESTAMP(timezone=True), server_default=func.now()),
)
class DatabaseManager:
"""数据库管理器,提供通用的数据库操作方法"""
def __init__(self):
self.database = database
async def fetch_one(self, query, values=None):
"""执行查询并返回单条记录"""
try:
return await self.database.fetch_one(query, values)
except Exception as e:
logger.error(f"数据库查询失败: {e}")
raise
async def fetch_all(self, query, values=None):
"""执行查询并返回所有记录"""
try:
return await self.database.fetch_all(query, values)
except Exception as e:
logger.error(f"数据库查询失败: {e}")
raise
async def execute(self, query, values=None):
"""执行 INSERT/UPDATE/DELETE 操作"""
try:
return await self.database.execute(query, values)
except Exception as e:
logger.error(f"数据库操作失败: {e}")
raise
async def execute_many(self, query, values):
"""批量执行操作"""
try:
return await self.database.execute_many(query, values)
except Exception as e:
logger.error(f"批量数据库操作失败: {e}")
raise
async def transaction(self):
"""创建数据库事务"""
return self.database.transaction()
# 创建全局数据库管理器实例
db_manager = DatabaseManager()
async def get_database():
"""依赖注入:获取数据库连接"""
return db_manager
async def check_database_connection():
"""检查数据库连接状态"""
try:
await database.fetch_one("SELECT 1")
return True
except Exception as e:
logger.error(f"数据库连接检查失败: {e}")
return False
async def initialize_database():
"""初始化数据库表结构"""
try:
# 创建所有表
metadata.create_all(engine)
logger.info("✅ 数据库表结构初始化完成")
# 检查并插入默认数据
await insert_default_data()
except Exception as e:
logger.error(f"数据库初始化失败: {e}")
raise
async def insert_default_data():
"""插入默认数据"""
try:
# 检查是否已有管理员用户
admin_exists = await database.fetch_one(
users_table.select().where(users_table.c.id == "admin")
)
if not admin_exists:
# 插入默认管理员用户
await database.execute(
users_table.insert().values(
id="admin",
phone="admin",
password="admin123",
name="系统管理员",
role="admin",
institutions=[]
)
)
logger.info("✅ 默认管理员用户创建成功")
# 检查并插入默认系统配置
config_items = [
("initialized", True, "系统是否已初始化"),
("version", "8.8.0", "系统版本"),
("hasDefaultData", False, "是否有默认示例数据")
]
for key, value, desc in config_items:
exists = await database.fetch_one(
system_config_table.select().where(system_config_table.c.config_key == key)
)
if not exists:
await database.execute(
system_config_table.insert().values(
config_key=key,
config_value=value,
description=desc
)
)
logger.info("✅ 默认系统配置创建成功")
except Exception as e:
logger.error(f"插入默认数据失败: {e}")
raise
"""
认证依赖注入模块
提供JWT认证、权限验证等依赖注入函数
"""
from typing import Optional, List
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy import select
from loguru import logger
from auth import jwt_manager
from database import get_database, DatabaseManager, users_table
from models import UserResponse
# HTTP Bearer token 安全方案
security = HTTPBearer()
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: DatabaseManager = Depends(get_database)
) -> UserResponse:
"""
获取当前认证用户
Args:
credentials: HTTP Bearer token凭据
db: 数据库管理器
Returns:
当前用户信息
Raises:
HTTPException: 认证失败时抛出异常
"""
try:
# 验证token
payload = jwt_manager.verify_token(credentials.credentials, "access")
user_id = payload.get("user_id")
if user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token中缺少用户ID"
)
# 从数据库获取用户信息
query = users_table.select().where(users_table.c.id == user_id)
user = await db.fetch_one(query)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户不存在"
)
# 返回用户信息
return UserResponse(
id=user["id"],
phone=user["phone"],
name=user["name"],
role=user["role"],
institutions=user["institutions"] or [],
created_at=user["created_at"],
updated_at=user["updated_at"]
)
except HTTPException:
raise
except Exception as e:
logger.error(f"获取当前用户失败: {e}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="认证失败"
)
async def get_current_active_user(
current_user: UserResponse = Depends(get_current_user)
) -> UserResponse:
"""
获取当前活跃用户(可扩展为检查用户状态)
Args:
current_user: 当前用户
Returns:
当前活跃用户信息
"""
# 这里可以添加用户状态检查逻辑
# 例如检查用户是否被禁用、是否需要重新验证等
return current_user
def require_roles(allowed_roles: List[str]):
"""
角色权限验证装饰器工厂
Args:
allowed_roles: 允许的角色列表
Returns:
依赖注入函数
"""
async def role_checker(
current_user: UserResponse = Depends(get_current_active_user)
) -> UserResponse:
"""
检查用户角色权限
Args:
current_user: 当前用户
Returns:
当前用户信息
Raises:
HTTPException: 权限不足时抛出异常
"""
if current_user.role not in allowed_roles:
logger.warning(f"用户 {current_user.name} (角色: {current_user.role}) 尝试访问需要角色 {allowed_roles} 的资源")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"权限不足,需要角色: {', '.join(allowed_roles)}"
)
return current_user
return role_checker
# 常用的角色权限依赖
require_admin = require_roles(["admin"])
require_user_or_admin = require_roles(["user", "admin"])
async def get_optional_current_user(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(HTTPBearer(auto_error=False)),
db: DatabaseManager = Depends(get_database)
) -> Optional[UserResponse]:
"""
获取可选的当前用户(不强制要求认证)
Args:
credentials: 可选的HTTP Bearer token凭据
db: 数据库管理器
Returns:
当前用户信息或None
"""
if not credentials:
return None
try:
# 验证token
payload = jwt_manager.verify_token(credentials.credentials, "access")
user_id = payload.get("user_id")
if user_id is None:
return None
# 从数据库获取用户信息
query = users_table.select().where(users_table.c.id == user_id)
user = await db.fetch_one(query)
if not user:
return None
# 返回用户信息
return UserResponse(
id=user["id"],
phone=user["phone"],
name=user["name"],
role=user["role"],
institutions=user["institutions"] or [],
created_at=user["created_at"],
updated_at=user["updated_at"]
)
except Exception as e:
logger.warning(f"可选用户认证失败: {e}")
return None
class TokenBlacklist:
"""Token黑名单管理(简单内存实现,生产环境建议使用Redis)"""
def __init__(self):
self._blacklisted_tokens = set()
def add_token(self, token: str):
"""将token添加到黑名单"""
self._blacklisted_tokens.add(token)
logger.info("Token已添加到黑名单")
def is_blacklisted(self, token: str) -> bool:
"""检查token是否在黑名单中"""
return token in self._blacklisted_tokens
def clear_expired_tokens(self):
"""清理过期的token(这里简化处理,实际应该根据token过期时间清理)"""
# 在实际应用中,应该解析token获取过期时间,然后清理过期的token
pass
# 创建全局token黑名单实例
token_blacklist = TokenBlacklist()
async def verify_token_not_blacklisted(
credentials: HTTPAuthorizationCredentials = Depends(security)
) -> HTTPAuthorizationCredentials:
"""
验证token不在黑名单中
Args:
credentials: HTTP Bearer token凭据
Returns:
验证通过的凭据
Raises:
HTTPException: token在黑名单中时抛出异常
"""
if token_blacklist.is_blacklisted(credentials.credentials):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token已失效"
)
return credentials
"""
绩效计分系统 FastAPI 后端服务
主要功能:
1. 提供完整的 CRUD API 接口
2. 替换前端的 localStorage 存储
3. 实现数据持久化到 PostgreSQL
4. 支持数据迁移和同步
"""
from fastapi import FastAPI, HTTPException, Depends
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from contextlib import asynccontextmanager
import os
from loguru import logger
from database import database, engine, metadata
from config import settings
from routers import users, institutions, system_config, history, migration
@asynccontextmanager
async def lifespan(app: FastAPI):
"""应用生命周期管理"""
# 启动时执行
logger.info("🚀 启动绩效计分系统 API 服务")
# 连接数据库
await database.connect()
logger.info("✅ 数据库连接成功")
# 创建表结构(如果不存在)
metadata.create_all(engine)
logger.info("✅ 数据库表结构检查完成")
yield
# 关闭时执行
logger.info("🔄 正在关闭 API 服务")
await database.disconnect()
logger.info("✅ 数据库连接已关闭")
# 创建 FastAPI 应用实例
app = FastAPI(
title="绩效计分系统 API",
description="为绩效计分系统提供数据持久化和 API 服务",
version="1.0.0",
lifespan=lifespan,
docs_url="/docs",
redoc_url="/redoc"
)
# 配置 CORS 中间件
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["*"],
)
# 注册路由
app.include_router(users.router, prefix="/api/users", tags=["用户管理"])
app.include_router(institutions.router, prefix="/api/institutions", tags=["机构管理"])
app.include_router(system_config.router, prefix="/api/config", tags=["系统配置"])
app.include_router(history.router, prefix="/api/history", tags=["历史数据"])
app.include_router(migration.router, prefix="/api/migration", tags=["数据迁移"])
@app.get("/", summary="根路径")
async def root():
"""API 根路径,返回服务信息"""
return {
"message": "绩效计分系统 API 服务",
"version": "1.0.0",
"status": "running",
"docs": "/docs"
}
@app.get("/health", summary="健康检查")
async def health_check():
"""健康检查端点,用于容器健康检查"""
try:
# 检查数据库连接
await database.fetch_one("SELECT 1")
from datetime import datetime
return {
"status": "healthy",
"database": "connected",
"timestamp": datetime.now().isoformat()
}
except Exception as e:
logger.error(f"健康检查失败: {e}")
raise HTTPException(status_code=503, detail="Service unavailable")
@app.exception_handler(Exception)
async def global_exception_handler(request, exc):
"""全局异常处理器"""
logger.error(f"未处理的异常: {exc}")
return JSONResponse(
status_code=500,
content={"detail": "Internal server error", "error": str(exc)}
)
if __name__ == "__main__":
import uvicorn
# 配置日志
logger.add(
"logs/api.log",
rotation="1 day",
retention="30 days",
level="INFO",
format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {message}"
)
# 启动服务
uvicorn.run(
"main:app",
host=settings.API_HOST,
port=settings.API_PORT,
reload=True if os.getenv("NODE_ENV") != "production" else False,
log_level="info"
)
"""
Pydantic 数据模型定义
用于 API 请求和响应的数据验证和序列化
"""
from pydantic import BaseModel, Field, validator
from typing import List, Optional, Any, Dict
from datetime import datetime
import json
# 基础模型
class BaseResponse(BaseModel):
"""基础响应模型"""
success: bool = True
message: str = "操作成功"
data: Optional[Any] = None
class ErrorResponse(BaseModel):
"""错误响应模型"""
success: bool = False
message: str
error: Optional[str] = None
# 用户相关模型
class UserBase(BaseModel):
"""用户基础模型"""
phone: str = Field(..., min_length=1, max_length=20, description="手机号")
name: str = Field(..., min_length=1, max_length=100, description="用户姓名")
role: str = Field(default="user", description="用户角色")
institutions: List[str] = Field(default=[], description="负责的机构ID列表")
class UserCreate(UserBase):
"""创建用户模型"""
id: str = Field(..., min_length=1, max_length=50, description="用户ID")
password: str = Field(..., min_length=1, max_length=255, description="密码")
class UserUpdate(BaseModel):
"""更新用户模型"""
phone: Optional[str] = Field(None, min_length=1, max_length=20)
name: Optional[str] = Field(None, min_length=1, max_length=100)
password: Optional[str] = Field(None, min_length=1, max_length=255)
role: Optional[str] = None
institutions: Optional[List[str]] = None
class UserResponse(UserBase):
"""用户响应模型"""
id: str
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
# 机构图片模型
class InstitutionImage(BaseModel):
"""机构图片模型"""
id: str = Field(..., description="图片ID")
url: str = Field(..., description="图片URL或Base64数据")
uploadTime: datetime = Field(..., description="上传时间")
class InstitutionImageCreate(BaseModel):
"""创建机构图片模型"""
id: str
url: str
upload_time: datetime
# 机构相关模型
class InstitutionBase(BaseModel):
"""机构基础模型"""
name: str = Field(..., min_length=1, max_length=200, description="机构名称")
institution_id: Optional[str] = Field(None, max_length=50, description="机构编号")
owner_id: Optional[str] = Field(None, max_length=50, description="负责人ID")
class InstitutionCreate(InstitutionBase):
"""创建机构模型"""
id: str = Field(..., min_length=1, max_length=50, description="机构ID")
class InstitutionUpdate(BaseModel):
"""更新机构模型"""
name: Optional[str] = Field(None, min_length=1, max_length=200)
institution_id: Optional[str] = Field(None, max_length=50)
owner_id: Optional[str] = Field(None, max_length=50)
class InstitutionBatchCreate(BaseModel):
"""批量创建机构模型"""
institutions: List[InstitutionCreate] = Field(..., description="机构列表")
class InstitutionBatchDelete(BaseModel):
"""批量删除机构模型"""
institution_ids: List[str] = Field(..., description="要删除的机构ID列表")
class InstitutionBatchResponse(BaseModel):
"""批量操作响应模型"""
success_count: int = Field(..., description="成功数量")
error_count: int = Field(..., description="失败数量")
errors: List[str] = Field(default=[], description="错误信息列表")
message: str = Field(..., description="操作结果消息")
class InstitutionBatchCreate(BaseModel):
"""批量创建机构模型"""
institutions: List[InstitutionCreate] = Field(..., description="机构列表")
class InstitutionBatchDelete(BaseModel):
"""批量删除机构模型"""
institution_ids: List[str] = Field(..., description="要删除的机构ID列表")
class InstitutionBatchResponse(BaseModel):
"""批量操作响应模型"""
success_count: int = Field(..., description="成功数量")
error_count: int = Field(..., description="失败数量")
errors: List[str] = Field(default=[], description="错误信息列表")
message: str = Field(..., description="操作结果消息")
class InstitutionResponse(InstitutionBase):
"""机构响应模型"""
id: str
images: List[InstitutionImage] = []
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
# 系统配置模型
class SystemConfigItem(BaseModel):
"""系统配置项模型"""
config_key: str = Field(..., max_length=100, description="配置键名")
config_value: Any = Field(..., description="配置值")
description: Optional[str] = Field(None, description="配置描述")
class SystemConfigResponse(BaseModel):
"""系统配置响应模型"""
id: int
config_key: str
config_value: Any
description: Optional[str]
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
# 历史统计数据模型
class UserStatsItem(BaseModel):
"""用户统计项模型"""
userId: str
userName: str
institutionCount: int
interactionScore: float
performanceScore: float
institutions: List[Dict[str, Any]]
class MonthlyHistoryCreate(BaseModel):
"""创建月度历史记录模型"""
month: str = Field(..., pattern=r'^\d{4}-\d{2}$', description="月份,格式:YYYY-MM")
save_time: datetime = Field(..., description="保存时间")
total_users: int = Field(..., ge=0, description="总用户数")
total_institutions: int = Field(..., ge=0, description="总机构数")
total_images: int = Field(..., ge=0, description="总图片数")
user_stats: List[UserStatsItem] = Field(..., description="用户统计数据")
class MonthlyHistoryResponse(BaseModel):
"""月度历史记录响应模型"""
id: int
month: str
save_time: datetime
total_users: int
total_institutions: int
total_images: int
user_stats: List[UserStatsItem]
created_at: datetime
class Config:
from_attributes = True
# 数据迁移模型
class MigrationData(BaseModel):
"""数据迁移模型"""
users: List[Dict[str, Any]] = Field(..., description="用户数据")
institutions: List[Dict[str, Any]] = Field(..., description="机构数据")
systemConfig: Dict[str, Any] = Field(..., description="系统配置数据")
historyData: Optional[Dict[str, Any]] = Field(None, description="历史统计数据")
class MigrationResponse(BaseModel):
"""数据迁移响应模型"""
success: bool
message: str
migrated_counts: Dict[str, int]
errors: List[str] = []
# 登录相关模型
class LoginRequest(BaseModel):
"""登录请求模型"""
phone: str = Field(..., description="手机号")
password: str = Field(..., description="密码")
class LoginResponse(BaseModel):
"""登录响应模型"""
success: bool
access_token: str
refresh_token: str
token_type: str = "bearer"
expires_in: int # token过期时间(秒)
user: UserResponse
message: str = "登录成功"
class TokenRefreshRequest(BaseModel):
"""Token刷新请求模型"""
refresh_token: str = Field(..., description="刷新token")
class TokenRefreshResponse(BaseModel):
"""Token刷新响应模型"""
success: bool
access_token: str
token_type: str = "bearer"
expires_in: int # token过期时间(秒)
message: str = "Token刷新成功"
# FastAPI 核心依赖
fastapi>=0.104.1
uvicorn[standard]>=0.24.0
# 数据库相关
asyncpg>=0.29.0
databases[postgresql]>=0.8.0
sqlalchemy>=2.0.23
psycopg2-binary>=2.9.0
# 数据验证和序列化
pydantic>=2.5.0
pydantic-settings>=2.1.0
# HTTP 客户端和工具
httpx>=0.25.2
python-multipart>=0.0.6
# 日期时间处理
python-dateutil>=2.8.2
# JSON 处理(使用标准库,避免 Rust 依赖)
# orjson==3.9.10 # 需要 Rust 编译器,在 Windows 上可能有问题
# 日志记录
loguru>=0.7.2
# JWT 认证和密码加密
python-jose[cryptography]>=3.3.0
passlib[bcrypt]>=1.7.4
# 开发和调试工具
python-dotenv>=1.0.0
# 路由模块初始化文件
"""
历史数据 API 路由
提供月度历史统计数据的 CRUD 操作接口
"""
from fastapi import APIRouter, HTTPException, Depends
from typing import List, Dict, Any
from loguru import logger
from datetime import datetime
from database import monthly_history_table, get_database, DatabaseManager
from models import MonthlyHistoryCreate, MonthlyHistoryResponse, BaseResponse, UserResponse
from dependencies import get_current_active_user, require_admin
router = APIRouter()
@router.get("/", response_model=List[MonthlyHistoryResponse], summary="获取所有历史记录")
async def get_all_history(
db: DatabaseManager = Depends(get_database),
current_user: UserResponse = Depends(get_current_active_user)
):
"""获取所有月度历史统计记录"""
try:
query = monthly_history_table.select().order_by(monthly_history_table.c.month.desc())
histories = await db.fetch_all(query)
return [
MonthlyHistoryResponse(
id=history["id"],
month=history["month"],
save_time=history["save_time"],
total_users=history["total_users"],
total_institutions=history["total_institutions"],
total_images=history["total_images"],
user_stats=history["user_stats"],
created_at=history["created_at"]
)
for history in histories
]
except Exception as e:
logger.error(f"获取历史记录失败: {e}")
raise HTTPException(status_code=500, detail="获取历史记录失败")
@router.get("/{month}", response_model=MonthlyHistoryResponse, summary="获取指定月份历史记录")
async def get_history_by_month(
month: str,
db: DatabaseManager = Depends(get_database),
current_user: UserResponse = Depends(get_current_active_user)
):
"""根据月份获取历史统计记录"""
try:
# 验证月份格式
if not month or len(month) != 7 or month[4] != '-':
raise HTTPException(status_code=400, detail="月份格式错误,应为 YYYY-MM")
query = monthly_history_table.select().where(monthly_history_table.c.month == month)
history = await db.fetch_one(query)
if not history:
raise HTTPException(status_code=404, detail="指定月份的历史记录不存在")
return MonthlyHistoryResponse(
id=history["id"],
month=history["month"],
save_time=history["save_time"],
total_users=history["total_users"],
total_institutions=history["total_institutions"],
total_images=history["total_images"],
user_stats=history["user_stats"],
created_at=history["created_at"]
)
except HTTPException:
raise
except Exception as e:
logger.error(f"获取指定月份历史记录失败: {e}")
raise HTTPException(status_code=500, detail="获取历史记录失败")
@router.post("/", response_model=BaseResponse, summary="保存月度历史记录")
async def save_monthly_history(
history_data: MonthlyHistoryCreate,
db: DatabaseManager = Depends(get_database),
current_user: UserResponse = Depends(require_admin)
):
"""保存月度历史统计记录"""
try:
# 检查该月份是否已有记录
existing_history = await db.fetch_one(
monthly_history_table.select().where(
monthly_history_table.c.month == history_data.month
)
)
if existing_history:
# 更新现有记录
query = monthly_history_table.update().where(
monthly_history_table.c.month == history_data.month
).values(
save_time=history_data.save_time,
total_users=history_data.total_users,
total_institutions=history_data.total_institutions,
total_images=history_data.total_images,
user_stats=history_data.user_stats
)
await db.execute(query)
message = f"{history_data.month} 月度记录更新成功"
else:
# 创建新记录
query = monthly_history_table.insert().values(
month=history_data.month,
save_time=history_data.save_time,
total_users=history_data.total_users,
total_institutions=history_data.total_institutions,
total_images=history_data.total_images,
user_stats=history_data.user_stats
)
await db.execute(query)
message = f"{history_data.month} 月度记录保存成功"
return BaseResponse(message=message)
except Exception as e:
logger.error(f"保存月度历史记录失败: {e}")
raise HTTPException(status_code=500, detail="保存历史记录失败")
@router.delete("/{month}", response_model=BaseResponse, summary="删除指定月份历史记录")
async def delete_history_by_month(month: str, db: DatabaseManager = Depends(get_database)):
"""删除指定月份的历史统计记录"""
try:
# 验证月份格式
if not month or len(month) != 7 or month[4] != '-':
raise HTTPException(status_code=400, detail="月份格式错误,应为 YYYY-MM")
# 检查记录是否存在
existing_history = await db.fetch_one(
monthly_history_table.select().where(monthly_history_table.c.month == month)
)
if not existing_history:
raise HTTPException(status_code=404, detail="指定月份的历史记录不存在")
# 删除记录
query = monthly_history_table.delete().where(monthly_history_table.c.month == month)
await db.execute(query)
return BaseResponse(message=f"{month} 月度记录删除成功")
except HTTPException:
raise
except Exception as e:
logger.error(f"删除历史记录失败: {e}")
raise HTTPException(status_code=500, detail="删除历史记录失败")
@router.get("/stats/summary", response_model=Dict[str, Any], summary="获取历史统计摘要")
async def get_history_summary(db: DatabaseManager = Depends(get_database)):
"""获取历史统计数据摘要"""
try:
# 获取总记录数
total_query = "SELECT COUNT(*) as total FROM monthly_history"
total_result = await db.fetch_one(total_query)
total_records = total_result["total"] if total_result else 0
# 获取最新记录
latest_query = monthly_history_table.select().order_by(
monthly_history_table.c.month.desc()
).limit(1)
latest_record = await db.fetch_one(latest_query)
# 获取最早记录
earliest_query = monthly_history_table.select().order_by(
monthly_history_table.c.month.asc()
).limit(1)
earliest_record = await db.fetch_one(earliest_query)
return {
"total_records": total_records,
"latest_month": latest_record["month"] if latest_record else None,
"earliest_month": earliest_record["month"] if earliest_record else None,
"latest_stats": {
"total_users": latest_record["total_users"],
"total_institutions": latest_record["total_institutions"],
"total_images": latest_record["total_images"],
"save_time": latest_record["save_time"]
} if latest_record else None
}
except Exception as e:
logger.error(f"获取历史统计摘要失败: {e}")
raise HTTPException(status_code=500, detail="获取统计摘要失败")
@router.post("/cleanup", response_model=BaseResponse, summary="清理历史数据")
async def cleanup_old_history(
keep_months: int = 12,
db: DatabaseManager = Depends(get_database)
):
"""清理旧的历史数据,保留最近N个月的记录"""
try:
if keep_months < 1:
raise HTTPException(status_code=400, detail="保留月数必须大于0")
# 获取需要保留的记录
keep_query = monthly_history_table.select().order_by(
monthly_history_table.c.month.desc()
).limit(keep_months)
keep_records = await db.fetch_all(keep_query)
if not keep_records:
return BaseResponse(message="没有历史记录需要清理")
# 获取最早需要保留的月份
oldest_keep_month = keep_records[-1]["month"]
# 删除更早的记录
delete_query = monthly_history_table.delete().where(
monthly_history_table.c.month < oldest_keep_month
)
deleted_count = await db.execute(delete_query)
return BaseResponse(
message=f"清理完成,删除了 {deleted_count} 条旧记录,保留最近 {keep_months} 个月的数据"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"清理历史数据失败: {e}")
raise HTTPException(status_code=500, detail="清理历史数据失败")
"""
系统配置 API 路由
提供系统配置的 CRUD 操作接口
"""
from fastapi import APIRouter, HTTPException, Depends
from typing import List, Dict, Any
from loguru import logger
from database import system_config_table, get_database, DatabaseManager
from models import SystemConfigItem, SystemConfigResponse, BaseResponse, UserResponse
from dependencies import get_current_active_user, require_admin
router = APIRouter()
@router.get("/", response_model=Dict[str, Any], summary="获取所有系统配置")
async def get_all_config(
db: DatabaseManager = Depends(get_database),
current_user: UserResponse = Depends(get_current_active_user)
):
"""获取所有系统配置,返回键值对格式"""
try:
query = system_config_table.select()
configs = await db.fetch_all(query)
# 转换为键值对格式,与前端localStorage格式保持一致
result = {}
for config in configs:
result[config["config_key"]] = config["config_value"]
return result
except Exception as e:
logger.error(f"获取系统配置失败: {e}")
raise HTTPException(status_code=500, detail="获取系统配置失败")
@router.get("/list", response_model=List[SystemConfigResponse], summary="获取系统配置列表")
async def get_config_list(db: DatabaseManager = Depends(get_database)):
"""获取系统配置列表(详细格式)"""
try:
query = system_config_table.select().order_by(system_config_table.c.config_key)
configs = await db.fetch_all(query)
return [
SystemConfigResponse(
id=config["id"],
config_key=config["config_key"],
config_value=config["config_value"],
description=config["description"],
created_at=config["created_at"],
updated_at=config["updated_at"]
)
for config in configs
]
except Exception as e:
logger.error(f"获取系统配置列表失败: {e}")
raise HTTPException(status_code=500, detail="获取系统配置列表失败")
@router.get("/{config_key}", response_model=Any, summary="获取指定配置项")
async def get_config_by_key(config_key: str, db: DatabaseManager = Depends(get_database)):
"""根据配置键名获取配置值"""
try:
query = system_config_table.select().where(system_config_table.c.config_key == config_key)
config = await db.fetch_one(query)
if not config:
raise HTTPException(status_code=404, detail="配置项不存在")
return config["config_value"]
except HTTPException:
raise
except Exception as e:
logger.error(f"获取配置项失败: {e}")
raise HTTPException(status_code=500, detail="获取配置项失败")
@router.post("/", response_model=BaseResponse, summary="创建或更新配置项")
async def set_config(
config_item: SystemConfigItem,
db: DatabaseManager = Depends(get_database),
current_user: UserResponse = Depends(require_admin)
):
"""创建或更新系统配置项"""
try:
# 检查配置项是否已存在
existing_config = await db.fetch_one(
system_config_table.select().where(
system_config_table.c.config_key == config_item.config_key
)
)
if existing_config:
# 更新现有配置
query = system_config_table.update().where(
system_config_table.c.config_key == config_item.config_key
).values(
config_value=config_item.config_value,
description=config_item.description
)
await db.execute(query)
message = "配置项更新成功"
else:
# 创建新配置
query = system_config_table.insert().values(
config_key=config_item.config_key,
config_value=config_item.config_value,
description=config_item.description
)
await db.execute(query)
message = "配置项创建成功"
return BaseResponse(message=message)
except Exception as e:
logger.error(f"设置配置项失败: {e}")
raise HTTPException(status_code=500, detail="设置配置项失败")
@router.put("/", response_model=BaseResponse, summary="批量更新配置")
async def update_multiple_configs(
configs: Dict[str, Any],
db: DatabaseManager = Depends(get_database),
current_user: UserResponse = Depends(require_admin)
):
"""批量更新系统配置"""
try:
async with db.transaction():
for key, value in configs.items():
# 检查配置项是否存在
existing_config = await db.fetch_one(
system_config_table.select().where(
system_config_table.c.config_key == key
)
)
if existing_config:
# 更新现有配置
query = system_config_table.update().where(
system_config_table.c.config_key == key
).values(config_value=value)
await db.execute(query)
else:
# 创建新配置
query = system_config_table.insert().values(
config_key=key,
config_value=value,
description=f"系统配置项: {key}"
)
await db.execute(query)
return BaseResponse(message=f"成功更新 {len(configs)} 个配置项")
except Exception as e:
logger.error(f"批量更新配置失败: {e}")
raise HTTPException(status_code=500, detail="批量更新配置失败")
@router.delete("/{config_key}", response_model=BaseResponse, summary="删除配置项")
async def delete_config(
config_key: str,
db: DatabaseManager = Depends(get_database),
current_user: UserResponse = Depends(require_admin)
):
"""删除系统配置项"""
try:
# 检查配置项是否存在
existing_config = await db.fetch_one(
system_config_table.select().where(system_config_table.c.config_key == config_key)
)
if not existing_config:
raise HTTPException(status_code=404, detail="配置项不存在")
# 不允许删除关键系统配置
protected_keys = ["initialized", "version"]
if config_key in protected_keys:
raise HTTPException(status_code=400, detail="不能删除系统关键配置")
# 删除配置项
query = system_config_table.delete().where(system_config_table.c.config_key == config_key)
await db.execute(query)
return BaseResponse(message="配置项删除成功")
except HTTPException:
raise
except Exception as e:
logger.error(f"删除配置项失败: {e}")
raise HTTPException(status_code=500, detail="删除配置项失败")
@router.post("/reset", response_model=BaseResponse, summary="重置为默认配置")
async def reset_to_default(
db: DatabaseManager = Depends(get_database),
current_user: UserResponse = Depends(require_admin)
):
"""重置系统配置为默认值"""
try:
# 默认配置项
default_configs = [
("initialized", True, "系统是否已初始化"),
("version", "8.8.0", "系统版本"),
("hasDefaultData", False, "是否有默认示例数据")
]
async with db.transaction():
# 清空现有配置
await db.execute(system_config_table.delete())
# 插入默认配置
for key, value, desc in default_configs:
query = system_config_table.insert().values(
config_key=key,
config_value=value,
description=desc
)
await db.execute(query)
return BaseResponse(message="系统配置已重置为默认值")
except Exception as e:
logger.error(f"重置配置失败: {e}")
raise HTTPException(status_code=500, detail="重置配置失败")
-- 绩效计分系统数据库初始化脚本
-- 基于localStorage数据结构设计的PostgreSQL表结构
-- 删除已存在的表(开发环境)
DROP TABLE IF EXISTS monthly_history CASCADE;
DROP TABLE IF EXISTS institution_images CASCADE;
DROP TABLE IF EXISTS institutions CASCADE;
DROP TABLE IF EXISTS users CASCADE;
DROP TABLE IF EXISTS system_config CASCADE;
-- 创建用户表 (对应 localStorage: score_system_users)
CREATE TABLE users (
id VARCHAR(50) PRIMARY KEY, -- 用户ID,如 'admin', 'user_1234567890'
phone VARCHAR(20) UNIQUE NOT NULL, -- 手机号,用于登录
password VARCHAR(255) NOT NULL, -- 密码(明文存储,与原系统保持一致)
name VARCHAR(100) NOT NULL, -- 用户姓名
role VARCHAR(20) DEFAULT 'user', -- 角色:'admin' 或 'user'
institutions JSONB DEFAULT '[]', -- 负责的机构ID数组,保持原有结构
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 创建机构表 (对应 localStorage: score_system_institutions)
CREATE TABLE institutions (
id VARCHAR(50) PRIMARY KEY, -- 内部ID,如 'inst_1234567890_abc123'
institution_id VARCHAR(50), -- 机构编号,如 '001', '002'
name VARCHAR(200) NOT NULL, -- 机构名称
owner_id VARCHAR(50), -- 负责人ID,可为NULL(公池机构)
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-- 外键约束
CONSTRAINT fk_institutions_owner FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE SET NULL,
-- 唯一约束
CONSTRAINT uk_institutions_institution_id UNIQUE (institution_id)
);
-- 创建机构图片表 (对应 institutions.images 数组)
CREATE TABLE institution_images (
id VARCHAR(50) PRIMARY KEY, -- 图片ID
institution_id VARCHAR(50) NOT NULL, -- 所属机构ID
url TEXT NOT NULL, -- 图片URL/Base64数据
upload_time TIMESTAMP NOT NULL, -- 上传时间
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-- 外键约束
CONSTRAINT fk_images_institution FOREIGN KEY (institution_id) REFERENCES institutions(id) ON DELETE CASCADE
);
-- 创建系统配置表 (对应 localStorage: score_system_config)
CREATE TABLE system_config (
id SERIAL PRIMARY KEY,
config_key VARCHAR(100) UNIQUE NOT NULL, -- 配置键名
config_value JSONB, -- 配置值(JSON格式)
description TEXT, -- 配置描述
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 创建月度历史统计表 (对应 localStorage: score_system_history)
CREATE TABLE monthly_history (
id SERIAL PRIMARY KEY,
month VARCHAR(7) NOT NULL, -- 月份,格式:YYYY-MM
save_time TIMESTAMP NOT NULL, -- 保存时间
total_users INTEGER NOT NULL, -- 总用户数
total_institutions INTEGER NOT NULL, -- 总机构数
total_images INTEGER NOT NULL, -- 总图片数
user_stats JSONB NOT NULL, -- 用户统计数据(JSON格式)
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-- 唯一约束:每月只能有一条记录
CONSTRAINT uk_monthly_history_month UNIQUE (month)
);
-- 创建索引以提高查询性能
CREATE INDEX idx_users_phone ON users(phone);
CREATE INDEX idx_users_role ON users(role);
CREATE INDEX idx_institutions_owner_id ON institutions(owner_id);
CREATE INDEX idx_institutions_institution_id ON institutions(institution_id);
CREATE INDEX idx_institution_images_institution_id ON institution_images(institution_id);
CREATE INDEX idx_institution_images_upload_time ON institution_images(upload_time);
CREATE INDEX idx_system_config_key ON system_config(config_key);
CREATE INDEX idx_monthly_history_month ON monthly_history(month);
-- 创建更新时间触发器函数
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';
-- 为相关表创建更新时间触发器
CREATE TRIGGER update_users_updated_at
BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_institutions_updated_at
BEFORE UPDATE ON institutions
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_system_config_updated_at
BEFORE UPDATE ON system_config
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- 插入默认系统配置
INSERT INTO system_config (config_key, config_value, description) VALUES
('initialized', 'true', '系统是否已初始化'),
('version', '"8.8.0"', '系统版本'),
('hasDefaultData', 'false', '是否有默认示例数据')
ON CONFLICT (config_key) DO UPDATE SET
config_value = EXCLUDED.config_value,
updated_at = CURRENT_TIMESTAMP;
-- 插入默认管理员用户
INSERT INTO users (id, phone, password, name, role, institutions) VALUES
('admin', 'admin', 'admin123', '系统管理员', 'admin', '[]')
ON CONFLICT (id) DO UPDATE SET
phone = EXCLUDED.phone,
password = EXCLUDED.password,
name = EXCLUDED.name,
role = EXCLUDED.role,
institutions = EXCLUDED.institutions,
updated_at = CURRENT_TIMESTAMP;
-- 创建数据迁移辅助视图
CREATE OR REPLACE VIEW v_institutions_with_images AS
SELECT
i.id,
i.institution_id,
i.name,
i.owner_id,
i.created_at,
i.updated_at,
COALESCE(
JSON_AGG(
JSON_BUILD_OBJECT(
'id', img.id,
'url', img.url,
'uploadTime', img.upload_time
) ORDER BY img.upload_time
) FILTER (WHERE img.id IS NOT NULL),
'[]'::json
) AS images
FROM institutions i
LEFT JOIN institution_images img ON i.id = img.institution_id
GROUP BY i.id, i.institution_id, i.name, i.owner_id, i.created_at, i.updated_at;
-- 创建数据完整性检查函数
CREATE OR REPLACE FUNCTION check_data_integrity()
RETURNS TABLE(
check_name TEXT,
status TEXT,
message TEXT,
count INTEGER
) AS $$
BEGIN
-- 检查孤立机构(负责人不存在)
RETURN QUERY
SELECT
'orphan_institutions'::TEXT,
CASE WHEN COUNT(*) = 0 THEN 'OK' ELSE 'WARNING' END::TEXT,
('发现 ' || COUNT(*) || ' 个孤立机构')::TEXT,
COUNT(*)::INTEGER
FROM institutions i
LEFT JOIN users u ON i.owner_id = u.id
WHERE i.owner_id IS NOT NULL AND u.id IS NULL;
-- 检查重复的机构编号
RETURN QUERY
SELECT
'duplicate_institution_ids'::TEXT,
CASE WHEN COUNT(*) = 0 THEN 'OK' ELSE 'ERROR' END::TEXT,
('发现 ' || COUNT(*) || ' 个重复的机构编号')::TEXT,
COUNT(*)::INTEGER
FROM (
SELECT institution_id, COUNT(*) as cnt
FROM institutions
WHERE institution_id IS NOT NULL
GROUP BY institution_id
HAVING COUNT(*) > 1
) duplicates;
-- 检查无效图片记录
RETURN QUERY
SELECT
'invalid_images'::TEXT,
CASE WHEN COUNT(*) = 0 THEN 'OK' ELSE 'WARNING' END::TEXT,
('发现 ' || COUNT(*) || ' 个无效图片记录')::TEXT,
COUNT(*)::INTEGER
FROM institution_images img
LEFT JOIN institutions i ON img.institution_id = i.id
WHERE i.id IS NULL;
END;
$$ LANGUAGE plpgsql;
COMMENT ON TABLE users IS '用户表,存储系统用户信息';
COMMENT ON TABLE institutions IS '机构表,存储机构基本信息';
COMMENT ON TABLE institution_images IS '机构图片表,存储机构上传的图片信息';
COMMENT ON TABLE system_config IS '系统配置表,存储系统配置参数';
COMMENT ON TABLE monthly_history IS '月度历史统计表,存储每月的统计数据';
COMMENT ON VIEW v_institutions_with_images IS '机构及其图片的聚合视图,便于数据查询';
COMMENT ON FUNCTION check_data_integrity() IS '数据完整性检查函数,用于验证数据一致性';
version: '3.8'
services: services:
scoring-app: # PostgreSQL 数据库服务
postgres:
image: postgres:15-alpine
container_name: performance_postgres
restart: unless-stopped
environment:
POSTGRES_DB: performance_db
POSTGRES_USER: performance_user
POSTGRES_PASSWORD: performance_pass
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./database/init_performance_system.sql:/docker-entrypoint-initdb.d/01-init.sql
networks:
- performance_network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U performance_user -d performance_db"]
interval: 10s
timeout: 5s
retries: 5
# FastAPI 后端服务
api:
build:
context: ./backend
dockerfile: Dockerfile
container_name: performance_api
restart: unless-stopped
environment:
- DATABASE_URL=postgresql://performance_user:performance_pass@postgres:5432/performance_db
- CORS_ORIGINS=http://localhost:5173,http://localhost:8080,http://localhost:4001
- API_HOST=0.0.0.0
- API_PORT=8000
ports:
- "8000:8000"
depends_on:
postgres:
condition: service_healthy
volumes:
- ./backend:/app
- api_logs:/app/logs
networks:
- performance_network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
# 前端应用服务
frontend:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: scoring-app-8.8 args:
- VITE_API_BASE_URL=http://localhost:8000
container_name: performance_frontend
ports: ports:
- "4001:80" - "4001:80"
restart: unless-stopped restart: unless-stopped
environment: environment:
- NODE_ENV=production - NODE_ENV=production
# 如果后续需要挂载静态资源或配置: depends_on:
# volumes: - api
# - ./some-dir:/usr/share/nginx/html/some-dir:ro networks:
- performance_network
# 数据卷定义
volumes:
postgres_data:
driver: local
name: performance_postgres_data
api_logs:
driver: local
name: performance_api_logs
# 网络定义
networks:
performance_network:
driver: bridge
name: performance_network
...@@ -24,12 +24,23 @@ app.use(pinia) ...@@ -24,12 +24,23 @@ app.use(pinia)
app.use(router) app.use(router)
app.use(ElementPlus) app.use(ElementPlus)
// 初始化数据和认证状态 // 初始化应用
const dataStore = useDataStore() const initializeApp = async () => {
const authStore = useAuthStore() try {
console.log('🚀 正在初始化绩效计分系统...')
// 先加载数据,再恢复认证状态 // 挂载应用(认证状态恢复在路由守卫中处理)
dataStore.loadFromStorage() app.mount('#app')
authStore.restoreAuth()
app.mount('#app') console.log('🎉 绩效计分系统初始化完成')
\ No newline at end of file
} catch (error) {
console.error('❌ 应用初始化失败:', error)
// 即使初始化失败,也要挂载应用(降级模式)
app.mount('#app')
}
}
// 启动应用
initializeApp()
\ No newline at end of file
...@@ -49,7 +49,7 @@ const router = createRouter({ ...@@ -49,7 +49,7 @@ const router = createRouter({
/** /**
* 路由守卫 - 检查用户认证状态和权限 * 路由守卫 - 检查用户认证状态和权限
*/ */
router.beforeEach((to, from, next) => { router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore() const authStore = useAuthStore()
// 设置页面标题 // 设置页面标题
...@@ -57,6 +57,15 @@ router.beforeEach((to, from, next) => { ...@@ -57,6 +57,15 @@ router.beforeEach((to, from, next) => {
document.title = `${to.meta.title} - 绩效计分系统` document.title = `${to.meta.title} - 绩效计分系统`
} }
// 如果是首次访问或刷新页面,且有token,等待认证状态恢复
if (!authStore.isAuthenticated && localStorage.getItem('auth_tokens')) {
try {
await authStore.restoreAuth()
} catch (error) {
console.warn('认证状态恢复失败:', error)
}
}
// 检查是否需要认证 // 检查是否需要认证
if (to.meta.requiresAuth) { if (to.meta.requiresAuth) {
if (!authStore.isAuthenticated) { if (!authStore.isAuthenticated) {
......
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { userApi } from '@/services/api'
import apiClient from '@/services/api'
import { useDataStore } from './data' import { useDataStore } from './data'
/** /**
...@@ -8,7 +10,6 @@ import { useDataStore } from './data' ...@@ -8,7 +10,6 @@ import { useDataStore } from './data'
*/ */
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
const currentUser = ref(null) const currentUser = ref(null)
const dataStore = useDataStore()
/** /**
* 计算属性:是否已认证 * 计算属性:是否已认证
...@@ -21,53 +22,132 @@ export const useAuthStore = defineStore('auth', () => { ...@@ -21,53 +22,132 @@ export const useAuthStore = defineStore('auth', () => {
const isAdmin = computed(() => currentUser.value?.role === 'admin') const isAdmin = computed(() => currentUser.value?.role === 'admin')
/** /**
* 登录后加载数据
*/
const loadDataAfterLogin = async () => {
try {
const dataStore = useDataStore()
console.log('📊 登录成功,开始加载数据...')
const dataLoaded = await dataStore.loadData()
if (dataLoaded) {
console.log('✅ 数据加载成功')
} else {
console.warn('⚠️ 数据加载失败,使用离线模式')
}
} catch (error) {
console.error('❌ 登录后数据加载失败:', error)
}
}
/**
* 用户登录 * 用户登录
* @param {string} phone - 手机号 * @param {string} phone - 手机号
* @param {string} password - 密码 * @param {string} password - 密码
* @returns {boolean} 登录是否成功 * @returns {boolean} 登录是否成功
*/ */
const login = (phone, password) => { const login = async (phone, password) => {
const users = dataStore.getUsers() try {
const user = users.find(u => u.phone === phone && u.password === password) // 使用 API 进行登录验证
const response = await userApi.login({ phone, password })
if (user) { if (response.success && response.user && response.access_token) {
currentUser.value = user // 保存用户信息
localStorage.setItem('score_system_current_user', JSON.stringify(user)) currentUser.value = response.user
localStorage.setItem('currentUser', JSON.stringify(response.user))
// 保存tokens到API客户端
apiClient.saveTokens(response.access_token, response.refresh_token)
// 登录成功后加载数据
await loadDataAfterLogin()
console.log('✅ 用户登录成功:', response.user.name)
return true return true
} }
console.log('❌ 登录失败:', response.message)
return false return false
} catch (error) {
console.error('登录请求失败:', error)
return false
}
} }
/** /**
* 用户登出 * 用户登出
*/ */
const logout = () => { const logout = async () => {
try {
// 调用后端登出接口
await userApi.logout()
} catch (error) {
console.error('登出请求失败:', error)
} finally {
// 清除本地数据
currentUser.value = null currentUser.value = null
localStorage.removeItem('score_system_current_user') localStorage.removeItem('currentUser')
// 清除tokens
apiClient.clearTokens()
console.log('✅ 用户已登出')
}
} }
/** /**
* 恢复登录状态 * 恢复登录状态
* 从localStorage恢复用户登录状态 * 从 localStorage 中恢复登录状态并验证token有效性
*/ */
const restoreAuth = () => { const restoreAuth = async () => {
const saved = localStorage.getItem('score_system_current_user')
if (saved) {
try { try {
const user = JSON.parse(saved) // 从 localStorage 中获取用户信息
// 验证用户是否仍然存在 const savedUser = localStorage.getItem('currentUser')
const users = dataStore.getUsers() const accessToken = apiClient.getAccessToken()
const existingUser = users.find(u => u.id === user.id)
if (existingUser) { if (savedUser && accessToken) {
currentUser.value = existingUser // 验证token是否有效
} else { try {
logout() // 用户不存在,清除登录状态 const userInfo = await userApi.getCurrentUser()
if (userInfo && userInfo.id) {
currentUser.value = userInfo
// 恢复登录状态后加载数据
await loadDataAfterLogin()
console.log('✅ 登录状态已恢复:', userInfo.name)
return
} }
} catch (error) { } catch (error) {
console.error('恢复登录状态失败:', error) console.warn('Token验证失败,尝试刷新:', error)
logout() // Token可能过期,尝试刷新
const refreshed = await apiClient.refreshAccessToken()
if (refreshed) {
try {
const userInfo = await userApi.getCurrentUser()
if (userInfo && userInfo.id) {
currentUser.value = userInfo
localStorage.setItem('currentUser', JSON.stringify(userInfo))
// 恢复登录状态后加载数据
await loadDataAfterLogin()
console.log('✅ 登录状态已恢复(刷新后):', userInfo.name)
return
}
} catch (retryError) {
console.error('刷新后仍无法获取用户信息:', retryError)
}
}
} }
} }
// 恢复失败,清除数据
currentUser.value = null
localStorage.removeItem('currentUser')
apiClient.clearTokens()
console.log('🔐 未找到有效登录状态,需要重新登录')
} catch (error) {
console.error('恢复登录状态失败:', error)
currentUser.value = null
}
} }
/** /**
...@@ -77,26 +157,33 @@ export const useAuthStore = defineStore('auth', () => { ...@@ -77,26 +157,33 @@ export const useAuthStore = defineStore('auth', () => {
const updateCurrentUser = (userData) => { const updateCurrentUser = (userData) => {
if (currentUser.value) { if (currentUser.value) {
currentUser.value = { ...currentUser.value, ...userData } currentUser.value = { ...currentUser.value, ...userData }
localStorage.setItem('score_system_current_user', JSON.stringify(currentUser.value)) console.log('✅ 当前用户信息已更新')
} }
} }
// 管理员用户切换功能(内存中保存)
const adminUser = ref(null)
/** /**
* 切换到指定用户视图(管理员功能) * 切换到指定用户视图(管理员功能)
* @param {string} userId - 要切换到的用户ID * @param {string} userId - 要切换到的用户ID
*/ */
const switchToUser = (userId) => { const switchToUser = async (userId) => {
const { useDataStore } = require('./data') try {
const { useDataStore } = await import('./data')
const dataStore = useDataStore() const dataStore = useDataStore()
const user = dataStore.getUserById(userId) const user = dataStore.getUserById(userId)
if (user && currentUser.value?.role === 'admin') { if (user && currentUser.value?.role === 'admin') {
// 保存原管理员信息 // 保存原管理员信息到内存
localStorage.setItem('score_system_admin_user', JSON.stringify(currentUser.value)) adminUser.value = currentUser.value
// 切换到目标用户 // 切换到目标用户
currentUser.value = user currentUser.value = user
localStorage.setItem('score_system_current_user', JSON.stringify(user)) console.log('✅ 已切换到用户视图:', user.name)
}
} catch (error) {
console.error('切换用户视图失败:', error)
} }
} }
...@@ -104,11 +191,10 @@ export const useAuthStore = defineStore('auth', () => { ...@@ -104,11 +191,10 @@ export const useAuthStore = defineStore('auth', () => {
* 从用户视图切换回管理员视图 * 从用户视图切换回管理员视图
*/ */
const switchBackToAdmin = () => { const switchBackToAdmin = () => {
const adminUser = localStorage.getItem('score_system_admin_user') if (adminUser.value) {
if (adminUser) { currentUser.value = adminUser.value
currentUser.value = JSON.parse(adminUser) adminUser.value = null
localStorage.setItem('score_system_current_user', JSON.stringify(currentUser.value)) console.log('✅ 已切换回管理员视图')
localStorage.removeItem('score_system_admin_user')
} }
} }
...@@ -121,6 +207,7 @@ export const useAuthStore = defineStore('auth', () => { ...@@ -121,6 +207,7 @@ export const useAuthStore = defineStore('auth', () => {
restoreAuth, restoreAuth,
updateCurrentUser, updateCurrentUser,
switchToUser, switchToUser,
switchBackToAdmin switchBackToAdmin,
loadDataAfterLogin
} }
}) })
\ No newline at end of file
...@@ -55,62 +55,33 @@ export const cleanupAllNonAdminData = async () => { ...@@ -55,62 +55,33 @@ export const cleanupAllNonAdminData = async () => {
} }
/** /**
* 清理浏览器缓存 * 清理浏览器缓存(数据库模式)
*/ */
export const clearBrowserCache = () => { export const clearBrowserCache = () => {
try { try {
// 清理localStorage中的相关数据 // 数据库模式下只清理 sessionStorage 和其他临时缓存
const keysToKeep = ['score_system_users', 'score_system_institutions', 'score_system_config'] sessionStorage.clear()
const keysToRemove = []
// 找出所有相关的localStorage键 // 清理可能残留的旧版本 localStorage 数据
const keysToRemove = []
for (let i = 0; i < localStorage.length; i++) { for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i) const key = localStorage.key(i)
if (key && key.startsWith('score_system_') && !keysToKeep.includes(key)) { if (key && key.startsWith('score_system_')) {
keysToRemove.push(key) keysToRemove.push(key)
} }
} }
// 删除不需要的键
keysToRemove.forEach(key => { keysToRemove.forEach(key => {
localStorage.removeItem(key) localStorage.removeItem(key)
}) })
// 清理sessionStorage console.log('✅ 浏览器缓存已清理(数据库模式)')
sessionStorage.clear()
console.log('浏览器缓存已清理')
} catch (error) { } catch (error) {
console.error('清理浏览器缓存失败:', error) console.error('清理浏览器缓存失败:', error)
} }
} }
/** // 删除了不必要的数据完整性检查
* 验证数据完整性
*/
export const validateDataIntegrity = () => {
try {
const dataStore = useDataStore()
const users = dataStore.getUsers()
const institutions = dataStore.getInstitutions()
const report = {
totalUsers: users.length,
adminUsers: users.filter(u => u.role === 'admin').length,
regularUsers: users.filter(u => u.role === 'user').length,
totalInstitutions: institutions.length,
orphanedInstitutions: institutions.filter(inst => {
return !users.some(user => user.institutions && user.institutions.includes(inst.institutionId))
}).length
}
console.log('数据完整性报告:', report)
return report
} catch (error) {
console.error('数据完整性验证失败:', error)
return null
}
}
/** /**
* 导出当前数据(用于备份) * 导出当前数据(用于备份)
...@@ -162,14 +133,13 @@ export const resetSystemToInitialState = async () => { ...@@ -162,14 +133,13 @@ export const resetSystemToInitialState = async () => {
const dataStore = useDataStore() const dataStore = useDataStore()
// 重置到默认状态 // 重置到默认状态
const success = dataStore.resetToDefault() const success = await dataStore.resetToDefault()
if (success) { if (success) {
ElMessage.success('系统重置完成') ElMessage.success('系统重置完成')
// 清理所有缓存 // 清理浏览器缓存
localStorage.clear() clearBrowserCache()
sessionStorage.clear()
// 刷新页面 // 刷新页面
setTimeout(() => { setTimeout(() => {
......
...@@ -106,59 +106,41 @@ export const deepClone = (obj) => { ...@@ -106,59 +106,41 @@ export const deepClone = (obj) => {
} }
/** /**
* 本地存储工具 * 数据存储工具(已迁移到数据库)
* 保留接口兼容性,但实际不再使用本地存储
*/ */
export const storage = { export const storage = {
/** /**
* 获取存储数据 * 获取存储数据(已废弃)
* @param {string} key - 存储键 * @deprecated 已迁移到数据库存储,请使用相应的 API
* @param {any} defaultValue - 默认值
* @returns {any} 存储的数据
*/ */
get(key, defaultValue = null) { get(key, defaultValue = null) {
try { console.warn('storage.get 已废弃,请使用数据库 API')
const value = localStorage.getItem(key)
return value ? JSON.parse(value) : defaultValue
} catch (error) {
console.error('读取本地存储失败:', error)
return defaultValue return defaultValue
}
}, },
/** /**
* 设置存储数据 * 设置存储数据(已废弃)
* @param {string} key - 存储键 * @deprecated 已迁移到数据库存储,请使用相应的 API
* @param {any} value - 要存储的数据
*/ */
set(key, value) { set(key, value) {
try { console.warn('storage.set 已废弃,请使用数据库 API')
localStorage.setItem(key, JSON.stringify(value))
} catch (error) {
console.error('设置本地存储失败:', error)
}
}, },
/** /**
* 删除存储数据 * 删除存储数据(已废弃)
* @param {string} key - 存储键 * @deprecated 已迁移到数据库存储,请使用相应的 API
*/ */
remove(key) { remove(key) {
try { console.warn('storage.remove 已废弃,请使用数据库 API')
localStorage.removeItem(key)
} catch (error) {
console.error('删除本地存储失败:', error)
}
}, },
/** /**
* 清空所有存储 * 清空所有存储(已废弃)
* @deprecated 已迁移到数据库存储,请使用相应的 API
*/ */
clear() { clear() {
try { console.warn('storage.clear 已废弃,请使用数据库 API')
localStorage.clear()
} catch (error) {
console.error('清空本地存储失败:', error)
}
} }
} }
......
/**
* 服务器端数据同步管理器
* 解决跨浏览器数据同步问题
*/
import { ElMessage } from 'element-plus'
class ServerDataSync {
constructor() {
this.serverUrl = 'http://localhost:3001'
this.isEnabled = false
this.syncInterval = null
}
/**
* 启用服务器端数据同步
*/
enable() {
this.isEnabled = true
console.log('✅ 服务器端数据同步已启用')
}
/**
* 禁用服务器端数据同步
*/
disable() {
this.isEnabled = false
if (this.syncInterval) {
clearInterval(this.syncInterval)
this.syncInterval = null
}
console.log('❌ 服务器端数据同步已禁用')
}
/**
* 从服务器获取系统数据
*/
async getSystemData() {
try {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 3000) // 3秒超时
const response = await fetch(`${this.serverUrl}/api/system-data`, {
signal: controller.signal
})
clearTimeout(timeoutId)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const data = await response.json()
console.log('📥 从服务器获取系统数据:', data)
return data
} catch (error) {
console.error('获取服务器数据失败:', error)
throw error
}
}
/**
* 向服务器保存系统数据
*/
async saveSystemData(systemData) {
if (!this.isEnabled) {
console.log('服务器同步未启用,跳过保存')
return false
}
try {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 5000) // 5秒超时
const response = await fetch(`${this.serverUrl}/api/system-data`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(systemData),
signal: controller.signal
})
clearTimeout(timeoutId)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const result = await response.json()
console.log('📤 数据已保存到服务器:', result)
return true
} catch (error) {
console.error('保存数据到服务器失败:', error)
// 不显示错误消息,因为服务器可能不可用
return false
}
}
/**
* 同步localStorage数据到服务器
*/
async syncToServer() {
try {
const users = JSON.parse(localStorage.getItem('score_system_users') || '[]')
const institutions = JSON.parse(localStorage.getItem('score_system_institutions') || '[]')
const systemConfig = JSON.parse(localStorage.getItem('score_system_config') || '{}')
const systemData = {
users,
institutions,
systemConfig
}
await this.saveSystemData(systemData)
console.log('✅ localStorage数据已同步到服务器')
return true
} catch (error) {
console.error('同步数据到服务器失败:', error)
return false
}
}
/**
* 从服务器同步数据到localStorage
*/
async syncFromServer() {
try {
const systemData = await this.getSystemData()
if (systemData.users) {
localStorage.setItem('score_system_users', JSON.stringify(systemData.users))
}
if (systemData.institutions) {
localStorage.setItem('score_system_institutions', JSON.stringify(systemData.institutions))
}
if (systemData.systemConfig) {
localStorage.setItem('score_system_config', JSON.stringify(systemData.systemConfig))
}
console.log('✅ 服务器数据已同步到localStorage')
// 触发数据刷新事件
window.dispatchEvent(new CustomEvent('serverDataSynced', {
detail: { systemData, timestamp: new Date().toISOString() }
}))
return true
} catch (error) {
console.error('从服务器同步数据失败:', error)
return false
}
}
/**
* 检查服务器连接状态
*/
async checkServerConnection() {
try {
const response = await fetch(`${this.serverUrl}/health`, {
method: 'GET',
timeout: 5000
})
return response.ok
} catch (error) {
console.warn('服务器连接检查失败:', error)
return false
}
}
/**
* 启动定期同步
*/
startPeriodicSync(intervalMs = 30000) {
if (this.syncInterval) {
clearInterval(this.syncInterval)
}
this.syncInterval = setInterval(async () => {
if (this.isEnabled) {
const isConnected = await this.checkServerConnection()
if (isConnected) {
await this.syncFromServer()
}
}
}, intervalMs)
console.log(`🔄 定期同步已启动,间隔: ${intervalMs}ms`)
}
/**
* 停止定期同步
*/
stopPeriodicSync() {
if (this.syncInterval) {
clearInterval(this.syncInterval)
this.syncInterval = null
console.log('⏹️ 定期同步已停止')
}
}
/**
* 强制全量同步
*/
async forceSync() {
try {
console.log('🔄 开始强制全量同步...')
// 先检查服务器连接
const isConnected = await this.checkServerConnection()
if (!isConnected) {
throw new Error('服务器连接失败')
}
// 从服务器获取最新数据
await this.syncFromServer()
ElMessage.success('数据同步完成')
return true
} catch (error) {
console.error('强制同步失败:', error)
ElMessage.error('数据同步失败: ' + error.message)
return false
}
}
/**
* 获取同步状态
*/
getStatus() {
return {
isEnabled: this.isEnabled,
hasPeriodicSync: !!this.syncInterval,
serverUrl: this.serverUrl
}
}
}
// 创建全局实例
const serverDataSync = new ServerDataSync()
// 导出实例和类
export default serverDataSync
export { ServerDataSync }
/**
* 简单的跨浏览器数据同步
* 使用localStorage和定期检查实现基本的数据同步
*/
class SimpleCrossBrowserSync {
constructor() {
this.isEnabled = false
this.syncInterval = null
this.lastSyncTime = 0
this.syncKey = 'score_system_sync_timestamp'
this.dataKeys = [
'score_system_users',
'score_system_institutions',
'score_system_config'
]
}
/**
* 启用跨浏览器同步
*/
enable() {
this.isEnabled = true
this.updateSyncTimestamp()
this.startPeriodicCheck()
console.log('✅ 简单跨浏览器同步已启用')
}
/**
* 禁用跨浏览器同步
*/
disable() {
this.isEnabled = false
if (this.syncInterval) {
clearInterval(this.syncInterval)
this.syncInterval = null
}
console.log('❌ 简单跨浏览器同步已禁用')
}
/**
* 更新同步时间戳
*/
updateSyncTimestamp() {
const timestamp = Date.now()
localStorage.setItem(this.syncKey, timestamp.toString())
this.lastSyncTime = timestamp
}
/**
* 检查是否有数据更新
*/
checkForUpdates() {
if (!this.isEnabled) return false
try {
const currentTimestamp = parseInt(localStorage.getItem(this.syncKey) || '0')
if (currentTimestamp > this.lastSyncTime) {
console.log('🔄 检测到数据更新,触发同步')
this.lastSyncTime = currentTimestamp
this.triggerDataRefresh()
return true
}
return false
} catch (error) {
console.error('检查数据更新失败:', error)
return false
}
}
/**
* 触发数据刷新事件
*/
triggerDataRefresh() {
try {
// 触发自定义事件通知页面刷新数据
const event = new CustomEvent('simpleSyncDataChanged', {
detail: {
timestamp: new Date().toISOString(),
source: 'simpleCrossBrowserSync'
}
})
window.dispatchEvent(event)
console.log('✅ 已触发数据刷新事件')
} catch (error) {
console.error('触发数据刷新事件失败:', error)
}
}
/**
* 开始定期检查
*/
startPeriodicCheck() {
if (this.syncInterval) {
clearInterval(this.syncInterval)
}
// 每5秒检查一次数据更新
this.syncInterval = setInterval(() => {
this.checkForUpdates()
}, 5000)
console.log('🔄 开始定期检查数据更新 (每5秒)')
}
/**
* 通知数据已更改
*/
notifyDataChanged() {
if (!this.isEnabled) return
this.updateSyncTimestamp()
console.log('📢 数据更改通知已发送')
}
/**
* 获取同步状态
*/
getSyncStatus() {
return {
isEnabled: this.isEnabled,
lastSyncTime: this.lastSyncTime,
syncTimestamp: parseInt(localStorage.getItem(this.syncKey) || '0')
}
}
/**
* 强制同步检查
*/
forceSyncCheck() {
console.log('🔄 强制执行同步检查')
return this.checkForUpdates()
}
}
// 创建全局实例
const simpleCrossBrowserSync = new SimpleCrossBrowserSync()
export default simpleCrossBrowserSync
...@@ -104,13 +104,13 @@ const handleLogin = async () => { ...@@ -104,13 +104,13 @@ const handleLogin = async () => {
loading.value = true loading.value = true
// 执行登录 // 执行登录
const success = authStore.login(loginForm.phone, loginForm.password) const success = await authStore.login(loginForm.phone, loginForm.password)
if (success) { if (success) {
ElMessage.success('登录成功!') ElMessage.success('登录成功!')
// 根据用户角色跳转 // 根据用户角色跳转
if (authStore.currentUser.role === 'admin') { if (authStore.currentUser && authStore.currentUser.role === 'admin') {
router.push('/admin') router.push('/admin')
} else { } else {
router.push('/user') router.push('/user')
...@@ -130,8 +130,8 @@ const handleLogin = async () => { ...@@ -130,8 +130,8 @@ const handleLogin = async () => {
* 组件挂载时初始化数据 * 组件挂载时初始化数据
*/ */
onMounted(() => { onMounted(() => {
// 加载系统数据 // 登录页面不需要加载数据,数据将在登录成功后加载
dataStore.loadFromStorage() console.log('登录页面已加载')
}) })
</script> </script>
......
...@@ -294,20 +294,7 @@ const currentPeriod = computed(() => { ...@@ -294,20 +294,7 @@ const currentPeriod = computed(() => {
const year = currentDate.getFullYear() const year = currentDate.getFullYear()
const month = currentDate.getMonth() + 1 const month = currentDate.getMonth() + 1
// 获取上次重置时间 // 数据库模式下,直接显示当前年月
const lastResetTime = localStorage.getItem('last_reset_time')
if (lastResetTime) {
const resetDate = new Date(lastResetTime)
const resetMonth = resetDate.getMonth() + 1
const resetYear = resetDate.getFullYear()
// 如果是同一年同一月,显示重置后的天数
if (resetYear === year && resetMonth === month) {
const daysSinceReset = Math.floor((currentDate - resetDate) / (1000 * 60 * 60 * 24))
return `${year}${month}月(重置后第${daysSinceReset + 1}天)`
}
}
return `${year}${month}月` return `${year}${month}月`
}) })
...@@ -400,14 +387,7 @@ const beforeUpload = (file, institutionId) => { ...@@ -400,14 +387,7 @@ const beforeUpload = (file, institutionId) => {
return false return false
} }
// 🔍 使用新的重复检测功能(暂时创建临时图片数据进行检测) // 基础文件验证
const tempImageData = {
name: file.name,
size: file.size,
url: 'temp_url_for_detection' // 临时URL,实际检测会在压缩后进行
}
// 注意:这里只是基础检测,完整的重复检测会在实际上传时进行
const isImage = file.type.startsWith('image/') const isImage = file.type.startsWith('image/')
const isLt5M = file.size / 1024 / 1024 < 5 const isLt5M = file.size / 1024 / 1024 < 5
...@@ -460,18 +440,8 @@ const handleImageUpload = (uploadFile, institutionId) => { ...@@ -460,18 +440,8 @@ const handleImageUpload = (uploadFile, institutionId) => {
currentImageCount: institution.images.length currentImageCount: institution.images.length
}) })
// 检查localStorage中的实际数据 // 数据库模式下,数据直接从内存状态获取
const savedData = JSON.parse(localStorage.getItem('score_system_institutions') || '[]') console.log('数据库模式:机构数据来自 API')
const savedInstitution = savedData.find(inst => inst.id === institutionId)
console.log('localStorage中的机构数据:', {
institutionId,
savedInstitution: savedInstitution ? {
id: savedInstitution.id,
institutionId: savedInstitution.institutionId,
name: savedInstitution.name,
imagesCount: savedInstitution.images?.length || 0
} : null
})
// 验证机构ID匹配 // 验证机构ID匹配
if (savedInstitution && savedInstitution.id !== institutionId) { if (savedInstitution && savedInstitution.id !== institutionId) {
...@@ -614,19 +584,14 @@ const removeImage = async (institutionId, imageId) => { ...@@ -614,19 +584,14 @@ const removeImage = async (institutionId, imageId) => {
ElMessage.success('图片删除成功!') ElMessage.success('图片删除成功!')
// 强制刷新数据确保界面更新 // 强制刷新数据确保界面更新
nextTick(() => { nextTick(async () => {
// 重新加载数据以确保界面同步 // 重新加载数据以确保界面同步
dataStore.loadFromStorage() await dataStore.loadData()
// 验证删除结果 // 验证删除结果
const userInstitutions = dataStore.getInstitutionsByUserId(authStore.currentUser.id) const userInstitutions = dataStore.getInstitutionsByUserId(authStore.currentUser.id)
const institution = userInstitutions.find(inst => inst.id === institutionId) const institution = userInstitutions.find(inst => inst.id === institutionId)
console.log('删除后机构图片数量:', institution?.images.length || 0) console.log('删除后机构图片数量:', institution?.images.length || 0)
// 验证localStorage中的数据
const savedData = JSON.parse(localStorage.getItem('score_system_institutions') || '[]')
const savedInstitution = savedData.find(inst => inst.id === institutionId)
console.log('localStorage中的图片数量:', savedInstitution?.images?.length || 0)
}) })
} }
} catch (error) { } catch (error) {
...@@ -729,16 +694,8 @@ onMounted(() => { ...@@ -729,16 +694,8 @@ onMounted(() => {
// 调试:检查页面加载时的数据状态 // 用户面板初始化完成
console.log('=== 页面加载时数据状态 ===') console.log('用户面板已加载,用户:', authStore.currentUser?.name)
console.log('当前用户:', authStore.currentUser)
console.log('用户机构数量:', userInstitutions.value.length)
console.log('localStorage机构数据:', localStorage.getItem('score_system_institutions'))
// 检查每个机构的图片数量
userInstitutions.value.forEach(inst => {
console.log(`机构 ${inst.name} 图片数量:`, inst.images.length)
})
}) })
......
@echo off
chcp 65001 >nul
echo ========================================
echo 绩效计分系统 - 数据库启动脚本
echo ========================================
echo.
REM 检查 Docker 是否安装
where docker >nul 2>&1
if errorlevel 1 (
echo ❌ 未检测到 Docker,请先安装 Docker Desktop
pause
exit /b 1
)
REM 检查 Docker 是否运行
docker info >nul 2>&1
if errorlevel 1 (
echo ❌ Docker 未运行,请启动 Docker Desktop
pause
exit /b 1
)
echo ✅ Docker 环境检查通过
echo.
echo 🚀 启动 PostgreSQL 数据库...
docker compose up -d postgres
if errorlevel 1 (
echo ❌ 数据库启动失败
pause
exit /b 1
)
echo.
echo ⏳ 等待数据库初始化完成...
timeout /t 10 /nobreak >nul
REM 检查数据库健康状态
echo 🔍 检查数据库连接状态...
docker compose exec postgres pg_isready -U performance_user -d performance_db >nul 2>&1
if errorlevel 1 (
echo ⚠️ 数据库可能还在初始化中,请稍后检查
) else (
echo ✅ 数据库连接正常
)
echo.
echo ========================================
echo 🎉 数据库启动完成!
echo ========================================
echo.
echo 📋 数据库信息:
echo - 主机: localhost
echo - 端口: 5432
echo - 数据库: performance_db
echo - 用户: performance_user
echo - 密码: performance_pass
echo.
echo 🔧 管理命令:
echo - 查看日志: docker compose logs postgres
echo - 停止数据库: docker compose stop postgres
echo - 连接数据库: docker compose exec postgres psql -U performance_user -d performance_db
echo.
pause
@echo off
chcp 65001 >nul
echo ========================================
echo 绩效计分系统 - 完整启动脚本
echo ========================================
echo.
REM 检查 Docker 是否安装
where docker >nul 2>&1
if errorlevel 1 (
echo ❌ 未检测到 Docker,请先安装 Docker Desktop
pause
exit /b 1
)
REM 检查 Docker 是否运行
docker info >nul 2>&1
if errorlevel 1 (
echo ❌ Docker 未运行,请启动 Docker Desktop
pause
exit /b 1
)
echo ✅ Docker 环境检查通过
echo.
echo 🚀 启动完整系统...
echo.
echo 📊 1. 启动 PostgreSQL 数据库...
docker compose up -d postgres
if errorlevel 1 (
echo ❌ 数据库启动失败
pause
exit /b 1
)
echo ⏳ 等待数据库初始化完成...
timeout /t 15 /nobreak >nul
echo 🔍 检查数据库连接状态...
docker compose exec postgres pg_isready -U performance_user -d performance_db >nul 2>&1
if errorlevel 1 (
echo ⚠️ 数据库可能还在初始化中,继续启动其他服务...
) else (
echo ✅ 数据库连接正常
)
echo.
echo 🔧 2. 启动 FastAPI 后端服务...
docker compose up -d api
if errorlevel 1 (
echo ❌ 后端服务启动失败
pause
exit /b 1
)
echo ⏳ 等待后端服务启动...
timeout /t 10 /nobreak >nul
echo.
echo 🌐 3. 启动前端应用...
docker compose up -d frontend
if errorlevel 1 (
echo ❌ 前端应用启动失败
pause
exit /b 1
)
echo.
echo ========================================
echo 🎉 系统启动完成!
echo ========================================
echo.
echo 📋 服务信息:
echo - 前端应用: http://localhost:4001
echo - 后端API: http://localhost:8000
echo - API文档: http://localhost:8000/docs
echo - 数据库: localhost:5432
echo.
echo 🔧 管理命令:
echo - 查看日志: docker compose logs -f
echo - 停止系统: docker compose down
echo - 重启系统: docker compose restart
echo.
echo 📖 默认登录信息:
echo - 用户名: admin
echo - 密码: admin123
echo.
REM 等待5秒后自动打开浏览器
echo 🌐 5秒后自动打开浏览器...
timeout /t 5 /nobreak >nul
start http://localhost:4001
pause
# 测试批量删除API
$loginData = @{phone='admin'; password='admin123'} | ConvertTo-Json
$loginResponse = Invoke-RestMethod -Uri 'http://localhost:8000/api/users/login' -Method Post -Body $loginData -ContentType 'application/json'
Write-Host 'Login successful, token obtained'
$token = $loginResponse.access_token
$headers = @{Authorization="Bearer $token"; 'Content-Type'='application/json'}
$deleteData = @{institution_ids=@('test_id')} | ConvertTo-Json
Write-Host 'Testing batch delete API...'
try {
$deleteResponse = Invoke-RestMethod -Uri 'http://localhost:8000/api/institutions/batch' -Method Delete -Body $deleteData -Headers $headers
Write-Host 'Success:' ($deleteResponse | ConvertTo-Json)
} catch {
Write-Host 'Error Status:' $_.Exception.Response.StatusCode
Write-Host 'Error Message:' $_.Exception.Message
}
#!/usr/bin/env python3
"""测试批量删除API"""
import sys
import os
sys.path.append(os.path.join(os.path.dirname(__file__), 'backend'))
import requests
import json
def test_batch_delete():
base_url = "http://localhost:8000"
# 1. 登录获取token
print("1. 登录...")
login_data = {
"username": "admin",
"password": "admin123"
}
try:
login_response = requests.post(f"{base_url}/api/users/login", json=login_data)
print(f"登录响应状态码: {login_response.status_code}")
if login_response.status_code != 200:
print(f"登录失败: {login_response.text}")
return
token = login_response.json()["access_token"]
print("登录成功,获取到token")
# 2. 设置请求头
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
# 3. 获取机构列表
print("\n2. 获取机构列表...")
institutions_response = requests.get(f"{base_url}/api/institutions/", headers=headers)
print(f"获取机构列表响应状态码: {institutions_response.status_code}")
if institutions_response.status_code == 200:
institutions = institutions_response.json()
print(f"找到 {len(institutions)} 个机构")
if institutions:
first_institution = institutions[0]
print(f"第一个机构: ID={first_institution.get('id')}, 名称={first_institution.get('name')}")
# 4. 测试批量删除API
print("\n3. 测试批量删除API...")
delete_data = {
"institution_ids": ["test_id_that_does_not_exist"]
}
delete_response = requests.delete(
f"{base_url}/api/institutions/batch",
json=delete_data,
headers=headers
)
print(f"批量删除响应状态码: {delete_response.status_code}")
print(f"批量删除响应内容: {delete_response.text}")
except requests.exceptions.RequestException as e:
print(f"请求异常: {e}")
except Exception as e:
print(f"其他异常: {e}")
if __name__ == "__main__":
test_batch_delete()
机构ID,机构名称,负责人
888,测试批量添加机构1,陈锐屏
999,测试批量添加机构2,张田田
# 测试批量删除路由
Write-Host 'Testing batch route...'
try {
$testResponse = Invoke-RestMethod -Uri 'http://localhost:8000/api/institutions/batch/test' -Method Get
Write-Host 'Test route success:' ($testResponse | ConvertTo-Json)
} catch {
Write-Host 'Test route error:' $_.Exception.Message
Write-Host 'Status code:' $_.Exception.Response.StatusCode
}
...@@ -10,9 +10,22 @@ export default defineConfig({ ...@@ -10,9 +10,22 @@ export default defineConfig({
} }
}, },
server: { server: {
port: 4001, port: 5173,
host: '0.0.0.0', host: '0.0.0.0',
hmr: true hmr: true,
proxy: {
// 代理 API 请求到后端服务
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
secure: false
},
'/health': {
target: 'http://localhost:8000',
changeOrigin: true,
secure: false
}
}
}, },
build: { build: {
rollupOptions: { rollupOptions: {
...@@ -20,5 +33,9 @@ export default defineConfig({ ...@@ -20,5 +33,9 @@ export default defineConfig({
manualChunks: undefined manualChunks: undefined
} }
} }
},
// 环境变量配置
define: {
__VUE_PROD_DEVTOOLS__: false
} }
}) })
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment