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