feat: 椤圭洰璇︽儏澧炲己+鎴愬憳璇︽儏椤?浠〃鐩樼泩鍒╂瑙?瑙掕壊鏉冮檺绯荤粺

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
seaislee1209 2026-02-12 17:41:27 +08:00
parent f7b9db6f42
commit 6ac44d47fb
27 changed files with 1732 additions and 184 deletions

Binary file not shown.

View File

@ -7,7 +7,6 @@ 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")
@ -29,8 +28,9 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> User:
def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)):
"""从 JWT token 解析当前用户"""
from models import User
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="登录已过期,请重新登录",
@ -51,13 +51,13 @@ def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(
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="权限不足"
)
def require_permission(*perms: str):
"""权限校验依赖:要求当前用户拥有指定权限中的至少一项"""
def perm_checker(current_user=Depends(get_current_user)):
if not current_user.role_ref:
raise HTTPException(status_code=403, detail="未分配角色")
user_perms = current_user.permissions or []
if not any(p in user_perms for p in perms):
raise HTTPException(status_code=403, detail="权限不足")
return current_user
return role_checker
return perm_checker

View File

@ -8,7 +8,7 @@ from collections import defaultdict
from datetime import date, timedelta
from models import (
User, Project, Submission, AIToolCost, AIToolCostAllocation,
OutsourceCost, CostOverride, WorkType, CostAllocationType
OutsourceCost, CostOverride, OverheadCost, WorkType, CostAllocationType
)
from config import WORKING_DAYS_PER_MONTH
@ -142,6 +142,31 @@ def calc_outsource_cost_for_project(project_id: int, db: Session) -> float:
return round(total, 2)
# ──────────────────────────── 固定开支分摊 ────────────────────────────
def calc_overhead_cost_for_project(project_id: int, db: Session) -> float:
"""
计算某项目分摊的固定开支办公室租金+水电费
规则按所有项目的产出秒数比例均摊
"""
total_overhead = db.query(sa_func.sum(OverheadCost.amount)).scalar() or 0
if total_overhead == 0:
return 0.0
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
return round(total_overhead * ratio, 2)
return 0.0
# ──────────────────────────── 损耗计算 ────────────────────────────
def calc_waste_for_project(project_id: int, db: Session) -> dict:
@ -243,7 +268,8 @@ def calc_project_settlement(project_id: int, db: Session) -> dict:
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
overhead = calc_overhead_cost_for_project(project_id, db)
total_cost = labor + ai_tool + outsource + overhead
waste = calc_waste_for_project(project_id, db)
efficiency = calc_team_efficiency(project_id, db)
@ -254,6 +280,7 @@ def calc_project_settlement(project_id: int, db: Session) -> dict:
"labor_cost": labor,
"ai_tool_cost": ai_tool,
"outsource_cost": outsource,
"overhead_cost": overhead,
"total_cost": round(total_cost, 2),
**waste,
"team_efficiency": efficiency,

View File

@ -4,7 +4,7 @@ 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 models import User, Role, PhaseGroup, BUILTIN_ROLES
from auth import hash_password
import os
@ -30,6 +30,7 @@ from routers.projects import router as projects_router
from routers.submissions import router as submissions_router
from routers.costs import router as costs_router
from routers.dashboard import router as dashboard_router
from routers.roles import router as roles_router
app.include_router(auth_router)
app.include_router(users_router)
@ -37,6 +38,7 @@ app.include_router(projects_router)
app.include_router(submissions_router)
app.include_router(costs_router)
app.include_router(dashboard_router)
app.include_router(roles_router)
# 前端静态文件
frontend_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "frontend", "dist")
@ -52,18 +54,34 @@ if os.path.exists(frontend_dir):
@app.on_event("startup")
def init_default_owner():
"""首次启动时创建默认 Owner 账号"""
def init_roles_and_admin():
"""首次启动时创建内置角色和默认管理员"""
from database import SessionLocal
db = SessionLocal()
try:
if not db.query(User).filter(User.role == UserRole.OWNER).first():
# 初始化内置角色
for role_name, role_def in BUILTIN_ROLES.items():
existing = db.query(Role).filter(Role.name == role_name).first()
if not existing:
role = Role(
name=role_name,
description=role_def["description"],
permissions=role_def["permissions"],
is_system=1,
)
db.add(role)
print(f"[OK] created role: {role_name}")
db.commit()
# 创建默认管理员(关联超级管理员角色)
admin_role = db.query(Role).filter(Role.name == "超级管理员").first()
if admin_role and not db.query(User).filter(User.username == "admin").first():
owner = User(
username="admin",
password_hash=hash_password("admin123"),
name="管理员",
phase_group=PhaseGroup.PRODUCTION,
role=UserRole.OWNER,
role_id=admin_role.id,
monthly_salary=0,
)
db.add(owner)

View File

@ -9,6 +9,71 @@ from database import Base
import enum
# ──────────────────────────── 权限标识符定义 ────────────────────────────
ALL_PERMISSIONS = [
# 仪表盘
("dashboard:view", "查看仪表盘", "仪表盘"),
# 项目管理
("project:view", "查看项目", "项目管理"),
("project:create", "创建项目", "项目管理"),
("project:edit", "编辑项目", "项目管理"),
("project:delete", "删除项目", "项目管理"),
("project:complete", "确认完成项目", "项目管理"),
# 内容提交
("submission:view", "查看提交记录", "内容提交"),
("submission:create", "新增提交", "内容提交"),
# 成本管理
("cost:view", "查看成本", "成本管理"),
("cost:create", "录入成本", "成本管理"),
("cost:delete", "删除成本", "成本管理"),
# 用户与角色
("user:view", "查看用户列表", "用户与角色"),
("user:manage", "管理用户", "用户与角色"),
("role:manage", "管理角色", "用户与角色"),
# 结算与效率
("settlement:view", "查看结算报告", "结算与效率"),
("efficiency:view", "查看团队效率", "结算与效率"),
]
PERMISSION_KEYS = [p[0] for p in ALL_PERMISSIONS]
# 内置角色定义
BUILTIN_ROLES = {
"超级管理员": {
"description": "系统最高权限,拥有全部功能",
"permissions": PERMISSION_KEYS[:], # 全部
},
"主管": {
"description": "管理项目和成本,不可管理用户和角色",
"permissions": [
"dashboard:view",
"project:view", "project:create", "project:edit", "project:complete",
"submission:view", "submission:create",
"cost:view", "cost:create", "cost:delete",
"user:view",
"settlement:view", "efficiency:view",
],
},
"组长": {
"description": "管理本组提交和查看成本",
"permissions": [
"project:view", "project:create",
"submission:view", "submission:create",
"cost:view", "cost:create",
"efficiency:view",
],
},
"成员": {
"description": "提交内容和查看项目",
"permissions": [
"project:view",
"submission:view", "submission:create",
],
},
}
# ──────────────────────────── 枚举定义 ────────────────────────────
class ProjectType(str, enum.Enum):
@ -29,13 +94,6 @@ class PhaseGroup(str, enum.Enum):
POST = "后期"
class UserRole(str, enum.Enum):
MEMBER = "成员"
LEADER = "组长"
SUPERVISOR = "主管"
OWNER = "Owner"
class WorkType(str, enum.Enum):
PRODUCTION = "制作"
TEST = "测试"
@ -73,6 +131,29 @@ class OutsourceType(str, enum.Enum):
FULL_EPISODE = "整集"
class OverheadCostType(str, enum.Enum):
OFFICE_RENT = "办公室租金"
UTILITIES = "水电费"
# ──────────────────────────── 角色 ────────────────────────────
class Role(Base):
__tablename__ = "roles"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(50), unique=True, nullable=False)
description = Column(String(200), nullable=True)
permissions = Column(JSON, nullable=False, default=[]) # 权限标识符列表
is_system = Column(Integer, nullable=False, default=0) # 1=内置角色不可删
created_at = Column(DateTime, server_default=func.now())
users = relationship("User", back_populates="role_ref")
def has_permission(self, perm: str) -> bool:
return perm in (self.permissions or [])
# ──────────────────────────── 用户 ────────────────────────────
class User(Base):
@ -83,20 +164,37 @@ class User(Base):
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)
role_id = Column(Integer, ForeignKey("roles.id"), nullable=False)
monthly_salary = Column(Float, nullable=False, default=0)
bonus = Column(Float, nullable=False, default=0)
social_insurance = Column(Float, nullable=False, default=0)
is_active = Column(Integer, nullable=False, default=1)
created_at = Column(DateTime, server_default=func.now())
# 关系
role_ref = relationship("Role", back_populates="users")
submissions = relationship("Submission", back_populates="user")
led_projects = relationship("Project", back_populates="leader")
@property
def role_name(self):
return self.role_ref.name if self.role_ref else ""
@property
def permissions(self):
return self.role_ref.permissions if self.role_ref else []
def has_permission(self, perm: str) -> bool:
return self.role_ref.has_permission(perm) if self.role_ref else False
@property
def monthly_total_cost(self):
return (self.monthly_salary or 0) + (self.bonus or 0) + (self.social_insurance or 0)
@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
return round(self.monthly_total_cost / WORKING_DAYS_PER_MONTH, 2) if self.monthly_total_cost else 0
# ──────────────────────────── 项目 ────────────────────────────
@ -114,10 +212,9 @@ class Project(Base):
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) # 仅客户正式项目
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")
@ -125,7 +222,6 @@ class Project(Base):
@property
def target_total_seconds(self):
"""目标总秒数 = 单集时长(分) × 60 × 集数"""
return int(self.episode_duration_minutes * 60 * self.episode_count)
@ -142,14 +238,13 @@ class Submission(Base):
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) # 可选:投入时长(小时)
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")
@ -165,23 +260,21 @@ class AIToolCost(Base):
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) # 指定项目时
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
percentage = Column(Float, nullable=False)
ai_tool_cost = relationship("AIToolCost", back_populates="allocations")
project = relationship("Project", back_populates="ai_tool_allocations")
@ -208,7 +301,6 @@ class OutsourceCost(Base):
# ──────────────────────────── 人力成本手动调整 ────────────────────────────
class CostOverride(Base):
"""管理员手动修改某人某天的成本分摊"""
__tablename__ = "cost_overrides"
id = Column(Integer, primary_key=True, index=True)
@ -224,7 +316,6 @@ class CostOverride(Base):
# ──────────────────────────── 提交历史版本 ────────────────────────────
class SubmissionHistory(Base):
"""内容提交的修改历史"""
__tablename__ = "submission_history"
id = Column(Integer, primary_key=True, index=True)
@ -236,3 +327,17 @@ class SubmissionHistory(Base):
created_at = Column(DateTime, server_default=func.now())
submission = relationship("Submission", back_populates="history")
# ──────────────────────────── 固定开支 ────────────────────────────
class OverheadCost(Base):
__tablename__ = "overhead_costs"
id = Column(Integer, primary_key=True, index=True)
cost_type = Column(SAEnum(OverheadCostType), nullable=False)
amount = Column(Float, nullable=False)
record_month = Column(String(7), nullable=False)
note = Column(Text, nullable=True)
recorded_by = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime, server_default=func.now())

View File

@ -27,8 +27,13 @@ def get_me(current_user: User = Depends(get_current_user)):
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,
role_id=current_user.role_id,
role_name=current_user.role_name,
permissions=current_user.permissions,
monthly_salary=current_user.monthly_salary,
bonus=current_user.bonus or 0,
social_insurance=current_user.social_insurance or 0,
monthly_total_cost=current_user.monthly_total_cost,
daily_cost=current_user.daily_cost,
is_active=current_user.is_active,
created_at=current_user.created_at,

View File

@ -5,14 +5,15 @@ 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
User, AIToolCost, AIToolCostAllocation, OutsourceCost,
CostOverride, OverheadCost, SubscriptionPeriod, CostAllocationType,
OutsourceType, OverheadCostType
)
from schemas import (
AIToolCostCreate, AIToolCostOut, OutsourceCostCreate, OutsourceCostOut,
CostOverrideCreate
CostOverrideCreate, OverheadCostCreate, OverheadCostOut
)
from auth import get_current_user, require_role
from auth import get_current_user, require_permission
router = APIRouter(prefix="/api/costs", tags=["成本管理"])
@ -22,7 +23,7 @@ router = APIRouter(prefix="/api/costs", tags=["成本管理"])
@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))
current_user: User = Depends(require_permission("cost:view"))
):
costs = db.query(AIToolCost).order_by(AIToolCost.record_date.desc()).all()
return [
@ -44,7 +45,7 @@ def list_ai_tool_costs(
def create_ai_tool_cost(
req: AIToolCostCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR, UserRole.LEADER))
current_user: User = Depends(require_permission("cost:create"))
):
cost = AIToolCost(
tool_name=req.tool_name,
@ -84,7 +85,7 @@ def create_ai_tool_cost(
def delete_ai_tool_cost(
cost_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_role(UserRole.OWNER))
current_user: User = Depends(require_permission("cost:delete"))
):
cost = db.query(AIToolCost).filter(AIToolCost.id == cost_id).first()
if not cost:
@ -101,7 +102,7 @@ def delete_ai_tool_cost(
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))
current_user: User = Depends(require_permission("cost:view"))
):
q = db.query(OutsourceCost)
if project_id:
@ -123,7 +124,7 @@ def list_outsource_costs(
def create_outsource_cost(
req: OutsourceCostCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR, UserRole.LEADER))
current_user: User = Depends(require_permission("cost:create"))
):
cost = OutsourceCost(
project_id=req.project_id,
@ -150,7 +151,7 @@ def create_outsource_cost(
def delete_outsource_cost(
cost_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_role(UserRole.OWNER))
current_user: User = Depends(require_permission("cost:delete"))
):
cost = db.query(OutsourceCost).filter(OutsourceCost.id == cost_id).first()
if not cost:
@ -166,7 +167,7 @@ def delete_outsource_cost(
def create_cost_override(
req: CostOverrideCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR))
current_user: User = Depends(require_permission("cost:create"))
):
override = CostOverride(
user_id=req.user_id,
@ -186,7 +187,7 @@ 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))
current_user: User = Depends(require_permission("cost:view"))
):
q = db.query(CostOverride)
if user_id:
@ -203,3 +204,66 @@ def list_cost_overrides(
}
for r in records
]
# ──────────────────── 固定开支(办公室租金、水电费) ────────────────────
@router.get("/overhead", response_model=List[OverheadCostOut])
def list_overhead_costs(
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("cost:view"))
):
costs = db.query(OverheadCost).order_by(OverheadCost.record_month.desc()).all()
return [
OverheadCostOut(
id=c.id,
cost_type=c.cost_type.value if hasattr(c.cost_type, 'value') else c.cost_type,
amount=c.amount,
record_month=c.record_month,
note=c.note,
recorded_by=c.recorded_by,
created_at=c.created_at,
)
for c in costs
]
@router.post("/overhead", response_model=OverheadCostOut)
def create_overhead_cost(
req: OverheadCostCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("cost:create"))
):
cost = OverheadCost(
cost_type=OverheadCostType(req.cost_type),
amount=req.amount,
record_month=req.record_month,
note=req.note,
recorded_by=current_user.id,
)
db.add(cost)
db.commit()
db.refresh(cost)
return OverheadCostOut(
id=cost.id,
cost_type=cost.cost_type.value,
amount=cost.amount,
record_month=cost.record_month,
note=cost.note,
recorded_by=cost.recorded_by,
created_at=cost.created_at,
)
@router.delete("/overhead/{cost_id}")
def delete_overhead_cost(
cost_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("cost:delete"))
):
cost = db.query(OverheadCost).filter(OverheadCost.id == cost_id).first()
if not cost:
raise HTTPException(status_code=404, detail="记录不存在")
db.delete(cost)
db.commit()
return {"message": "已删除"}

View File

@ -5,14 +5,15 @@ 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,
User, Project, Submission, AIToolCost,
ProjectStatus, ProjectType, WorkType
)
from auth import get_current_user, require_role
from auth import get_current_user, require_permission
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
calc_outsource_cost_for_project, calc_overhead_cost_for_project,
calc_team_efficiency
)
router = APIRouter(prefix="/api", tags=["仪表盘与结算"])
@ -21,7 +22,7 @@ router = APIRouter(prefix="/api", tags=["仪表盘与结算"])
@router.get("/dashboard")
def get_dashboard(
db: Session = Depends(get_db),
current_user: User = Depends(require_role(UserRole.OWNER))
current_user: User = Depends(require_permission("dashboard:view"))
):
"""全局仪表盘数据"""
# 项目概览
@ -133,15 +134,18 @@ def get_dashboard(
total_labor_all = 0.0
total_ai_all = 0.0
total_outsource_all = 0.0
total_overhead_all = 0.0
for p in active + completed:
total_labor_all += calc_labor_cost_for_project(p.id, db)
total_ai_all += calc_ai_tool_cost_for_project(p.id, db)
total_outsource_all += calc_outsource_cost_for_project(p.id, db)
total_overhead_all += calc_overhead_cost_for_project(p.id, db)
cost_breakdown = [
{"name": "人力成本", "value": round(total_labor_all, 0)},
{"name": "AI工具", "value": round(total_ai_all, 0)},
{"name": "外包", "value": round(total_outsource_all, 0)},
{"name": "固定开支", "value": round(total_overhead_all, 0)},
]
# ── 图表数据:各项目产出对比(进行中项目) ──
@ -157,6 +161,43 @@ def get_dashboard(
"target": p.target_total_seconds,
})
# ── 盈利概览 ──
total_contract = 0.0
total_cost_completed = 0.0
for s in settled:
if s.get("contract_amount"):
total_contract += s["contract_amount"]
total_cost_completed += s.get("total_cost", 0)
total_profit = total_contract - total_cost_completed
profit_rate = round(total_profit / total_contract * 100, 1) if total_contract > 0 else 0
# 进行中项目的合同额和当前成本
in_progress_contract = 0.0
in_progress_cost = 0.0
for p in active:
if p.contract_amount:
in_progress_contract += p.contract_amount
in_progress_cost += calc_labor_cost_for_project(p.id, db) + calc_ai_tool_cost_for_project(p.id, db) + calc_outsource_cost_for_project(p.id, db) + calc_overhead_cost_for_project(p.id, db)
# 每个项目的盈亏(用于柱状图)
profit_by_project = []
for s in settled:
if s.get("contract_amount") and s["contract_amount"] > 0:
profit_by_project.append({
"name": s["project_name"],
"profit": round((s.get("contract_amount", 0) or 0) - s.get("total_cost", 0), 0),
})
profitability = {
"total_contract": round(total_contract, 0),
"total_cost": round(total_cost_completed, 0),
"total_profit": round(total_profit, 0),
"profit_rate": profit_rate,
"in_progress_contract": round(in_progress_contract, 0),
"in_progress_cost": round(in_progress_cost, 0),
"profit_by_project": profit_by_project,
}
return {
"active_projects": len(active),
"completed_projects": len(completed),
@ -167,6 +208,7 @@ def get_dashboard(
"projects": project_summaries,
"waste_ranking": waste_ranking,
"settled_projects": settled,
"profitability": profitability,
# 图表数据
"daily_trend": daily_trend,
"cost_breakdown": cost_breakdown,
@ -178,7 +220,7 @@ def get_dashboard(
def get_settlement(
project_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_role(UserRole.OWNER))
current_user: User = Depends(require_permission("settlement:view"))
):
"""项目结算报告"""
project = db.query(Project).filter(Project.id == project_id).first()
@ -191,7 +233,7 @@ def get_settlement(
def get_efficiency(
project_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR, UserRole.LEADER))
current_user: User = Depends(require_permission("efficiency:view"))
):
"""项目团队效率数据"""
return calc_team_efficiency(project_id, db)

View File

@ -5,11 +5,11 @@ 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,
User, Project, Submission, ProjectType,
ProjectStatus, PhaseGroup, WorkType
)
from schemas import ProjectCreate, ProjectUpdate, ProjectOut
from auth import get_current_user, require_role
from auth import get_current_user, require_permission
router = APIRouter(prefix="/api/projects", tags=["项目管理"])
@ -76,7 +76,7 @@ def list_projects(
def create_project(
req: ProjectCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR))
current_user: User = Depends(require_permission("project:create"))
):
project = Project(
name=req.name,
@ -111,7 +111,7 @@ def update_project(
project_id: int,
req: ProjectUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR))
current_user: User = Depends(require_permission("project:edit"))
):
p = db.query(Project).filter(Project.id == project_id).first()
if not p:
@ -141,11 +141,36 @@ def update_project(
return enrich_project(p, db)
@router.delete("/{project_id}")
def delete_project(
project_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("project:delete"))
):
"""删除项目及其关联数据"""
from models import OutsourceCost, AIToolCostAllocation, CostOverride, SubmissionHistory
p = db.query(Project).filter(Project.id == project_id).first()
if not p:
raise HTTPException(status_code=404, detail="项目不存在")
# 删除关联数据
subs = db.query(Submission).filter(Submission.project_id == project_id).all()
for s in subs:
db.query(SubmissionHistory).filter(SubmissionHistory.submission_id == s.id).delete()
db.query(Submission).filter(Submission.project_id == project_id).delete()
db.query(OutsourceCost).filter(OutsourceCost.project_id == project_id).delete()
db.query(AIToolCostAllocation).filter(AIToolCostAllocation.project_id == project_id).delete()
db.query(CostOverride).filter(CostOverride.project_id == project_id).delete()
db.delete(p)
db.commit()
return {"message": "项目已删除"}
@router.post("/{project_id}/complete")
def complete_project(
project_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_role(UserRole.OWNER))
current_user: User = Depends(require_permission("project:complete"))
):
"""Owner 手动确认项目完成"""
p = db.query(Project).filter(Project.id == project_id).first()

113
backend/routers/roles.py Normal file
View File

@ -0,0 +1,113 @@
"""角色管理路由"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List
from database import get_db
from models import Role, User, ALL_PERMISSIONS, PERMISSION_KEYS
from auth import get_current_user, require_permission
router = APIRouter(prefix="/api/roles", tags=["角色管理"])
@router.get("/permissions")
def get_all_permissions(current_user: User = Depends(get_current_user)):
"""获取系统全部权限定义(供前端勾选面板使用)"""
groups = {}
for key, label, group in ALL_PERMISSIONS:
if group not in groups:
groups[group] = []
groups[group].append({"key": key, "label": label})
return [{"group": g, "permissions": perms} for g, perms in groups.items()]
@router.get("/")
def list_roles(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
roles = db.query(Role).order_by(Role.is_system.desc(), Role.id).all()
return [
{
"id": r.id,
"name": r.name,
"description": r.description,
"permissions": r.permissions or [],
"is_system": bool(r.is_system),
"user_count": db.query(User).filter(User.role_id == r.id).count(),
"created_at": r.created_at,
}
for r in roles
]
@router.post("/")
def create_role(
req: dict,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("role:manage"))
):
name = req.get("name", "").strip()
if not name:
raise HTTPException(status_code=400, detail="角色名称不能为空")
if db.query(Role).filter(Role.name == name).first():
raise HTTPException(status_code=400, detail="角色名称已存在")
perms = [p for p in req.get("permissions", []) if p in PERMISSION_KEYS]
role = Role(
name=name,
description=req.get("description", ""),
permissions=perms,
is_system=0,
)
db.add(role)
db.commit()
db.refresh(role)
return {"id": role.id, "name": role.name, "message": "角色已创建"}
@router.put("/{role_id}")
def update_role(
role_id: int,
req: dict,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("role:manage"))
):
role = db.query(Role).filter(Role.id == role_id).first()
if not role:
raise HTTPException(status_code=404, detail="角色不存在")
name = req.get("name")
if name is not None:
name = name.strip()
existing = db.query(Role).filter(Role.name == name, Role.id != role_id).first()
if existing:
raise HTTPException(status_code=400, detail="角色名称已存在")
role.name = name
if "description" in req:
role.description = req["description"]
if "permissions" in req:
role.permissions = [p for p in req["permissions"] if p in PERMISSION_KEYS]
db.commit()
return {"message": "角色已更新"}
@router.delete("/{role_id}")
def delete_role(
role_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("role:manage"))
):
role = db.query(Role).filter(Role.id == role_id).first()
if not role:
raise HTTPException(status_code=404, detail="角色不存在")
if role.is_system:
raise HTTPException(status_code=400, detail="系统内置角色不可删除")
user_count = db.query(User).filter(User.role_id == role_id).count()
if user_count > 0:
raise HTTPException(status_code=400, detail=f"该角色下还有 {user_count} 个用户,请先转移用户再删除")
db.delete(role)
db.commit()
return {"message": "角色已删除"}

View File

@ -5,11 +5,11 @@ from typing import List, Optional
from datetime import date
from database import get_db
from models import (
User, Submission, SubmissionHistory, Project, UserRole,
User, Submission, SubmissionHistory, Project,
PhaseGroup, WorkType, ContentType, SubmitTo
)
from schemas import SubmissionCreate, SubmissionUpdate, SubmissionOut
from auth import get_current_user, require_role
from auth import get_current_user, require_permission
router = APIRouter(prefix="/api/submissions", tags=["内容提交"])
@ -44,8 +44,8 @@ def list_submissions(
current_user: User = Depends(get_current_user)
):
q = db.query(Submission)
# 成员只能看自己的
if current_user.role == UserRole.MEMBER:
# 没有 user:view 权限的只能看自己的
if not current_user.has_permission("user:view"):
q = q.filter(Submission.user_id == current_user.id)
elif user_id:
q = q.filter(Submission.user_id == user_id)
@ -98,7 +98,7 @@ 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))
current_user: User = Depends(require_permission("submission:create"))
):
"""高权限修改提交记录(需填写原因)"""
sub = db.query(Submission).filter(Submission.id == submission_id).first()
@ -174,7 +174,7 @@ def update_submission(
def get_submission_history(
submission_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR, UserRole.LEADER))
current_user: User = Depends(require_permission("submission:view"))
):
"""查看提交的修改历史"""
records = db.query(SubmissionHistory).filter(

View File

@ -3,9 +3,9 @@ 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 models import User, Role, PhaseGroup
from schemas import UserCreate, UserUpdate, UserOut
from auth import get_current_user, hash_password, require_role
from auth import get_current_user, hash_password, require_permission
router = APIRouter(prefix="/api/users", tags=["用户管理"])
@ -14,8 +14,12 @@ 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,
role_id=u.role_id, role_name=u.role_name, permissions=u.permissions,
monthly_salary=u.monthly_salary,
bonus=u.bonus or 0,
social_insurance=u.social_insurance or 0,
monthly_total_cost=u.monthly_total_cost,
daily_cost=u.daily_cost,
is_active=u.is_active, created_at=u.created_at,
)
@ -23,7 +27,7 @@ def user_to_out(u: User) -> UserOut:
@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))
current_user: User = Depends(require_permission("user:view"))
):
users = db.query(User).order_by(User.created_at.desc()).all()
return [user_to_out(u) for u in users]
@ -33,7 +37,7 @@ def list_users(
def create_user(
req: UserCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_role(UserRole.OWNER))
current_user: User = Depends(require_permission("user:manage"))
):
if db.query(User).filter(User.username == req.username).first():
raise HTTPException(status_code=400, detail="用户名已存在")
@ -42,8 +46,10 @@ def create_user(
password_hash=hash_password(req.password),
name=req.name,
phase_group=PhaseGroup(req.phase_group),
role=UserRole(req.role),
role_id=req.role_id,
monthly_salary=req.monthly_salary,
bonus=req.bonus,
social_insurance=req.social_insurance,
)
db.add(user)
db.commit()
@ -56,7 +62,7 @@ def update_user(
user_id: int,
req: UserUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_role(UserRole.OWNER))
current_user: User = Depends(require_permission("user:manage"))
):
user = db.query(User).filter(User.id == user_id).first()
if not user:
@ -65,10 +71,14 @@ def update_user(
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.role_id is not None:
user.role_id = req.role_id
if req.monthly_salary is not None:
user.monthly_salary = req.monthly_salary
if req.bonus is not None:
user.bonus = req.bonus
if req.social_insurance is not None:
user.social_insurance = req.social_insurance
if req.is_active is not None:
user.is_active = req.is_active
db.commit()

View File

@ -23,15 +23,19 @@ class UserCreate(BaseModel):
password: str
name: str
phase_group: str # 前期/制作/后期
role: str = "成员"
role_id: int
monthly_salary: float = 0
bonus: float = 0
social_insurance: float = 0
class UserUpdate(BaseModel):
name: Optional[str] = None
phase_group: Optional[str] = None
role: Optional[str] = None
role_id: Optional[int] = None
monthly_salary: Optional[float] = None
bonus: Optional[float] = None
social_insurance: Optional[float] = None
is_active: Optional[int] = None
@ -40,8 +44,13 @@ class UserOut(BaseModel):
username: str
name: str
phase_group: str
role: str
role_id: int
role_name: str
permissions: List[str] = []
monthly_salary: float
bonus: float
social_insurance: float
monthly_total_cost: float
daily_cost: float
is_active: int
created_at: Optional[datetime] = None
@ -214,6 +223,28 @@ class CostOverrideCreate(BaseModel):
reason: Optional[str] = None
# ──────────────────────────── 固定开支 ────────────────────────────
class OverheadCostCreate(BaseModel):
cost_type: str # 办公室租金/水电费
amount: float
record_month: str # YYYY-MM
note: Optional[str] = None
class OverheadCostOut(BaseModel):
id: int
cost_type: str
amount: float
record_month: str
note: Optional[str] = None
recorded_by: int
created_at: Optional[datetime] = None
class Config:
from_attributes = True
# ──────────────────────────── 仪表盘 ────────────────────────────
class DashboardSummary(BaseModel):

View File

@ -14,22 +14,35 @@ def seed():
db.execute(table.delete())
db.commit()
# ── 初始化内置角色 ──
roles = {}
for role_name, role_def in BUILTIN_ROLES.items():
role = Role(
name=role_name,
description=role_def["description"],
permissions=role_def["permissions"],
is_system=1,
)
db.add(role)
roles[role_name] = role
db.flush()
# ── 用户 ──
users = [
User(username="admin", password_hash=hash_password("admin123"),
name="老板", phase_group=PhaseGroup.PRODUCTION, role=UserRole.OWNER, monthly_salary=0),
name="老板", phase_group=PhaseGroup.PRODUCTION, role_id=roles["超级管理员"].id, monthly_salary=0),
User(username="zhangsan", password_hash=hash_password("123456"),
name="张三", phase_group=PhaseGroup.PRE, role=UserRole.LEADER, monthly_salary=15000),
name="张三", phase_group=PhaseGroup.PRE, role_id=roles["组长"].id, monthly_salary=15000),
User(username="lisi", password_hash=hash_password("123456"),
name="李四", phase_group=PhaseGroup.PRODUCTION, role=UserRole.LEADER, monthly_salary=18000),
name="李四", phase_group=PhaseGroup.PRODUCTION, role_id=roles["组长"].id, monthly_salary=18000),
User(username="wangwu", password_hash=hash_password("123456"),
name="王五", phase_group=PhaseGroup.PRODUCTION, role=UserRole.MEMBER, monthly_salary=12000),
name="王五", phase_group=PhaseGroup.PRODUCTION, role_id=roles["成员"].id, monthly_salary=12000),
User(username="zhaoliu", password_hash=hash_password("123456"),
name="赵六", phase_group=PhaseGroup.PRODUCTION, role=UserRole.MEMBER, monthly_salary=12000),
name="赵六", phase_group=PhaseGroup.PRODUCTION, role_id=roles["成员"].id, monthly_salary=12000),
User(username="sunqi", password_hash=hash_password("123456"),
name="孙七", phase_group=PhaseGroup.POST, role=UserRole.MEMBER, monthly_salary=13000),
name="孙七", phase_group=PhaseGroup.POST, role_id=roles["成员"].id, monthly_salary=13000),
User(username="producer", password_hash=hash_password("123456"),
name="陈制片", phase_group=PhaseGroup.PRODUCTION, role=UserRole.SUPERVISOR, monthly_salary=20000),
name="陈制片", phase_group=PhaseGroup.PRODUCTION, role_id=roles["主管"].id, monthly_salary=20000),
]
db.add_all(users)
db.flush()
@ -69,7 +82,6 @@ def seed():
base_date = date.today() - timedelta(days=14)
submissions = []
# 张三(前期组)给项目 A 和 D 做前期
for i in range(5):
d = base_date + timedelta(days=i)
submissions.append(Submission(
@ -89,10 +101,9 @@ def seed():
submit_date=d,
))
# 李四(制作组组长)主要做项目 A
for i in range(10):
d = base_date + timedelta(days=i)
secs = 45 + (i % 3) * 15 # 45-75秒
secs = 45 + (i % 3) * 15
wt = WorkType.TEST if i < 2 else WorkType.PRODUCTION
submissions.append(Submission(
user_id=lisi.id, project_id=proj_a.id,
@ -103,10 +114,9 @@ def seed():
submit_date=d,
))
# 王五(制作组)做项目 A 和 B
for i in range(8):
d = base_date + timedelta(days=i)
secs = 30 + (i % 4) * 20 # 30-90秒
secs = 30 + (i % 4) * 20
submissions.append(Submission(
user_id=wangwu.id, project_id=proj_a.id,
project_phase=PhaseGroup.PRODUCTION, work_type=WorkType.PRODUCTION,
@ -127,10 +137,9 @@ def seed():
submit_date=d,
))
# 赵六(制作组)做项目 A
for i in range(10):
d = base_date + timedelta(days=i)
secs = 50 + (i % 2) * 30 # 50-80秒
secs = 50 + (i % 2) * 30
wt = WorkType.TEST if i < 1 else WorkType.PRODUCTION
submissions.append(Submission(
user_id=zhaoliu.id, project_id=proj_a.id,
@ -141,7 +150,6 @@ def seed():
submit_date=d,
))
# 孙七(后期组)剪辑
for i in range(3):
d = base_date + timedelta(days=i + 10)
submissions.append(Submission(
@ -151,7 +159,6 @@ def seed():
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,
@ -163,7 +170,6 @@ def seed():
db.add_all(submissions)
# ── AI 工具成本 ──
db.add(AIToolCost(
tool_name="Midjourney", subscription_period=SubscriptionPeriod.MONTHLY,
amount=200, allocation_type=CostAllocationType.TEAM,
@ -176,7 +182,6 @@ def seed():
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,
@ -185,6 +190,7 @@ def seed():
db.commit()
print("[OK] seed data generated")
print(f" - roles: {len(roles)}")
print(f" - users: {len(users)}")
print(f" - projects: 4")
print(f" - submissions: {len(submissions)}")

View File

@ -57,6 +57,7 @@ export const projectApi = {
create: (data) => api.post('/projects/', data),
update: (id, data) => api.put(`/projects/${id}`, data),
get: (id) => api.get(`/projects/${id}`),
delete: (id) => api.delete(`/projects/${id}`),
complete: (id) => api.post(`/projects/${id}/complete`),
settlement: (id) => api.get(`/projects/${id}/settlement`),
efficiency: (id) => api.get(`/projects/${id}/efficiency`),
@ -80,6 +81,18 @@ export const costApi = {
deleteOutsource: (id) => api.delete(`/costs/outsource/${id}`),
createOverride: (data) => api.post('/costs/overrides', data),
listOverrides: (params) => api.get('/costs/overrides', { params }),
listOverhead: () => api.get('/costs/overhead'),
createOverhead: (data) => api.post('/costs/overhead', data),
deleteOverhead: (id) => api.delete(`/costs/overhead/${id}`),
}
// ── 角色 ──
export const roleApi = {
list: () => api.get('/roles/'),
create: (data) => api.post('/roles/', data),
update: (id, data) => api.put(`/roles/${id}`, data),
delete: (id) => api.delete(`/roles/${id}`),
permissions: () => api.get('/roles/permissions'),
}
// ── 仪表盘 ──

View File

@ -19,7 +19,7 @@
:to="item.path"
class="nav-item"
:class="{ active: isActive(item.path) }"
v-show="!item.role || hasRole(item.role)"
v-show="!item.perm || authStore.hasPermission(item.perm)"
>
<el-icon :size="18"><component :is="item.icon" /></el-icon>
<span v-show="!isCollapsed" class="nav-label">{{ item.label }}</span>
@ -32,7 +32,7 @@
<div class="user-avatar">{{ authStore.user?.name?.[0] || '?' }}</div>
<div class="user-meta">
<div class="user-name">{{ authStore.user?.name }}</div>
<div class="user-role">{{ authStore.user?.role }}</div>
<div class="user-role">{{ authStore.user?.role_name }}</div>
</div>
</div>
</div>
@ -69,11 +69,12 @@ const authStore = useAuthStore()
const isCollapsed = ref(false)
const menuItems = [
{ path: '/dashboard', label: '仪表盘', icon: 'Odometer', role: 'Owner' },
{ path: '/projects', label: '项目管理', icon: 'FolderOpened' },
{ path: '/submissions', label: '内容提交', icon: 'EditPen' },
{ path: '/costs', label: '成本管理', icon: 'Money', role: 'leader+' },
{ path: '/users', label: '用户管理', icon: 'User', role: 'Owner' },
{ path: '/dashboard', label: '仪表盘', icon: 'Odometer', perm: 'dashboard:view' },
{ path: '/projects', label: '项目管理', icon: 'FolderOpened', perm: 'project:view' },
{ path: '/submissions', label: '内容提交', icon: 'EditPen', perm: 'submission:view' },
{ path: '/costs', label: '成本管理', icon: 'Money', perm: 'cost:view' },
{ path: '/users', label: '用户管理', icon: 'User', perm: 'user:manage' },
{ path: '/roles', label: '角色管理', icon: 'Lock', perm: 'role:manage' },
]
const titleMap = {
@ -82,6 +83,7 @@ const titleMap = {
'/submissions': '内容提交',
'/costs': '成本管理',
'/users': '用户管理',
'/roles': '角色管理',
}
const currentTitle = computed(() => {
@ -94,12 +96,6 @@ function isActive(path) {
return route.path === path || route.path.startsWith(path + '/')
}
function hasRole(role) {
if (role === 'Owner') return authStore.isOwner()
if (role === 'leader+') return authStore.isLeaderOrAbove()
return true
}
onMounted(async () => {
if (authStore.token && !authStore.user) {
await authStore.fetchUser()

View File

@ -5,15 +5,17 @@ const routes = [
{
path: '/',
component: () => import('../components/Layout.vue'),
redirect: '/dashboard',
redirect: '/projects',
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'] } },
{ path: 'dashboard', name: 'Dashboard', component: () => import('../views/Dashboard.vue'), meta: { perm: 'dashboard:view' } },
{ path: 'projects', name: 'Projects', component: () => import('../views/Projects.vue'), meta: { perm: 'project:view' } },
{ path: 'projects/:id', name: 'ProjectDetail', component: () => import('../views/ProjectDetail.vue'), meta: { perm: 'project:view' } },
{ path: 'submissions', name: 'Submissions', component: () => import('../views/Submissions.vue'), meta: { perm: 'submission:view' } },
{ path: 'costs', name: 'Costs', component: () => import('../views/Costs.vue'), meta: { perm: 'cost:view' } },
{ path: 'users', name: 'Users', component: () => import('../views/Users.vue'), meta: { perm: 'user:manage' } },
{ path: 'users/:id/detail', name: 'MemberDetail', component: () => import('../views/MemberDetail.vue'), meta: { perm: 'user:view' } },
{ path: 'roles', name: 'Roles', component: () => import('../views/Roles.vue'), meta: { perm: 'role:manage' } },
{ path: 'settlement/:id', name: 'Settlement', component: () => import('../views/Settlement.vue'), meta: { perm: 'settlement:view' } },
],
},
]
@ -26,18 +28,43 @@ const router = createRouter({
// 路由守卫
router.beforeEach(async (to, from, next) => {
const token = localStorage.getItem('token')
if (to.meta.public) {
// 已登录时访问登录页,直接跳首页
if (to.path === '/login' && token) {
next('/')
} else {
next()
}
} else if (!token) {
next('/login')
} else {
next()
return
}
if (!token) {
next('/login')
return
}
// 有 token 但还没加载用户信息(刷新页面时),先加载
const { useAuthStore } = await import('../stores/auth')
const authStore = useAuthStore()
if (!authStore.user) {
await authStore.fetchUser()
}
// fetchUser 失败会 logout 清 token
if (!localStorage.getItem('token')) {
next('/login')
return
}
// 权限校验:如果路由要求特定权限,且用户没有,跳到第一个有权限的页面
if (to.meta.perm && !authStore.hasPermission(to.meta.perm)) {
// 找到第一个有权限的页面
const fallback = routes[1].children.find(r => !r.meta?.perm || authStore.hasPermission(r.meta.perm))
next(fallback ? '/' + fallback.path : '/login')
return
}
next()
})
export default router

View File

@ -10,7 +10,6 @@ export const useAuthStore = defineStore('auth', () => {
const res = await authApi.login({ username, password })
token.value = res.access_token
localStorage.setItem('token', res.access_token)
// 登录后立即获取用户信息,失败不影响登录流程
try {
user.value = await authApi.me()
} catch (e) {
@ -33,9 +32,13 @@ export const useAuthStore = defineStore('auth', () => {
localStorage.removeItem('token')
}
const isOwner = () => user.value?.role === 'Owner'
const isSupervisor = () => ['Owner', '主管'].includes(user.value?.role)
const isLeaderOrAbove = () => ['Owner', '主管', '组长'].includes(user.value?.role)
/**
* 核心权限判断方法
* @param {string} perm - 权限标识符 'dashboard:view'
*/
function hasPermission(perm) {
return (user.value?.permissions || []).includes(perm)
}
return { user, token, login, fetchUser, logout, isOwner, isSupervisor, isLeaderOrAbove }
return { user, token, login, fetchUser, logout, hasPermission }
})

View File

@ -16,7 +16,7 @@
</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()">
<el-table-column label="操作" width="80" v-if="authStore.hasPermission('cost:delete')">
<template #default="{row}"><el-button text type="danger" size="small" @click="deleteAI(row.id)">删除</el-button></template>
</el-table-column>
</el-table>
@ -39,11 +39,29 @@
<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()">
<el-table-column label="操作" width="80" v-if="authStore.hasPermission('cost:delete')">
<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-tab-pane label="固定开支" name="overhead">
<div class="tab-header">
<el-button type="primary" size="small" @click="showOHForm = true"><el-icon><Plus /></el-icon> 新增</el-button>
</div>
<el-table :data="overheadCosts" v-loading="loadingOH" stripe size="small">
<el-table-column prop="cost_type" label="费用类型" width="140" />
<el-table-column label="金额" width="120" align="right">
<template #default="{row}">¥{{ row.amount.toLocaleString() }}</template>
</el-table-column>
<el-table-column prop="record_month" label="所属月份" width="110" />
<el-table-column prop="note" label="备注" show-overflow-tooltip />
<el-table-column label="操作" width="80" v-if="authStore.hasPermission('cost:delete')">
<template #default="{row}"><el-button text type="danger" size="small" @click="deleteOH(row.id)">删除</el-button></template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
<!-- AI 工具新增弹窗 -->
@ -112,6 +130,31 @@
<el-button type="primary" @click="createOut">保存</el-button>
</template>
</el-dialog>
<!-- 固定开支新增弹窗 -->
<el-dialog v-model="showOHForm" title="新增固定开支" width="480px" destroy-on-close>
<el-form :model="ohForm" label-width="100px">
<el-form-item label="费用类型">
<el-select v-model="ohForm.cost_type" placeholder="选择开支类型" 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="ohForm.amount" :min="0" :controls="false" placeholder="当月费用(元)" style="width:100%" />
</el-form-item>
<el-form-item label="所属月份">
<el-date-picker v-model="ohForm.record_month" type="month" value-format="YYYY-MM" placeholder="选择月份" style="width:100%" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="ohForm.note" placeholder="(选填)补充说明" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showOHForm = false">取消</el-button>
<el-button type="primary" @click="createOH">保存</el-button>
</template>
</el-dialog>
</div>
</template>
@ -125,22 +168,28 @@ const authStore = useAuthStore()
const activeTab = ref('ai')
const loadingAI = ref(false)
const loadingOut = ref(false)
const loadingOH = ref(false)
const aiCosts = ref([])
const outCosts = ref([])
const overheadCosts = ref([])
const projects = ref([])
const showAIForm = ref(false)
const showOutForm = ref(false)
const showOHForm = 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 currentMonth = today.slice(0, 7)
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 })
const ohForm = reactive({ cost_type: '办公室租金', amount: 0, record_month: currentMonth, note: '' })
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 loadOH() { loadingOH.value = true; try { overheadCosts.value = await costApi.listOverhead() } finally { loadingOH.value = false } }
async function createAI() {
await costApi.createAITool(aiForm); ElMessage.success('已添加'); showAIForm.value = false; loadAI()
@ -148,15 +197,21 @@ async function createAI() {
async function createOut() {
await costApi.createOutsource(outForm); ElMessage.success('已添加'); showOutForm.value = false; loadOut()
}
async function createOH() {
await costApi.createOverhead(ohForm); ElMessage.success('已添加'); showOHForm.value = false; loadOH()
}
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()
}
async function deleteOH(id) {
await ElMessageBox.confirm('确认删除?'); await costApi.deleteOverhead(id); ElMessage.success('已删除'); loadOH()
}
onMounted(async () => {
loadAI(); loadOut()
loadAI(); loadOut(); loadOH()
try { projects.value = await projectApi.list({}) } catch {}
})
</script>

View File

@ -1,7 +1,7 @@
<template>
<div class="dashboard" v-loading="loading">
<!-- 顶部统计卡片 -->
<div class="stat-grid">
<div class="stat-grid six">
<div class="stat-card">
<div class="stat-icon blue"><el-icon :size="20"><FolderOpened /></el-icon></div>
<div class="stat-body">
@ -30,6 +30,28 @@
<div class="stat-label">人均日产出</div>
</div>
</div>
<div class="stat-card" v-if="data.profitability">
<div class="stat-icon" :class="data.profitability.total_profit >= 0 ? 'green' : 'red'">
<el-icon :size="20"><Coin /></el-icon>
</div>
<div class="stat-body">
<div class="stat-value" :class="{ profit: data.profitability.total_profit >= 0, loss: data.profitability.total_profit < 0 }">
{{ data.profitability.total_profit >= 0 ? '+' : '' }}¥{{ formatNum(data.profitability.total_profit) }}
</div>
<div class="stat-label">已结算利润</div>
</div>
</div>
<div class="stat-card" v-if="data.profitability">
<div class="stat-icon" :class="data.profitability.profit_rate >= 0 ? 'green' : 'red'">
<el-icon :size="20"><DataAnalysis /></el-icon>
</div>
<div class="stat-body">
<div class="stat-value" :class="{ profit: data.profitability.profit_rate >= 0, loss: data.profitability.profit_rate < 0 }">
{{ data.profitability.profit_rate }}%
</div>
<div class="stat-label">利润率</div>
</div>
</div>
</div>
<!-- 图表行产出趋势 + 成本构成 -->
@ -82,18 +104,37 @@
</div>
</div>
<!-- 盈利分析图表 -->
<div class="chart-row" v-if="data.profitability?.profit_by_project?.length">
<div class="card chart-card full-width">
<div class="card-header">
<span class="card-title">项目盈亏分析</span>
<span class="card-count" v-if="data.profitability">
合同总额 ¥{{ formatNum(data.profitability.total_contract) }} · 总成本 ¥{{ formatNum(data.profitability.total_cost) }}
</span>
</div>
<div class="card-body"><div ref="profitChartRef" class="chart-container"></div></div>
</div>
</div>
<!-- 已结算项目 -->
<div class="card" v-if="data.settled_projects?.length">
<div class="card-header"><span class="card-title">已结算项目</span></div>
<div class="card-body">
<el-table :data="data.settled_projects" size="small">
<el-table-column prop="project_name" label="项目" />
<el-table-column label="合同金额" align="right" width="120">
<template #default="{ row }">
<span v-if="row.contract_amount">¥{{ formatNum(row.contract_amount) }}</span>
<span v-else class="text-muted"></span>
</template>
</el-table-column>
<el-table-column label="总成本" align="right" width="120">
<template #default="{ row }">¥{{ formatNum(row.total_cost) }}</template>
</el-table-column>
<el-table-column label="盈亏" align="right" width="120">
<template #default="{ row }">
<span v-if="row.profit_loss != null" class="profit" :class="{ loss: row.profit_loss < 0 }">
<span v-if="row.profit_loss != null" class="profit-text" :class="{ loss: row.profit_loss < 0 }">
{{ row.profit_loss >= 0 ? '+' : '' }}¥{{ formatNum(row.profit_loss) }}
</span>
<span v-else class="text-muted"></span>
@ -118,8 +159,9 @@ const trendChartRef = ref(null)
const costChartRef = ref(null)
const comparisonChartRef = ref(null)
const wasteChartRef = ref(null)
const profitChartRef = ref(null)
let trendChart, costChart, comparisonChart, wasteChart
let trendChart, costChart, comparisonChart, wasteChart, profitChart
function formatNum(n) { return (n || 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 }) }
function formatSecs(s) {
@ -226,11 +268,34 @@ function initWasteChart(ranking) {
})
}
function initProfitChart(profitData) {
if (!profitChartRef.value || !profitData?.length) return
profitChart = echarts.init(profitChartRef.value)
const names = profitData.map(p => p.name.length > 10 ? p.name.slice(0, 10) + '…' : p.name)
profitChart.setOption({
tooltip: { trigger: 'axis', formatter: p => `${p[0].name}<br/>盈亏 <b>${p[0].value >= 0 ? '+' : ''}¥${p[0].value.toLocaleString()}</b>` },
grid: { left: 12, right: 24, top: 16, bottom: 16, containLabel: true },
xAxis: { type: 'value', axisLabel: { fontSize: 11, color: '#8F959E', formatter: v => v >= 1000 ? (v/1000) + 'k' : v }, splitLine: { lineStyle: { color: '#F0F1F2' } } },
yAxis: { type: 'category', data: names, axisLabel: { fontSize: 12, color: '#3B3F46' }, axisLine: { show: false }, axisTick: { show: false } },
series: [{
type: 'bar', barWidth: 16,
data: profitData.map(p => ({
value: p.profit,
itemStyle: {
color: p.profit >= 0 ? '#34C759' : '#FF3B30',
borderRadius: p.profit >= 0 ? [0, 4, 4, 0] : [4, 0, 0, 4],
},
})),
}],
})
}
function handleResize() {
trendChart?.resize()
costChart?.resize()
comparisonChart?.resize()
wasteChart?.resize()
profitChart?.resize()
}
onMounted(async () => {
@ -242,6 +307,9 @@ onMounted(async () => {
initCostChart(data.value.cost_breakdown)
initComparisonChart(data.value.project_comparison)
initWasteChart(data.value.waste_ranking)
if (data.value.profitability?.profit_by_project?.length) {
initProfitChart(data.value.profitability.profit_by_project)
}
window.addEventListener('resize', handleResize)
} finally {
loading.value = false
@ -254,12 +322,15 @@ onUnmounted(() => {
costChart?.dispose()
comparisonChart?.dispose()
wasteChart?.dispose()
profitChart?.dispose()
})
</script>
<style scoped>
/* 统计网格 */
.stat-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 20px; }
.stat-grid.six { grid-template-columns: repeat(6, 1fr); }
@media (max-width: 1200px) { .stat-grid.six { grid-template-columns: repeat(3, 1fr); } }
.stat-card {
background: var(--bg-card); border: 1px solid var(--border-color);
border-radius: var(--radius-md); padding: 20px; display: flex; align-items: center; gap: 16px;
@ -269,6 +340,7 @@ onUnmounted(() => {
.stat-icon.orange { background: #FFF3E0; color: #FF9500; }
.stat-icon.green { background: #E8F8EE; color: #34C759; }
.stat-icon.purple { background: #F0E8FE; color: #9B59B6; }
.stat-icon.red { background: #FFE8E7; color: #FF3B30; }
.stat-body { flex: 1; }
.stat-value { font-size: 22px; font-weight: 700; color: var(--text-primary); line-height: 1.2; }
.stat-label { font-size: 12px; color: var(--text-secondary); margin-top: 4px; }
@ -279,6 +351,7 @@ onUnmounted(() => {
.chart-card.wide { flex: 2; }
.chart-card.narrow { flex: 1; }
.chart-card.half { flex: 1; }
.chart-card.full-width { flex: 1; }
.chart-container { width: 100%; height: 260px; }
/* 卡片 */
@ -307,7 +380,9 @@ onUnmounted(() => {
.progress-pct { font-size: 14px; font-weight: 600; color: var(--primary); }
.progress-meta { display: flex; justify-content: space-between; font-size: 12px; color: var(--text-secondary); margin-top: 6px; }
.profit { font-weight: 600; color: #34C759; }
.profit.loss { color: #FF3B30; }
.stat-value.profit { color: #34C759; }
.stat-value.loss { color: #FF3B30; }
.profit-text { font-weight: 600; color: #34C759; }
.profit-text.loss { color: #FF3B30; }
.text-muted { color: var(--text-secondary); }
</style>

View File

@ -23,7 +23,7 @@
<el-form-item>
<el-input v-model="form.password" placeholder="密码" type="password" size="large" show-password />
</el-form-item>
<el-button type="primary" size="large" :loading="loading" @click="handleLogin" class="login-btn">
<el-button type="primary" size="large" :loading="loading" native-type="submit" class="login-btn">
</el-button>
</el-form>

View File

@ -0,0 +1,280 @@
<template>
<div v-loading="loading">
<div class="page-header">
<div class="page-header-left">
<el-button text @click="$router.back()" class="back-btn"><el-icon><ArrowLeft /></el-icon></el-button>
<h2>{{ member.name || '成员详情' }}</h2>
<el-tag size="small">{{ member.role_name }}</el-tag>
<el-tag size="small" type="info">{{ member.phase_group }}</el-tag>
</div>
</div>
<!-- 基本信息 -->
<div class="card info-card">
<div class="info-grid">
<div class="info-item">
<span class="info-label">日成本</span>
<span class="info-value">¥{{ member.daily_cost || 0 }}</span>
</div>
<div class="info-item">
<span class="info-label">月总成本</span>
<span class="info-value">¥{{ (member.monthly_total_cost || 0).toLocaleString() }}</span>
</div>
<div class="info-item">
<span class="info-label">总提交</span>
<span class="info-value">{{ allSubmissions.length }} </span>
</div>
<div class="info-item">
<span class="info-label">总产出</span>
<span class="info-value">{{ formatSecs(totalOutputSecs) }}</span>
</div>
<div class="info-item">
<span class="info-label">活跃天数</span>
<span class="info-value">{{ activeDays }} </span>
</div>
<div class="info-item">
<span class="info-label">日均产出</span>
<span class="info-value">{{ formatSecs(avgDailySecs) }}</span>
</div>
</div>
</div>
<!-- 90天日历热力图 -->
<div class="card">
<div class="card-header"><span class="card-title"> 90 天提交热力图</span></div>
<div class="card-body">
<div class="calendar-heatmap" v-if="calendarData.length">
<div class="calendar-row">
<div v-for="week in calendarWeeks" :key="week[0]" class="calendar-week">
<div v-for="day in week" :key="day.date" class="calendar-day"
:class="getDayClass(day)" :title="getDayTitle(day)">
</div>
</div>
</div>
<div class="calendar-legend">
<span class="legend-text"></span>
<span class="calendar-day level-0"></span>
<span class="calendar-day level-1"></span>
<span class="calendar-day level-2"></span>
<span class="calendar-day level-3"></span>
<span class="calendar-day level-4"></span>
<span class="legend-text"></span>
</div>
</div>
<el-empty v-else description="暂无提交数据" :image-size="60" />
</div>
</div>
<!-- 按项目分组的提交 -->
<div class="card">
<div class="card-header"><span class="card-title">按项目统计</span></div>
<div class="card-body" v-if="projectGroups.length">
<div v-for="pg in projectGroups" :key="pg.projectId" class="project-group">
<div class="project-group-header" @click="pg.expanded = !pg.expanded">
<div class="project-group-info">
<span class="project-group-name">{{ pg.projectName }}</span>
<el-tag size="small" type="info">{{ pg.submissions.length }} 次提交</el-tag>
<span class="project-group-secs">总产出 {{ formatSecs(pg.totalSecs) }}</span>
</div>
<el-icon :class="{ rotated: pg.expanded }"><ArrowRight /></el-icon>
</div>
<transition name="expand">
<div v-if="pg.expanded" class="project-group-body">
<el-table :data="pg.submissions" size="small">
<el-table-column prop="submit_date" label="日期" width="110" />
<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="100" />
<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>
</div>
</transition>
</div>
</div>
<div class="card-body" v-else>
<el-empty description="暂无提交记录" :image-size="60" />
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, reactive, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { userApi, submissionApi } from '../api'
const route = useRoute()
const loading = ref(false)
const member = ref({})
const allSubmissions = ref([])
const totalOutputSecs = computed(() => allSubmissions.value.reduce((s, r) => s + (r.total_seconds || 0), 0))
const activeDays = computed(() => {
const dates = new Set(allSubmissions.value.map(s => s.submit_date))
return dates.size
})
const avgDailySecs = computed(() => {
return activeDays.value > 0 ? Math.round(totalOutputSecs.value / activeDays.value) : 0
})
// 90
const calendarData = computed(() => {
const result = []
const today = new Date()
for (let i = 89; i >= 0; i--) {
const d = new Date(today)
d.setDate(d.getDate() - i)
result.push({ date: d.toISOString().slice(0, 10), secs: 0, count: 0 })
}
//
const dateMap = {}
result.forEach((d, idx) => { dateMap[d.date] = idx })
allSubmissions.value.forEach(s => {
const idx = dateMap[s.submit_date]
if (idx !== undefined) {
result[idx].secs += s.total_seconds || 0
result[idx].count += 1
}
})
return result
})
const calendarWeeks = computed(() => {
const weeks = []
const data = calendarData.value
let week = []
//
const firstDay = new Date(data[0]?.date).getDay()
for (let i = 0; i < firstDay; i++) week.push({ date: '', secs: 0, count: 0, empty: true })
data.forEach(d => {
week.push(d)
if (week.length === 7) { weeks.push(week); week = [] }
})
if (week.length) weeks.push(week)
return weeks
})
function getDayClass(day) {
if (day.empty) return 'empty'
if (day.count === 0) return 'level-0'
if (day.secs < 30) return 'level-1'
if (day.secs < 120) return 'level-2'
if (day.secs < 300) return 'level-3'
return 'level-4'
}
function getDayTitle(day) {
if (day.empty) return ''
if (day.count === 0) return `${day.date}: 无提交`
return `${day.date}: ${day.count}次提交, 产出${formatSecs(day.secs)}`
}
//
const projectGroups = computed(() => {
const groups = {}
allSubmissions.value.forEach(s => {
if (!groups[s.project_id]) {
groups[s.project_id] = reactive({
projectId: s.project_id,
projectName: s.project_name,
submissions: [],
totalSecs: 0,
expanded: false,
})
}
groups[s.project_id].submissions.push(s)
groups[s.project_id].totalSecs += s.total_seconds || 0
})
return Object.values(groups).sort((a, b) => b.totalSecs - a.totalSecs)
})
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
member.value = await userApi.get(id)
allSubmissions.value = await submissionApi.list({ user_id: id })
} finally { loading.value = false }
}
onMounted(load)
</script>
<style scoped>
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.page-header-left { display: flex; align-items: center; gap: 8px; }
.page-header-left h2 { font-size: 18px; font-weight: 600; }
.back-btn { font-size: 16px !important; padding: 4px !important; }
.info-card { margin-bottom: 16px; }
.info-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0; padding: 16px 20px; }
.info-item { display: flex; flex-direction: column; padding: 8px 0; border-bottom: 1px solid #f0f1f2; }
.info-item:nth-last-child(-n+3) { border-bottom: none; }
.info-label { font-size: 12px; color: var(--text-secondary); margin-bottom: 4px; }
.info-value { font-size: 14px; color: var(--text-primary); font-weight: 500; }
.card {
background: var(--bg-card); border: 1px solid var(--border-color);
border-radius: var(--radius-md); margin-bottom: 16px;
}
.card-header {
padding: 16px 20px; border-bottom: 1px solid var(--border-light);
display: flex; justify-content: space-between; align-items: center;
}
.card-title { font-size: 14px; font-weight: 600; }
.card-body { padding: 20px; }
/* 日历热力图 */
.calendar-heatmap { overflow-x: auto; }
.calendar-row { display: flex; gap: 3px; }
.calendar-week { display: flex; flex-direction: column; gap: 3px; }
.calendar-day {
width: 12px; height: 12px; border-radius: 2px; background: #EBEDF0;
}
.calendar-day.empty { background: transparent; }
.calendar-day.level-0 { background: #EBEDF0; }
.calendar-day.level-1 { background: #9be9a8; }
.calendar-day.level-2 { background: #40c463; }
.calendar-day.level-3 { background: #30a14e; }
.calendar-day.level-4 { background: #216e39; }
.calendar-legend {
display: flex; align-items: center; gap: 3px; margin-top: 8px;
justify-content: flex-end;
}
.legend-text { font-size: 11px; color: var(--text-secondary); margin: 0 4px; }
/* 项目分组 */
.project-group { border-bottom: 1px solid #f0f1f2; }
.project-group:last-child { border-bottom: none; }
.project-group-header {
display: flex; justify-content: space-between; align-items: center;
padding: 12px 0; cursor: pointer;
}
.project-group-header:hover { background: #FAFBFC; margin: 0 -20px; padding-left: 20px; padding-right: 20px; }
.project-group-info { display: flex; align-items: center; gap: 8px; }
.project-group-name { font-size: 14px; font-weight: 500; color: var(--text-primary); }
.project-group-secs { font-size: 13px; color: var(--text-secondary); }
.project-group-header .el-icon { transition: transform 0.2s; color: var(--text-secondary); }
.project-group-header .el-icon.rotated { transform: rotate(90deg); }
.project-group-body { padding-bottom: 12px; }
.expand-enter-active, .expand-leave-active { transition: all 0.2s; overflow: hidden; }
.expand-enter-from, .expand-leave-to { opacity: 0; max-height: 0; }
</style>

View File

@ -8,11 +8,42 @@
<el-tag :type="project.status === '已完成' ? 'success' : 'info'" size="small">{{ project.status }}</el-tag>
</div>
<el-space>
<el-button v-if="authStore.isOwner() && project.status === '制作中'" type="danger" plain @click="handleComplete">确认完成</el-button>
<el-button v-if="authStore.isOwner() && project.status === '已完成'" type="primary" @click="$router.push(`/settlement/${project.id}`)">查看结算</el-button>
<el-button v-if="authStore.hasPermission('project:delete')" type="danger" text @click="handleDelete">删除项目</el-button>
<el-button v-if="authStore.hasPermission('project:complete') && project.status === '制作中'" type="danger" plain @click="handleComplete">确认完成</el-button>
<el-button v-if="authStore.hasPermission('settlement:view') && project.status === '已完成'" type="primary" @click="$router.push(`/settlement/${project.id}`)">查看结算</el-button>
</el-space>
</div>
<!-- 项目信息 -->
<div class="card info-card">
<div class="info-grid">
<div class="info-item">
<span class="info-label">合同金额</span>
<span class="info-value price">{{ project.contract_amount ? '¥' + project.contract_amount.toLocaleString() : '未设置' }}</span>
</div>
<div class="info-item">
<span class="info-label">负责人</span>
<span class="info-value">{{ project.leader_name || '未指定' }}</span>
</div>
<div class="info-item">
<span class="info-label">当前阶段</span>
<span class="info-value">{{ project.current_phase || '—' }}</span>
</div>
<div class="info-item">
<span class="info-label">创建时间</span>
<span class="info-value">{{ project.created_at ? project.created_at.slice(0,10) : '—' }}</span>
</div>
<div class="info-item">
<span class="info-label">预计完成</span>
<span class="info-value">{{ project.estimated_completion_date || '未设置' }}</span>
</div>
<div class="info-item">
<span class="info-label">目标时长</span>
<span class="info-value">{{ project.episode_count }} × {{ project.episode_duration_minutes }} = {{ formatSecs(project.target_total_seconds) }}</span>
</div>
</div>
</div>
<!-- 概览卡片 -->
<div class="stat-grid">
<div class="stat-card">
@ -33,21 +64,47 @@
</div>
</div>
<!-- 进度条 -->
<!-- 项目进度时间轴 -->
<div class="card">
<div class="card-header"><span class="card-title">项目进度</span></div>
<div class="card-header">
<span class="card-title">项目进度</span>
<span class="card-count" v-if="remainingDays !== null">
{{ remainingDays > 0 ? `剩余 ${remainingDays}` : remainingDays === 0 ? '今天截止' : `已超期 ${Math.abs(remainingDays)}` }}
</span>
</div>
<div class="card-body">
<el-progress :percentage="Math.min(project.progress_percent, 100)" :stroke-width="8" :show-text="false"
: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 class="progress-section">
<div class="progress-label-row">
<span>产出进度</span>
<span :style="{color: progressColor, fontWeight: 600}">{{ project.progress_percent }}%</span>
</div>
<el-progress :percentage="Math.min(project.progress_percent, 100)" :stroke-width="8" :show-text="false" :color="progressColor" />
</div>
<!-- 时间轴 -->
<div class="timeline-section" v-if="project.created_at && project.estimated_completion_date">
<div class="progress-label-row">
<span>时间进度</span>
<span :style="{color: timePercent > 100 ? '#FF3B30' : '#8F959E', fontWeight: 600}">{{ timePercent }}%</span>
</div>
<div class="timeline-bar-wrapper">
<div class="timeline-bar">
<div class="timeline-elapsed" :style="{width: Math.min(timePercent, 100) + '%'}"></div>
<div class="timeline-today-marker" :style="{left: Math.min(timePercent, 100) + '%'}" v-if="timePercent <= 100">
<span class="timeline-today-label">今天</span>
</div>
</div>
<div class="timeline-labels">
<span>{{ project.created_at ? project.created_at.slice(0,10) : '' }}</span>
<span>{{ project.estimated_completion_date }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 团队效率 -->
<div v-if="authStore.isLeaderOrAbove() && efficiency.length" class="card">
<div v-if="authStore.hasPermission('efficiency:view') && efficiency.length" class="card">
<div class="card-header"><span class="card-title">团队效率</span></div>
<div class="card-body">
<el-table :data="efficiency" size="small">
@ -70,6 +127,87 @@
</div>
</div>
<!-- 提交概览热力图 -->
<div class="card">
<div class="card-header">
<span class="card-title">提交概览</span>
<div class="heatmap-controls">
<el-radio-group v-model="heatmapRange" size="small">
<el-radio-button label="30">最近30天</el-radio-button>
<el-radio-button label="all">项目全周期</el-radio-button>
</el-radio-group>
</div>
</div>
<div class="card-body heatmap-body" v-if="heatmapUsers.length">
<div class="heatmap-scroll">
<table class="heatmap-table">
<thead>
<tr>
<th class="heatmap-name-col">成员</th>
<th v-for="d in heatmapDates" :key="d" class="heatmap-date-col" :class="{ today: d === todayStr }">
{{ d.slice(5) }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="user in heatmapUsers" :key="user.id">
<td class="heatmap-name-col">
<a class="member-link" @click="openMemberDrawer(user)">{{ user.name }}</a>
</td>
<td v-for="d in heatmapDates" :key="d" class="heatmap-cell"
:class="getCellClass(user.id, d)"
@mouseenter="showCellTooltip($event, user.id, d)"
@mouseleave="hideCellTooltip">
<span v-if="getCellData(user.id, d)">{{ getCellData(user.id, d).totalSecs }}s</span>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 图例 -->
<div class="heatmap-legend">
<span class="legend-item"><span class="legend-dot type-制作"></span>制作</span>
<span class="legend-item"><span class="legend-dot type-测试"></span>测试</span>
<span class="legend-item"><span class="legend-dot type-方案"></span>方案</span>
</div>
</div>
<div class="card-body" v-else>
<el-empty description="暂无提交记录" :image-size="60" />
</div>
</div>
<!-- 悬浮提示 -->
<div class="cell-tooltip" v-if="tooltip.visible" :style="{ top: tooltip.y + 'px', left: tooltip.x + 'px' }">
<div v-for="(s, i) in tooltip.items" :key="i" class="tooltip-row">
<el-tag :type="s.work_type === '测试' ? 'warning' : s.work_type === '方案' ? 'info' : ''" size="small">{{ s.work_type }}</el-tag>
<span class="tooltip-type">{{ s.content_type }}</span>
<span class="tooltip-secs" v-if="s.total_seconds > 0">{{ formatSecs(s.total_seconds) }}</span>
<span class="tooltip-desc" v-if="s.description">{{ s.description }}</span>
</div>
</div>
<!-- 成员提交抽屉 -->
<el-drawer v-model="drawerVisible" :title="drawerUser?.name + ' 的提交记录'" size="480px">
<div v-if="drawerUser" class="drawer-content">
<div class="drawer-summary">
<span>总提交 <strong>{{ drawerSubmissions.length }}</strong> </span>
<span>总产出 <strong>{{ formatSecs(drawerTotalSecs) }}</strong></span>
<router-link :to="`/users/${drawerUser.id}/detail`" class="drawer-link">查看完整详情 </router-link>
</div>
<div v-for="(group, date) in drawerGrouped" :key="date" class="drawer-date-group">
<div class="drawer-date-header">{{ date }}</div>
<div v-for="s in group" :key="s.id" class="drawer-sub-item">
<div class="drawer-sub-top">
<el-tag :type="s.work_type === '测试' ? 'warning' : s.work_type === '方案' ? 'info' : ''" size="small">{{ s.work_type }}</el-tag>
<span>{{ s.content_type }}</span>
<span v-if="s.total_seconds > 0" class="drawer-sub-secs">{{ formatSecs(s.total_seconds) }}</span>
</div>
<div v-if="s.description" class="drawer-sub-desc">{{ s.description }}</div>
</div>
</div>
</div>
</el-drawer>
<!-- 提交记录 -->
<div class="card">
<div class="card-header">
@ -98,7 +236,7 @@
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ref, computed, reactive, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { projectApi, submissionApi } from '../api'
import { useAuthStore } from '../stores/auth'
@ -115,6 +253,146 @@ const typeTagMap = { '客户正式项目': 'success', '客户测试项目': 'war
const progressColor = computed(() => project.value.progress_percent > 100 ? '#FF9500' : '#3370FF')
//
const todayStr = new Date().toISOString().slice(0, 10)
const remainingDays = computed(() => {
if (!project.value.estimated_completion_date) return null
const end = new Date(project.value.estimated_completion_date)
const now = new Date()
return Math.ceil((end - now) / (1000 * 60 * 60 * 24))
})
const timePercent = computed(() => {
const p = project.value
if (!p.created_at || !p.estimated_completion_date) return 0
const start = new Date(p.created_at)
const end = new Date(p.estimated_completion_date)
const now = new Date()
const total = end - start
if (total <= 0) return 100
return Math.round((now - start) / total * 100)
})
//
const heatmapRange = ref('30')
const heatmapDates = computed(() => {
const subs = submissions.value
if (!subs.length) return []
if (heatmapRange.value === 'all') {
//
const dates = subs.map(s => s.submit_date).sort()
const start = new Date(dates[0])
const end = new Date(todayStr)
const result = []
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
result.push(d.toISOString().slice(0, 10))
}
return result
} else {
// 30
const result = []
const today = new Date()
for (let i = 29; i >= 0; i--) {
const d = new Date(today)
d.setDate(d.getDate() - i)
result.push(d.toISOString().slice(0, 10))
}
return result
}
})
const heatmapUsers = computed(() => {
const subs = submissions.value
const userMap = new Map()
subs.forEach(s => {
if (!userMap.has(s.user_id)) {
userMap.set(s.user_id, { id: s.user_id, name: s.user_name })
}
})
return Array.from(userMap.values())
})
// userId + date
const heatmapIndex = computed(() => {
const idx = {}
submissions.value.forEach(s => {
const key = `${s.user_id}_${s.submit_date}`
if (!idx[key]) idx[key] = { items: [], totalSecs: 0, mainType: '' }
idx[key].items.push(s)
idx[key].totalSecs += s.total_seconds || 0
})
//
Object.values(idx).forEach(cell => {
const typeMap = {}
cell.items.forEach(s => {
typeMap[s.work_type] = (typeMap[s.work_type] || 0) + (s.total_seconds || 1)
})
cell.mainType = Object.entries(typeMap).sort((a, b) => b[1] - a[1])[0]?.[0] || '制作'
cell.totalSecs = Math.round(cell.totalSecs)
})
return idx
})
function getCellData(userId, date) {
return heatmapIndex.value[`${userId}_${date}`] || null
}
function getCellClass(userId, date) {
const cell = getCellData(userId, date)
if (!cell) return ''
return `has-data type-${cell.mainType}`
}
//
const tooltip = reactive({ visible: false, x: 0, y: 0, items: [] })
function showCellTooltip(e, userId, date) {
const cell = getCellData(userId, date)
if (!cell) return
const rect = e.target.getBoundingClientRect()
tooltip.x = rect.left
tooltip.y = rect.bottom + 4
tooltip.items = cell.items
tooltip.visible = true
}
function hideCellTooltip() {
tooltip.visible = false
}
//
const drawerVisible = ref(false)
const drawerUser = ref(null)
const drawerSubmissions = computed(() => {
if (!drawerUser.value) return []
return submissions.value.filter(s => s.user_id === drawerUser.value.id)
})
const drawerTotalSecs = computed(() => {
return drawerSubmissions.value.reduce((sum, s) => sum + (s.total_seconds || 0), 0)
})
const drawerGrouped = computed(() => {
const groups = {}
drawerSubmissions.value.forEach(s => {
if (!groups[s.submit_date]) groups[s.submit_date] = []
groups[s.submit_date].push(s)
})
//
const sorted = {}
Object.keys(groups).sort((a, b) => b.localeCompare(a)).forEach(k => { sorted[k] = groups[k] })
return sorted
})
function openMemberDrawer(user) {
drawerUser.value = user
drawerVisible.value = true
}
//
function formatSecs(s) {
if (!s) return '0秒'
const abs = Math.abs(s)
@ -130,12 +408,25 @@ async function load() {
const id = route.params.id
project.value = await projectApi.get(id)
submissions.value = await submissionApi.list({ project_id: id })
if (authStore.isLeaderOrAbove()) {
if (authStore.hasPermission('efficiency:view')) {
try { efficiency.value = await projectApi.efficiency(id) } catch {}
}
} finally { loading.value = false }
}
async function handleDelete() {
try {
await ElMessageBox.confirm(
'删除后项目及其所有提交记录、成本数据将被永久清除,此操作不可撤销。',
'确认删除项目',
{ type: 'error', confirmButtonText: '确认删除', cancelButtonText: '取消' }
)
await projectApi.delete(route.params.id)
ElMessage.success('项目已删除')
router.push('/projects')
} catch {}
}
async function handleComplete() {
try {
await ElMessageBox.confirm('确认将此项目标记为完成并进行结算?此操作不可撤销。', '确认完成', { type: 'warning' })
@ -154,6 +445,21 @@ onMounted(load)
.page-header-left h2 { font-size: 18px; font-weight: 600; }
.back-btn { font-size: 16px !important; padding: 4px !important; }
/* 项目信息卡片 */
.info-card { margin-bottom: 16px; }
.info-grid {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 0;
padding: 16px 20px;
}
.info-item {
display: flex; flex-direction: column; padding: 8px 0;
border-bottom: 1px solid var(--border-light, #f0f1f2);
}
.info-item:nth-last-child(-n+3) { border-bottom: none; }
.info-label { font-size: 12px; color: var(--text-secondary); margin-bottom: 4px; }
.info-value { font-size: 14px; color: var(--text-primary); font-weight: 500; }
.info-value.price { color: #FF9500; font-weight: 700; font-size: 16px; }
.stat-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 16px; }
.stat-card {
background: var(--bg-card); border: 1px solid var(--border-color);
@ -173,10 +479,113 @@ onMounted(load)
.card-title { font-size: 14px; font-weight: 600; }
.card-count { font-size: 12px; color: var(--text-secondary); }
.card-body { padding: 20px; }
.meta-row { display: flex; justify-content: space-between; font-size: 13px; color: var(--text-secondary); }
.rate-badge {
font-size: 12px; font-weight: 600; color: var(--text-secondary);
background: var(--bg-hover); padding: 2px 8px; border-radius: 4px; display: inline-block;
}
.rate-badge.danger { background: #FFE8E7; color: #FF3B30; }
/* 进度时间轴 */
.progress-section { margin-bottom: 20px; }
.progress-label-row {
display: flex; justify-content: space-between; align-items: center;
font-size: 13px; color: var(--text-secondary); margin-bottom: 8px;
}
.timeline-section { margin-top: 4px; }
.timeline-bar-wrapper { position: relative; }
.timeline-bar {
height: 8px; background: #E5E6EB; border-radius: 4px; position: relative; overflow: visible;
}
.timeline-elapsed {
height: 100%; background: #3370FF; border-radius: 4px 0 0 4px;
transition: width 0.3s;
}
.timeline-today-marker {
position: absolute; top: -4px; transform: translateX(-50%);
width: 16px; height: 16px; background: #3370FF; border: 2px solid #fff;
border-radius: 50%; box-shadow: 0 1px 4px rgba(0,0,0,0.15);
}
.timeline-today-label {
position: absolute; top: -20px; left: 50%; transform: translateX(-50%);
font-size: 11px; color: #3370FF; font-weight: 600; white-space: nowrap;
}
.timeline-labels {
display: flex; justify-content: space-between; font-size: 12px; color: var(--text-secondary); margin-top: 8px;
}
/* 热力图 */
.heatmap-controls { display: flex; gap: 8px; }
.heatmap-body { padding: 16px 20px !important; }
.heatmap-scroll { overflow-x: auto; margin-bottom: 12px; }
.heatmap-table {
border-collapse: collapse; width: max-content; min-width: 100%;
}
.heatmap-table th, .heatmap-table td {
border: 1px solid var(--border-light, #f0f1f2); padding: 0; text-align: center;
font-size: 11px; height: 32px; min-width: 44px;
}
.heatmap-table thead th {
background: #F7F8FA; color: var(--text-secondary); font-weight: 500;
position: sticky; top: 0; z-index: 1;
}
.heatmap-table thead th.today { background: #E8F0FE; color: #3370FF; font-weight: 600; }
.heatmap-name-col {
position: sticky; left: 0; z-index: 2; background: #fff;
min-width: 80px; max-width: 80px; text-align: left; padding: 0 8px !important;
font-weight: 500; font-size: 12px; color: var(--text-primary);
}
.heatmap-table thead .heatmap-name-col { background: #F7F8FA; }
.heatmap-cell { cursor: default; transition: background 0.15s; }
.heatmap-cell:hover { filter: brightness(0.92); }
.heatmap-cell.has-data { font-weight: 600; color: #fff; }
.heatmap-cell.type-制作 { background: #3370FF; }
.heatmap-cell.type-测试 { background: #FF9500; }
.heatmap-cell.type-方案 { background: #8F959E; }
.member-link {
color: #3370FF; cursor: pointer; text-decoration: none;
}
.member-link:hover { text-decoration: underline; }
.heatmap-legend { display: flex; gap: 16px; font-size: 12px; color: var(--text-secondary); }
.legend-item { display: flex; align-items: center; gap: 4px; }
.legend-dot { width: 10px; height: 10px; border-radius: 2px; display: inline-block; }
.legend-dot.type-制作 { background: #3370FF; }
.legend-dot.type-测试 { background: #FF9500; }
.legend-dot.type-方案 { background: #8F959E; }
/* 悬浮提示 */
.cell-tooltip {
position: fixed; z-index: 9999;
background: #fff; border: 1px solid var(--border-color); border-radius: 8px;
box-shadow: 0 4px 16px rgba(0,0,0,0.12); padding: 10px 14px;
max-width: 320px; pointer-events: none;
}
.tooltip-row { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; flex-wrap: wrap; }
.tooltip-row:last-child { margin-bottom: 0; }
.tooltip-type { font-size: 12px; color: var(--text-secondary); }
.tooltip-secs { font-size: 12px; font-weight: 600; color: var(--text-primary); }
.tooltip-desc { font-size: 11px; color: #8F959E; width: 100%; margin-top: 2px; }
/* 成员抽屉 */
.drawer-content { padding: 0 4px; }
.drawer-summary {
display: flex; gap: 16px; align-items: center; flex-wrap: wrap;
padding: 12px 16px; background: #F7F8FA; border-radius: 8px;
font-size: 13px; color: var(--text-secondary); margin-bottom: 16px;
}
.drawer-summary strong { color: var(--text-primary); }
.drawer-link { color: #3370FF; font-size: 13px; margin-left: auto; }
.drawer-date-group { margin-bottom: 16px; }
.drawer-date-header {
font-size: 13px; font-weight: 600; color: var(--text-primary);
padding-bottom: 8px; border-bottom: 1px solid var(--border-light, #f0f1f2);
margin-bottom: 8px;
}
.drawer-sub-item {
padding: 6px 0; border-bottom: 1px solid var(--border-light, #f0f1f2);
}
.drawer-sub-item:last-child { border-bottom: none; }
.drawer-sub-top { display: flex; align-items: center; gap: 8px; font-size: 13px; }
.drawer-sub-secs { font-weight: 600; color: var(--text-primary); margin-left: auto; }
.drawer-sub-desc { font-size: 12px; color: #8F959E; margin-top: 4px; }
</style>

View File

@ -2,7 +2,7 @@
<div>
<div class="page-header">
<div></div>
<el-button v-if="authStore.isSupervisor()" type="primary" @click="showCreate = true">
<el-button v-if="authStore.hasPermission('project:create')" type="primary" @click="showCreate = true">
<el-icon><Plus /></el-icon> 新建项目
</el-button>
</div>
@ -142,7 +142,7 @@ async function handleCreate() {
onMounted(async () => {
load()
if (authStore.isLeaderOrAbove()) {
if (authStore.hasPermission('user:view')) {
try { users.value = await userApi.list() } catch {}
}
})

View File

@ -0,0 +1,196 @@
<template>
<div>
<div class="page-header">
<h2>角色管理</h2>
<el-button type="primary" @click="openCreate"><el-icon><Plus /></el-icon> 新建角色</el-button>
</div>
<el-table :data="roles" v-loading="loading" stripe>
<el-table-column prop="name" label="角色名称" width="140">
<template #default="{row}">
<span class="role-name">{{ row.name }}</span>
<el-tag v-if="row.is_system" size="small" type="info" style="margin-left:6px">内置</el-tag>
</template>
</el-table-column>
<el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip />
<el-table-column label="权限数" width="90" align="center">
<template #default="{row}">
<span class="perm-count">{{ row.permissions.length }}</span>
</template>
</el-table-column>
<el-table-column label="用户数" width="90" align="center">
<template #default="{row}">{{ row.user_count }}</template>
</el-table-column>
<el-table-column label="操作" width="150">
<template #default="{row}">
<el-button text size="small" @click="openEdit(row)">编辑权限</el-button>
<el-button v-if="!row.is_system" text type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 新建/编辑角色弹窗 -->
<el-dialog v-model="showDialog" :title="editingId ? '编辑角色' : '新建角色'" width="640px" destroy-on-close>
<el-form :model="form" label-width="80px">
<el-form-item label="角色名称">
<el-input v-model="form.name" :disabled="editingSystem" placeholder="如:制片人、实习生等" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" placeholder="(选填)角色职责说明" />
</el-form-item>
</el-form>
<div class="perm-panel">
<div class="perm-panel-header">
<span class="perm-panel-title">权限配置</span>
<el-button text size="small" @click="toggleAll">{{ isAllSelected ? '取消全选' : '全选' }}</el-button>
</div>
<div v-for="group in permGroups" :key="group.group" class="perm-group">
<div class="perm-group-header">
<el-checkbox
:model-value="isGroupAllSelected(group)"
:indeterminate="isGroupIndeterminate(group)"
@change="toggleGroup(group, $event)"
>{{ group.group }}</el-checkbox>
</div>
<div class="perm-items">
<el-checkbox
v-for="p in group.permissions"
:key="p.key"
:model-value="form.permissions.includes(p.key)"
@change="togglePerm(p.key, $event)"
>{{ p.label }}</el-checkbox>
</div>
</div>
</div>
<template #footer>
<el-button @click="showDialog = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { roleApi } from '../api'
import { ElMessage, ElMessageBox } from 'element-plus'
const loading = ref(false)
const saving = ref(false)
const showDialog = ref(false)
const editingId = ref(null)
const editingSystem = ref(false)
const roles = ref([])
const permGroups = ref([])
const form = ref({ name: '', description: '', permissions: [] })
const allPermKeys = computed(() => permGroups.value.flatMap(g => g.permissions.map(p => p.key)))
const isAllSelected = computed(() => allPermKeys.value.length > 0 && allPermKeys.value.every(k => form.value.permissions.includes(k)))
function isGroupAllSelected(group) {
return group.permissions.every(p => form.value.permissions.includes(p.key))
}
function isGroupIndeterminate(group) {
const selected = group.permissions.filter(p => form.value.permissions.includes(p.key)).length
return selected > 0 && selected < group.permissions.length
}
function toggleGroup(group, checked) {
const keys = group.permissions.map(p => p.key)
if (checked) {
form.value.permissions = [...new Set([...form.value.permissions, ...keys])]
} else {
form.value.permissions = form.value.permissions.filter(k => !keys.includes(k))
}
}
function togglePerm(key, checked) {
if (checked) {
form.value.permissions.push(key)
} else {
form.value.permissions = form.value.permissions.filter(k => k !== key)
}
}
function toggleAll() {
if (isAllSelected.value) {
form.value.permissions = []
} else {
form.value.permissions = [...allPermKeys.value]
}
}
function openCreate() {
editingId.value = null
editingSystem.value = false
form.value = { name: '', description: '', permissions: [] }
showDialog.value = true
}
function openEdit(role) {
editingId.value = role.id
editingSystem.value = role.is_system
form.value = {
name: role.name,
description: role.description || '',
permissions: [...role.permissions],
}
showDialog.value = true
}
async function handleSave() {
if (!form.value.name.trim()) { ElMessage.warning('请输入角色名称'); return }
saving.value = true
try {
if (editingId.value) {
await roleApi.update(editingId.value, form.value)
ElMessage.success('角色已更新')
} else {
await roleApi.create(form.value)
ElMessage.success('角色已创建')
}
showDialog.value = false
load()
} finally { saving.value = false }
}
async function handleDelete(role) {
try {
await ElMessageBox.confirm(`确认删除角色「${role.name}」?`, '删除角色', { type: 'warning' })
await roleApi.delete(role.id)
ElMessage.success('角色已删除')
load()
} catch {}
}
async function load() {
loading.value = true
try { roles.value = await roleApi.list() } finally { loading.value = false }
}
onMounted(async () => {
load()
try { permGroups.value = await roleApi.permissions() } catch {}
})
</script>
<style scoped>
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.page-header h2 { font-size: 18px; font-weight: 600; }
.role-name { font-weight: 600; color: var(--text-primary); }
.perm-count { font-weight: 600; color: var(--primary); }
/* 权限勾选面板 */
.perm-panel { margin-top: 8px; }
.perm-panel-header {
display: flex; justify-content: space-between; align-items: center;
padding: 8px 0; border-bottom: 1px solid var(--border-light); margin-bottom: 8px;
}
.perm-panel-title { font-size: 14px; font-weight: 600; color: var(--text-primary); }
.perm-group { margin-bottom: 12px; }
.perm-group-header {
padding: 6px 12px; background: #F7F8FA; border-radius: 6px;
margin-bottom: 6px; font-weight: 500;
}
.perm-items { padding: 4px 12px 4px 28px; display: flex; flex-wrap: wrap; gap: 8px 20px; }
</style>

View File

@ -18,16 +18,19 @@
<!-- 成本汇总 -->
<el-row :gutter="16" class="stat-row">
<el-col :span="6">
<el-col :span="5">
<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-col :span="5">
<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-col :span="4">
<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-col :span="5">
<el-card shadow="hover"><div class="stat-label">固定开支</div><div class="stat-value">¥{{ fmt(data.overhead_cost) }}</div></el-card>
</el-col>
<el-col :span="5">
<el-card shadow="hover">
<div class="stat-label">项目总成本</div>
<div class="stat-value" style="color:#e6a23c">¥{{ fmt(data.total_cost) }}</div>

View File

@ -6,24 +6,39 @@
</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">
<el-table-column label="姓名" width="100">
<template #default="{row}">
<el-tag :type="roleMap[row.role]" size="small">{{ row.role }}</el-tag>
<router-link :to="`/users/${row.id}/detail`" class="user-link">{{ row.name }}</router-link>
</template>
</el-table-column>
<el-table-column label="月薪" width="110" align="right">
<el-table-column prop="username" label="用户名" width="120" />
<el-table-column prop="phase_group" label="阶段" width="70" />
<el-table-column label="角色" width="80">
<template #default="{row}">
<el-tag size="small">{{ row.role_name }}</el-tag>
</template>
</el-table-column>
<el-table-column label="月薪" width="100" align="right">
<template #default="{row}">¥{{ row.monthly_salary.toLocaleString() }}</template>
</el-table-column>
<el-table-column label="日成本" width="100" align="right">
<el-table-column label="奖金" width="90" align="right">
<template #default="{row}">{{ row.bonus > 0 ? '¥' + row.bonus.toLocaleString() : '—' }}</template>
</el-table-column>
<el-table-column label="五险一金" width="100" align="right">
<template #default="{row}">{{ row.social_insurance > 0 ? '¥' + row.social_insurance.toLocaleString() : '—' }}</template>
</el-table-column>
<el-table-column label="月总成本" width="110" align="right">
<template #default="{row}">
<span style="font-weight:600;color:var(--text-primary)">¥{{ row.monthly_total_cost.toLocaleString() }}</span>
</template>
</el-table-column>
<el-table-column label="日成本" width="90" align="right">
<template #default="{row}">¥{{ row.daily_cost.toLocaleString() }}</template>
</el-table-column>
<el-table-column label="状态" width="80">
<el-table-column label="状态" width="70">
<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">
<el-table-column label="操作" width="80">
<template #default="{row}">
<el-button text size="small" @click="editUser(row)">编辑</el-button>
</template>
@ -31,8 +46,8 @@
</el-table>
<!-- 新增/编辑对话框 -->
<el-dialog v-model="showCreate" :title="editingId ? '编辑用户' : '新增用户'" width="480px" destroy-on-close>
<el-form :model="form" label-width="80px">
<el-dialog v-model="showCreate" :title="editingId ? '编辑用户' : '新增用户'" width="520px" destroy-on-close>
<el-form :model="form" label-width="90px">
<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>
@ -44,17 +59,25 @@
</el-select>
</el-form-item>
<el-form-item label="角色">
<el-select v-model="form.role" placeholder="系统权限角色" style="width:100%">
<el-option label="成员 — 仅提交内容" value="成员" />
<el-option label="组长 — 管理本组提交" value="组长" />
<el-option label="主管 — 管理项目和人员" value="主管" />
<el-option label="Owner — 全部权限" value="Owner" />
<el-select v-model="form.role_id" placeholder="系统权限角色" style="width:100%">
<el-option v-for="r in roles" :key="r.id" :label="r.name" :value="r.id" />
</el-select>
</el-form-item>
<el-divider content-position="left">成本信息</el-divider>
<el-form-item label="月薪">
<el-input-number v-model="form.monthly_salary" :min="0" :step="1000" :controls="false" placeholder="月薪(元),日成本=月薪÷22" style="width:100%" />
<el-input-number v-model="form.monthly_salary" :min="0" :step="1000" :controls="false" placeholder="基本月薪(元)" style="width:100%" />
</el-form-item>
<el-form-item v-if="editingId" label="状态">
<el-form-item label="奖金">
<el-input-number v-model="form.bonus" :min="0" :step="500" :controls="false" placeholder="月度奖金无则填0" style="width:100%" />
</el-form-item>
<el-form-item label="五险一金">
<el-input-number v-model="form.social_insurance" :min="0" :step="500" :controls="false" placeholder="公司承担的五险一金(元/月)" style="width:100%" />
</el-form-item>
<div class="cost-summary" v-if="form.monthly_salary || form.bonus || form.social_insurance">
月总成本 = ¥{{ (form.monthly_salary || 0).toLocaleString() }} + ¥{{ (form.bonus || 0).toLocaleString() }} + ¥{{ (form.social_insurance || 0).toLocaleString() }} = <strong>¥{{ ((form.monthly_salary || 0) + (form.bonus || 0) + (form.social_insurance || 0)).toLocaleString() }}</strong>
&nbsp;&nbsp; 日成本 <strong>¥{{ (((form.monthly_salary || 0) + (form.bonus || 0) + (form.social_insurance || 0)) / 22).toFixed(0) }}</strong>
</div>
<el-form-item v-if="editingId" label="状态" style="margin-top:16px">
<el-switch v-model="form.is_active" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="停用" />
</el-form-item>
</el-form>
@ -68,27 +91,38 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { userApi } from '../api'
import { userApi, roleApi } 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 })
const roles = ref([])
const form = reactive({
username: '', password: '', name: '', phase_group: '制作', role_id: null,
monthly_salary: 0, bonus: 0, social_insurance: 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 })
Object.assign(form, {
name: u.name, phase_group: u.phase_group, role_id: u.role_id,
monthly_salary: u.monthly_salary, bonus: u.bonus || 0,
social_insurance: u.social_insurance || 0, 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 })
await userApi.update(editingId.value, {
name: form.name, phase_group: form.phase_group, role_id: form.role_id,
monthly_salary: form.monthly_salary, bonus: form.bonus,
social_insurance: form.social_insurance, is_active: form.is_active,
})
} else {
await userApi.create(form)
}
@ -98,10 +132,21 @@ async function handleSave() {
load()
}
onMounted(load)
onMounted(async () => {
load()
try { roles.value = await roleApi.list() } catch {}
})
</script>
<style scoped>
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.page-header h2 { font-size: 18px; font-weight: 600; }
.cost-summary {
background: #F7F8FA; border-radius: 6px; padding: 10px 16px;
font-size: 13px; color: var(--text-secondary); margin: -4px 0 8px;
line-height: 1.6;
}
.cost-summary strong { color: var(--text-primary); }
.user-link { color: #3370FF; text-decoration: none; font-weight: 500; }
.user-link:hover { text-decoration: underline; }
</style>