- 仪表盘双色进度条(超100%蓝红分段)、工时损耗展示、chart tooltip增强 - 修复 Submissions.vue 延期原因字段始终显示的Bug - 修复 SPA catch-all 路由拦截 API 请求(去尾部斜杠) - seed_demo.py 重写:5项目/4类型/32里程碑/124提交,真实时间线 - 三阶段损耗计算(前期工时/制作秒数/后期工时) - ContentType 扩展为11种,里程碑增强(预估天数/开始日期/超期检测) - 更新 PRD 和项目总结文档 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
255 lines
10 KiB
Python
255 lines
10 KiB
Python
"""AirLabs Project —— 主入口"""
|
||
from dotenv import load_dotenv
|
||
load_dotenv()
|
||
|
||
from fastapi import FastAPI, HTTPException
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
from fastapi.staticfiles import StaticFiles
|
||
from fastapi.responses import FileResponse
|
||
from database import engine, Base
|
||
from models import (
|
||
User, Role, PhaseGroup, BUILTIN_ROLES, COST_PERM_MIGRATION,
|
||
Project, ProjectMilestone, DEFAULT_MILESTONES
|
||
)
|
||
from auth import hash_password
|
||
from sqlalchemy.orm.attributes import flag_modified
|
||
from sqlalchemy import text, inspect
|
||
import os
|
||
import logging
|
||
|
||
logging.basicConfig(level=logging.INFO)
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# 创建所有表
|
||
Base.metadata.create_all(bind=engine)
|
||
|
||
# ── 手动迁移:为旧 SQLite 数据库补充 role_id 列 ──
|
||
from config import DATABASE_URL as _db_url
|
||
if _db_url.startswith("sqlite"):
|
||
import sqlalchemy
|
||
with engine.connect() as conn:
|
||
cols = [r[1] for r in conn.execute(sqlalchemy.text("PRAGMA table_info('users')"))]
|
||
if "role_id" not in cols:
|
||
conn.execute(sqlalchemy.text("ALTER TABLE users ADD COLUMN role_id INTEGER"))
|
||
conn.commit()
|
||
logger.info("[MIGRATE] added column users.role_id")
|
||
|
||
app = FastAPI(title="AirLabs Project", version="1.0.0")
|
||
|
||
# CORS
|
||
from config import CORS_ORIGINS
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=CORS_ORIGINS,
|
||
allow_credentials=True,
|
||
allow_methods=["*"],
|
||
allow_headers=["*"],
|
||
)
|
||
|
||
# 注册路由
|
||
from routers.auth import router as auth_router
|
||
from routers.users import router as users_router
|
||
from routers.projects import router as projects_router
|
||
from routers.submissions import router as submissions_router
|
||
from routers.costs import router as costs_router
|
||
from routers.dashboard import router as dashboard_router
|
||
from routers.roles import router as roles_router
|
||
try:
|
||
from routers.reports import router as reports_router
|
||
except ImportError as e:
|
||
reports_router = None
|
||
logging.warning(f"[路由] reports 模块加载失败: {e}")
|
||
|
||
app.include_router(auth_router)
|
||
app.include_router(users_router)
|
||
app.include_router(projects_router)
|
||
app.include_router(submissions_router)
|
||
app.include_router(costs_router)
|
||
app.include_router(dashboard_router)
|
||
app.include_router(roles_router)
|
||
if reports_router:
|
||
app.include_router(reports_router)
|
||
|
||
# 前端静态文件
|
||
frontend_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "frontend", "dist")
|
||
if os.path.exists(frontend_dir):
|
||
app.mount("/assets", StaticFiles(directory=os.path.join(frontend_dir, "assets")), name="assets")
|
||
|
||
@app.get("/{full_path:path}")
|
||
async def serve_frontend(full_path: str):
|
||
# 不拦截 API 路径,让 FastAPI 路由正常处理(含 redirect_slashes)
|
||
if full_path.startswith("api/") or full_path == "api":
|
||
raise HTTPException(status_code=404, detail="Not Found")
|
||
file_path = os.path.join(frontend_dir, full_path)
|
||
if os.path.isfile(file_path):
|
||
return FileResponse(file_path)
|
||
return FileResponse(os.path.join(frontend_dir, "index.html"))
|
||
|
||
|
||
@app.on_event("startup")
|
||
async def start_scheduler():
|
||
"""启动定时任务调度器"""
|
||
try:
|
||
from services.scheduler_service import setup_scheduler
|
||
setup_scheduler()
|
||
except Exception as e:
|
||
logger.warning(f"[定时任务] 启动失败(不影响核心功能): {e}")
|
||
|
||
|
||
@app.on_event("shutdown")
|
||
async def stop_scheduler():
|
||
"""关闭定时任务调度器"""
|
||
try:
|
||
from services.scheduler_service import scheduler
|
||
scheduler.shutdown(wait=False)
|
||
logger.info("[定时任务] 已关闭")
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
@app.on_event("startup")
|
||
def init_roles_and_admin():
|
||
"""首次启动时创建内置角色和默认管理员"""
|
||
from database import SessionLocal
|
||
db = SessionLocal()
|
||
try:
|
||
# ── 数据库结构迁移(幂等,必须在 ORM 查询之前) ──
|
||
try:
|
||
inspector = inspect(engine)
|
||
ms_cols = [c['name'] for c in inspector.get_columns('project_milestones')]
|
||
sub_cols = [c['name'] for c in inspector.get_columns('submissions')]
|
||
|
||
with engine.connect() as conn:
|
||
# ProjectMilestone 新字段
|
||
if 'estimated_days' not in ms_cols:
|
||
conn.execute(text("ALTER TABLE project_milestones ADD COLUMN estimated_days INT NULL"))
|
||
conn.execute(text("ALTER TABLE project_milestones ADD COLUMN start_date DATE NULL"))
|
||
conn.commit()
|
||
print("[MIGRATE] added estimated_days, start_date to project_milestones")
|
||
|
||
# Submission 新字段
|
||
if 'milestone_id' not in sub_cols:
|
||
conn.execute(text("ALTER TABLE submissions ADD COLUMN milestone_id INT NULL"))
|
||
conn.execute(text("ALTER TABLE submissions ADD COLUMN delay_reason TEXT NULL"))
|
||
conn.commit()
|
||
print("[MIGRATE] added milestone_id, delay_reason to submissions")
|
||
|
||
# MySQL: 扩展 content_type 枚举(使用 Python enum 名称)+ 旧值迁移
|
||
from config import DATABASE_URL
|
||
if not DATABASE_URL.startswith("sqlite"):
|
||
try:
|
||
conn.execute(text("""
|
||
ALTER TABLE submissions MODIFY COLUMN content_type
|
||
ENUM('PLANNING','SCRIPT','STORYBOARD','CHARACTER_DESIGN','SCENE_DESIGN',
|
||
'ANIMATION','DUBBING','SOUND_EFFECTS','SHOT_REPAIR','EDITING','OTHER',
|
||
'DESIGN') NOT NULL
|
||
"""))
|
||
conn.commit()
|
||
print("[MIGRATE] expanded content_type enum")
|
||
except Exception:
|
||
pass # 已经扩展过
|
||
|
||
# 旧值迁移:DESIGN → PLANNING
|
||
r1 = conn.execute(text("UPDATE submissions SET content_type='ANIMATION' WHERE content_type='DESIGN'"))
|
||
conn.commit()
|
||
if (r1.rowcount or 0) > 0:
|
||
print(f"[MIGRATE] remapped {r1.rowcount} old DESIGN → ANIMATION")
|
||
except Exception as e:
|
||
print(f"[MIGRATE] schema migration error (non-fatal): {e}")
|
||
|
||
# 初始化 / 同步内置角色权限
|
||
for role_name, role_def in BUILTIN_ROLES.items():
|
||
existing = db.query(Role).filter(Role.name == role_name).first()
|
||
if not existing:
|
||
role = Role(
|
||
name=role_name,
|
||
description=role_def["description"],
|
||
permissions=role_def["permissions"],
|
||
is_system=1,
|
||
)
|
||
db.add(role)
|
||
print(f"[OK] created role: {role_name}")
|
||
elif existing.is_system:
|
||
# 同步内置角色:补充代码中新增的权限
|
||
current = set(existing.permissions or [])
|
||
defined = set(role_def["permissions"])
|
||
missing = defined - current
|
||
if missing:
|
||
existing.permissions = list(current | missing)
|
||
flag_modified(existing, "permissions")
|
||
print(f"[SYNC] added permissions to {role_name}: {missing}")
|
||
db.commit()
|
||
|
||
# 迁移旧成本权限 → 细分权限
|
||
old_cost_perms = set(COST_PERM_MIGRATION.keys())
|
||
for role in db.query(Role).all():
|
||
perms = list(role.permissions or [])
|
||
changed = False
|
||
for old_perm, new_perms in COST_PERM_MIGRATION.items():
|
||
if old_perm in perms:
|
||
perms.remove(old_perm)
|
||
for np in new_perms:
|
||
if np not in perms:
|
||
perms.append(np)
|
||
changed = True
|
||
if changed:
|
||
role.permissions = perms
|
||
print(f"[MIGRATE] upgraded cost permissions for role: {role.name}")
|
||
db.commit()
|
||
|
||
# 为已有项目补充默认里程碑
|
||
for proj in db.query(Project).all():
|
||
has_ms = db.query(ProjectMilestone).filter(
|
||
ProjectMilestone.project_id == proj.id
|
||
).first()
|
||
if not has_ms:
|
||
for ms in DEFAULT_MILESTONES:
|
||
db.add(ProjectMilestone(
|
||
project_id=proj.id,
|
||
name=ms["name"],
|
||
phase=PhaseGroup(ms["phase"]),
|
||
sort_order=ms.get("sort_order", 0),
|
||
))
|
||
print(f"[MIGRATE] added default milestones for project: {proj.name}")
|
||
db.commit()
|
||
|
||
# 为已有项目补充"剪辑"里程碑(如缺失)
|
||
for proj in db.query(Project).all():
|
||
has_edit = db.query(ProjectMilestone).filter(
|
||
ProjectMilestone.project_id == proj.id,
|
||
ProjectMilestone.name == "剪辑",
|
||
).first()
|
||
if not has_edit:
|
||
db.add(ProjectMilestone(
|
||
project_id=proj.id, name="剪辑",
|
||
phase=PhaseGroup("后期"), sort_order=4,
|
||
))
|
||
db.commit()
|
||
|
||
# 迁移:为旧用户补充默认 role_id(成员角色)
|
||
member_role = db.query(Role).filter(Role.name == "成员").first()
|
||
if member_role:
|
||
orphans = db.query(User).filter(User.role_id.is_(None)).all()
|
||
for u in orphans:
|
||
u.role_id = member_role.id
|
||
print(f"[MIGRATE] assigned default role '成员' to user: {u.username}")
|
||
if orphans:
|
||
db.commit()
|
||
|
||
# 创建默认管理员(关联超级管理员角色)
|
||
admin_role = db.query(Role).filter(Role.name == "超级管理员").first()
|
||
if admin_role and not db.query(User).filter(User.username == "admin").first():
|
||
owner = User(
|
||
username="admin",
|
||
password_hash=hash_password("admin123"),
|
||
name="管理员",
|
||
phase_group=PhaseGroup.PRODUCTION,
|
||
role_id=admin_role.id,
|
||
monthly_salary=0,
|
||
)
|
||
db.add(owner)
|
||
db.commit()
|
||
print("[OK] default admin created: admin / admin123")
|
||
finally:
|
||
db.close()
|