feat: MVP V1 鍏ㄦ爤鎼缓瀹屾垚 - FastAPI鍚庣 + Vue3鍓嶇 + 绉嶅瓙鏁版嵁

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
seaislee1209 2026-02-12 14:24:05 +08:00
parent a9aaee6a57
commit e76e856dba
56 changed files with 5601 additions and 0 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
backend/airlabs.db Normal file

Binary file not shown.

62
backend/auth.py Normal file
View File

@ -0,0 +1,62 @@
"""JWT 认证 + 权限控制"""
from datetime import datetime, timedelta
from typing import Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from passlib.context import CryptContext
from sqlalchemy.orm import Session
from database import get_db
from models import User, UserRole
from config import SECRET_KEY, ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy()
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
to_encode.update({"exp": expire})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> User:
"""从 JWT token 解析当前用户"""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="登录已过期,请重新登录",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
user_id: int = payload.get("sub")
if user_id is None:
raise credentials_exception
except JWTError:
raise credentials_exception
user = db.query(User).filter(User.id == user_id).first()
if user is None or not user.is_active:
raise credentials_exception
return user
def require_role(*roles: UserRole):
"""权限装饰器:要求当前用户具有指定角色之一"""
def role_checker(current_user: User = Depends(get_current_user)):
if current_user.role not in [r.value for r in roles] and current_user.role not in roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="权限不足"
)
return current_user
return role_checker

271
backend/calculations.py Normal file
View File

@ -0,0 +1,271 @@
"""
计算引擎 所有成本分摊损耗效率计算逻辑集中在此模块
修改计算规则只需改此文件
"""
from sqlalchemy.orm import Session
from sqlalchemy import func as sa_func, and_
from collections import defaultdict
from datetime import date, timedelta
from models import (
User, Project, Submission, AIToolCost, AIToolCostAllocation,
OutsourceCost, CostOverride, WorkType, CostAllocationType
)
from config import WORKING_DAYS_PER_MONTH
# ──────────────────────────── 人力成本分摊 ────────────────────────────
def calc_labor_cost_for_project(project_id: int, db: Session) -> float:
"""
计算某项目的累计人力成本
规则
- 有秒数的提交 按各项目产出秒数比例分摊日成本
- 无秒数的提交 按各项目提交条数比例分摊日成本
- 管理员手动调整优先
"""
# 找出所有给此项目提交过的人
submitters = db.query(Submission.user_id).filter(
Submission.project_id == project_id
).distinct().all()
submitter_ids = [s[0] for s in submitters]
total_labor = 0.0
for uid in submitter_ids:
user = db.query(User).filter(User.id == uid).first()
if not user:
continue
daily_cost = user.daily_cost
# 找这个人在此项目的所有提交日期
dates = db.query(Submission.submit_date).filter(
Submission.user_id == uid,
Submission.project_id == project_id,
).distinct().all()
for (d,) in dates:
# 检查是否有手动调整
override = db.query(CostOverride).filter(
CostOverride.user_id == uid,
CostOverride.date == d,
CostOverride.project_id == project_id,
).first()
if override:
total_labor += override.override_amount
continue
# 这个人这天所有项目的提交
day_subs = db.query(Submission).filter(
Submission.user_id == uid,
Submission.submit_date == d,
).all()
# 计算这天各项目的秒数和条数
project_seconds = defaultdict(float)
project_counts = defaultdict(int)
total_day_seconds = 0.0
total_day_count = 0
for s in day_subs:
project_seconds[s.project_id] += s.total_seconds
project_counts[s.project_id] += 1
total_day_seconds += s.total_seconds
total_day_count += 1
# 分摊
if total_day_seconds > 0:
# 有秒数 → 按秒数比例
ratio = project_seconds.get(project_id, 0) / total_day_seconds
elif total_day_count > 0:
# 无秒数 → 按条数比例
ratio = project_counts.get(project_id, 0) / total_day_count
else:
ratio = 0
total_labor += daily_cost * ratio
return round(total_labor, 2)
# ──────────────────────────── AI 工具成本 ────────────────────────────
def calc_ai_tool_cost_for_project(project_id: int, db: Session) -> float:
"""计算某项目的 AI 工具成本"""
total = 0.0
# 1. 直接指定项目的
direct = db.query(sa_func.sum(AIToolCost.amount)).filter(
AIToolCost.allocation_type == CostAllocationType.PROJECT,
AIToolCost.project_id == project_id,
).scalar() or 0
total += direct
# 2. 手动分摊的
manual = db.query(AIToolCostAllocation).filter(
AIToolCostAllocation.project_id == project_id,
).all()
for alloc in manual:
cost = db.query(AIToolCost).filter(AIToolCost.id == alloc.ai_tool_cost_id).first()
if cost:
total += cost.amount * alloc.percentage / 100
# 3. 内容组整体(按产出秒数比例分摊)
team_costs = db.query(AIToolCost).filter(
AIToolCost.allocation_type == CostAllocationType.TEAM,
).all()
if team_costs:
# 所有项目的总秒数
all_secs = db.query(sa_func.sum(Submission.total_seconds)).filter(
Submission.total_seconds > 0
).scalar() or 0
# 此项目的秒数
proj_secs = db.query(sa_func.sum(Submission.total_seconds)).filter(
Submission.project_id == project_id,
Submission.total_seconds > 0,
).scalar() or 0
if all_secs > 0:
ratio = proj_secs / all_secs
for c in team_costs:
total += c.amount * ratio
return round(total, 2)
# ──────────────────────────── 外包成本 ────────────────────────────
def calc_outsource_cost_for_project(project_id: int, db: Session) -> float:
"""计算某项目的外包成本"""
total = db.query(sa_func.sum(OutsourceCost.amount)).filter(
OutsourceCost.project_id == project_id,
).scalar() or 0
return round(total, 2)
# ──────────────────────────── 损耗计算 ────────────────────────────
def calc_waste_for_project(project_id: int, db: Session) -> dict:
"""
计算项目损耗
返回: {test_waste, overproduction_waste, total_waste, waste_rate, target_seconds}
"""
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
return {}
target = project.target_total_seconds
# 测试损耗:工作类型为"测试"的全部秒数
test_waste = db.query(sa_func.sum(Submission.total_seconds)).filter(
Submission.project_id == project_id,
Submission.work_type == WorkType.TEST,
).scalar() or 0
# 全部有秒数的提交总量
total_submitted = db.query(sa_func.sum(Submission.total_seconds)).filter(
Submission.project_id == project_id,
Submission.total_seconds > 0,
).scalar() or 0
# 超产损耗
overproduction_waste = max(0, total_submitted - target)
total_waste = test_waste + overproduction_waste
waste_rate = round(total_waste / target * 100, 1) if target > 0 else 0
return {
"target_seconds": target,
"total_submitted_seconds": round(total_submitted, 1),
"test_waste_seconds": round(test_waste, 1),
"overproduction_waste_seconds": round(overproduction_waste, 1),
"total_waste_seconds": round(total_waste, 1),
"waste_rate": waste_rate,
}
# ──────────────────────────── 团队效率 ────────────────────────────
def calc_team_efficiency(project_id: int, db: Session) -> list:
"""
人均基准对比法
- 人均基准 = 目标秒数 ÷ 参与制作人数
- 每人超出比例 = (个人提交 - 人均基准) / 人均基准
"""
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
return []
target = project.target_total_seconds
# 获取每个人的提交总秒数(仅有秒数的提交)
per_user = db.query(
Submission.user_id,
sa_func.sum(Submission.total_seconds).label("total_secs"),
sa_func.count(Submission.id).label("count"),
).filter(
Submission.project_id == project_id,
Submission.total_seconds > 0,
).group_by(Submission.user_id).all()
if not per_user:
return []
num_people = len(per_user)
baseline = target / num_people if num_people > 0 else 0
result = []
for user_id, total_secs, count in per_user:
user = db.query(User).filter(User.id == user_id).first()
excess = total_secs - baseline
excess_rate = round(excess / baseline * 100, 1) if baseline > 0 else 0
result.append({
"user_id": user_id,
"user_name": user.name if user else "未知",
"total_seconds": round(total_secs, 1),
"submission_count": count,
"baseline": round(baseline, 1),
"excess_seconds": round(excess, 1),
"excess_rate": excess_rate,
})
result.sort(key=lambda x: x["total_seconds"], reverse=True)
return result
# ──────────────────────────── 项目完整结算 ────────────────────────────
def calc_project_settlement(project_id: int, db: Session) -> dict:
"""生成项目结算报告"""
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
return {}
labor = calc_labor_cost_for_project(project_id, db)
ai_tool = calc_ai_tool_cost_for_project(project_id, db)
outsource = calc_outsource_cost_for_project(project_id, db)
total_cost = labor + ai_tool + outsource
waste = calc_waste_for_project(project_id, db)
efficiency = calc_team_efficiency(project_id, db)
result = {
"project_id": project.id,
"project_name": project.name,
"project_type": project.project_type.value if hasattr(project.project_type, 'value') else project.project_type,
"labor_cost": labor,
"ai_tool_cost": ai_tool,
"outsource_cost": outsource,
"total_cost": round(total_cost, 2),
**waste,
"team_efficiency": efficiency,
}
# 客户正式项目计算盈亏
pt = project.project_type.value if hasattr(project.project_type, 'value') else project.project_type
if pt == "客户正式项目" and project.contract_amount:
result["contract_amount"] = project.contract_amount
result["profit_loss"] = round(project.contract_amount - total_cost, 2)
else:
result["contract_amount"] = None
result["profit_loss"] = None
return result

13
backend/config.py Normal file
View File

@ -0,0 +1,13 @@
"""应用配置"""
import os
# 数据库
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./airlabs.db")
# JWT 认证
SECRET_KEY = os.getenv("SECRET_KEY", "airlabs-project-secret-key-change-in-production")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 24 小时
# 成本计算
WORKING_DAYS_PER_MONTH = 22

17
backend/database.py Normal file
View File

@ -0,0 +1,17 @@
"""数据库初始化"""
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base
from config import DATABASE_URL
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
"""获取数据库会话FastAPI 依赖注入用)"""
db = SessionLocal()
try:
yield db
finally:
db.close()

72
backend/main.py Normal file
View File

@ -0,0 +1,72 @@
"""AirLabs Project —— 主入口"""
from fastapi import FastAPI
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, UserRole, PhaseGroup
from auth import hash_password
import os
# 创建所有表
Base.metadata.create_all(bind=engine)
app = FastAPI(title="AirLabs Project", version="1.0.0")
# CORS开发阶段允许所有来源
app.add_middleware(
CORSMiddleware,
allow_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
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)
# 前端静态文件
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):
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")
def init_default_owner():
"""首次启动时创建默认 Owner 账号"""
from database import SessionLocal
db = SessionLocal()
try:
if not db.query(User).filter(User.role == UserRole.OWNER).first():
owner = User(
username="admin",
password_hash=hash_password("admin123"),
name="管理员",
phase_group=PhaseGroup.PRODUCTION,
role=UserRole.OWNER,
monthly_salary=0,
)
db.add(owner)
db.commit()
print("[OK] default admin created: admin / admin123")
finally:
db.close()

238
backend/models.py Normal file
View File

@ -0,0 +1,238 @@
"""数据库模型 —— 所有表定义"""
from sqlalchemy import (
Column, Integer, String, Float, Date, DateTime, Text,
ForeignKey, Enum as SAEnum, JSON
)
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from database import Base
import enum
# ──────────────────────────── 枚举定义 ────────────────────────────
class ProjectType(str, enum.Enum):
CLIENT_FORMAL = "客户正式项目"
CLIENT_TEST = "客户测试项目"
INTERNAL_ORIGINAL = "内部原创项目"
INTERNAL_TEST = "内部测试项目"
class ProjectStatus(str, enum.Enum):
IN_PROGRESS = "制作中"
COMPLETED = "已完成"
class PhaseGroup(str, enum.Enum):
PRE = "前期"
PRODUCTION = "制作"
POST = "后期"
class UserRole(str, enum.Enum):
MEMBER = "成员"
LEADER = "组长"
SUPERVISOR = "主管"
OWNER = "Owner"
class WorkType(str, enum.Enum):
PRODUCTION = "制作"
TEST = "测试"
PLAN = "方案"
class ContentType(str, enum.Enum):
ANIMATION = "内容制作"
DESIGN = "设定策划"
EDITING = "剪辑后期"
OTHER = "其他"
class SubmitTo(str, enum.Enum):
LEADER = "组长"
PRODUCER = "制片"
INTERNAL = "内部"
EXTERNAL = "外部"
class SubscriptionPeriod(str, enum.Enum):
MONTHLY = ""
YEARLY = ""
class CostAllocationType(str, enum.Enum):
PROJECT = "指定项目"
TEAM = "内容组整体"
MANUAL = "手动分摊"
class OutsourceType(str, enum.Enum):
ANIMATION = "动画"
EDITING = "剪辑"
FULL_EPISODE = "整集"
# ──────────────────────────── 用户 ────────────────────────────
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
username = Column(String(50), unique=True, nullable=False, index=True)
password_hash = Column(String(255), nullable=False)
name = Column(String(50), nullable=False)
phase_group = Column(SAEnum(PhaseGroup), nullable=False)
role = Column(SAEnum(UserRole), nullable=False, default=UserRole.MEMBER)
monthly_salary = Column(Float, nullable=False, default=0)
is_active = Column(Integer, nullable=False, default=1)
created_at = Column(DateTime, server_default=func.now())
# 关系
submissions = relationship("Submission", back_populates="user")
led_projects = relationship("Project", back_populates="leader")
@property
def daily_cost(self):
"""日成本 = 月薪 ÷ 22"""
from config import WORKING_DAYS_PER_MONTH
return round(self.monthly_salary / WORKING_DAYS_PER_MONTH, 2) if self.monthly_salary else 0
# ──────────────────────────── 项目 ────────────────────────────
class Project(Base):
__tablename__ = "projects"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), nullable=False)
project_type = Column(SAEnum(ProjectType), nullable=False)
status = Column(SAEnum(ProjectStatus), nullable=False, default=ProjectStatus.IN_PROGRESS)
leader_id = Column(Integer, ForeignKey("users.id"), nullable=True)
current_phase = Column(SAEnum(PhaseGroup), nullable=False, default=PhaseGroup.PRE)
episode_duration_minutes = Column(Float, nullable=False)
episode_count = Column(Integer, nullable=False)
estimated_completion_date = Column(Date, nullable=True)
actual_completion_date = Column(Date, nullable=True)
contract_amount = Column(Float, nullable=True) # 仅客户正式项目
created_at = Column(DateTime, server_default=func.now())
# 关系
leader = relationship("User", back_populates="led_projects")
submissions = relationship("Submission", back_populates="project")
outsource_costs = relationship("OutsourceCost", back_populates="project")
ai_tool_allocations = relationship("AIToolCostAllocation", back_populates="project")
@property
def target_total_seconds(self):
"""目标总秒数 = 单集时长(分) × 60 × 集数"""
return int(self.episode_duration_minutes * 60 * self.episode_count)
# ──────────────────────────── 内容提交 ────────────────────────────
class Submission(Base):
__tablename__ = "submissions"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
project_phase = Column(SAEnum(PhaseGroup), nullable=False)
work_type = Column(SAEnum(WorkType), nullable=False)
content_type = Column(SAEnum(ContentType), nullable=False)
duration_minutes = Column(Float, nullable=True, default=0)
duration_seconds = Column(Float, nullable=True, default=0)
total_seconds = Column(Float, nullable=False, default=0) # 系统自动计算
hours_spent = Column(Float, nullable=True) # 可选:投入时长(小时)
submit_to = Column(SAEnum(SubmitTo), nullable=False)
description = Column(Text, nullable=True)
submit_date = Column(Date, nullable=False)
created_at = Column(DateTime, server_default=func.now())
# 关系
user = relationship("User", back_populates="submissions")
project = relationship("Project", back_populates="submissions")
history = relationship("SubmissionHistory", back_populates="submission")
# ──────────────────────────── AI 工具成本 ────────────────────────────
class AIToolCost(Base):
__tablename__ = "ai_tool_costs"
id = Column(Integer, primary_key=True, index=True)
tool_name = Column(String(100), nullable=False)
subscription_period = Column(SAEnum(SubscriptionPeriod), nullable=False)
amount = Column(Float, nullable=False)
allocation_type = Column(SAEnum(CostAllocationType), nullable=False)
project_id = Column(Integer, ForeignKey("projects.id"), nullable=True) # 指定项目时
recorded_by = Column(Integer, ForeignKey("users.id"), nullable=False)
record_date = Column(Date, nullable=False)
created_at = Column(DateTime, server_default=func.now())
# 关系
allocations = relationship("AIToolCostAllocation", back_populates="ai_tool_cost")
class AIToolCostAllocation(Base):
"""AI 工具成本手动分摊明细"""
__tablename__ = "ai_tool_cost_allocations"
id = Column(Integer, primary_key=True, index=True)
ai_tool_cost_id = Column(Integer, ForeignKey("ai_tool_costs.id"), nullable=False)
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
percentage = Column(Float, nullable=False) # 0-100
ai_tool_cost = relationship("AIToolCost", back_populates="allocations")
project = relationship("Project", back_populates="ai_tool_allocations")
# ──────────────────────────── 外包成本 ────────────────────────────
class OutsourceCost(Base):
__tablename__ = "outsource_costs"
id = Column(Integer, primary_key=True, index=True)
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
outsource_type = Column(SAEnum(OutsourceType), nullable=False)
episode_start = Column(Integer, nullable=True)
episode_end = Column(Integer, nullable=True)
amount = Column(Float, nullable=False)
recorded_by = Column(Integer, ForeignKey("users.id"), nullable=False)
record_date = Column(Date, nullable=False)
created_at = Column(DateTime, server_default=func.now())
project = relationship("Project", back_populates="outsource_costs")
# ──────────────────────────── 人力成本手动调整 ────────────────────────────
class CostOverride(Base):
"""管理员手动修改某人某天的成本分摊"""
__tablename__ = "cost_overrides"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
date = Column(Date, nullable=False)
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
override_amount = Column(Float, nullable=False)
adjusted_by = Column(Integer, ForeignKey("users.id"), nullable=False)
reason = Column(Text, nullable=True)
created_at = Column(DateTime, server_default=func.now())
# ──────────────────────────── 提交历史版本 ────────────────────────────
class SubmissionHistory(Base):
"""内容提交的修改历史"""
__tablename__ = "submission_history"
id = Column(Integer, primary_key=True, index=True)
submission_id = Column(Integer, ForeignKey("submissions.id"), nullable=False)
changed_by = Column(Integer, ForeignKey("users.id"), nullable=False)
change_reason = Column(Text, nullable=False)
old_data = Column(JSON, nullable=False)
new_data = Column(JSON, nullable=False)
created_at = Column(DateTime, server_default=func.now())
submission = relationship("Submission", back_populates="history")

7
backend/requirements.txt Normal file
View File

@ -0,0 +1,7 @@
fastapi
uvicorn[standard]
sqlalchemy
pydantic
python-jose[cryptography]
passlib[bcrypt]
python-multipart

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

35
backend/routers/auth.py Normal file
View File

@ -0,0 +1,35 @@
"""认证路由"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from database import get_db
from models import User
from schemas import LoginRequest, Token, UserOut
from auth import verify_password, create_access_token, get_current_user
router = APIRouter(prefix="/api/auth", tags=["认证"])
@router.post("/login", response_model=Token)
def login(req: LoginRequest, db: Session = Depends(get_db)):
user = db.query(User).filter(User.username == req.username).first()
if not user or not verify_password(req.password, user.password_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误")
if not user.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已停用")
token = create_access_token(data={"sub": user.id})
return {"access_token": token, "token_type": "bearer"}
@router.get("/me", response_model=UserOut)
def get_me(current_user: User = Depends(get_current_user)):
return UserOut(
id=current_user.id,
username=current_user.username,
name=current_user.name,
phase_group=current_user.phase_group.value if hasattr(current_user.phase_group, 'value') else current_user.phase_group,
role=current_user.role.value if hasattr(current_user.role, 'value') else current_user.role,
monthly_salary=current_user.monthly_salary,
daily_cost=current_user.daily_cost,
is_active=current_user.is_active,
created_at=current_user.created_at,
)

205
backend/routers/costs.py Normal file
View File

@ -0,0 +1,205 @@
"""成本管理路由"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import List, Optional
from datetime import date
from database import get_db
from models import (
User, UserRole, AIToolCost, AIToolCostAllocation, OutsourceCost,
CostOverride, SubscriptionPeriod, CostAllocationType, OutsourceType
)
from schemas import (
AIToolCostCreate, AIToolCostOut, OutsourceCostCreate, OutsourceCostOut,
CostOverrideCreate
)
from auth import get_current_user, require_role
router = APIRouter(prefix="/api/costs", tags=["成本管理"])
# ──────────────────── AI 工具成本 ────────────────────
@router.get("/ai-tools", response_model=List[AIToolCostOut])
def list_ai_tool_costs(
db: Session = Depends(get_db),
current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR, UserRole.LEADER))
):
costs = db.query(AIToolCost).order_by(AIToolCost.record_date.desc()).all()
return [
AIToolCostOut(
id=c.id, tool_name=c.tool_name,
subscription_period=c.subscription_period.value if hasattr(c.subscription_period, 'value') else c.subscription_period,
amount=c.amount,
allocation_type=c.allocation_type.value if hasattr(c.allocation_type, 'value') else c.allocation_type,
project_id=c.project_id,
recorded_by=c.recorded_by,
record_date=c.record_date,
created_at=c.created_at,
)
for c in costs
]
@router.post("/ai-tools", response_model=AIToolCostOut)
def create_ai_tool_cost(
req: AIToolCostCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR, UserRole.LEADER))
):
cost = AIToolCost(
tool_name=req.tool_name,
subscription_period=SubscriptionPeriod(req.subscription_period),
amount=req.amount,
allocation_type=CostAllocationType(req.allocation_type),
project_id=req.project_id,
recorded_by=current_user.id,
record_date=req.record_date,
)
db.add(cost)
db.flush()
# 处理手动分摊
if req.allocation_type == "手动分摊" and req.allocations:
for alloc in req.allocations:
db.add(AIToolCostAllocation(
ai_tool_cost_id=cost.id,
project_id=alloc["project_id"],
percentage=alloc["percentage"],
))
db.commit()
db.refresh(cost)
return AIToolCostOut(
id=cost.id, tool_name=cost.tool_name,
subscription_period=cost.subscription_period.value,
amount=cost.amount,
allocation_type=cost.allocation_type.value,
project_id=cost.project_id,
recorded_by=cost.recorded_by,
record_date=cost.record_date,
created_at=cost.created_at,
)
@router.delete("/ai-tools/{cost_id}")
def delete_ai_tool_cost(
cost_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_role(UserRole.OWNER))
):
cost = db.query(AIToolCost).filter(AIToolCost.id == cost_id).first()
if not cost:
raise HTTPException(status_code=404, detail="记录不存在")
db.query(AIToolCostAllocation).filter(AIToolCostAllocation.ai_tool_cost_id == cost_id).delete()
db.delete(cost)
db.commit()
return {"message": "已删除"}
# ──────────────────── 外包成本 ────────────────────
@router.get("/outsource", response_model=List[OutsourceCostOut])
def list_outsource_costs(
project_id: Optional[int] = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR, UserRole.LEADER))
):
q = db.query(OutsourceCost)
if project_id:
q = q.filter(OutsourceCost.project_id == project_id)
costs = q.order_by(OutsourceCost.record_date.desc()).all()
return [
OutsourceCostOut(
id=c.id, project_id=c.project_id,
outsource_type=c.outsource_type.value if hasattr(c.outsource_type, 'value') else c.outsource_type,
episode_start=c.episode_start, episode_end=c.episode_end,
amount=c.amount, recorded_by=c.recorded_by,
record_date=c.record_date, created_at=c.created_at,
)
for c in costs
]
@router.post("/outsource", response_model=OutsourceCostOut)
def create_outsource_cost(
req: OutsourceCostCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR, UserRole.LEADER))
):
cost = OutsourceCost(
project_id=req.project_id,
outsource_type=OutsourceType(req.outsource_type),
episode_start=req.episode_start,
episode_end=req.episode_end,
amount=req.amount,
recorded_by=current_user.id,
record_date=req.record_date,
)
db.add(cost)
db.commit()
db.refresh(cost)
return OutsourceCostOut(
id=cost.id, project_id=cost.project_id,
outsource_type=cost.outsource_type.value,
episode_start=cost.episode_start, episode_end=cost.episode_end,
amount=cost.amount, recorded_by=cost.recorded_by,
record_date=cost.record_date, created_at=cost.created_at,
)
@router.delete("/outsource/{cost_id}")
def delete_outsource_cost(
cost_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_role(UserRole.OWNER))
):
cost = db.query(OutsourceCost).filter(OutsourceCost.id == cost_id).first()
if not cost:
raise HTTPException(status_code=404, detail="记录不存在")
db.delete(cost)
db.commit()
return {"message": "已删除"}
# ──────────────────── 人力成本手动调整 ────────────────────
@router.post("/overrides")
def create_cost_override(
req: CostOverrideCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR))
):
override = CostOverride(
user_id=req.user_id,
date=req.date,
project_id=req.project_id,
override_amount=req.override_amount,
adjusted_by=current_user.id,
reason=req.reason,
)
db.add(override)
db.commit()
return {"message": "已保存成本调整"}
@router.get("/overrides")
def list_cost_overrides(
user_id: Optional[int] = Query(None),
project_id: Optional[int] = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR))
):
q = db.query(CostOverride)
if user_id:
q = q.filter(CostOverride.user_id == user_id)
if project_id:
q = q.filter(CostOverride.project_id == project_id)
records = q.order_by(CostOverride.date.desc()).all()
return [
{
"id": r.id, "user_id": r.user_id, "date": r.date,
"project_id": r.project_id, "override_amount": r.override_amount,
"adjusted_by": r.adjusted_by, "reason": r.reason,
"created_at": r.created_at,
}
for r in records
]

View File

@ -0,0 +1,152 @@
"""仪表盘 + 结算路由"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import func as sa_func
from datetime import date, timedelta
from database import get_db
from models import (
User, UserRole, Project, Submission, AIToolCost,
ProjectStatus, ProjectType, WorkType
)
from auth import get_current_user, require_role
from calculations import (
calc_project_settlement, calc_waste_for_project,
calc_labor_cost_for_project, calc_ai_tool_cost_for_project,
calc_outsource_cost_for_project, calc_team_efficiency
)
router = APIRouter(prefix="/api", tags=["仪表盘与结算"])
@router.get("/dashboard")
def get_dashboard(
db: Session = Depends(get_db),
current_user: User = Depends(require_role(UserRole.OWNER))
):
"""全局仪表盘数据"""
# 项目概览
active = db.query(Project).filter(Project.status == ProjectStatus.IN_PROGRESS).all()
completed = db.query(Project).filter(Project.status == ProjectStatus.COMPLETED).all()
# 当月日期范围
today = date.today()
month_start = today.replace(day=1)
# 本月人力成本(简化:统计本月所有有提交的人的日成本)
monthly_submitters = db.query(Submission.user_id, Submission.submit_date).filter(
Submission.submit_date >= month_start,
Submission.submit_date <= today,
).distinct().all()
monthly_labor = 0.0
processed_user_dates = set()
for uid, d in monthly_submitters:
key = (uid, d)
if key not in processed_user_dates:
processed_user_dates.add(key)
user = db.query(User).filter(User.id == uid).first()
if user:
monthly_labor += user.daily_cost
# 本月 AI 工具成本
monthly_ai = db.query(sa_func.sum(AIToolCost.amount)).filter(
AIToolCost.record_date >= month_start,
AIToolCost.record_date <= today,
).scalar() or 0
# 本月总产出秒数
monthly_secs = db.query(sa_func.sum(Submission.total_seconds)).filter(
Submission.submit_date >= month_start,
Submission.submit_date <= today,
Submission.total_seconds > 0,
).scalar() or 0
# 活跃人数
active_users = db.query(Submission.user_id).filter(
Submission.submit_date >= month_start,
).distinct().count()
working_days = max(1, (today - month_start).days + 1)
avg_daily = round(monthly_secs / max(1, active_users) / working_days, 1)
# 各项目摘要
project_summaries = []
for p in active:
waste = calc_waste_for_project(p.id, db)
total_secs = waste.get("total_submitted_seconds", 0)
target = p.target_total_seconds
progress = round(total_secs / target * 100, 1) if target > 0 else 0
is_overdue = (
p.estimated_completion_date and today > p.estimated_completion_date
)
project_summaries.append({
"id": p.id,
"name": p.name,
"project_type": p.project_type.value if hasattr(p.project_type, 'value') else p.project_type,
"progress_percent": progress,
"target_seconds": target,
"submitted_seconds": total_secs,
"waste_rate": waste.get("waste_rate", 0),
"is_overdue": bool(is_overdue),
"estimated_completion_date": str(p.estimated_completion_date) if p.estimated_completion_date else None,
})
# 损耗排行
waste_ranking = []
for p in active + completed:
w = calc_waste_for_project(p.id, db)
if w.get("total_waste_seconds", 0) > 0:
waste_ranking.append({
"project_id": p.id,
"project_name": p.name,
"waste_seconds": w["total_waste_seconds"],
"waste_rate": w["waste_rate"],
})
waste_ranking.sort(key=lambda x: x["waste_rate"], reverse=True)
# 已结算项目
settled = []
for p in completed:
settlement = calc_project_settlement(p.id, db)
settled.append({
"project_id": p.id,
"project_name": p.name,
"project_type": settlement.get("project_type", ""),
"total_cost": settlement.get("total_cost", 0),
"contract_amount": settlement.get("contract_amount"),
"profit_loss": settlement.get("profit_loss"),
})
return {
"active_projects": len(active),
"completed_projects": len(completed),
"monthly_labor_cost": round(monthly_labor, 2),
"monthly_ai_tool_cost": round(monthly_ai, 2),
"monthly_total_seconds": round(monthly_secs, 1),
"avg_daily_seconds_per_person": avg_daily,
"projects": project_summaries,
"waste_ranking": waste_ranking,
"settled_projects": settled,
}
@router.get("/projects/{project_id}/settlement")
def get_settlement(
project_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_role(UserRole.OWNER))
):
"""项目结算报告"""
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
return calc_project_settlement(project_id, db)
@router.get("/projects/{project_id}/efficiency")
def get_efficiency(
project_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR, UserRole.LEADER))
):
"""项目团队效率数据"""
return calc_team_efficiency(project_id, db)

158
backend/routers/projects.py Normal file
View File

@ -0,0 +1,158 @@
"""项目管理路由"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import func as sa_func
from typing import List, Optional
from database import get_db
from models import (
User, Project, Submission, UserRole, ProjectType,
ProjectStatus, PhaseGroup, WorkType
)
from schemas import ProjectCreate, ProjectUpdate, ProjectOut
from auth import get_current_user, require_role
router = APIRouter(prefix="/api/projects", tags=["项目管理"])
def enrich_project(p: Project, db: Session) -> ProjectOut:
"""将项目对象转为带计算字段的输出"""
# 累计提交秒数(仅有秒数的提交)
total_secs = db.query(sa_func.sum(Submission.total_seconds)).filter(
Submission.project_id == p.id,
Submission.total_seconds > 0
).scalar() or 0
target = p.target_total_seconds
progress = round(total_secs / target * 100, 1) if target > 0 else 0
# 损耗 = 测试损耗 + 超产损耗
test_secs = db.query(sa_func.sum(Submission.total_seconds)).filter(
Submission.project_id == p.id,
Submission.work_type == WorkType.TEST
).scalar() or 0
overproduction = max(0, total_secs - target)
waste = test_secs + overproduction
waste_rate = round(waste / target * 100, 1) if target > 0 else 0
leader_name = p.leader.name if p.leader else None
return ProjectOut(
id=p.id, name=p.name,
project_type=p.project_type.value if hasattr(p.project_type, 'value') else p.project_type,
status=p.status.value if hasattr(p.status, 'value') else p.status,
leader_id=p.leader_id, leader_name=leader_name,
current_phase=p.current_phase.value if hasattr(p.current_phase, 'value') else p.current_phase,
episode_duration_minutes=p.episode_duration_minutes,
episode_count=p.episode_count,
target_total_seconds=target,
estimated_completion_date=p.estimated_completion_date,
actual_completion_date=p.actual_completion_date,
contract_amount=p.contract_amount,
created_at=p.created_at,
total_submitted_seconds=round(total_secs, 1),
progress_percent=progress,
waste_seconds=round(waste, 1),
waste_rate=waste_rate,
)
@router.get("/", response_model=List[ProjectOut])
def list_projects(
status: Optional[str] = Query(None),
project_type: Optional[str] = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
q = db.query(Project)
if status:
q = q.filter(Project.status == ProjectStatus(status))
if project_type:
q = q.filter(Project.project_type == ProjectType(project_type))
projects = q.order_by(Project.created_at.desc()).all()
return [enrich_project(p, db) for p in projects]
@router.post("/", response_model=ProjectOut)
def create_project(
req: ProjectCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR))
):
project = Project(
name=req.name,
project_type=ProjectType(req.project_type),
leader_id=req.leader_id,
current_phase=PhaseGroup(req.current_phase),
episode_duration_minutes=req.episode_duration_minutes,
episode_count=req.episode_count,
estimated_completion_date=req.estimated_completion_date,
contract_amount=req.contract_amount,
)
db.add(project)
db.commit()
db.refresh(project)
return enrich_project(project, db)
@router.get("/{project_id}", response_model=ProjectOut)
def get_project(
project_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
p = db.query(Project).filter(Project.id == project_id).first()
if not p:
raise HTTPException(status_code=404, detail="项目不存在")
return enrich_project(p, db)
@router.put("/{project_id}", response_model=ProjectOut)
def update_project(
project_id: int,
req: ProjectUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR))
):
p = db.query(Project).filter(Project.id == project_id).first()
if not p:
raise HTTPException(status_code=404, detail="项目不存在")
if req.name is not None:
p.name = req.name
if req.project_type is not None:
p.project_type = ProjectType(req.project_type)
if req.status is not None:
p.status = ProjectStatus(req.status)
if req.leader_id is not None:
p.leader_id = req.leader_id
if req.current_phase is not None:
p.current_phase = PhaseGroup(req.current_phase)
if req.episode_duration_minutes is not None:
p.episode_duration_minutes = req.episode_duration_minutes
if req.episode_count is not None:
p.episode_count = req.episode_count
if req.estimated_completion_date is not None:
p.estimated_completion_date = req.estimated_completion_date
if req.actual_completion_date is not None:
p.actual_completion_date = req.actual_completion_date
if req.contract_amount is not None:
p.contract_amount = req.contract_amount
db.commit()
db.refresh(p)
return enrich_project(p, db)
@router.post("/{project_id}/complete")
def complete_project(
project_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_role(UserRole.OWNER))
):
"""Owner 手动确认项目完成"""
p = db.query(Project).filter(Project.id == project_id).first()
if not p:
raise HTTPException(status_code=404, detail="项目不存在")
from datetime import date
p.status = ProjectStatus.COMPLETED
p.actual_completion_date = date.today()
db.commit()
return {"message": "项目已标记为完成", "project_id": project_id}

View File

@ -0,0 +1,193 @@
"""内容提交路由"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import List, Optional
from datetime import date
from database import get_db
from models import (
User, Submission, SubmissionHistory, Project, UserRole,
PhaseGroup, WorkType, ContentType, SubmitTo
)
from schemas import SubmissionCreate, SubmissionUpdate, SubmissionOut
from auth import get_current_user, require_role
router = APIRouter(prefix="/api/submissions", tags=["内容提交"])
def submission_to_out(s: Submission) -> SubmissionOut:
return SubmissionOut(
id=s.id, user_id=s.user_id,
user_name=s.user.name if s.user else None,
project_id=s.project_id,
project_name=s.project.name if s.project else None,
project_phase=s.project_phase.value if hasattr(s.project_phase, 'value') else s.project_phase,
work_type=s.work_type.value if hasattr(s.work_type, 'value') else s.work_type,
content_type=s.content_type.value if hasattr(s.content_type, 'value') else s.content_type,
duration_minutes=s.duration_minutes,
duration_seconds=s.duration_seconds,
total_seconds=s.total_seconds,
hours_spent=s.hours_spent,
submit_to=s.submit_to.value if hasattr(s.submit_to, 'value') else s.submit_to,
description=s.description,
submit_date=s.submit_date,
created_at=s.created_at,
)
@router.get("/", response_model=List[SubmissionOut])
def list_submissions(
project_id: Optional[int] = Query(None),
user_id: Optional[int] = Query(None),
start_date: Optional[date] = Query(None),
end_date: Optional[date] = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
q = db.query(Submission)
# 成员只能看自己的
if current_user.role == UserRole.MEMBER:
q = q.filter(Submission.user_id == current_user.id)
elif user_id:
q = q.filter(Submission.user_id == user_id)
if project_id:
q = q.filter(Submission.project_id == project_id)
if start_date:
q = q.filter(Submission.submit_date >= start_date)
if end_date:
q = q.filter(Submission.submit_date <= end_date)
subs = q.order_by(Submission.submit_date.desc(), Submission.created_at.desc()).all()
return [submission_to_out(s) for s in subs]
@router.post("/", response_model=SubmissionOut)
def create_submission(
req: SubmissionCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
# 校验项目存在
project = db.query(Project).filter(Project.id == req.project_id).first()
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
# 自动计算总秒数
total_seconds = (req.duration_minutes or 0) * 60 + (req.duration_seconds or 0)
sub = Submission(
user_id=current_user.id,
project_id=req.project_id,
project_phase=PhaseGroup(req.project_phase),
work_type=WorkType(req.work_type),
content_type=ContentType(req.content_type),
duration_minutes=req.duration_minutes or 0,
duration_seconds=req.duration_seconds or 0,
total_seconds=total_seconds,
hours_spent=req.hours_spent,
submit_to=SubmitTo(req.submit_to),
description=req.description,
submit_date=req.submit_date,
)
db.add(sub)
db.commit()
db.refresh(sub)
return submission_to_out(sub)
@router.put("/{submission_id}", response_model=SubmissionOut)
def update_submission(
submission_id: int,
req: SubmissionUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR, UserRole.LEADER))
):
"""高权限修改提交记录(需填写原因)"""
sub = db.query(Submission).filter(Submission.id == submission_id).first()
if not sub:
raise HTTPException(status_code=404, detail="提交记录不存在")
# 保存旧数据用于历史记录
old_data = {
"project_phase": sub.project_phase.value if hasattr(sub.project_phase, 'value') else sub.project_phase,
"work_type": sub.work_type.value if hasattr(sub.work_type, 'value') else sub.work_type,
"content_type": sub.content_type.value if hasattr(sub.content_type, 'value') else sub.content_type,
"duration_minutes": sub.duration_minutes,
"duration_seconds": sub.duration_seconds,
"total_seconds": sub.total_seconds,
"hours_spent": sub.hours_spent,
"submit_to": sub.submit_to.value if hasattr(sub.submit_to, 'value') else sub.submit_to,
"description": sub.description,
"submit_date": str(sub.submit_date),
}
# 更新字段
if req.project_phase is not None:
sub.project_phase = PhaseGroup(req.project_phase)
if req.work_type is not None:
sub.work_type = WorkType(req.work_type)
if req.content_type is not None:
sub.content_type = ContentType(req.content_type)
if req.duration_minutes is not None:
sub.duration_minutes = req.duration_minutes
if req.duration_seconds is not None:
sub.duration_seconds = req.duration_seconds
if req.hours_spent is not None:
sub.hours_spent = req.hours_spent
if req.submit_to is not None:
sub.submit_to = SubmitTo(req.submit_to)
if req.description is not None:
sub.description = req.description
if req.submit_date is not None:
sub.submit_date = req.submit_date
# 重算总秒数
sub.total_seconds = (sub.duration_minutes or 0) * 60 + (sub.duration_seconds or 0)
# 保存新数据
new_data = {
"project_phase": sub.project_phase.value if hasattr(sub.project_phase, 'value') else sub.project_phase,
"work_type": sub.work_type.value if hasattr(sub.work_type, 'value') else sub.work_type,
"content_type": sub.content_type.value if hasattr(sub.content_type, 'value') else sub.content_type,
"duration_minutes": sub.duration_minutes,
"duration_seconds": sub.duration_seconds,
"total_seconds": sub.total_seconds,
"hours_spent": sub.hours_spent,
"submit_to": sub.submit_to.value if hasattr(sub.submit_to, 'value') else sub.submit_to,
"description": sub.description,
"submit_date": str(sub.submit_date),
}
# 写入修改历史
history = SubmissionHistory(
submission_id=sub.id,
changed_by=current_user.id,
change_reason=req.change_reason,
old_data=old_data,
new_data=new_data,
)
db.add(history)
db.commit()
db.refresh(sub)
return submission_to_out(sub)
@router.get("/{submission_id}/history")
def get_submission_history(
submission_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR, UserRole.LEADER))
):
"""查看提交的修改历史"""
records = db.query(SubmissionHistory).filter(
SubmissionHistory.submission_id == submission_id
).order_by(SubmissionHistory.created_at.desc()).all()
return [
{
"id": r.id,
"changed_by": r.changed_by,
"change_reason": r.change_reason,
"old_data": r.old_data,
"new_data": r.new_data,
"created_at": r.created_at,
}
for r in records
]

88
backend/routers/users.py Normal file
View File

@ -0,0 +1,88 @@
"""用户管理路由"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List
from database import get_db
from models import User, UserRole, PhaseGroup
from schemas import UserCreate, UserUpdate, UserOut
from auth import get_current_user, hash_password, require_role
router = APIRouter(prefix="/api/users", tags=["用户管理"])
def user_to_out(u: User) -> UserOut:
return UserOut(
id=u.id, username=u.username, name=u.name,
phase_group=u.phase_group.value if hasattr(u.phase_group, 'value') else u.phase_group,
role=u.role.value if hasattr(u.role, 'value') else u.role,
monthly_salary=u.monthly_salary, daily_cost=u.daily_cost,
is_active=u.is_active, created_at=u.created_at,
)
@router.get("/", response_model=List[UserOut])
def list_users(
db: Session = Depends(get_db),
current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR, UserRole.LEADER))
):
users = db.query(User).order_by(User.created_at.desc()).all()
return [user_to_out(u) for u in users]
@router.post("/", response_model=UserOut)
def create_user(
req: UserCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_role(UserRole.OWNER))
):
if db.query(User).filter(User.username == req.username).first():
raise HTTPException(status_code=400, detail="用户名已存在")
user = User(
username=req.username,
password_hash=hash_password(req.password),
name=req.name,
phase_group=PhaseGroup(req.phase_group),
role=UserRole(req.role),
monthly_salary=req.monthly_salary,
)
db.add(user)
db.commit()
db.refresh(user)
return user_to_out(user)
@router.put("/{user_id}", response_model=UserOut)
def update_user(
user_id: int,
req: UserUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_role(UserRole.OWNER))
):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
if req.name is not None:
user.name = req.name
if req.phase_group is not None:
user.phase_group = PhaseGroup(req.phase_group)
if req.role is not None:
user.role = UserRole(req.role)
if req.monthly_salary is not None:
user.monthly_salary = req.monthly_salary
if req.is_active is not None:
user.is_active = req.is_active
db.commit()
db.refresh(user)
return user_to_out(user)
@router.get("/{user_id}", response_model=UserOut)
def get_user(
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
return user_to_out(user)

249
backend/schemas.py Normal file
View File

@ -0,0 +1,249 @@
"""Pydantic 数据模型 —— API 请求/响应格式定义"""
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import date, datetime
# ──────────────────────────── 认证 ────────────────────────────
class LoginRequest(BaseModel):
username: str
password: str
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
# ──────────────────────────── 用户 ────────────────────────────
class UserCreate(BaseModel):
username: str
password: str
name: str
phase_group: str # 前期/制作/后期
role: str = "成员"
monthly_salary: float = 0
class UserUpdate(BaseModel):
name: Optional[str] = None
phase_group: Optional[str] = None
role: Optional[str] = None
monthly_salary: Optional[float] = None
is_active: Optional[int] = None
class UserOut(BaseModel):
id: int
username: str
name: str
phase_group: str
role: str
monthly_salary: float
daily_cost: float
is_active: int
created_at: Optional[datetime] = None
class Config:
from_attributes = True
# ──────────────────────────── 项目 ────────────────────────────
class ProjectCreate(BaseModel):
name: str
project_type: str
leader_id: Optional[int] = None
current_phase: str = "前期"
episode_duration_minutes: float
episode_count: int
estimated_completion_date: Optional[date] = None
contract_amount: Optional[float] = None
class ProjectUpdate(BaseModel):
name: Optional[str] = None
project_type: Optional[str] = None
status: Optional[str] = None
leader_id: Optional[int] = None
current_phase: Optional[str] = None
episode_duration_minutes: Optional[float] = None
episode_count: Optional[int] = None
estimated_completion_date: Optional[date] = None
actual_completion_date: Optional[date] = None
contract_amount: Optional[float] = None
class ProjectOut(BaseModel):
id: int
name: str
project_type: str
status: str
leader_id: Optional[int] = None
leader_name: Optional[str] = None
current_phase: str
episode_duration_minutes: float
episode_count: int
target_total_seconds: int
estimated_completion_date: Optional[date] = None
actual_completion_date: Optional[date] = None
contract_amount: Optional[float] = None
created_at: Optional[datetime] = None
# 动态计算字段
total_submitted_seconds: Optional[float] = 0
progress_percent: Optional[float] = 0
waste_seconds: Optional[float] = 0
waste_rate: Optional[float] = 0
class Config:
from_attributes = True
# ──────────────────────────── 内容提交 ────────────────────────────
class SubmissionCreate(BaseModel):
project_id: int
project_phase: str
work_type: str
content_type: str
duration_minutes: Optional[float] = 0
duration_seconds: Optional[float] = 0
hours_spent: Optional[float] = None
submit_to: str
description: Optional[str] = None
submit_date: date
class SubmissionUpdate(BaseModel):
project_phase: Optional[str] = None
work_type: Optional[str] = None
content_type: Optional[str] = None
duration_minutes: Optional[float] = None
duration_seconds: Optional[float] = None
hours_spent: Optional[float] = None
submit_to: Optional[str] = None
description: Optional[str] = None
submit_date: Optional[date] = None
change_reason: str # 修改必须填原因
class SubmissionOut(BaseModel):
id: int
user_id: int
user_name: Optional[str] = None
project_id: int
project_name: Optional[str] = None
project_phase: str
work_type: str
content_type: str
duration_minutes: Optional[float] = 0
duration_seconds: Optional[float] = 0
total_seconds: float
hours_spent: Optional[float] = None
submit_to: str
description: Optional[str] = None
submit_date: date
created_at: Optional[datetime] = None
class Config:
from_attributes = True
# ──────────────────────────── AI 工具成本 ────────────────────────────
class AIToolCostCreate(BaseModel):
tool_name: str
subscription_period: str
amount: float
allocation_type: str
project_id: Optional[int] = None
record_date: date
allocations: Optional[List[dict]] = None # [{project_id, percentage}]
class AIToolCostOut(BaseModel):
id: int
tool_name: str
subscription_period: str
amount: float
allocation_type: str
project_id: Optional[int] = None
recorded_by: int
record_date: date
created_at: Optional[datetime] = None
class Config:
from_attributes = True
# ──────────────────────────── 外包成本 ────────────────────────────
class OutsourceCostCreate(BaseModel):
project_id: int
outsource_type: str
episode_start: Optional[int] = None
episode_end: Optional[int] = None
amount: float
record_date: date
class OutsourceCostOut(BaseModel):
id: int
project_id: int
outsource_type: str
episode_start: Optional[int] = None
episode_end: Optional[int] = None
amount: float
recorded_by: int
record_date: date
created_at: Optional[datetime] = None
class Config:
from_attributes = True
# ──────────────────────────── 成本调整 ────────────────────────────
class CostOverrideCreate(BaseModel):
user_id: int
date: date
project_id: int
override_amount: float
reason: Optional[str] = None
# ──────────────────────────── 仪表盘 ────────────────────────────
class DashboardSummary(BaseModel):
"""全局仪表盘数据"""
active_projects: int = 0
completed_projects: int = 0
monthly_labor_cost: float = 0
monthly_ai_tool_cost: float = 0
monthly_total_seconds: float = 0
avg_daily_seconds_per_person: float = 0
projects: List[dict] = []
waste_ranking: List[dict] = []
settled_projects: List[dict] = []
# ──────────────────────────── 项目结算 ────────────────────────────
class SettlementOut(BaseModel):
"""项目结算报告"""
project_id: int
project_name: str
project_type: str
labor_cost: float = 0
ai_tool_cost: float = 0
outsource_cost: float = 0
total_cost: float = 0
waste_seconds: float = 0
waste_rate: float = 0
test_waste_seconds: float = 0
overproduction_waste_seconds: float = 0
contract_amount: Optional[float] = None
profit_loss: Optional[float] = None
team_efficiency: List[dict] = []

195
backend/seed.py Normal file
View File

@ -0,0 +1,195 @@
"""种子数据 —— 用于测试和演示"""
from datetime import date, timedelta
from database import SessionLocal, engine, Base
from models import *
from auth import hash_password
Base.metadata.create_all(bind=engine)
db = SessionLocal()
def seed():
# 清空数据
for table in reversed(Base.metadata.sorted_tables):
db.execute(table.delete())
db.commit()
# ── 用户 ──
users = [
User(username="admin", password_hash=hash_password("admin123"),
name="老板", phase_group=PhaseGroup.PRODUCTION, role=UserRole.OWNER, monthly_salary=0),
User(username="zhangsan", password_hash=hash_password("123456"),
name="张三", phase_group=PhaseGroup.PRE, role=UserRole.LEADER, monthly_salary=15000),
User(username="lisi", password_hash=hash_password("123456"),
name="李四", phase_group=PhaseGroup.PRODUCTION, role=UserRole.LEADER, monthly_salary=18000),
User(username="wangwu", password_hash=hash_password("123456"),
name="王五", phase_group=PhaseGroup.PRODUCTION, role=UserRole.MEMBER, monthly_salary=12000),
User(username="zhaoliu", password_hash=hash_password("123456"),
name="赵六", phase_group=PhaseGroup.PRODUCTION, role=UserRole.MEMBER, monthly_salary=12000),
User(username="sunqi", password_hash=hash_password("123456"),
name="孙七", phase_group=PhaseGroup.POST, role=UserRole.MEMBER, monthly_salary=13000),
User(username="producer", password_hash=hash_password("123456"),
name="陈制片", phase_group=PhaseGroup.PRODUCTION, role=UserRole.SUPERVISOR, monthly_salary=20000),
]
db.add_all(users)
db.flush()
admin, zhangsan, lisi, wangwu, zhaoliu, sunqi, producer = users
# ── 项目 ──
proj_a = Project(
name="星际漫游 第一季", project_type=ProjectType.CLIENT_FORMAL,
leader_id=lisi.id, current_phase=PhaseGroup.PRODUCTION,
episode_duration_minutes=5, episode_count=13,
estimated_completion_date=date.today() + timedelta(days=60),
contract_amount=100000,
)
proj_b = Project(
name="品牌方 TVC", project_type=ProjectType.CLIENT_FORMAL,
leader_id=lisi.id, current_phase=PhaseGroup.PRODUCTION,
episode_duration_minutes=1, episode_count=3,
estimated_completion_date=date.today() + timedelta(days=20),
contract_amount=50000,
)
proj_c = Project(
name="甲方风格测试", project_type=ProjectType.CLIENT_TEST,
leader_id=zhangsan.id, current_phase=PhaseGroup.PRE,
episode_duration_minutes=1, episode_count=1,
)
proj_d = Project(
name="AI 短剧原创", project_type=ProjectType.INTERNAL_ORIGINAL,
leader_id=lisi.id, current_phase=PhaseGroup.PRE,
episode_duration_minutes=8, episode_count=6,
estimated_completion_date=date.today() + timedelta(days=90),
)
db.add_all([proj_a, proj_b, proj_c, proj_d])
db.flush()
# ── 内容提交(模拟近两周的数据) ──
base_date = date.today() - timedelta(days=14)
submissions = []
# 张三(前期组)给项目 A 和 D 做前期
for i in range(5):
d = base_date + timedelta(days=i)
submissions.append(Submission(
user_id=zhangsan.id, project_id=proj_a.id,
project_phase=PhaseGroup.PRE, work_type=WorkType.PLAN,
content_type=ContentType.DESIGN, total_seconds=0,
submit_to=SubmitTo.INTERNAL, description=f"角色设定第{i+1}",
submit_date=d,
))
for i in range(3):
d = base_date + timedelta(days=i + 5)
submissions.append(Submission(
user_id=zhangsan.id, project_id=proj_d.id,
project_phase=PhaseGroup.PRE, work_type=WorkType.PLAN,
content_type=ContentType.DESIGN, total_seconds=0,
submit_to=SubmitTo.INTERNAL, description=f"剧本大纲第{i+1}稿",
submit_date=d,
))
# 李四(制作组组长)主要做项目 A
for i in range(10):
d = base_date + timedelta(days=i)
secs = 45 + (i % 3) * 15 # 45-75秒
wt = WorkType.TEST if i < 2 else WorkType.PRODUCTION
submissions.append(Submission(
user_id=lisi.id, project_id=proj_a.id,
project_phase=PhaseGroup.PRODUCTION, work_type=wt,
content_type=ContentType.ANIMATION, total_seconds=secs,
duration_minutes=secs // 60, duration_seconds=secs % 60,
submit_to=SubmitTo.INTERNAL, description=f"第1集片段 - 场景{i+1}",
submit_date=d,
))
# 王五(制作组)做项目 A 和 B
for i in range(8):
d = base_date + timedelta(days=i)
secs = 30 + (i % 4) * 20 # 30-90秒
submissions.append(Submission(
user_id=wangwu.id, project_id=proj_a.id,
project_phase=PhaseGroup.PRODUCTION, work_type=WorkType.PRODUCTION,
content_type=ContentType.ANIMATION, total_seconds=secs,
duration_minutes=secs // 60, duration_seconds=secs % 60,
submit_to=SubmitTo.LEADER, description=f"第2集动画片段{i+1}",
submit_date=d,
))
for i in range(4):
d = base_date + timedelta(days=i + 8)
secs = 20 + i * 10
submissions.append(Submission(
user_id=wangwu.id, project_id=proj_b.id,
project_phase=PhaseGroup.PRODUCTION, work_type=WorkType.PRODUCTION,
content_type=ContentType.ANIMATION, total_seconds=secs,
duration_minutes=secs // 60, duration_seconds=secs % 60,
submit_to=SubmitTo.LEADER, description=f"TVC 片段{i+1}",
submit_date=d,
))
# 赵六(制作组)做项目 A
for i in range(10):
d = base_date + timedelta(days=i)
secs = 50 + (i % 2) * 30 # 50-80秒
wt = WorkType.TEST if i < 1 else WorkType.PRODUCTION
submissions.append(Submission(
user_id=zhaoliu.id, project_id=proj_a.id,
project_phase=PhaseGroup.PRODUCTION, work_type=wt,
content_type=ContentType.ANIMATION, total_seconds=secs,
duration_minutes=secs // 60, duration_seconds=secs % 60,
submit_to=SubmitTo.LEADER, description=f"第3集场景动画{i+1}",
submit_date=d,
))
# 孙七(后期组)剪辑
for i in range(3):
d = base_date + timedelta(days=i + 10)
submissions.append(Submission(
user_id=sunqi.id, project_id=proj_a.id,
project_phase=PhaseGroup.POST, work_type=WorkType.PRODUCTION,
content_type=ContentType.EDITING, total_seconds=0,
submit_to=SubmitTo.PRODUCER, description=f"{i+1}集粗剪完成",
submit_date=d,
))
# 后期补拍
submissions.append(Submission(
user_id=sunqi.id, project_id=proj_a.id,
project_phase=PhaseGroup.POST, work_type=WorkType.PRODUCTION,
content_type=ContentType.ANIMATION, total_seconds=15,
duration_seconds=15,
submit_to=SubmitTo.PRODUCER, description="第1集补拍修改镜头",
submit_date=base_date + timedelta(days=12),
))
db.add_all(submissions)
# ── AI 工具成本 ──
db.add(AIToolCost(
tool_name="Midjourney", subscription_period=SubscriptionPeriod.MONTHLY,
amount=200, allocation_type=CostAllocationType.TEAM,
recorded_by=producer.id, record_date=date.today().replace(day=1),
))
db.add(AIToolCost(
tool_name="Runway", subscription_period=SubscriptionPeriod.MONTHLY,
amount=600, allocation_type=CostAllocationType.PROJECT,
project_id=proj_a.id,
recorded_by=producer.id, record_date=date.today().replace(day=1),
))
# ── 外包成本 ──
db.add(OutsourceCost(
project_id=proj_a.id, outsource_type=OutsourceType.ANIMATION,
episode_start=10, episode_end=13, amount=20000,
recorded_by=producer.id, record_date=date.today() - timedelta(days=5),
))
db.commit()
print("[OK] seed data generated")
print(f" - users: {len(users)}")
print(f" - projects: 4")
print(f" - submissions: {len(submissions)}")
print(f" - default account: admin / admin123")
if __name__ == "__main__":
seed()

24
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
frontend/.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

5
frontend/README.md Normal file
View File

@ -0,0 +1,5 @@
# Vue 3 + Vite
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).

13
frontend/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

2041
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
frontend/package.json Normal file
View File

@ -0,0 +1,24 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.13.5",
"echarts": "^6.0.0",
"element-plus": "^2.13.2",
"pinia": "^3.0.4",
"vue": "^3.5.25",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.2",
"vite": "^7.3.1"
}
}

1
frontend/public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

8
frontend/src/App.vue Normal file
View File

@ -0,0 +1,8 @@
<template>
<router-view />
</template>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif; }
</style>

85
frontend/src/api/index.js Normal file
View File

@ -0,0 +1,85 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '../router'
const api = axios.create({
baseURL: '/api',
timeout: 30000,
})
// 请求拦截:自动带 token
api.interceptors.request.use(config => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// 响应拦截:统一错误处理
api.interceptors.response.use(
res => res.data,
err => {
const msg = err.response?.data?.detail || '请求失败'
if (err.response?.status === 401) {
localStorage.removeItem('token')
router.push('/login')
ElMessage.error('登录已过期,请重新登录')
} else {
ElMessage.error(msg)
}
return Promise.reject(err)
}
)
export default api
// ── 认证 ──
export const authApi = {
login: (data) => api.post('/auth/login', data),
me: () => api.get('/auth/me'),
}
// ── 用户 ──
export const userApi = {
list: () => api.get('/users/'),
create: (data) => api.post('/users/', data),
update: (id, data) => api.put(`/users/${id}`, data),
get: (id) => api.get(`/users/${id}`),
}
// ── 项目 ──
export const projectApi = {
list: (params) => api.get('/projects/', { params }),
create: (data) => api.post('/projects/', data),
update: (id, data) => api.put(`/projects/${id}`, data),
get: (id) => api.get(`/projects/${id}`),
complete: (id) => api.post(`/projects/${id}/complete`),
settlement: (id) => api.get(`/projects/${id}/settlement`),
efficiency: (id) => api.get(`/projects/${id}/efficiency`),
}
// ── 内容提交 ──
export const submissionApi = {
list: (params) => api.get('/submissions/', { params }),
create: (data) => api.post('/submissions/', data),
update: (id, data) => api.put(`/submissions/${id}`, data),
history: (id) => api.get(`/submissions/${id}/history`),
}
// ── 成本 ──
export const costApi = {
listAITools: () => api.get('/costs/ai-tools'),
createAITool: (data) => api.post('/costs/ai-tools', data),
deleteAITool: (id) => api.delete(`/costs/ai-tools/${id}`),
listOutsource: (params) => api.get('/costs/outsource', { params }),
createOutsource: (data) => api.post('/costs/outsource', data),
deleteOutsource: (id) => api.delete(`/costs/outsource/${id}`),
createOverride: (data) => api.post('/costs/overrides', data),
listOverrides: (params) => api.get('/costs/overrides', { params }),
}
// ── 仪表盘 ──
export const dashboardApi = {
get: () => api.get('/dashboard'),
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@ -0,0 +1,43 @@
<script setup>
import { ref } from 'vue'
defineProps({
msg: String,
})
const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p>
Check out
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
>create-vue</a
>, the official Vue + Vite starter
</p>
<p>
Learn more about IDE Support for Vue in the
<a
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
target="_blank"
>Vue Docs Scaling up Guide</a
>.
</p>
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>

View File

@ -0,0 +1,121 @@
<template>
<el-container class="layout">
<!-- 侧边栏 -->
<el-aside :width="isCollapsed ? '64px' : '220px'" class="aside">
<div class="logo" @click="isCollapsed = !isCollapsed">
<el-icon :size="24"><DataAnalysis /></el-icon>
<span v-show="!isCollapsed" class="logo-text">AirLabs</span>
</div>
<el-menu
:default-active="$route.path"
:collapse="isCollapsed"
router
background-color="#1d1e2c"
text-color="#a0a3bd"
active-text-color="#ffffff"
class="side-menu"
>
<el-menu-item v-if="authStore.isOwner()" index="/dashboard">
<el-icon><Odometer /></el-icon>
<span>仪表盘</span>
</el-menu-item>
<el-menu-item index="/projects">
<el-icon><FolderOpened /></el-icon>
<span>项目管理</span>
</el-menu-item>
<el-menu-item index="/submissions">
<el-icon><EditPen /></el-icon>
<span>内容提交</span>
</el-menu-item>
<el-menu-item v-if="authStore.isLeaderOrAbove()" index="/costs">
<el-icon><Money /></el-icon>
<span>成本管理</span>
</el-menu-item>
<el-menu-item v-if="authStore.isOwner()" index="/users">
<el-icon><User /></el-icon>
<span>用户管理</span>
</el-menu-item>
</el-menu>
</el-aside>
<!-- 主内容区 -->
<el-container>
<el-header class="header">
<div class="header-left">
<el-breadcrumb separator="/">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item>{{ $route.name }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="header-right">
<el-tag :type="roleTagType" size="small" class="role-tag">{{ authStore.user?.role }}</el-tag>
<span class="user-name">{{ authStore.user?.name }}</span>
<el-button text @click="handleLogout">
<el-icon><SwitchButton /></el-icon>
</el-button>
</div>
</el-header>
<el-main class="main">
<router-view />
</el-main>
</el-container>
</el-container>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
const router = useRouter()
const authStore = useAuthStore()
const isCollapsed = ref(false)
const roleTagType = computed(() => {
const map = { 'Owner': 'danger', '主管': 'warning', '组长': '', '成员': 'info' }
return map[authStore.user?.role] || 'info'
})
onMounted(async () => {
if (authStore.token && !authStore.user) {
await authStore.fetchUser()
}
})
function handleLogout() {
authStore.logout()
router.push('/login')
}
</script>
<style scoped>
.layout { height: 100vh; }
.aside {
background: #1d1e2c;
transition: width 0.3s;
overflow: hidden;
}
.logo {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: #fff;
cursor: pointer;
border-bottom: 1px solid #2d2e3e;
}
.logo-text { font-size: 18px; font-weight: 700; letter-spacing: 2px; }
.side-menu { border-right: none; }
.header {
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #e8e8e8;
background: #fff;
}
.header-right { display: flex; align-items: center; gap: 12px; }
.user-name { font-size: 14px; color: #333; }
.role-tag { margin-right: 4px; }
.main { background: #f5f7fa; min-height: 0; }
</style>

20
frontend/src/main.js Normal file
View File

@ -0,0 +1,20 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
const app = createApp(App)
// 注册所有图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(createPinia())
app.use(ElementPlus, { locale: zhCn })
app.use(router)
app.mount('#app')

View File

@ -0,0 +1,38 @@
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{ path: '/login', name: 'Login', component: () => import('../views/Login.vue'), meta: { public: true } },
{
path: '/',
component: () => import('../components/Layout.vue'),
redirect: '/dashboard',
children: [
{ path: 'dashboard', name: 'Dashboard', component: () => import('../views/Dashboard.vue'), meta: { roles: ['Owner'] } },
{ path: 'projects', name: 'Projects', component: () => import('../views/Projects.vue') },
{ path: 'projects/:id', name: 'ProjectDetail', component: () => import('../views/ProjectDetail.vue') },
{ path: 'submissions', name: 'Submissions', component: () => import('../views/Submissions.vue') },
{ path: 'costs', name: 'Costs', component: () => import('../views/Costs.vue'), meta: { roles: ['Owner', '主管', '组长'] } },
{ path: 'users', name: 'Users', component: () => import('../views/Users.vue'), meta: { roles: ['Owner'] } },
{ path: 'settlement/:id', name: 'Settlement', component: () => import('../views/Settlement.vue'), meta: { roles: ['Owner'] } },
],
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
// 路由守卫
router.beforeEach(async (to, from, next) => {
const token = localStorage.getItem('token')
if (to.meta.public) {
next()
} else if (!token) {
next('/login')
} else {
next()
}
})
export default router

View File

@ -0,0 +1,35 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { authApi } from '../api'
export const useAuthStore = defineStore('auth', () => {
const user = ref(null)
const token = ref(localStorage.getItem('token') || '')
async function login(username, password) {
const res = await authApi.login({ username, password })
token.value = res.access_token
localStorage.setItem('token', res.access_token)
await fetchUser()
}
async function fetchUser() {
try {
user.value = await authApi.me()
} catch {
logout()
}
}
function logout() {
user.value = null
token.value = ''
localStorage.removeItem('token')
}
const isOwner = () => user.value?.role === 'Owner'
const isSupervisor = () => ['Owner', '主管'].includes(user.value?.role)
const isLeaderOrAbove = () => ['Owner', '主管', '组长'].includes(user.value?.role)
return { user, token, login, fetchUser, logout, isOwner, isSupervisor, isLeaderOrAbove }
})

79
frontend/src/style.css Normal file
View File

@ -0,0 +1,79 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
.card {
padding: 2em;
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

View File

@ -0,0 +1,156 @@
<template>
<div>
<h2 class="page-title">成本管理</h2>
<el-tabs v-model="activeTab">
<!-- AI 工具成本 -->
<el-tab-pane label="AI 工具成本" name="ai">
<div class="tab-header">
<el-button type="primary" size="small" @click="showAIForm = true"><el-icon><Plus /></el-icon> 新增</el-button>
</div>
<el-table :data="aiCosts" v-loading="loadingAI" stripe size="small">
<el-table-column prop="tool_name" label="工具名称" width="140" />
<el-table-column prop="subscription_period" label="周期" width="60" />
<el-table-column label="金额" width="100" align="right">
<template #default="{row}">¥{{ row.amount.toLocaleString() }}</template>
</el-table-column>
<el-table-column prop="allocation_type" label="归属方式" width="120" />
<el-table-column prop="record_date" label="录入日期" width="110" />
<el-table-column label="操作" width="80" v-if="authStore.isOwner()">
<template #default="{row}"><el-button text type="danger" size="small" @click="deleteAI(row.id)">删除</el-button></template>
</el-table-column>
</el-table>
</el-tab-pane>
<!-- 外包成本 -->
<el-tab-pane label="外包成本" name="outsource">
<div class="tab-header">
<el-button type="primary" size="small" @click="showOutForm = true"><el-icon><Plus /></el-icon> 新增</el-button>
</div>
<el-table :data="outCosts" v-loading="loadingOut" stripe size="small">
<el-table-column label="项目" width="160">
<template #default="{row}">{{ projectMap[row.project_id] || row.project_id }}</template>
</el-table-column>
<el-table-column prop="outsource_type" label="类型" width="80" />
<el-table-column label="集数范围" width="100">
<template #default="{row}">{{ row.episode_start && row.episode_end ? `${row.episode_start}-${row.episode_end}` : '—' }}</template>
</el-table-column>
<el-table-column label="金额" width="100" align="right">
<template #default="{row}">¥{{ row.amount.toLocaleString() }}</template>
</el-table-column>
<el-table-column prop="record_date" label="录入日期" width="110" />
<el-table-column label="操作" width="80" v-if="authStore.isOwner()">
<template #default="{row}"><el-button text type="danger" size="small" @click="deleteOut(row.id)">删除</el-button></template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
<!-- AI 工具新增弹窗 -->
<el-dialog v-model="showAIForm" title="新增 AI 工具成本" width="480px" destroy-on-close>
<el-form :model="aiForm" label-width="100px">
<el-form-item label="工具名称"><el-input v-model="aiForm.tool_name" /></el-form-item>
<el-form-item label="订阅周期">
<el-select v-model="aiForm.subscription_period" style="width:100%">
<el-option label="月" value="月" /><el-option label="年" value="年" />
</el-select>
</el-form-item>
<el-form-item label="费用金额"><el-input-number v-model="aiForm.amount" :min="0" :controls="false" style="width:100%" /></el-form-item>
<el-form-item label="归属方式">
<el-select v-model="aiForm.allocation_type" style="width:100%">
<el-option label="指定项目" value="指定项目" />
<el-option label="内容组整体" value="内容组整体" />
<el-option label="手动分摊" value="手动分摊" />
</el-select>
</el-form-item>
<el-form-item v-if="aiForm.allocation_type === '指定项目'" label="指定项目">
<el-select v-model="aiForm.project_id" style="width:100%">
<el-option v-for="p in projects" :key="p.id" :label="p.name" :value="p.id" />
</el-select>
</el-form-item>
<el-form-item label="录入日期"><el-date-picker v-model="aiForm.record_date" value-format="YYYY-MM-DD" style="width:100%" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="showAIForm = false">取消</el-button>
<el-button type="primary" @click="createAI">保存</el-button>
</template>
</el-dialog>
<!-- 外包新增弹窗 -->
<el-dialog v-model="showOutForm" title="新增外包成本" width="480px" destroy-on-close>
<el-form :model="outForm" label-width="100px">
<el-form-item label="所属项目">
<el-select v-model="outForm.project_id" style="width:100%">
<el-option v-for="p in projects" :key="p.id" :label="p.name" :value="p.id" />
</el-select>
</el-form-item>
<el-form-item label="外包类型">
<el-select v-model="outForm.outsource_type" style="width:100%">
<el-option label="动画" value="动画" /><el-option label="剪辑" value="剪辑" /><el-option label="整集" value="整集" />
</el-select>
</el-form-item>
<el-row :gutter="16">
<el-col :span="12"><el-form-item label="起始集"><el-input-number v-model="outForm.episode_start" :min="1" style="width:100%" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="结束集"><el-input-number v-model="outForm.episode_end" :min="1" style="width:100%" /></el-form-item></el-col>
</el-row>
<el-form-item label="金额"><el-input-number v-model="outForm.amount" :min="0" :controls="false" style="width:100%" /></el-form-item>
<el-form-item label="录入日期"><el-date-picker v-model="outForm.record_date" value-format="YYYY-MM-DD" style="width:100%" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="showOutForm = false">取消</el-button>
<el-button type="primary" @click="createOut">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, reactive, onMounted } from 'vue'
import { costApi, projectApi } from '../api'
import { useAuthStore } from '../stores/auth'
import { ElMessage, ElMessageBox } from 'element-plus'
const authStore = useAuthStore()
const activeTab = ref('ai')
const loadingAI = ref(false)
const loadingOut = ref(false)
const aiCosts = ref([])
const outCosts = ref([])
const projects = ref([])
const showAIForm = ref(false)
const showOutForm = ref(false)
const projectMap = computed(() => {
const m = {}; projects.value.forEach(p => m[p.id] = p.name); return m
})
const today = new Date().toISOString().split('T')[0]
const aiForm = reactive({ tool_name: '', subscription_period: '月', amount: 0, allocation_type: '内容组整体', project_id: null, record_date: today })
const outForm = reactive({ project_id: null, outsource_type: '动画', episode_start: 1, episode_end: 1, amount: 0, record_date: today })
async function loadAI() { loadingAI.value = true; try { aiCosts.value = await costApi.listAITools() } finally { loadingAI.value = false } }
async function loadOut() { loadingOut.value = true; try { outCosts.value = await costApi.listOutsource({}) } finally { loadingOut.value = false } }
async function createAI() {
await costApi.createAITool(aiForm); ElMessage.success('已添加'); showAIForm.value = false; loadAI()
}
async function createOut() {
await costApi.createOutsource(outForm); ElMessage.success('已添加'); showOutForm.value = false; loadOut()
}
async function deleteAI(id) {
await ElMessageBox.confirm('确认删除?'); await costApi.deleteAITool(id); ElMessage.success('已删除'); loadAI()
}
async function deleteOut(id) {
await ElMessageBox.confirm('确认删除?'); await costApi.deleteOutsource(id); ElMessage.success('已删除'); loadOut()
}
onMounted(async () => {
loadAI(); loadOut()
try { projects.value = await projectApi.list({}) } catch {}
})
</script>
<style scoped>
.page-title { font-size: 20px; font-weight: 600; margin-bottom: 16px; }
.tab-header { margin-bottom: 12px; }
</style>

View File

@ -0,0 +1,147 @@
<template>
<div class="dashboard" v-loading="loading">
<h2 class="page-title">仪表盘</h2>
<!-- 顶部统计卡片 -->
<el-row :gutter="16" class="stat-row">
<el-col :span="6">
<el-card shadow="hover" class="stat-card">
<div class="stat-label">进行中项目</div>
<div class="stat-value blue">{{ data.active_projects }}</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" class="stat-card">
<div class="stat-label">本月人力成本</div>
<div class="stat-value orange">¥{{ formatNum(data.monthly_labor_cost) }}</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" class="stat-card">
<div class="stat-label">本月产出</div>
<div class="stat-value green">{{ formatSecs(data.monthly_total_seconds) }}</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" class="stat-card">
<div class="stat-label">人均日产出</div>
<div class="stat-value purple">{{ formatSecs(data.avg_daily_seconds_per_person) }}</div>
</el-card>
</el-col>
</el-row>
<!-- 项目进度 -->
<el-card class="section-card">
<template #header>
<span class="section-title">项目进度一览</span>
</template>
<div v-for="p in data.projects" :key="p.id" class="project-row" @click="$router.push(`/projects/${p.id}`)">
<div class="project-info">
<span class="project-name">{{ p.name }}</span>
<el-tag size="small" :type="typeTagMap[p.project_type]">{{ p.project_type }}</el-tag>
<el-tag v-if="p.is_overdue" size="small" type="danger">超期</el-tag>
</div>
<div class="project-progress">
<el-progress
:percentage="Math.min(p.progress_percent, 100)"
:color="p.is_overdue ? '#f56c6c' : '#67c23a'"
:stroke-width="14"
:text-inside="true"
:format="() => p.progress_percent + '%'"
/>
<span class="progress-detail">{{ formatSecs(p.submitted_seconds) }} / {{ formatSecs(p.target_seconds) }}</span>
</div>
</div>
<el-empty v-if="!data.projects?.length" description="暂无进行中的项目" />
</el-card>
<el-row :gutter="16">
<!-- 损耗排行 -->
<el-col :span="12">
<el-card class="section-card">
<template #header><span class="section-title">损耗排行</span></template>
<el-table :data="data.waste_ranking" size="small" stripe>
<el-table-column prop="project_name" label="项目" />
<el-table-column label="损耗秒数" align="right">
<template #default="{ row }">{{ formatSecs(row.waste_seconds) }}</template>
</el-table-column>
<el-table-column label="损耗率" align="right" width="100">
<template #default="{ row }">
<span :style="{ color: row.waste_rate > 30 ? '#f56c6c' : '#333' }">{{ row.waste_rate }}%</span>
</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
<!-- 已结算项目 -->
<el-col :span="12">
<el-card class="section-card">
<template #header><span class="section-title">已结算项目</span></template>
<el-table :data="data.settled_projects" size="small" stripe>
<el-table-column prop="project_name" label="项目" />
<el-table-column label="总成本" align="right">
<template #default="{ row }">¥{{ formatNum(row.total_cost) }}</template>
</el-table-column>
<el-table-column label="盈亏" align="right">
<template #default="{ row }">
<span v-if="row.profit_loss != null" :style="{ color: row.profit_loss >= 0 ? '#67c23a' : '#f56c6c', fontWeight: 600 }">
{{ row.profit_loss >= 0 ? '+' : '' }}¥{{ formatNum(row.profit_loss) }}
</span>
<span v-else style="color:#999"></span>
</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { dashboardApi } from '../api'
const loading = ref(false)
const data = ref({})
const typeTagMap = {
'客户正式项目': 'success',
'客户测试项目': 'warning',
'内部原创项目': '',
'内部测试项目': 'info',
}
function formatNum(n) { return (n || 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 }) }
function formatSecs(s) {
if (!s) return '0秒'
const m = Math.floor(s / 60)
const sec = Math.round(s % 60)
return m > 0 ? `${m}${sec > 0 ? sec + '秒' : ''}` : `${sec}`
}
onMounted(async () => {
loading.value = true
try { data.value = await dashboardApi.get() } finally { loading.value = false }
})
</script>
<style scoped>
.page-title { font-size: 20px; font-weight: 600; margin-bottom: 16px; }
.stat-row { margin-bottom: 16px; }
.stat-card { text-align: center; }
.stat-label { font-size: 13px; color: #909399; margin-bottom: 8px; }
.stat-value { font-size: 28px; font-weight: 700; }
.stat-value.blue { color: #409eff; }
.stat-value.orange { color: #e6a23c; }
.stat-value.green { color: #67c23a; }
.stat-value.purple { color: #9b59b6; }
.section-card { margin-bottom: 16px; }
.section-title { font-weight: 600; }
.project-row { display: flex; align-items: center; justify-content: space-between; padding: 12px 0; border-bottom: 1px solid #f0f0f0; cursor: pointer; }
.project-row:hover { background: #fafafa; }
.project-info { display: flex; align-items: center; gap: 8px; min-width: 260px; }
.project-name { font-weight: 500; }
.project-progress { flex: 1; display: flex; align-items: center; gap: 12px; }
.project-progress .el-progress { flex: 1; }
.progress-detail { font-size: 12px; color: #909399; white-space: nowrap; }
</style>

View File

@ -0,0 +1,75 @@
<template>
<div class="login-page">
<div class="login-card">
<div class="login-header">
<el-icon :size="36" color="#409eff"><DataAnalysis /></el-icon>
<h1>AirLabs Project</h1>
<p>内容组项目管理系统</p>
</div>
<el-form :model="form" @submit.prevent="handleLogin" class="login-form">
<el-form-item>
<el-input v-model="form.username" placeholder="用户名" prefix-icon="User" size="large" />
</el-form-item>
<el-form-item>
<el-input v-model="form.password" placeholder="密码" prefix-icon="Lock" type="password" size="large" show-password />
</el-form-item>
<el-button type="primary" size="large" :loading="loading" @click="handleLogin" style="width:100%">
</el-button>
</el-form>
</div>
</div>
</template>
<script setup>
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { ElMessage } from 'element-plus'
const router = useRouter()
const authStore = useAuthStore()
const loading = ref(false)
const form = reactive({ username: '', password: '' })
async function handleLogin() {
if (!form.username || !form.password) {
ElMessage.warning('请输入用户名和密码')
return
}
loading.value = true
try {
await authStore.login(form.username, form.password)
ElMessage.success(`欢迎,${authStore.user?.name}`)
router.push('/')
} catch {
//
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #1d1e2c 0%, #2d3a5c 100%);
}
.login-card {
width: 400px;
padding: 40px;
background: #fff;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
.login-header {
text-align: center;
margin-bottom: 32px;
}
.login-header h1 { font-size: 24px; margin: 12px 0 4px; color: #1d1e2c; }
.login-header p { color: #999; font-size: 14px; }
.login-form { margin-top: 8px; }
</style>

View File

@ -0,0 +1,154 @@
<template>
<div v-loading="loading">
<div class="page-header">
<div>
<el-button text @click="$router.push('/projects')"><el-icon><ArrowLeft /></el-icon> 返回</el-button>
<h2 style="display:inline; margin-left:8px">{{ project.name }}</h2>
<el-tag :type="typeTagMap[project.project_type]" style="margin-left:8px">{{ project.project_type }}</el-tag>
<el-tag :type="project.status === '已完成' ? 'success' : ''" style="margin-left:4px">{{ project.status }}</el-tag>
</div>
<el-space>
<el-button v-if="authStore.isOwner() && project.status === '制作中'" type="danger" @click="handleComplete">
确认完成结算
</el-button>
<el-button v-if="authStore.isOwner() && project.status === '已完成'" type="primary" @click="$router.push(`/settlement/${project.id}`)">
查看结算报告
</el-button>
</el-space>
</div>
<!-- 项目概览 -->
<el-row :gutter="16" class="stat-row">
<el-col :span="6">
<el-card shadow="hover"><div class="stat-label">目标时长</div><div class="stat-value">{{ formatSecs(project.target_total_seconds) }}</div></el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover"><div class="stat-label">已提交</div><div class="stat-value">{{ formatSecs(project.total_submitted_seconds) }}</div></el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover"><div class="stat-label">完成进度</div><div class="stat-value" :style="{color: project.progress_percent > 100 ? '#e6a23c' : '#409eff'}">{{ project.progress_percent }}%</div></el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover"><div class="stat-label">损耗率</div><div class="stat-value" :style="{color: project.waste_rate > 30 ? '#f56c6c' : '#67c23a'}">{{ project.waste_rate }}%</div></el-card>
</el-col>
</el-row>
<!-- 进度条 -->
<el-card class="section-card">
<template #header><span class="section-title">项目进度</span></template>
<el-progress :percentage="Math.min(project.progress_percent, 100)" :stroke-width="20" :text-inside="true"
:format="() => project.progress_percent + '%'"
:color="progressColor" style="margin-bottom:12px" />
<div class="meta-row">
<span>目标{{ project.episode_count }} × {{ project.episode_duration_minutes }} = {{ formatSecs(project.target_total_seconds) }}</span>
<span v-if="project.estimated_completion_date">预估完成{{ project.estimated_completion_date }}</span>
</div>
</el-card>
<!-- 团队效率 -->
<el-card v-if="authStore.isLeaderOrAbove() && efficiency.length" class="section-card">
<template #header><span class="section-title">团队效率人均基准对比</span></template>
<el-table :data="efficiency" size="small" stripe>
<el-table-column prop="user_name" label="成员" width="100" />
<el-table-column label="提交总秒数" align="right"><template #default="{row}">{{ formatSecs(row.total_seconds) }}</template></el-table-column>
<el-table-column label="人均基准" align="right"><template #default="{row}">{{ formatSecs(row.baseline) }}</template></el-table-column>
<el-table-column label="超出基准" align="right">
<template #default="{row}">
<span :style="{color: row.excess_seconds > 0 ? '#f56c6c' : '#67c23a'}">
{{ row.excess_seconds > 0 ? '+' : '' }}{{ formatSecs(row.excess_seconds) }}
</span>
</template>
</el-table-column>
<el-table-column label="超出比例" align="right" width="100">
<template #default="{row}">
<span :style="{color: row.excess_rate > 20 ? '#f56c6c' : '#333'}">{{ row.excess_rate > 0 ? '+' : '' }}{{ row.excess_rate }}%</span>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 提交记录 -->
<el-card class="section-card">
<template #header><span class="section-title">提交记录</span></template>
<el-table :data="submissions" size="small" stripe>
<el-table-column prop="submit_date" label="日期" width="110" />
<el-table-column prop="user_name" label="提交人" width="80" />
<el-table-column prop="project_phase" label="阶段" width="70" />
<el-table-column prop="work_type" label="工作类型" width="80">
<template #default="{row}">
<el-tag :type="row.work_type === '测试' ? 'warning' : ''" size="small">{{ row.work_type }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="content_type" label="内容类型" width="90" />
<el-table-column label="产出时长" width="90" align="right">
<template #default="{row}">{{ row.total_seconds > 0 ? formatSecs(row.total_seconds) : '—' }}</template>
</el-table-column>
<el-table-column prop="description" label="描述" show-overflow-tooltip />
</el-table>
</el-card>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { projectApi, submissionApi } from '../api'
import { useAuthStore } from '../stores/auth'
import { ElMessage, ElMessageBox } from 'element-plus'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const loading = ref(false)
const project = ref({})
const submissions = ref([])
const efficiency = ref([])
const typeTagMap = { '客户正式项目': 'success', '客户测试项目': 'warning', '内部原创项目': '', '内部测试项目': 'info' }
const progressColor = computed(() => {
if (project.value.progress_percent > 100) return '#e6a23c'
return '#67c23a'
})
function formatSecs(s) {
if (!s) return '0秒'
const abs = Math.abs(s)
const m = Math.floor(abs / 60)
const sec = Math.round(abs % 60)
const sign = s < 0 ? '-' : ''
return m > 0 ? `${sign}${m}${sec > 0 ? sec + '秒' : ''}` : `${sign}${sec}`
}
async function load() {
loading.value = true
try {
const id = route.params.id
project.value = await projectApi.get(id)
submissions.value = await submissionApi.list({ project_id: id })
if (authStore.isLeaderOrAbove()) {
try { efficiency.value = await projectApi.efficiency(id) } catch {}
}
} finally { loading.value = false }
}
async function handleComplete() {
try {
await ElMessageBox.confirm('确认将此项目标记为完成并进行结算?此操作不可撤销。', '确认完成', { type: 'warning' })
await projectApi.complete(route.params.id)
ElMessage.success('项目已完成')
load()
} catch {}
}
onMounted(load)
</script>
<style scoped>
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.stat-row { margin-bottom: 16px; }
.stat-label { font-size: 13px; color: #909399; margin-bottom: 4px; }
.stat-value { font-size: 24px; font-weight: 700; }
.section-card { margin-bottom: 16px; }
.section-title { font-weight: 600; }
.meta-row { display: flex; justify-content: space-between; font-size: 13px; color: #909399; }
</style>

View File

@ -0,0 +1,146 @@
<template>
<div>
<div class="page-header">
<h2>项目管理</h2>
<el-button v-if="authStore.isSupervisor()" type="primary" @click="showCreate = true">
<el-icon><Plus /></el-icon> 新建项目
</el-button>
</div>
<!-- 筛选 -->
<el-card class="filter-card">
<el-space>
<el-select v-model="filter.status" placeholder="状态" clearable style="width:130px" @change="load">
<el-option label="制作中" value="制作中" />
<el-option label="已完成" value="已完成" />
</el-select>
<el-select v-model="filter.project_type" placeholder="项目类型" clearable style="width:160px" @change="load">
<el-option v-for="t in projectTypes" :key="t" :label="t" :value="t" />
</el-select>
</el-space>
</el-card>
<!-- 项目列表 -->
<el-table :data="projects" v-loading="loading" stripe @row-click="row => $router.push(`/projects/${row.id}`)">
<el-table-column prop="name" label="项目名称" min-width="160" />
<el-table-column label="类型" width="140">
<template #default="{ row }">
<el-tag size="small" :type="typeTagMap[row.project_type]">{{ row.project_type }}</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag size="small" :type="row.status === '已完成' ? 'success' : ''">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="leader_name" label="负责人" width="100" />
<el-table-column label="目标" width="140">
<template #default="{ row }">{{ row.episode_count }} × {{ row.episode_duration_minutes }}</template>
</el-table-column>
<el-table-column label="进度" width="180">
<template #default="{ row }">
<el-progress :percentage="Math.min(row.progress_percent, 100)" :stroke-width="10" :text-inside="true"
:format="() => row.progress_percent + '%'"
:color="row.progress_percent > 100 ? '#e6a23c' : '#409eff'" />
</template>
</el-table-column>
<el-table-column label="损耗率" width="90" align="right">
<template #default="{ row }">
<span :style="{ color: row.waste_rate > 30 ? '#f56c6c' : '#333' }">{{ row.waste_rate }}%</span>
</template>
</el-table-column>
</el-table>
<!-- 新建项目对话框 -->
<el-dialog v-model="showCreate" title="新建项目" width="560px" destroy-on-close>
<el-form :model="form" label-width="120px" label-position="left">
<el-form-item label="项目名称" required>
<el-input v-model="form.name" placeholder="输入项目名称" />
</el-form-item>
<el-form-item label="项目类型" required>
<el-select v-model="form.project_type" style="width:100%">
<el-option v-for="t in projectTypes" :key="t" :label="t" :value="t" />
</el-select>
</el-form-item>
<el-form-item label="负责人">
<el-select v-model="form.leader_id" placeholder="选择负责人" clearable style="width:100%">
<el-option v-for="u in users" :key="u.id" :label="u.name" :value="u.id" />
</el-select>
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="单集时长(分)" required>
<el-input-number v-model="form.episode_duration_minutes" :min="0.1" :step="0.5" style="width:100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="集数" required>
<el-input-number v-model="form.episode_count" :min="1" style="width:100%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="预估完成日期">
<el-date-picker v-model="form.estimated_completion_date" value-format="YYYY-MM-DD" style="width:100%" />
</el-form-item>
<el-form-item v-if="form.project_type === '客户正式项目'" label="合同金额">
<el-input-number v-model="form.contract_amount" :min="0" :step="10000" :controls="false" style="width:100%" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showCreate = false">取消</el-button>
<el-button type="primary" :loading="creating" @click="handleCreate">创建</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted, reactive } from 'vue'
import { projectApi, userApi } from '../api'
import { useAuthStore } from '../stores/auth'
import { ElMessage } from 'element-plus'
const authStore = useAuthStore()
const loading = ref(false)
const creating = ref(false)
const showCreate = ref(false)
const projects = ref([])
const users = ref([])
const projectTypes = ['客户正式项目', '客户测试项目', '内部原创项目', '内部测试项目']
const typeTagMap = { '客户正式项目': 'success', '客户测试项目': 'warning', '内部原创项目': '', '内部测试项目': 'info' }
const filter = reactive({ status: null, project_type: null })
const form = reactive({
name: '', project_type: '客户正式项目', leader_id: null,
episode_duration_minutes: 5, episode_count: 1,
estimated_completion_date: null, contract_amount: null,
})
async function load() {
loading.value = true
try { projects.value = await projectApi.list(filter) } finally { loading.value = false }
}
async function handleCreate() {
if (!form.name) { ElMessage.warning('请输入项目名称'); return }
creating.value = true
try {
await projectApi.create(form)
ElMessage.success('项目已创建')
showCreate.value = false
load()
} finally { creating.value = false }
}
onMounted(async () => {
load()
if (authStore.isLeaderOrAbove()) {
try { users.value = await userApi.list() } catch {}
}
})
</script>
<style scoped>
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.page-header h2 { font-size: 20px; }
.filter-card { margin-bottom: 16px; }
</style>

View File

@ -0,0 +1,131 @@
<template>
<div v-loading="loading">
<div class="page-header">
<el-button text @click="$router.back()"><el-icon><ArrowLeft /></el-icon> 返回</el-button>
<h2 style="display:inline;margin-left:8px">项目结算报告</h2>
</div>
<template v-if="data.project_name">
<!-- 项目基本信息 -->
<el-card class="section-card">
<template #header><span class="section-title">{{ data.project_name }}</span></template>
<el-descriptions :column="3" border size="small">
<el-descriptions-item label="项目类型">{{ data.project_type }}</el-descriptions-item>
<el-descriptions-item label="目标时长">{{ formatSecs(data.target_seconds) }}</el-descriptions-item>
<el-descriptions-item label="实际提交">{{ formatSecs(data.total_submitted_seconds) }}</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 成本汇总 -->
<el-row :gutter="16" class="stat-row">
<el-col :span="6">
<el-card shadow="hover"><div class="stat-label">人力成本</div><div class="stat-value">¥{{ fmt(data.labor_cost) }}</div></el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover"><div class="stat-label">AI 工具成本</div><div class="stat-value">¥{{ fmt(data.ai_tool_cost) }}</div></el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover"><div class="stat-label">外包成本</div><div class="stat-value">¥{{ fmt(data.outsource_cost) }}</div></el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-label">项目总成本</div>
<div class="stat-value" style="color:#e6a23c">¥{{ fmt(data.total_cost) }}</div>
</el-card>
</el-col>
</el-row>
<!-- 盈亏仅客户正式项目 -->
<el-card v-if="data.contract_amount != null" class="section-card">
<template #header><span class="section-title">项目盈亏</span></template>
<el-row :gutter="16">
<el-col :span="8">
<div class="big-stat"><div class="stat-label">合同金额</div><div class="stat-value" style="color:#409eff">¥{{ fmt(data.contract_amount) }}</div></div>
</el-col>
<el-col :span="8">
<div class="big-stat"><div class="stat-label">项目总成本</div><div class="stat-value" style="color:#e6a23c">¥{{ fmt(data.total_cost) }}</div></div>
</el-col>
<el-col :span="8">
<div class="big-stat">
<div class="stat-label">盈亏结果</div>
<div class="stat-value" :style="{color: data.profit_loss >= 0 ? '#67c23a' : '#f56c6c'}">
{{ data.profit_loss >= 0 ? '+' : '' }}¥{{ fmt(data.profit_loss) }}
</div>
</div>
</el-col>
</el-row>
</el-card>
<!-- 损耗分析 -->
<el-card class="section-card">
<template #header><span class="section-title">损耗分析</span></template>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="测试损耗">{{ formatSecs(data.test_waste_seconds) }}</el-descriptions-item>
<el-descriptions-item label="超产损耗">{{ formatSecs(data.overproduction_waste_seconds) }}</el-descriptions-item>
<el-descriptions-item label="总损耗">{{ formatSecs(data.total_waste_seconds) }}</el-descriptions-item>
<el-descriptions-item label="损耗率">
<span :style="{color: data.waste_rate > 30 ? '#f56c6c' : '#333', fontWeight:600}">{{ data.waste_rate }}%</span>
</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 团队效率 -->
<el-card v-if="data.team_efficiency?.length" class="section-card">
<template #header><span class="section-title">团队效率排行</span></template>
<el-table :data="data.team_efficiency" size="small" stripe>
<el-table-column prop="user_name" label="成员" width="100" />
<el-table-column label="提交总量" align="right"><template #default="{row}">{{ formatSecs(row.total_seconds) }}</template></el-table-column>
<el-table-column label="人均基准" align="right"><template #default="{row}">{{ formatSecs(row.baseline) }}</template></el-table-column>
<el-table-column label="超出" align="right">
<template #default="{row}">
<span :style="{color: row.excess_seconds > 0 ? '#f56c6c' : '#67c23a'}">
{{ row.excess_seconds > 0 ? '+' : '' }}{{ formatSecs(row.excess_seconds) }}
</span>
</template>
</el-table-column>
<el-table-column label="超出比例" align="right" width="100">
<template #default="{row}">
<span :style="{color: row.excess_rate > 20 ? '#f56c6c' : '#333'}">{{ row.excess_rate > 0 ? '+' : '' }}{{ row.excess_rate }}%</span>
</template>
</el-table-column>
<el-table-column prop="submission_count" label="提交次数" width="90" align="right" />
</el-table>
</el-card>
</template>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { projectApi } from '../api'
const route = useRoute()
const loading = ref(false)
const data = ref({})
function fmt(n) { return (n || 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 }) }
function formatSecs(s) {
if (!s) return '0秒'
const abs = Math.abs(s)
const m = Math.floor(abs / 60)
const sec = Math.round(abs % 60)
const sign = s < 0 ? '-' : ''
return m > 0 ? `${sign}${m}${sec > 0 ? sec + '秒' : ''}` : `${sign}${sec}`
}
onMounted(async () => {
loading.value = true
try { data.value = await projectApi.settlement(route.params.id) } finally { loading.value = false }
})
</script>
<style scoped>
.page-header { margin-bottom: 16px; }
.section-card { margin-bottom: 16px; }
.section-title { font-weight: 600; }
.stat-row { margin-bottom: 16px; }
.stat-label { font-size: 13px; color: #909399; margin-bottom: 4px; }
.stat-value { font-size: 24px; font-weight: 700; }
.big-stat { text-align: center; padding: 12px 0; }
</style>

View File

@ -0,0 +1,180 @@
<template>
<div>
<div class="page-header">
<h2>内容提交</h2>
<el-button type="primary" @click="showCreate = true"><el-icon><Plus /></el-icon> 新增提交</el-button>
</div>
<!-- 筛选 -->
<el-card class="filter-card">
<el-space wrap>
<el-select v-model="filter.project_id" placeholder="按项目筛选" clearable style="width:200px" @change="load">
<el-option v-for="p in projects" :key="p.id" :label="p.name" :value="p.id" />
</el-select>
<el-date-picker v-model="dateRange" type="daterange" start-placeholder="开始日期" end-placeholder="结束日期"
value-format="YYYY-MM-DD" style="width:260px" @change="onDateChange" />
</el-space>
</el-card>
<!-- 列表 -->
<el-table :data="submissions" v-loading="loading" stripe>
<el-table-column prop="submit_date" label="日期" width="110" sortable />
<el-table-column prop="user_name" label="提交人" width="80" />
<el-table-column prop="project_name" label="项目" min-width="140" show-overflow-tooltip />
<el-table-column prop="project_phase" label="阶段" width="70" />
<el-table-column label="工作类型" width="80">
<template #default="{row}">
<el-tag :type="row.work_type === '测试' ? 'warning' : row.work_type === '方案' ? 'info' : ''" size="small">{{ row.work_type }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="content_type" label="内容类型" width="90" />
<el-table-column label="产出时长" width="100" align="right">
<template #default="{row}">{{ row.total_seconds > 0 ? formatSecs(row.total_seconds) : '—' }}</template>
</el-table-column>
<el-table-column label="投入时长" width="90" align="right">
<template #default="{row}">{{ row.hours_spent ? row.hours_spent + 'h' : '—' }}</template>
</el-table-column>
<el-table-column prop="description" label="描述" show-overflow-tooltip />
</el-table>
<!-- 新增提交对话框 -->
<el-dialog v-model="showCreate" title="新增内容提交" width="580px" destroy-on-close>
<el-form :model="form" label-width="110px" label-position="left">
<el-form-item label="所属项目" required>
<el-select v-model="form.project_id" placeholder="选择项目" style="width:100%">
<el-option v-for="p in activeProjects" :key="p.id" :label="p.name" :value="p.id" />
</el-select>
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="项目阶段" required>
<el-select v-model="form.project_phase" style="width:100%">
<el-option label="前期" value="前期" />
<el-option label="制作" value="制作" />
<el-option label="后期" value="后期" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="工作类型" required>
<el-select v-model="form.work_type" style="width:100%">
<el-option label="制作" value="制作" />
<el-option label="测试" value="测试" />
<el-option label="方案" value="方案" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="内容制作类型" required>
<el-select v-model="form.content_type" style="width:100%">
<el-option label="内容制作" value="内容制作" />
<el-option label="设定策划" value="设定策划" />
<el-option label="剪辑后期" value="剪辑后期" />
<el-option label="其他" value="其他" />
</el-select>
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="产出(分钟)">
<el-input-number v-model="form.duration_minutes" :min="0" :step="1" style="width:100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="产出(秒)">
<el-input-number v-model="form.duration_seconds" :min="0" :step="5" style="width:100%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="投入时长(时)">
<el-input-number v-model="form.hours_spent" :min="0" :step="0.5" :precision="1" style="width:100%" placeholder="可选" />
</el-form-item>
<el-form-item label="提交对象" required>
<el-select v-model="form.submit_to" style="width:100%">
<el-option label="组长" value="组长" />
<el-option label="制片" value="制片" />
<el-option label="内部" value="内部" />
<el-option label="外部" value="外部" />
</el-select>
</el-form-item>
<el-form-item label="制作描述">
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="简要描述本次提交内容" />
</el-form-item>
<el-form-item label="提交日期" required>
<el-date-picker v-model="form.submit_date" value-format="YYYY-MM-DD" style="width:100%" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showCreate = false">取消</el-button>
<el-button type="primary" :loading="creating" @click="handleCreate">提交</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted, reactive } from 'vue'
import { submissionApi, projectApi } from '../api'
import { ElMessage } from 'element-plus'
const loading = ref(false)
const creating = ref(false)
const showCreate = ref(false)
const submissions = ref([])
const projects = ref([])
const dateRange = ref(null)
const filter = reactive({ project_id: null, start_date: null, end_date: null })
const activeProjects = computed(() => projects.value.filter(p => p.status === '制作中'))
const today = new Date().toISOString().split('T')[0]
const form = reactive({
project_id: null, project_phase: '制作', work_type: '制作',
content_type: '内容制作', duration_minutes: 0, duration_seconds: 0,
hours_spent: null, submit_to: '组长', description: '', submit_date: today,
})
function formatSecs(s) {
if (!s) return '0秒'
const m = Math.floor(s / 60)
const sec = Math.round(s % 60)
return m > 0 ? `${m}${sec > 0 ? sec + '秒' : ''}` : `${sec}`
}
function onDateChange(val) {
if (val) { filter.start_date = val[0]; filter.end_date = val[1] }
else { filter.start_date = null; filter.end_date = null }
load()
}
async function load() {
loading.value = true
try { submissions.value = await submissionApi.list(filter) } finally { loading.value = false }
}
async function handleCreate() {
if (!form.project_id) { ElMessage.warning('请选择项目'); return }
creating.value = true
try {
await submissionApi.create(form)
ElMessage.success('提交成功')
showCreate.value = false
//
form.duration_minutes = 0
form.duration_seconds = 0
form.hours_spent = null
form.description = ''
load()
} finally { creating.value = false }
}
onMounted(async () => {
load()
try { projects.value = await projectApi.list({}) } catch {}
})
</script>
<style scoped>
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.page-header h2 { font-size: 20px; }
.filter-card { margin-bottom: 16px; }
</style>

View File

@ -0,0 +1,101 @@
<template>
<div>
<div class="page-header">
<h2>用户管理</h2>
<el-button type="primary" @click="showCreate = true"><el-icon><Plus /></el-icon> 新增用户</el-button>
</div>
<el-table :data="users" v-loading="loading" stripe>
<el-table-column prop="name" label="姓名" width="100" />
<el-table-column prop="username" label="用户名" width="120" />
<el-table-column prop="phase_group" label="阶段组" width="80" />
<el-table-column label="角色" width="90">
<template #default="{row}">
<el-tag :type="roleMap[row.role]" size="small">{{ row.role }}</el-tag>
</template>
</el-table-column>
<el-table-column label="月薪" width="110" align="right">
<template #default="{row}">¥{{ row.monthly_salary.toLocaleString() }}</template>
</el-table-column>
<el-table-column label="日成本" width="100" align="right">
<template #default="{row}">¥{{ row.daily_cost.toLocaleString() }}</template>
</el-table-column>
<el-table-column label="状态" width="80">
<template #default="{row}"><el-tag :type="row.is_active ? 'success' : 'danger'" size="small">{{ row.is_active ? '启用' : '停用' }}</el-tag></template>
</el-table-column>
<el-table-column label="操作" width="100">
<template #default="{row}">
<el-button text size="small" @click="editUser(row)">编辑</el-button>
</template>
</el-table-column>
</el-table>
<!-- 新增/编辑对话框 -->
<el-dialog v-model="showCreate" :title="editingId ? '编辑用户' : '新增用户'" width="480px" destroy-on-close>
<el-form :model="form" label-width="80px">
<el-form-item label="姓名"><el-input v-model="form.name" /></el-form-item>
<el-form-item v-if="!editingId" label="用户名"><el-input v-model="form.username" /></el-form-item>
<el-form-item v-if="!editingId" label="密码"><el-input v-model="form.password" type="password" /></el-form-item>
<el-form-item label="阶段组">
<el-select v-model="form.phase_group" style="width:100%">
<el-option label="前期" value="前期" /><el-option label="制作" value="制作" /><el-option label="后期" value="后期" />
</el-select>
</el-form-item>
<el-form-item label="角色">
<el-select v-model="form.role" style="width:100%">
<el-option label="成员" value="成员" /><el-option label="组长" value="组长" />
<el-option label="主管" value="主管" /><el-option label="Owner" value="Owner" />
</el-select>
</el-form-item>
<el-form-item label="月薪"><el-input-number v-model="form.monthly_salary" :min="0" :step="1000" :controls="false" style="width:100%" /></el-form-item>
<el-form-item v-if="editingId" label="状态">
<el-switch v-model="form.is_active" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="停用" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showCreate = false">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { userApi } from '../api'
import { ElMessage } from 'element-plus'
const loading = ref(false)
const showCreate = ref(false)
const editingId = ref(null)
const users = ref([])
const roleMap = { 'Owner': 'danger', '主管': 'warning', '组长': '', '成员': 'info' }
const form = reactive({ username: '', password: '', name: '', phase_group: '制作', role: '成员', monthly_salary: 0, is_active: 1 })
async function load() { loading.value = true; try { users.value = await userApi.list() } finally { loading.value = false } }
function editUser(u) {
editingId.value = u.id
Object.assign(form, { name: u.name, phase_group: u.phase_group, role: u.role, monthly_salary: u.monthly_salary, is_active: u.is_active })
showCreate.value = true
}
async function handleSave() {
if (editingId.value) {
await userApi.update(editingId.value, { name: form.name, phase_group: form.phase_group, role: form.role, monthly_salary: form.monthly_salary, is_active: form.is_active })
} else {
await userApi.create(form)
}
ElMessage.success('已保存')
showCreate.value = false
editingId.value = null
load()
}
onMounted(load)
</script>
<style scoped>
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.page-header h2 { font-size: 20px; }
</style>

15
frontend/vite.config.js Normal file
View File

@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
}
}
}
})