seaislee1209 f07126e0ca
All checks were successful
Build and Deploy Backend / build-and-deploy (push) Successful in 2m4s
Build and Deploy Web / build-and-deploy (push) Successful in 1m13s
feat: 内容提交表单全面优化 — 阶段重命名+内容类型扩展+集数条件必填
- 阶段重命名: "制作" → "中期",全前后端同步
- 新增前期内容类型: 大纲/梗概、概念设计图、测试片
- 配音改名为AI配音(零迁移,DB存英文NAME)
- 集数按内容类型条件显示: 项目级隐藏,集数级必填
- 工作类型全阶段统一: 制作/修改/测试/QC
- 前期内容隐藏产出时长字段
- 必填校验: 描述+投入时长+集数(条件)
- 今日工时进度条(8h目标)+提交后提醒
- 效率计算只统计中期阶段
- EP集数进度查询优化(N+1→批量GROUP BY)
- 提交列表增加按提交人筛选

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:34:11 +08:00

316 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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, Submission
)
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")
if 'episode_number' not in sub_cols:
conn.execute(text("ALTER TABLE submissions ADD COLUMN episode_number INT NULL"))
conn.commit()
print("[MIGRATE] added episode_number 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','SYNOPSIS','CONCEPT_DESIGN','TEST_FOOTAGE',
'SCRIPT','STORYBOARD','CHARACTER_DESIGN','SCENE_DESIGN',
'PROP_DESIGN','ANIMATION','DUBBING','AI_DUBBING','SOUND_EFFECTS',
'SHOT_REPAIR','EDITING','MUSIC','SUBTITLE','OTHER',
'DESIGN') NOT NULL
"""))
conn.commit()
print("[MIGRATE] expanded content_type enum")
except Exception:
pass # 已经扩展过
# MySQL: 扩展 work_type 枚举(加入 REVISION/修改)
try:
conn.execute(text("""
ALTER TABLE submissions MODIFY COLUMN work_type
ENUM('PRODUCTION','TEST','PLAN','REVISION','QC') NOT NULL
"""))
conn.commit()
print("[MIGRATE] expanded work_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}")
# 一次性迁移:阶段"制作"→"中期"
try:
phase_tables = [
("submissions", "project_phase"),
("projects", "current_phase"),
("users", "phase_group"),
("project_milestones", "phase"),
]
total_migrated = 0
for table, col in phase_tables:
r = conn.execute(text(f"UPDATE {table} SET {col} = '中期' WHERE {col} = '制作'"))
total_migrated += r.rowcount or 0
conn.commit()
if total_migrated > 0:
print(f"[MIGRATE] renamed phase '制作''中期' in {total_migrated} rows")
except Exception as e:
print(f"[MIGRATE] phase rename 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"])
added = defined - current
removed = current - defined
if added or removed:
existing.permissions = list(defined)
flag_modified(existing, "permissions")
parts = []
if added:
parts.append(f"added {added}")
if removed:
parts.append(f"removed {removed}")
print(f"[SYNC] {role_name}: {', '.join(parts)}")
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()
# 迁移:从 description 提取 EP 号填充 episode_number
try:
import re as _re
orphan_subs = db.query(Submission).filter(
Submission.episode_number.is_(None),
Submission.description.isnot(None),
).all()
migrated_ep = 0
for s in orphan_subs:
m = _re.match(r'EP(\d+)', s.description or '', _re.IGNORECASE)
if m:
s.episode_number = int(m.group(1))
migrated_ep += 1
if migrated_ep:
db.commit()
print(f"[MIGRATE] extracted episode_number for {migrated_ep} submissions")
except Exception as e:
print(f"[MIGRATE] episode extraction error (non-fatal): {e}")
# 创建默认管理员(关联超级管理员角色)
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()