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 passlib.context import CryptContext
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from database import get_db from database import get_db
from models import User, UserRole
from config import SECRET_KEY, ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES from config import SECRET_KEY, ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") 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) 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 解析当前用户""" """从 JWT token 解析当前用户"""
from models import User
credentials_exception = HTTPException( credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="登录已过期,请重新登录", detail="登录已过期,请重新登录",
@ -51,13 +51,13 @@ def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(
return user return user
def require_role(*roles: UserRole): def require_permission(*perms: str):
"""权限装饰器:要求当前用户具有指定角色之一""" """权限校验依赖:要求当前用户拥有指定权限中的至少一项"""
def role_checker(current_user: User = Depends(get_current_user)): def perm_checker(current_user=Depends(get_current_user)):
if current_user.role not in [r.value for r in roles] and current_user.role not in roles: if not current_user.role_ref:
raise HTTPException( raise HTTPException(status_code=403, detail="未分配角色")
status_code=status.HTTP_403_FORBIDDEN, user_perms = current_user.permissions or []
detail="权限不足" if not any(p in user_perms for p in perms):
) raise HTTPException(status_code=403, detail="权限不足")
return current_user 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 datetime import date, timedelta
from models import ( from models import (
User, Project, Submission, AIToolCost, AIToolCostAllocation, User, Project, Submission, AIToolCost, AIToolCostAllocation,
OutsourceCost, CostOverride, WorkType, CostAllocationType OutsourceCost, CostOverride, OverheadCost, WorkType, CostAllocationType
) )
from config import WORKING_DAYS_PER_MONTH 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) 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: 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) labor = calc_labor_cost_for_project(project_id, db)
ai_tool = calc_ai_tool_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) 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) waste = calc_waste_for_project(project_id, db)
efficiency = calc_team_efficiency(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, "labor_cost": labor,
"ai_tool_cost": ai_tool, "ai_tool_cost": ai_tool,
"outsource_cost": outsource, "outsource_cost": outsource,
"overhead_cost": overhead,
"total_cost": round(total_cost, 2), "total_cost": round(total_cost, 2),
**waste, **waste,
"team_efficiency": efficiency, "team_efficiency": efficiency,

View File

@ -4,7 +4,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from database import engine, Base from database import engine, Base
from models import User, UserRole, PhaseGroup from models import User, Role, PhaseGroup, BUILTIN_ROLES
from auth import hash_password from auth import hash_password
import os import os
@ -30,6 +30,7 @@ from routers.projects import router as projects_router
from routers.submissions import router as submissions_router from routers.submissions import router as submissions_router
from routers.costs import router as costs_router from routers.costs import router as costs_router
from routers.dashboard import router as dashboard_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(auth_router)
app.include_router(users_router) app.include_router(users_router)
@ -37,6 +38,7 @@ app.include_router(projects_router)
app.include_router(submissions_router) app.include_router(submissions_router)
app.include_router(costs_router) app.include_router(costs_router)
app.include_router(dashboard_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") 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") @app.on_event("startup")
def init_default_owner(): def init_roles_and_admin():
"""首次启动时创建默认 Owner 账号""" """首次启动时创建内置角色和默认管理员"""
from database import SessionLocal from database import SessionLocal
db = SessionLocal() db = SessionLocal()
try: 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( owner = User(
username="admin", username="admin",
password_hash=hash_password("admin123"), password_hash=hash_password("admin123"),
name="管理员", name="管理员",
phase_group=PhaseGroup.PRODUCTION, phase_group=PhaseGroup.PRODUCTION,
role=UserRole.OWNER, role_id=admin_role.id,
monthly_salary=0, monthly_salary=0,
) )
db.add(owner) db.add(owner)

View File

@ -9,6 +9,71 @@ from database import Base
import enum 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): class ProjectType(str, enum.Enum):
@ -29,13 +94,6 @@ class PhaseGroup(str, enum.Enum):
POST = "后期" POST = "后期"
class UserRole(str, enum.Enum):
MEMBER = "成员"
LEADER = "组长"
SUPERVISOR = "主管"
OWNER = "Owner"
class WorkType(str, enum.Enum): class WorkType(str, enum.Enum):
PRODUCTION = "制作" PRODUCTION = "制作"
TEST = "测试" TEST = "测试"
@ -73,6 +131,29 @@ class OutsourceType(str, enum.Enum):
FULL_EPISODE = "整集" 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): class User(Base):
@ -83,20 +164,37 @@ class User(Base):
password_hash = Column(String(255), nullable=False) password_hash = Column(String(255), nullable=False)
name = Column(String(50), nullable=False) name = Column(String(50), nullable=False)
phase_group = Column(SAEnum(PhaseGroup), 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) 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) is_active = Column(Integer, nullable=False, default=1)
created_at = Column(DateTime, server_default=func.now()) created_at = Column(DateTime, server_default=func.now())
# 关系 # 关系
role_ref = relationship("Role", back_populates="users")
submissions = relationship("Submission", back_populates="user") submissions = relationship("Submission", back_populates="user")
led_projects = relationship("Project", back_populates="leader") 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 @property
def daily_cost(self): def daily_cost(self):
"""日成本 = 月薪 ÷ 22"""
from config import WORKING_DAYS_PER_MONTH 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) episode_count = Column(Integer, nullable=False)
estimated_completion_date = Column(Date, nullable=True) estimated_completion_date = Column(Date, nullable=True)
actual_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()) created_at = Column(DateTime, server_default=func.now())
# 关系
leader = relationship("User", back_populates="led_projects") leader = relationship("User", back_populates="led_projects")
submissions = relationship("Submission", back_populates="project") submissions = relationship("Submission", back_populates="project")
outsource_costs = relationship("OutsourceCost", back_populates="project") outsource_costs = relationship("OutsourceCost", back_populates="project")
@ -125,7 +222,6 @@ class Project(Base):
@property @property
def target_total_seconds(self): def target_total_seconds(self):
"""目标总秒数 = 单集时长(分) × 60 × 集数"""
return int(self.episode_duration_minutes * 60 * self.episode_count) return int(self.episode_duration_minutes * 60 * self.episode_count)
@ -142,14 +238,13 @@ class Submission(Base):
content_type = Column(SAEnum(ContentType), nullable=False) content_type = Column(SAEnum(ContentType), nullable=False)
duration_minutes = Column(Float, nullable=True, default=0) duration_minutes = Column(Float, nullable=True, default=0)
duration_seconds = Column(Float, nullable=True, default=0) duration_seconds = Column(Float, nullable=True, default=0)
total_seconds = Column(Float, nullable=False, default=0) # 系统自动计算 total_seconds = Column(Float, nullable=False, default=0)
hours_spent = Column(Float, nullable=True) # 可选:投入时长(小时) hours_spent = Column(Float, nullable=True)
submit_to = Column(SAEnum(SubmitTo), nullable=False) submit_to = Column(SAEnum(SubmitTo), nullable=False)
description = Column(Text, nullable=True) description = Column(Text, nullable=True)
submit_date = Column(Date, nullable=False) submit_date = Column(Date, nullable=False)
created_at = Column(DateTime, server_default=func.now()) created_at = Column(DateTime, server_default=func.now())
# 关系
user = relationship("User", back_populates="submissions") user = relationship("User", back_populates="submissions")
project = relationship("Project", back_populates="submissions") project = relationship("Project", back_populates="submissions")
history = relationship("SubmissionHistory", back_populates="submission") history = relationship("SubmissionHistory", back_populates="submission")
@ -165,23 +260,21 @@ class AIToolCost(Base):
subscription_period = Column(SAEnum(SubscriptionPeriod), nullable=False) subscription_period = Column(SAEnum(SubscriptionPeriod), nullable=False)
amount = Column(Float, nullable=False) amount = Column(Float, nullable=False)
allocation_type = Column(SAEnum(CostAllocationType), 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) recorded_by = Column(Integer, ForeignKey("users.id"), nullable=False)
record_date = Column(Date, nullable=False) record_date = Column(Date, nullable=False)
created_at = Column(DateTime, server_default=func.now()) created_at = Column(DateTime, server_default=func.now())
# 关系
allocations = relationship("AIToolCostAllocation", back_populates="ai_tool_cost") allocations = relationship("AIToolCostAllocation", back_populates="ai_tool_cost")
class AIToolCostAllocation(Base): class AIToolCostAllocation(Base):
"""AI 工具成本手动分摊明细"""
__tablename__ = "ai_tool_cost_allocations" __tablename__ = "ai_tool_cost_allocations"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
ai_tool_cost_id = Column(Integer, ForeignKey("ai_tool_costs.id"), nullable=False) ai_tool_cost_id = Column(Integer, ForeignKey("ai_tool_costs.id"), nullable=False)
project_id = Column(Integer, ForeignKey("projects.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") ai_tool_cost = relationship("AIToolCost", back_populates="allocations")
project = relationship("Project", back_populates="ai_tool_allocations") project = relationship("Project", back_populates="ai_tool_allocations")
@ -208,7 +301,6 @@ class OutsourceCost(Base):
# ──────────────────────────── 人力成本手动调整 ──────────────────────────── # ──────────────────────────── 人力成本手动调整 ────────────────────────────
class CostOverride(Base): class CostOverride(Base):
"""管理员手动修改某人某天的成本分摊"""
__tablename__ = "cost_overrides" __tablename__ = "cost_overrides"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
@ -224,7 +316,6 @@ class CostOverride(Base):
# ──────────────────────────── 提交历史版本 ──────────────────────────── # ──────────────────────────── 提交历史版本 ────────────────────────────
class SubmissionHistory(Base): class SubmissionHistory(Base):
"""内容提交的修改历史"""
__tablename__ = "submission_history" __tablename__ = "submission_history"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
@ -236,3 +327,17 @@ class SubmissionHistory(Base):
created_at = Column(DateTime, server_default=func.now()) created_at = Column(DateTime, server_default=func.now())
submission = relationship("Submission", back_populates="history") 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, username=current_user.username,
name=current_user.name, name=current_user.name,
phase_group=current_user.phase_group.value if hasattr(current_user.phase_group, 'value') else current_user.phase_group, 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, 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, daily_cost=current_user.daily_cost,
is_active=current_user.is_active, is_active=current_user.is_active,
created_at=current_user.created_at, created_at=current_user.created_at,

View File

@ -5,14 +5,15 @@ from typing import List, Optional
from datetime import date from datetime import date
from database import get_db from database import get_db
from models import ( from models import (
User, UserRole, AIToolCost, AIToolCostAllocation, OutsourceCost, User, AIToolCost, AIToolCostAllocation, OutsourceCost,
CostOverride, SubscriptionPeriod, CostAllocationType, OutsourceType CostOverride, OverheadCost, SubscriptionPeriod, CostAllocationType,
OutsourceType, OverheadCostType
) )
from schemas import ( from schemas import (
AIToolCostCreate, AIToolCostOut, OutsourceCostCreate, OutsourceCostOut, 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=["成本管理"]) router = APIRouter(prefix="/api/costs", tags=["成本管理"])
@ -22,7 +23,7 @@ router = APIRouter(prefix="/api/costs", tags=["成本管理"])
@router.get("/ai-tools", response_model=List[AIToolCostOut]) @router.get("/ai-tools", response_model=List[AIToolCostOut])
def list_ai_tool_costs( def list_ai_tool_costs(
db: Session = Depends(get_db), 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() costs = db.query(AIToolCost).order_by(AIToolCost.record_date.desc()).all()
return [ return [
@ -44,7 +45,7 @@ def list_ai_tool_costs(
def create_ai_tool_cost( def create_ai_tool_cost(
req: AIToolCostCreate, req: AIToolCostCreate,
db: Session = Depends(get_db), 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( cost = AIToolCost(
tool_name=req.tool_name, tool_name=req.tool_name,
@ -84,7 +85,7 @@ def create_ai_tool_cost(
def delete_ai_tool_cost( def delete_ai_tool_cost(
cost_id: int, cost_id: int,
db: Session = Depends(get_db), 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() cost = db.query(AIToolCost).filter(AIToolCost.id == cost_id).first()
if not cost: if not cost:
@ -101,7 +102,7 @@ def delete_ai_tool_cost(
def list_outsource_costs( def list_outsource_costs(
project_id: Optional[int] = Query(None), project_id: Optional[int] = Query(None),
db: Session = Depends(get_db), 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) q = db.query(OutsourceCost)
if project_id: if project_id:
@ -123,7 +124,7 @@ def list_outsource_costs(
def create_outsource_cost( def create_outsource_cost(
req: OutsourceCostCreate, req: OutsourceCostCreate,
db: Session = Depends(get_db), 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( cost = OutsourceCost(
project_id=req.project_id, project_id=req.project_id,
@ -150,7 +151,7 @@ def create_outsource_cost(
def delete_outsource_cost( def delete_outsource_cost(
cost_id: int, cost_id: int,
db: Session = Depends(get_db), 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() cost = db.query(OutsourceCost).filter(OutsourceCost.id == cost_id).first()
if not cost: if not cost:
@ -166,7 +167,7 @@ def delete_outsource_cost(
def create_cost_override( def create_cost_override(
req: CostOverrideCreate, req: CostOverrideCreate,
db: Session = Depends(get_db), 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( override = CostOverride(
user_id=req.user_id, user_id=req.user_id,
@ -186,7 +187,7 @@ def list_cost_overrides(
user_id: Optional[int] = Query(None), user_id: Optional[int] = Query(None),
project_id: Optional[int] = Query(None), project_id: Optional[int] = Query(None),
db: Session = Depends(get_db), 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) q = db.query(CostOverride)
if user_id: if user_id:
@ -203,3 +204,66 @@ def list_cost_overrides(
} }
for r in records 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 datetime import date, timedelta
from database import get_db from database import get_db
from models import ( from models import (
User, UserRole, Project, Submission, AIToolCost, User, Project, Submission, AIToolCost,
ProjectStatus, ProjectType, WorkType ProjectStatus, ProjectType, WorkType
) )
from auth import get_current_user, require_role from auth import get_current_user, require_permission
from calculations import ( from calculations import (
calc_project_settlement, calc_waste_for_project, calc_project_settlement, calc_waste_for_project,
calc_labor_cost_for_project, calc_ai_tool_cost_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=["仪表盘与结算"]) router = APIRouter(prefix="/api", tags=["仪表盘与结算"])
@ -21,7 +22,7 @@ router = APIRouter(prefix="/api", tags=["仪表盘与结算"])
@router.get("/dashboard") @router.get("/dashboard")
def get_dashboard( def get_dashboard(
db: Session = Depends(get_db), 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_labor_all = 0.0
total_ai_all = 0.0 total_ai_all = 0.0
total_outsource_all = 0.0 total_outsource_all = 0.0
total_overhead_all = 0.0
for p in active + completed: for p in active + completed:
total_labor_all += calc_labor_cost_for_project(p.id, db) total_labor_all += calc_labor_cost_for_project(p.id, db)
total_ai_all += calc_ai_tool_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_outsource_all += calc_outsource_cost_for_project(p.id, db)
total_overhead_all += calc_overhead_cost_for_project(p.id, db)
cost_breakdown = [ cost_breakdown = [
{"name": "人力成本", "value": round(total_labor_all, 0)}, {"name": "人力成本", "value": round(total_labor_all, 0)},
{"name": "AI工具", "value": round(total_ai_all, 0)}, {"name": "AI工具", "value": round(total_ai_all, 0)},
{"name": "外包", "value": round(total_outsource_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, "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 { return {
"active_projects": len(active), "active_projects": len(active),
"completed_projects": len(completed), "completed_projects": len(completed),
@ -167,6 +208,7 @@ def get_dashboard(
"projects": project_summaries, "projects": project_summaries,
"waste_ranking": waste_ranking, "waste_ranking": waste_ranking,
"settled_projects": settled, "settled_projects": settled,
"profitability": profitability,
# 图表数据 # 图表数据
"daily_trend": daily_trend, "daily_trend": daily_trend,
"cost_breakdown": cost_breakdown, "cost_breakdown": cost_breakdown,
@ -178,7 +220,7 @@ def get_dashboard(
def get_settlement( def get_settlement(
project_id: int, project_id: int,
db: Session = Depends(get_db), 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() project = db.query(Project).filter(Project.id == project_id).first()
@ -191,7 +233,7 @@ def get_settlement(
def get_efficiency( def get_efficiency(
project_id: int, project_id: int,
db: Session = Depends(get_db), 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) 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 typing import List, Optional
from database import get_db from database import get_db
from models import ( from models import (
User, Project, Submission, UserRole, ProjectType, User, Project, Submission, ProjectType,
ProjectStatus, PhaseGroup, WorkType ProjectStatus, PhaseGroup, WorkType
) )
from schemas import ProjectCreate, ProjectUpdate, ProjectOut 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=["项目管理"]) router = APIRouter(prefix="/api/projects", tags=["项目管理"])
@ -76,7 +76,7 @@ def list_projects(
def create_project( def create_project(
req: ProjectCreate, req: ProjectCreate,
db: Session = Depends(get_db), 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( project = Project(
name=req.name, name=req.name,
@ -111,7 +111,7 @@ def update_project(
project_id: int, project_id: int,
req: ProjectUpdate, req: ProjectUpdate,
db: Session = Depends(get_db), 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() p = db.query(Project).filter(Project.id == project_id).first()
if not p: if not p:
@ -141,11 +141,36 @@ def update_project(
return enrich_project(p, db) 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") @router.post("/{project_id}/complete")
def complete_project( def complete_project(
project_id: int, project_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_role(UserRole.OWNER)) current_user: User = Depends(require_permission("project:complete"))
): ):
"""Owner 手动确认项目完成""" """Owner 手动确认项目完成"""
p = db.query(Project).filter(Project.id == project_id).first() 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 datetime import date
from database import get_db from database import get_db
from models import ( from models import (
User, Submission, SubmissionHistory, Project, UserRole, User, Submission, SubmissionHistory, Project,
PhaseGroup, WorkType, ContentType, SubmitTo PhaseGroup, WorkType, ContentType, SubmitTo
) )
from schemas import SubmissionCreate, SubmissionUpdate, SubmissionOut 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=["内容提交"]) router = APIRouter(prefix="/api/submissions", tags=["内容提交"])
@ -44,8 +44,8 @@ def list_submissions(
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
q = db.query(Submission) q = db.query(Submission)
# 成员只能看自己的 # 没有 user:view 权限的只能看自己的
if current_user.role == UserRole.MEMBER: if not current_user.has_permission("user:view"):
q = q.filter(Submission.user_id == current_user.id) q = q.filter(Submission.user_id == current_user.id)
elif user_id: elif user_id:
q = q.filter(Submission.user_id == user_id) q = q.filter(Submission.user_id == user_id)
@ -98,7 +98,7 @@ def update_submission(
submission_id: int, submission_id: int,
req: SubmissionUpdate, req: SubmissionUpdate,
db: Session = Depends(get_db), 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() sub = db.query(Submission).filter(Submission.id == submission_id).first()
@ -174,7 +174,7 @@ def update_submission(
def get_submission_history( def get_submission_history(
submission_id: int, submission_id: int,
db: Session = Depends(get_db), 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( records = db.query(SubmissionHistory).filter(

View File

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

View File

@ -23,15 +23,19 @@ class UserCreate(BaseModel):
password: str password: str
name: str name: str
phase_group: str # 前期/制作/后期 phase_group: str # 前期/制作/后期
role: str = "成员" role_id: int
monthly_salary: float = 0 monthly_salary: float = 0
bonus: float = 0
social_insurance: float = 0
class UserUpdate(BaseModel): class UserUpdate(BaseModel):
name: Optional[str] = None name: Optional[str] = None
phase_group: Optional[str] = None phase_group: Optional[str] = None
role: Optional[str] = None role_id: Optional[int] = None
monthly_salary: Optional[float] = None monthly_salary: Optional[float] = None
bonus: Optional[float] = None
social_insurance: Optional[float] = None
is_active: Optional[int] = None is_active: Optional[int] = None
@ -40,8 +44,13 @@ class UserOut(BaseModel):
username: str username: str
name: str name: str
phase_group: str phase_group: str
role: str role_id: int
role_name: str
permissions: List[str] = []
monthly_salary: float monthly_salary: float
bonus: float
social_insurance: float
monthly_total_cost: float
daily_cost: float daily_cost: float
is_active: int is_active: int
created_at: Optional[datetime] = None created_at: Optional[datetime] = None
@ -214,6 +223,28 @@ class CostOverrideCreate(BaseModel):
reason: Optional[str] = None 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): class DashboardSummary(BaseModel):

View File

@ -14,22 +14,35 @@ def seed():
db.execute(table.delete()) db.execute(table.delete())
db.commit() 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 = [ users = [
User(username="admin", password_hash=hash_password("admin123"), 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"), 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"), 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"), 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"), 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"), 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"), 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.add_all(users)
db.flush() db.flush()
@ -69,7 +82,6 @@ def seed():
base_date = date.today() - timedelta(days=14) base_date = date.today() - timedelta(days=14)
submissions = [] submissions = []
# 张三(前期组)给项目 A 和 D 做前期
for i in range(5): for i in range(5):
d = base_date + timedelta(days=i) d = base_date + timedelta(days=i)
submissions.append(Submission( submissions.append(Submission(
@ -89,10 +101,9 @@ def seed():
submit_date=d, submit_date=d,
)) ))
# 李四(制作组组长)主要做项目 A
for i in range(10): for i in range(10):
d = base_date + timedelta(days=i) 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 wt = WorkType.TEST if i < 2 else WorkType.PRODUCTION
submissions.append(Submission( submissions.append(Submission(
user_id=lisi.id, project_id=proj_a.id, user_id=lisi.id, project_id=proj_a.id,
@ -103,10 +114,9 @@ def seed():
submit_date=d, submit_date=d,
)) ))
# 王五(制作组)做项目 A 和 B
for i in range(8): for i in range(8):
d = base_date + timedelta(days=i) d = base_date + timedelta(days=i)
secs = 30 + (i % 4) * 20 # 30-90秒 secs = 30 + (i % 4) * 20
submissions.append(Submission( submissions.append(Submission(
user_id=wangwu.id, project_id=proj_a.id, user_id=wangwu.id, project_id=proj_a.id,
project_phase=PhaseGroup.PRODUCTION, work_type=WorkType.PRODUCTION, project_phase=PhaseGroup.PRODUCTION, work_type=WorkType.PRODUCTION,
@ -127,10 +137,9 @@ def seed():
submit_date=d, submit_date=d,
)) ))
# 赵六(制作组)做项目 A
for i in range(10): for i in range(10):
d = base_date + timedelta(days=i) 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 wt = WorkType.TEST if i < 1 else WorkType.PRODUCTION
submissions.append(Submission( submissions.append(Submission(
user_id=zhaoliu.id, project_id=proj_a.id, user_id=zhaoliu.id, project_id=proj_a.id,
@ -141,7 +150,6 @@ def seed():
submit_date=d, submit_date=d,
)) ))
# 孙七(后期组)剪辑
for i in range(3): for i in range(3):
d = base_date + timedelta(days=i + 10) d = base_date + timedelta(days=i + 10)
submissions.append(Submission( submissions.append(Submission(
@ -151,7 +159,6 @@ def seed():
submit_to=SubmitTo.PRODUCER, description=f"{i+1}集粗剪完成", submit_to=SubmitTo.PRODUCER, description=f"{i+1}集粗剪完成",
submit_date=d, submit_date=d,
)) ))
# 后期补拍
submissions.append(Submission( submissions.append(Submission(
user_id=sunqi.id, project_id=proj_a.id, user_id=sunqi.id, project_id=proj_a.id,
project_phase=PhaseGroup.POST, work_type=WorkType.PRODUCTION, project_phase=PhaseGroup.POST, work_type=WorkType.PRODUCTION,
@ -163,7 +170,6 @@ def seed():
db.add_all(submissions) db.add_all(submissions)
# ── AI 工具成本 ──
db.add(AIToolCost( db.add(AIToolCost(
tool_name="Midjourney", subscription_period=SubscriptionPeriod.MONTHLY, tool_name="Midjourney", subscription_period=SubscriptionPeriod.MONTHLY,
amount=200, allocation_type=CostAllocationType.TEAM, amount=200, allocation_type=CostAllocationType.TEAM,
@ -176,7 +182,6 @@ def seed():
recorded_by=producer.id, record_date=date.today().replace(day=1), recorded_by=producer.id, record_date=date.today().replace(day=1),
)) ))
# ── 外包成本 ──
db.add(OutsourceCost( db.add(OutsourceCost(
project_id=proj_a.id, outsource_type=OutsourceType.ANIMATION, project_id=proj_a.id, outsource_type=OutsourceType.ANIMATION,
episode_start=10, episode_end=13, amount=20000, episode_start=10, episode_end=13, amount=20000,
@ -185,6 +190,7 @@ def seed():
db.commit() db.commit()
print("[OK] seed data generated") print("[OK] seed data generated")
print(f" - roles: {len(roles)}")
print(f" - users: {len(users)}") print(f" - users: {len(users)}")
print(f" - projects: 4") print(f" - projects: 4")
print(f" - submissions: {len(submissions)}") print(f" - submissions: {len(submissions)}")

View File

@ -57,6 +57,7 @@ export const projectApi = {
create: (data) => api.post('/projects/', data), create: (data) => api.post('/projects/', data),
update: (id, data) => api.put(`/projects/${id}`, data), update: (id, data) => api.put(`/projects/${id}`, data),
get: (id) => api.get(`/projects/${id}`), get: (id) => api.get(`/projects/${id}`),
delete: (id) => api.delete(`/projects/${id}`),
complete: (id) => api.post(`/projects/${id}/complete`), complete: (id) => api.post(`/projects/${id}/complete`),
settlement: (id) => api.get(`/projects/${id}/settlement`), settlement: (id) => api.get(`/projects/${id}/settlement`),
efficiency: (id) => api.get(`/projects/${id}/efficiency`), efficiency: (id) => api.get(`/projects/${id}/efficiency`),
@ -80,6 +81,18 @@ export const costApi = {
deleteOutsource: (id) => api.delete(`/costs/outsource/${id}`), deleteOutsource: (id) => api.delete(`/costs/outsource/${id}`),
createOverride: (data) => api.post('/costs/overrides', data), createOverride: (data) => api.post('/costs/overrides', data),
listOverrides: (params) => api.get('/costs/overrides', { params }), 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" :to="item.path"
class="nav-item" class="nav-item"
:class="{ active: isActive(item.path) }" :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> <el-icon :size="18"><component :is="item.icon" /></el-icon>
<span v-show="!isCollapsed" class="nav-label">{{ item.label }}</span> <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-avatar">{{ authStore.user?.name?.[0] || '?' }}</div>
<div class="user-meta"> <div class="user-meta">
<div class="user-name">{{ authStore.user?.name }}</div> <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> </div>
</div> </div>
@ -69,11 +69,12 @@ const authStore = useAuthStore()
const isCollapsed = ref(false) const isCollapsed = ref(false)
const menuItems = [ const menuItems = [
{ path: '/dashboard', label: '仪表盘', icon: 'Odometer', role: 'Owner' }, { path: '/dashboard', label: '仪表盘', icon: 'Odometer', perm: 'dashboard:view' },
{ path: '/projects', label: '项目管理', icon: 'FolderOpened' }, { path: '/projects', label: '项目管理', icon: 'FolderOpened', perm: 'project:view' },
{ path: '/submissions', label: '内容提交', icon: 'EditPen' }, { path: '/submissions', label: '内容提交', icon: 'EditPen', perm: 'submission:view' },
{ path: '/costs', label: '成本管理', icon: 'Money', role: 'leader+' }, { path: '/costs', label: '成本管理', icon: 'Money', perm: 'cost:view' },
{ path: '/users', label: '用户管理', icon: 'User', role: 'Owner' }, { path: '/users', label: '用户管理', icon: 'User', perm: 'user:manage' },
{ path: '/roles', label: '角色管理', icon: 'Lock', perm: 'role:manage' },
] ]
const titleMap = { const titleMap = {
@ -82,6 +83,7 @@ const titleMap = {
'/submissions': '内容提交', '/submissions': '内容提交',
'/costs': '成本管理', '/costs': '成本管理',
'/users': '用户管理', '/users': '用户管理',
'/roles': '角色管理',
} }
const currentTitle = computed(() => { const currentTitle = computed(() => {
@ -94,12 +96,6 @@ function isActive(path) {
return route.path === path || route.path.startsWith(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 () => { onMounted(async () => {
if (authStore.token && !authStore.user) { if (authStore.token && !authStore.user) {
await authStore.fetchUser() await authStore.fetchUser()

View File

@ -5,15 +5,17 @@ const routes = [
{ {
path: '/', path: '/',
component: () => import('../components/Layout.vue'), component: () => import('../components/Layout.vue'),
redirect: '/dashboard', redirect: '/projects',
children: [ children: [
{ path: 'dashboard', name: 'Dashboard', component: () => import('../views/Dashboard.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') }, { path: 'projects', name: 'Projects', component: () => import('../views/Projects.vue'), meta: { perm: 'project:view' } },
{ path: 'projects/:id', name: 'ProjectDetail', component: () => import('../views/ProjectDetail.vue') }, { path: 'projects/:id', name: 'ProjectDetail', component: () => import('../views/ProjectDetail.vue'), meta: { perm: 'project:view' } },
{ path: 'submissions', name: 'Submissions', component: () => import('../views/Submissions.vue') }, { path: 'submissions', name: 'Submissions', component: () => import('../views/Submissions.vue'), meta: { perm: 'submission:view' } },
{ path: 'costs', name: 'Costs', component: () => import('../views/Costs.vue'), meta: { roles: ['Owner', '主管', '组长'] } }, { path: 'costs', name: 'Costs', component: () => import('../views/Costs.vue'), meta: { perm: 'cost:view' } },
{ path: 'users', name: 'Users', component: () => import('../views/Users.vue'), meta: { roles: ['Owner'] } }, { path: 'users', name: 'Users', component: () => import('../views/Users.vue'), meta: { perm: 'user:manage' } },
{ path: 'settlement/:id', name: 'Settlement', component: () => import('../views/Settlement.vue'), meta: { roles: ['Owner'] } }, { 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) => { router.beforeEach(async (to, from, next) => {
const token = localStorage.getItem('token') const token = localStorage.getItem('token')
if (to.meta.public) { if (to.meta.public) {
// 已登录时访问登录页,直接跳首页
if (to.path === '/login' && token) { if (to.path === '/login' && token) {
next('/') next('/')
} else { } else {
next() next()
} }
} else if (!token) { return
next('/login')
} else {
next()
} }
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 export default router

View File

@ -10,7 +10,6 @@ export const useAuthStore = defineStore('auth', () => {
const res = await authApi.login({ username, password }) const res = await authApi.login({ username, password })
token.value = res.access_token token.value = res.access_token
localStorage.setItem('token', res.access_token) localStorage.setItem('token', res.access_token)
// 登录后立即获取用户信息,失败不影响登录流程
try { try {
user.value = await authApi.me() user.value = await authApi.me()
} catch (e) { } catch (e) {
@ -33,9 +32,13 @@ export const useAuthStore = defineStore('auth', () => {
localStorage.removeItem('token') 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>
<el-table-column prop="allocation_type" label="归属方式" width="120" /> <el-table-column prop="allocation_type" label="归属方式" width="120" />
<el-table-column prop="record_date" label="录入日期" width="110" /> <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> <template #default="{row}"><el-button text type="danger" size="small" @click="deleteAI(row.id)">删除</el-button></template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@ -39,11 +39,29 @@
<template #default="{row}">¥{{ row.amount.toLocaleString() }}</template> <template #default="{row}">¥{{ row.amount.toLocaleString() }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="record_date" label="录入日期" width="110" /> <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> <template #default="{row}"><el-button text type="danger" size="small" @click="deleteOut(row.id)">删除</el-button></template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</el-tab-pane> </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> </el-tabs>
<!-- AI 工具新增弹窗 --> <!-- AI 工具新增弹窗 -->
@ -112,6 +130,31 @@
<el-button type="primary" @click="createOut">保存</el-button> <el-button type="primary" @click="createOut">保存</el-button>
</template> </template>
</el-dialog> </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> </div>
</template> </template>
@ -125,22 +168,28 @@ const authStore = useAuthStore()
const activeTab = ref('ai') const activeTab = ref('ai')
const loadingAI = ref(false) const loadingAI = ref(false)
const loadingOut = ref(false) const loadingOut = ref(false)
const loadingOH = ref(false)
const aiCosts = ref([]) const aiCosts = ref([])
const outCosts = ref([]) const outCosts = ref([])
const overheadCosts = ref([])
const projects = ref([]) const projects = ref([])
const showAIForm = ref(false) const showAIForm = ref(false)
const showOutForm = ref(false) const showOutForm = ref(false)
const showOHForm = ref(false)
const projectMap = computed(() => { const projectMap = computed(() => {
const m = {}; projects.value.forEach(p => m[p.id] = p.name); return m const m = {}; projects.value.forEach(p => m[p.id] = p.name); return m
}) })
const today = new Date().toISOString().split('T')[0] 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 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 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 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 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() { async function createAI() {
await costApi.createAITool(aiForm); ElMessage.success('已添加'); showAIForm.value = false; loadAI() await costApi.createAITool(aiForm); ElMessage.success('已添加'); showAIForm.value = false; loadAI()
@ -148,15 +197,21 @@ async function createAI() {
async function createOut() { async function createOut() {
await costApi.createOutsource(outForm); ElMessage.success('已添加'); showOutForm.value = false; loadOut() 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) { async function deleteAI(id) {
await ElMessageBox.confirm('确认删除?'); await costApi.deleteAITool(id); ElMessage.success('已删除'); loadAI() await ElMessageBox.confirm('确认删除?'); await costApi.deleteAITool(id); ElMessage.success('已删除'); loadAI()
} }
async function deleteOut(id) { async function deleteOut(id) {
await ElMessageBox.confirm('确认删除?'); await costApi.deleteOutsource(id); ElMessage.success('已删除'); loadOut() 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 () => { onMounted(async () => {
loadAI(); loadOut() loadAI(); loadOut(); loadOH()
try { projects.value = await projectApi.list({}) } catch {} try { projects.value = await projectApi.list({}) } catch {}
}) })
</script> </script>

View File

@ -1,7 +1,7 @@
<template> <template>
<div class="dashboard" v-loading="loading"> <div class="dashboard" v-loading="loading">
<!-- 顶部统计卡片 --> <!-- 顶部统计卡片 -->
<div class="stat-grid"> <div class="stat-grid six">
<div class="stat-card"> <div class="stat-card">
<div class="stat-icon blue"><el-icon :size="20"><FolderOpened /></el-icon></div> <div class="stat-icon blue"><el-icon :size="20"><FolderOpened /></el-icon></div>
<div class="stat-body"> <div class="stat-body">
@ -30,6 +30,28 @@
<div class="stat-label">人均日产出</div> <div class="stat-label">人均日产出</div>
</div> </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> </div>
<!-- 图表行产出趋势 + 成本构成 --> <!-- 图表行产出趋势 + 成本构成 -->
@ -82,18 +104,37 @@
</div> </div>
</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" v-if="data.settled_projects?.length">
<div class="card-header"><span class="card-title">已结算项目</span></div> <div class="card-header"><span class="card-title">已结算项目</span></div>
<div class="card-body"> <div class="card-body">
<el-table :data="data.settled_projects" size="small"> <el-table :data="data.settled_projects" size="small">
<el-table-column prop="project_name" label="项目" /> <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"> <el-table-column label="总成本" align="right" width="120">
<template #default="{ row }">¥{{ formatNum(row.total_cost) }}</template> <template #default="{ row }">¥{{ formatNum(row.total_cost) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="盈亏" align="right" width="120"> <el-table-column label="盈亏" align="right" width="120">
<template #default="{ row }"> <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) }} {{ row.profit_loss >= 0 ? '+' : '' }}¥{{ formatNum(row.profit_loss) }}
</span> </span>
<span v-else class="text-muted"></span> <span v-else class="text-muted"></span>
@ -118,8 +159,9 @@ const trendChartRef = ref(null)
const costChartRef = ref(null) const costChartRef = ref(null)
const comparisonChartRef = ref(null) const comparisonChartRef = ref(null)
const wasteChartRef = 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 formatNum(n) { return (n || 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 }) }
function formatSecs(s) { 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() { function handleResize() {
trendChart?.resize() trendChart?.resize()
costChart?.resize() costChart?.resize()
comparisonChart?.resize() comparisonChart?.resize()
wasteChart?.resize() wasteChart?.resize()
profitChart?.resize()
} }
onMounted(async () => { onMounted(async () => {
@ -242,6 +307,9 @@ onMounted(async () => {
initCostChart(data.value.cost_breakdown) initCostChart(data.value.cost_breakdown)
initComparisonChart(data.value.project_comparison) initComparisonChart(data.value.project_comparison)
initWasteChart(data.value.waste_ranking) initWasteChart(data.value.waste_ranking)
if (data.value.profitability?.profit_by_project?.length) {
initProfitChart(data.value.profitability.profit_by_project)
}
window.addEventListener('resize', handleResize) window.addEventListener('resize', handleResize)
} finally { } finally {
loading.value = false loading.value = false
@ -254,12 +322,15 @@ onUnmounted(() => {
costChart?.dispose() costChart?.dispose()
comparisonChart?.dispose() comparisonChart?.dispose()
wasteChart?.dispose() wasteChart?.dispose()
profitChart?.dispose()
}) })
</script> </script>
<style scoped> <style scoped>
/* 统计网格 */ /* 统计网格 */
.stat-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 20px; } .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 { .stat-card {
background: var(--bg-card); border: 1px solid var(--border-color); background: var(--bg-card); border: 1px solid var(--border-color);
border-radius: var(--radius-md); padding: 20px; display: flex; align-items: center; gap: 16px; 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.orange { background: #FFF3E0; color: #FF9500; }
.stat-icon.green { background: #E8F8EE; color: #34C759; } .stat-icon.green { background: #E8F8EE; color: #34C759; }
.stat-icon.purple { background: #F0E8FE; color: #9B59B6; } .stat-icon.purple { background: #F0E8FE; color: #9B59B6; }
.stat-icon.red { background: #FFE8E7; color: #FF3B30; }
.stat-body { flex: 1; } .stat-body { flex: 1; }
.stat-value { font-size: 22px; font-weight: 700; color: var(--text-primary); line-height: 1.2; } .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; } .stat-label { font-size: 12px; color: var(--text-secondary); margin-top: 4px; }
@ -279,6 +351,7 @@ onUnmounted(() => {
.chart-card.wide { flex: 2; } .chart-card.wide { flex: 2; }
.chart-card.narrow { flex: 1; } .chart-card.narrow { flex: 1; }
.chart-card.half { flex: 1; } .chart-card.half { flex: 1; }
.chart-card.full-width { flex: 1; }
.chart-container { width: 100%; height: 260px; } .chart-container { width: 100%; height: 260px; }
/* 卡片 */ /* 卡片 */
@ -307,7 +380,9 @@ onUnmounted(() => {
.progress-pct { font-size: 14px; font-weight: 600; color: var(--primary); } .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; } .progress-meta { display: flex; justify-content: space-between; font-size: 12px; color: var(--text-secondary); margin-top: 6px; }
.profit { font-weight: 600; color: #34C759; } .stat-value.profit { color: #34C759; }
.profit.loss { color: #FF3B30; } .stat-value.loss { color: #FF3B30; }
.profit-text { font-weight: 600; color: #34C759; }
.profit-text.loss { color: #FF3B30; }
.text-muted { color: var(--text-secondary); } .text-muted { color: var(--text-secondary); }
</style> </style>

View File

@ -23,7 +23,7 @@
<el-form-item> <el-form-item>
<el-input v-model="form.password" placeholder="密码" type="password" size="large" show-password /> <el-input v-model="form.password" placeholder="密码" type="password" size="large" show-password />
</el-form-item> </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-button>
</el-form> </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> <el-tag :type="project.status === '已完成' ? 'success' : 'info'" size="small">{{ project.status }}</el-tag>
</div> </div>
<el-space> <el-space>
<el-button v-if="authStore.isOwner() && project.status === '制作中'" type="danger" plain @click="handleComplete">确认完成</el-button> <el-button v-if="authStore.hasPermission('project:delete')" type="danger" text @click="handleDelete">删除项目</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: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> </el-space>
</div> </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-grid">
<div class="stat-card"> <div class="stat-card">
@ -33,21 +64,47 @@
</div> </div>
</div> </div>
<!-- 进度条 --> <!-- 项目进度时间轴 -->
<div class="card"> <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"> <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="progress-section">
<div class="meta-row"> <div class="progress-label-row">
<span>目标{{ project.episode_count }} × {{ project.episode_duration_minutes }} = {{ formatSecs(project.target_total_seconds) }}</span> <span>产出进度</span>
<span v-if="project.estimated_completion_date">预估完成{{ project.estimated_completion_date }}</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>
</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-header"><span class="card-title">团队效率</span></div>
<div class="card-body"> <div class="card-body">
<el-table :data="efficiency" size="small"> <el-table :data="efficiency" size="small">
@ -70,6 +127,87 @@
</div> </div>
</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">
<div class="card-header"> <div class="card-header">
@ -98,7 +236,7 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, reactive, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { projectApi, submissionApi } from '../api' import { projectApi, submissionApi } from '../api'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
@ -115,6 +253,146 @@ const typeTagMap = { '客户正式项目': 'success', '客户测试项目': 'war
const progressColor = computed(() => project.value.progress_percent > 100 ? '#FF9500' : '#3370FF') 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) { function formatSecs(s) {
if (!s) return '0秒' if (!s) return '0秒'
const abs = Math.abs(s) const abs = Math.abs(s)
@ -130,12 +408,25 @@ async function load() {
const id = route.params.id const id = route.params.id
project.value = await projectApi.get(id) project.value = await projectApi.get(id)
submissions.value = await submissionApi.list({ project_id: 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 {} try { efficiency.value = await projectApi.efficiency(id) } catch {}
} }
} finally { loading.value = false } } 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() { async function handleComplete() {
try { try {
await ElMessageBox.confirm('确认将此项目标记为完成并进行结算?此操作不可撤销。', '确认完成', { type: 'warning' }) await ElMessageBox.confirm('确认将此项目标记为完成并进行结算?此操作不可撤销。', '确认完成', { type: 'warning' })
@ -154,6 +445,21 @@ onMounted(load)
.page-header-left h2 { font-size: 18px; font-weight: 600; } .page-header-left h2 { font-size: 18px; font-weight: 600; }
.back-btn { font-size: 16px !important; padding: 4px !important; } .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-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 16px; }
.stat-card { .stat-card {
background: var(--bg-card); border: 1px solid var(--border-color); 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-title { font-size: 14px; font-weight: 600; }
.card-count { font-size: 12px; color: var(--text-secondary); } .card-count { font-size: 12px; color: var(--text-secondary); }
.card-body { padding: 20px; } .card-body { padding: 20px; }
.meta-row { display: flex; justify-content: space-between; font-size: 13px; color: var(--text-secondary); }
.rate-badge { .rate-badge {
font-size: 12px; font-weight: 600; color: var(--text-secondary); font-size: 12px; font-weight: 600; color: var(--text-secondary);
background: var(--bg-hover); padding: 2px 8px; border-radius: 4px; display: inline-block; background: var(--bg-hover); padding: 2px 8px; border-radius: 4px; display: inline-block;
} }
.rate-badge.danger { background: #FFE8E7; color: #FF3B30; } .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> </style>

View File

@ -2,7 +2,7 @@
<div> <div>
<div class="page-header"> <div class="page-header">
<div></div> <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-icon><Plus /></el-icon> 新建项目
</el-button> </el-button>
</div> </div>
@ -142,7 +142,7 @@ async function handleCreate() {
onMounted(async () => { onMounted(async () => {
load() load()
if (authStore.isLeaderOrAbove()) { if (authStore.hasPermission('user:view')) {
try { users.value = await userApi.list() } catch {} 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-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-card shadow="hover"><div class="stat-label">人力成本</div><div class="stat-value">¥{{ fmt(data.labor_cost) }}</div></el-card>
</el-col> </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-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>
<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-card shadow="hover"><div class="stat-label">外包成本</div><div class="stat-value">¥{{ fmt(data.outsource_cost) }}</div></el-card>
</el-col> </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"> <el-card shadow="hover">
<div class="stat-label">项目总成本</div> <div class="stat-label">项目总成本</div>
<div class="stat-value" style="color:#e6a23c">¥{{ fmt(data.total_cost) }}</div> <div class="stat-value" style="color:#e6a23c">¥{{ fmt(data.total_cost) }}</div>

View File

@ -6,24 +6,39 @@
</div> </div>
<el-table :data="users" v-loading="loading" stripe> <el-table :data="users" v-loading="loading" stripe>
<el-table-column prop="name" label="姓名" width="100" /> <el-table-column label="姓名" width="100">
<el-table-column prop="username" label="用户名" width="120" />
<el-table-column prop="phase_group" label="阶段组" width="80" />
<el-table-column label="角色" width="90">
<template #default="{row}"> <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> </template>
</el-table-column> </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> <template #default="{row}">¥{{ row.monthly_salary.toLocaleString() }}</template>
</el-table-column> </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> <template #default="{row}">¥{{ row.daily_cost.toLocaleString() }}</template>
</el-table-column> </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> <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>
<el-table-column label="操作" width="100"> <el-table-column label="操作" width="80">
<template #default="{row}"> <template #default="{row}">
<el-button text size="small" @click="editUser(row)">编辑</el-button> <el-button text size="small" @click="editUser(row)">编辑</el-button>
</template> </template>
@ -31,8 +46,8 @@
</el-table> </el-table>
<!-- 新增/编辑对话框 --> <!-- 新增/编辑对话框 -->
<el-dialog v-model="showCreate" :title="editingId ? '编辑用户' : '新增用户'" width="480px" destroy-on-close> <el-dialog v-model="showCreate" :title="editingId ? '编辑用户' : '新增用户'" width="520px" destroy-on-close>
<el-form :model="form" label-width="80px"> <el-form :model="form" label-width="90px">
<el-form-item label="姓名"><el-input v-model="form.name" /></el-form-item> <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.username" /></el-form-item>
<el-form-item v-if="!editingId" label="密码"><el-input v-model="form.password" type="password" /></el-form-item> <el-form-item v-if="!editingId" label="密码"><el-input v-model="form.password" type="password" /></el-form-item>
@ -44,17 +59,25 @@
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="角色"> <el-form-item label="角色">
<el-select v-model="form.role" placeholder="系统权限角色" style="width:100%"> <el-select v-model="form.role_id" placeholder="系统权限角色" style="width:100%">
<el-option label="成员 — 仅提交内容" value="成员" /> <el-option v-for="r in roles" :key="r.id" :label="r.name" :value="r.id" />
<el-option label="组长 — 管理本组提交" value="组长" />
<el-option label="主管 — 管理项目和人员" value="主管" />
<el-option label="Owner — 全部权限" value="Owner" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-divider content-position="left">成本信息</el-divider>
<el-form-item label="月薪"> <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>
<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-switch v-model="form.is_active" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="停用" />
</el-form-item> </el-form-item>
</el-form> </el-form>
@ -68,27 +91,38 @@
<script setup> <script setup>
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { userApi } from '../api' import { userApi, roleApi } from '../api'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
const loading = ref(false) const loading = ref(false)
const showCreate = ref(false) const showCreate = ref(false)
const editingId = ref(null) const editingId = ref(null)
const users = ref([]) const users = ref([])
const roleMap = { 'Owner': 'danger', '主管': 'warning', '组长': '', '成员': 'info' } const roles = ref([])
const form = reactive({ username: '', password: '', name: '', phase_group: '制作', role: '成员', monthly_salary: 0, is_active: 1 }) 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 } } async function load() { loading.value = true; try { users.value = await userApi.list() } finally { loading.value = false } }
function editUser(u) { function editUser(u) {
editingId.value = u.id 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 showCreate.value = true
} }
async function handleSave() { async function handleSave() {
if (editingId.value) { 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 { } else {
await userApi.create(form) await userApi.create(form)
} }
@ -98,10 +132,21 @@ async function handleSave() {
load() load()
} }
onMounted(load) onMounted(async () => {
load()
try { roles.value = await roleApi.list() } catch {}
})
</script> </script>
<style scoped> <style scoped>
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; } .page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.page-header h2 { font-size: 18px; font-weight: 600; } .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> </style>