feat: MVP V1 鍏ㄦ爤鎼缓瀹屾垚 - FastAPI鍚庣 + Vue3鍓嶇 + 绉嶅瓙鏁版嵁
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
a9aaee6a57
commit
e76e856dba
BIN
backend/__pycache__/auth.cpython-310.pyc
Normal file
BIN
backend/__pycache__/auth.cpython-310.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/calculations.cpython-310.pyc
Normal file
BIN
backend/__pycache__/calculations.cpython-310.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/config.cpython-310.pyc
Normal file
BIN
backend/__pycache__/config.cpython-310.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/database.cpython-310.pyc
Normal file
BIN
backend/__pycache__/database.cpython-310.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/main.cpython-310.pyc
Normal file
BIN
backend/__pycache__/main.cpython-310.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/models.cpython-310.pyc
Normal file
BIN
backend/__pycache__/models.cpython-310.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/schemas.cpython-310.pyc
Normal file
BIN
backend/__pycache__/schemas.cpython-310.pyc
Normal file
Binary file not shown.
BIN
backend/airlabs.db
Normal file
BIN
backend/airlabs.db
Normal file
Binary file not shown.
62
backend/auth.py
Normal file
62
backend/auth.py
Normal 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
271
backend/calculations.py
Normal 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
13
backend/config.py
Normal 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
17
backend/database.py
Normal 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
72
backend/main.py
Normal 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
238
backend/models.py
Normal 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
7
backend/requirements.txt
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
fastapi
|
||||||
|
uvicorn[standard]
|
||||||
|
sqlalchemy
|
||||||
|
pydantic
|
||||||
|
python-jose[cryptography]
|
||||||
|
passlib[bcrypt]
|
||||||
|
python-multipart
|
||||||
0
backend/routers/__init__.py
Normal file
0
backend/routers/__init__.py
Normal file
BIN
backend/routers/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
backend/routers/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
backend/routers/__pycache__/auth.cpython-310.pyc
Normal file
BIN
backend/routers/__pycache__/auth.cpython-310.pyc
Normal file
Binary file not shown.
BIN
backend/routers/__pycache__/costs.cpython-310.pyc
Normal file
BIN
backend/routers/__pycache__/costs.cpython-310.pyc
Normal file
Binary file not shown.
BIN
backend/routers/__pycache__/dashboard.cpython-310.pyc
Normal file
BIN
backend/routers/__pycache__/dashboard.cpython-310.pyc
Normal file
Binary file not shown.
BIN
backend/routers/__pycache__/projects.cpython-310.pyc
Normal file
BIN
backend/routers/__pycache__/projects.cpython-310.pyc
Normal file
Binary file not shown.
BIN
backend/routers/__pycache__/submissions.cpython-310.pyc
Normal file
BIN
backend/routers/__pycache__/submissions.cpython-310.pyc
Normal file
Binary file not shown.
BIN
backend/routers/__pycache__/users.cpython-310.pyc
Normal file
BIN
backend/routers/__pycache__/users.cpython-310.pyc
Normal file
Binary file not shown.
35
backend/routers/auth.py
Normal file
35
backend/routers/auth.py
Normal 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
205
backend/routers/costs.py
Normal 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
|
||||||
|
]
|
||||||
152
backend/routers/dashboard.py
Normal file
152
backend/routers/dashboard.py
Normal 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
158
backend/routers/projects.py
Normal 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}
|
||||||
193
backend/routers/submissions.py
Normal file
193
backend/routers/submissions.py
Normal 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
88
backend/routers/users.py
Normal 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
249
backend/schemas.py
Normal 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
195
backend/seed.py
Normal 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
24
frontend/.gitignore
vendored
Normal 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
3
frontend/.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar"]
|
||||||
|
}
|
||||||
5
frontend/README.md
Normal file
5
frontend/README.md
Normal 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
13
frontend/index.html
Normal 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
2041
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
frontend/package.json
Normal file
24
frontend/package.json
Normal 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
1
frontend/public/vite.svg
Normal 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
8
frontend/src/App.vue
Normal 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
85
frontend/src/api/index.js
Normal 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'),
|
||||||
|
}
|
||||||
1
frontend/src/assets/vue.svg
Normal file
1
frontend/src/assets/vue.svg
Normal 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 |
43
frontend/src/components/HelloWorld.vue
Normal file
43
frontend/src/components/HelloWorld.vue
Normal 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>
|
||||||
121
frontend/src/components/Layout.vue
Normal file
121
frontend/src/components/Layout.vue
Normal 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
20
frontend/src/main.js
Normal 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')
|
||||||
38
frontend/src/router/index.js
Normal file
38
frontend/src/router/index.js
Normal 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
|
||||||
35
frontend/src/stores/auth.js
Normal file
35
frontend/src/stores/auth.js
Normal 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
79
frontend/src/style.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
156
frontend/src/views/Costs.vue
Normal file
156
frontend/src/views/Costs.vue
Normal 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>
|
||||||
147
frontend/src/views/Dashboard.vue
Normal file
147
frontend/src/views/Dashboard.vue
Normal 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>
|
||||||
75
frontend/src/views/Login.vue
Normal file
75
frontend/src/views/Login.vue
Normal 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>
|
||||||
154
frontend/src/views/ProjectDetail.vue
Normal file
154
frontend/src/views/ProjectDetail.vue
Normal 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>
|
||||||
146
frontend/src/views/Projects.vue
Normal file
146
frontend/src/views/Projects.vue
Normal 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>
|
||||||
131
frontend/src/views/Settlement.vue
Normal file
131
frontend/src/views/Settlement.vue
Normal 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>
|
||||||
180
frontend/src/views/Submissions.vue
Normal file
180
frontend/src/views/Submissions.vue
Normal 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>
|
||||||
101
frontend/src/views/Users.vue
Normal file
101
frontend/src/views/Users.vue
Normal 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
15
frontend/vite.config.js
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
Loading…
x
Reference in New Issue
Block a user