From 6ac44d47fb77a3857fcd2a3a17fe7bf5cbccc60c Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Thu, 12 Feb 2026 17:41:27 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=A4=A4=E5=9C=AD=E6=B4=B0=E7=92=87?= =?UTF-8?q?=EF=B8=BD=E5=84=8F=E6=BE=A7=E7=82=B2=E5=B7=B1+=E9=8E=B4?= =?UTF-8?q?=E6=84=AC=E6=86=B3=E7=92=87=EF=B8=BD=E5=84=8F=E6=A4=A4=3F?= =?UTF-8?q?=E6=B5=A0=EE=81=87=E3=80=83=E9=90=A9=E6=A8=BC=E6=B3=A9=E9=8D=92?= =?UTF-8?q?=E2=95=82=EE=9B=A7=E7=91=99=3F=E7=91=99=E6=8E=95=E5=A3=8A?= =?UTF-8?q?=E9=8F=89=E5=86=AE=E6=AA=BA=E7=BB=AF=E8=8D=A4=E7=B2=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- backend/airlabs.db | Bin 90112 -> 110592 bytes backend/auth.py | 22 +- backend/calculations.py | 31 +- backend/main.py | 28 +- backend/models.py | 149 +++++++-- backend/routers/auth.py | 7 +- backend/routers/costs.py | 88 +++++- backend/routers/dashboard.py | 54 +++- backend/routers/projects.py | 35 ++- backend/routers/roles.py | 113 +++++++ backend/routers/submissions.py | 12 +- backend/routers/users.py | 30 +- backend/schemas.py | 37 ++- backend/seed.py | 42 +-- frontend/src/api/index.js | 13 + frontend/src/components/Layout.vue | 22 +- frontend/src/router/index.js | 53 +++- frontend/src/stores/auth.js | 13 +- frontend/src/views/Costs.vue | 61 +++- frontend/src/views/Dashboard.vue | 85 +++++- frontend/src/views/Login.vue | 2 +- frontend/src/views/MemberDetail.vue | 280 +++++++++++++++++ frontend/src/views/ProjectDetail.vue | 435 ++++++++++++++++++++++++++- frontend/src/views/Projects.vue | 4 +- frontend/src/views/Roles.vue | 196 ++++++++++++ frontend/src/views/Settlement.vue | 11 +- frontend/src/views/Users.vue | 93 ++++-- 27 files changed, 1732 insertions(+), 184 deletions(-) create mode 100644 backend/routers/roles.py create mode 100644 frontend/src/views/MemberDetail.vue create mode 100644 frontend/src/views/Roles.vue diff --git a/backend/airlabs.db b/backend/airlabs.db index 1ceb16a98bf24d4f358b1a557d349eba277c3529..6ff9f2e8124893091f3ed6fbd7fb7d2d5c3a3169 100644 GIT binary patch delta 5708 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 diff --git a/backend/auth.py b/backend/auth.py index 7820778..9be5348 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -7,7 +7,6 @@ from jose import JWTError, jwt from passlib.context import CryptContext from sqlalchemy.orm import Session from database import get_db -from models import User, UserRole from config import SECRET_KEY, ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") @@ -29,8 +28,9 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) - return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) -def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> User: +def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)): """从 JWT token 解析当前用户""" + from models import User credentials_exception = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="登录已过期,请重新登录", @@ -51,13 +51,13 @@ def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends( return user -def require_role(*roles: UserRole): - """权限装饰器:要求当前用户具有指定角色之一""" - def role_checker(current_user: User = Depends(get_current_user)): - if current_user.role not in [r.value for r in roles] and current_user.role not in roles: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="权限不足" - ) +def require_permission(*perms: str): + """权限校验依赖:要求当前用户拥有指定权限中的至少一项""" + def perm_checker(current_user=Depends(get_current_user)): + if not current_user.role_ref: + raise HTTPException(status_code=403, detail="未分配角色") + user_perms = current_user.permissions or [] + if not any(p in user_perms for p in perms): + raise HTTPException(status_code=403, detail="权限不足") return current_user - return role_checker + return perm_checker diff --git a/backend/calculations.py b/backend/calculations.py index c678849..9a41c25 100644 --- a/backend/calculations.py +++ b/backend/calculations.py @@ -8,7 +8,7 @@ from collections import defaultdict from datetime import date, timedelta from models import ( User, Project, Submission, AIToolCost, AIToolCostAllocation, - OutsourceCost, CostOverride, WorkType, CostAllocationType + OutsourceCost, CostOverride, OverheadCost, WorkType, CostAllocationType ) from config import WORKING_DAYS_PER_MONTH @@ -142,6 +142,31 @@ def calc_outsource_cost_for_project(project_id: int, db: Session) -> float: return round(total, 2) +# ──────────────────────────── 固定开支分摊 ──────────────────────────── + +def calc_overhead_cost_for_project(project_id: int, db: Session) -> float: + """ + 计算某项目分摊的固定开支(办公室租金+水电费) + 规则:按所有项目的产出秒数比例均摊 + """ + total_overhead = db.query(sa_func.sum(OverheadCost.amount)).scalar() or 0 + if total_overhead == 0: + return 0.0 + + all_secs = db.query(sa_func.sum(Submission.total_seconds)).filter( + Submission.total_seconds > 0 + ).scalar() or 0 + proj_secs = db.query(sa_func.sum(Submission.total_seconds)).filter( + Submission.project_id == project_id, + Submission.total_seconds > 0, + ).scalar() or 0 + + if all_secs > 0: + ratio = proj_secs / all_secs + return round(total_overhead * ratio, 2) + return 0.0 + + # ──────────────────────────── 损耗计算 ──────────────────────────── def calc_waste_for_project(project_id: int, db: Session) -> dict: @@ -243,7 +268,8 @@ def calc_project_settlement(project_id: int, db: Session) -> dict: labor = calc_labor_cost_for_project(project_id, db) ai_tool = calc_ai_tool_cost_for_project(project_id, db) outsource = calc_outsource_cost_for_project(project_id, db) - total_cost = labor + ai_tool + outsource + overhead = calc_overhead_cost_for_project(project_id, db) + total_cost = labor + ai_tool + outsource + overhead waste = calc_waste_for_project(project_id, db) efficiency = calc_team_efficiency(project_id, db) @@ -254,6 +280,7 @@ def calc_project_settlement(project_id: int, db: Session) -> dict: "labor_cost": labor, "ai_tool_cost": ai_tool, "outsource_cost": outsource, + "overhead_cost": overhead, "total_cost": round(total_cost, 2), **waste, "team_efficiency": efficiency, diff --git a/backend/main.py b/backend/main.py index 58e34dd..28754ea 100644 --- a/backend/main.py +++ b/backend/main.py @@ -4,7 +4,7 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse from database import engine, Base -from models import User, UserRole, PhaseGroup +from models import User, Role, PhaseGroup, BUILTIN_ROLES from auth import hash_password import os @@ -30,6 +30,7 @@ from routers.projects import router as projects_router from routers.submissions import router as submissions_router from routers.costs import router as costs_router from routers.dashboard import router as dashboard_router +from routers.roles import router as roles_router app.include_router(auth_router) app.include_router(users_router) @@ -37,6 +38,7 @@ app.include_router(projects_router) app.include_router(submissions_router) app.include_router(costs_router) app.include_router(dashboard_router) +app.include_router(roles_router) # 前端静态文件 frontend_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "frontend", "dist") @@ -52,18 +54,34 @@ if os.path.exists(frontend_dir): @app.on_event("startup") -def init_default_owner(): - """首次启动时创建默认 Owner 账号""" +def init_roles_and_admin(): + """首次启动时创建内置角色和默认管理员""" from database import SessionLocal db = SessionLocal() try: - if not db.query(User).filter(User.role == UserRole.OWNER).first(): + # 初始化内置角色 + for role_name, role_def in BUILTIN_ROLES.items(): + existing = db.query(Role).filter(Role.name == role_name).first() + if not existing: + role = Role( + name=role_name, + description=role_def["description"], + permissions=role_def["permissions"], + is_system=1, + ) + db.add(role) + print(f"[OK] created role: {role_name}") + db.commit() + + # 创建默认管理员(关联超级管理员角色) + admin_role = db.query(Role).filter(Role.name == "超级管理员").first() + if admin_role and not db.query(User).filter(User.username == "admin").first(): owner = User( username="admin", password_hash=hash_password("admin123"), name="管理员", phase_group=PhaseGroup.PRODUCTION, - role=UserRole.OWNER, + role_id=admin_role.id, monthly_salary=0, ) db.add(owner) diff --git a/backend/models.py b/backend/models.py index ab32cb1..c091d64 100644 --- a/backend/models.py +++ b/backend/models.py @@ -9,6 +9,71 @@ from database import Base import enum +# ──────────────────────────── 权限标识符定义 ──────────────────────────── + +ALL_PERMISSIONS = [ + # 仪表盘 + ("dashboard:view", "查看仪表盘", "仪表盘"), + # 项目管理 + ("project:view", "查看项目", "项目管理"), + ("project:create", "创建项目", "项目管理"), + ("project:edit", "编辑项目", "项目管理"), + ("project:delete", "删除项目", "项目管理"), + ("project:complete", "确认完成项目", "项目管理"), + # 内容提交 + ("submission:view", "查看提交记录", "内容提交"), + ("submission:create", "新增提交", "内容提交"), + # 成本管理 + ("cost:view", "查看成本", "成本管理"), + ("cost:create", "录入成本", "成本管理"), + ("cost:delete", "删除成本", "成本管理"), + # 用户与角色 + ("user:view", "查看用户列表", "用户与角色"), + ("user:manage", "管理用户", "用户与角色"), + ("role:manage", "管理角色", "用户与角色"), + # 结算与效率 + ("settlement:view", "查看结算报告", "结算与效率"), + ("efficiency:view", "查看团队效率", "结算与效率"), +] + +PERMISSION_KEYS = [p[0] for p in ALL_PERMISSIONS] + +# 内置角色定义 +BUILTIN_ROLES = { + "超级管理员": { + "description": "系统最高权限,拥有全部功能", + "permissions": PERMISSION_KEYS[:], # 全部 + }, + "主管": { + "description": "管理项目和成本,不可管理用户和角色", + "permissions": [ + "dashboard:view", + "project:view", "project:create", "project:edit", "project:complete", + "submission:view", "submission:create", + "cost:view", "cost:create", "cost:delete", + "user:view", + "settlement:view", "efficiency:view", + ], + }, + "组长": { + "description": "管理本组提交和查看成本", + "permissions": [ + "project:view", "project:create", + "submission:view", "submission:create", + "cost:view", "cost:create", + "efficiency:view", + ], + }, + "成员": { + "description": "提交内容和查看项目", + "permissions": [ + "project:view", + "submission:view", "submission:create", + ], + }, +} + + # ──────────────────────────── 枚举定义 ──────────────────────────── class ProjectType(str, enum.Enum): @@ -29,13 +94,6 @@ class PhaseGroup(str, enum.Enum): POST = "后期" -class UserRole(str, enum.Enum): - MEMBER = "成员" - LEADER = "组长" - SUPERVISOR = "主管" - OWNER = "Owner" - - class WorkType(str, enum.Enum): PRODUCTION = "制作" TEST = "测试" @@ -73,6 +131,29 @@ class OutsourceType(str, enum.Enum): FULL_EPISODE = "整集" +class OverheadCostType(str, enum.Enum): + OFFICE_RENT = "办公室租金" + UTILITIES = "水电费" + + +# ──────────────────────────── 角色 ──────────────────────────── + +class Role(Base): + __tablename__ = "roles" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(50), unique=True, nullable=False) + description = Column(String(200), nullable=True) + permissions = Column(JSON, nullable=False, default=[]) # 权限标识符列表 + is_system = Column(Integer, nullable=False, default=0) # 1=内置角色不可删 + created_at = Column(DateTime, server_default=func.now()) + + users = relationship("User", back_populates="role_ref") + + def has_permission(self, perm: str) -> bool: + return perm in (self.permissions or []) + + # ──────────────────────────── 用户 ──────────────────────────── class User(Base): @@ -83,20 +164,37 @@ class User(Base): password_hash = Column(String(255), nullable=False) name = Column(String(50), nullable=False) phase_group = Column(SAEnum(PhaseGroup), nullable=False) - role = Column(SAEnum(UserRole), nullable=False, default=UserRole.MEMBER) + role_id = Column(Integer, ForeignKey("roles.id"), nullable=False) monthly_salary = Column(Float, nullable=False, default=0) + bonus = Column(Float, nullable=False, default=0) + social_insurance = Column(Float, nullable=False, default=0) is_active = Column(Integer, nullable=False, default=1) created_at = Column(DateTime, server_default=func.now()) # 关系 + role_ref = relationship("Role", back_populates="users") submissions = relationship("Submission", back_populates="user") led_projects = relationship("Project", back_populates="leader") + @property + def role_name(self): + return self.role_ref.name if self.role_ref else "" + + @property + def permissions(self): + return self.role_ref.permissions if self.role_ref else [] + + def has_permission(self, perm: str) -> bool: + return self.role_ref.has_permission(perm) if self.role_ref else False + + @property + def monthly_total_cost(self): + return (self.monthly_salary or 0) + (self.bonus or 0) + (self.social_insurance or 0) + @property def daily_cost(self): - """日成本 = 月薪 ÷ 22""" from config import WORKING_DAYS_PER_MONTH - return round(self.monthly_salary / WORKING_DAYS_PER_MONTH, 2) if self.monthly_salary else 0 + return round(self.monthly_total_cost / WORKING_DAYS_PER_MONTH, 2) if self.monthly_total_cost else 0 # ──────────────────────────── 项目 ──────────────────────────── @@ -114,10 +212,9 @@ class Project(Base): episode_count = Column(Integer, nullable=False) estimated_completion_date = Column(Date, nullable=True) actual_completion_date = Column(Date, nullable=True) - contract_amount = Column(Float, nullable=True) # 仅客户正式项目 + contract_amount = Column(Float, nullable=True) created_at = Column(DateTime, server_default=func.now()) - # 关系 leader = relationship("User", back_populates="led_projects") submissions = relationship("Submission", back_populates="project") outsource_costs = relationship("OutsourceCost", back_populates="project") @@ -125,7 +222,6 @@ class Project(Base): @property def target_total_seconds(self): - """目标总秒数 = 单集时长(分) × 60 × 集数""" return int(self.episode_duration_minutes * 60 * self.episode_count) @@ -142,14 +238,13 @@ class Submission(Base): content_type = Column(SAEnum(ContentType), nullable=False) duration_minutes = Column(Float, nullable=True, default=0) duration_seconds = Column(Float, nullable=True, default=0) - total_seconds = Column(Float, nullable=False, default=0) # 系统自动计算 - hours_spent = Column(Float, nullable=True) # 可选:投入时长(小时) + total_seconds = Column(Float, nullable=False, default=0) + hours_spent = Column(Float, nullable=True) submit_to = Column(SAEnum(SubmitTo), nullable=False) description = Column(Text, nullable=True) submit_date = Column(Date, nullable=False) created_at = Column(DateTime, server_default=func.now()) - # 关系 user = relationship("User", back_populates="submissions") project = relationship("Project", back_populates="submissions") history = relationship("SubmissionHistory", back_populates="submission") @@ -165,23 +260,21 @@ class AIToolCost(Base): subscription_period = Column(SAEnum(SubscriptionPeriod), nullable=False) amount = Column(Float, nullable=False) allocation_type = Column(SAEnum(CostAllocationType), nullable=False) - project_id = Column(Integer, ForeignKey("projects.id"), nullable=True) # 指定项目时 + project_id = Column(Integer, ForeignKey("projects.id"), nullable=True) recorded_by = Column(Integer, ForeignKey("users.id"), nullable=False) record_date = Column(Date, nullable=False) created_at = Column(DateTime, server_default=func.now()) - # 关系 allocations = relationship("AIToolCostAllocation", back_populates="ai_tool_cost") class AIToolCostAllocation(Base): - """AI 工具成本手动分摊明细""" __tablename__ = "ai_tool_cost_allocations" id = Column(Integer, primary_key=True, index=True) ai_tool_cost_id = Column(Integer, ForeignKey("ai_tool_costs.id"), nullable=False) project_id = Column(Integer, ForeignKey("projects.id"), nullable=False) - percentage = Column(Float, nullable=False) # 0-100 + percentage = Column(Float, nullable=False) ai_tool_cost = relationship("AIToolCost", back_populates="allocations") project = relationship("Project", back_populates="ai_tool_allocations") @@ -208,7 +301,6 @@ class OutsourceCost(Base): # ──────────────────────────── 人力成本手动调整 ──────────────────────────── class CostOverride(Base): - """管理员手动修改某人某天的成本分摊""" __tablename__ = "cost_overrides" id = Column(Integer, primary_key=True, index=True) @@ -224,7 +316,6 @@ class CostOverride(Base): # ──────────────────────────── 提交历史版本 ──────────────────────────── class SubmissionHistory(Base): - """内容提交的修改历史""" __tablename__ = "submission_history" id = Column(Integer, primary_key=True, index=True) @@ -236,3 +327,17 @@ class SubmissionHistory(Base): created_at = Column(DateTime, server_default=func.now()) submission = relationship("Submission", back_populates="history") + + +# ──────────────────────────── 固定开支 ──────────────────────────── + +class OverheadCost(Base): + __tablename__ = "overhead_costs" + + id = Column(Integer, primary_key=True, index=True) + cost_type = Column(SAEnum(OverheadCostType), nullable=False) + amount = Column(Float, nullable=False) + record_month = Column(String(7), nullable=False) + note = Column(Text, nullable=True) + recorded_by = Column(Integer, ForeignKey("users.id"), nullable=False) + created_at = Column(DateTime, server_default=func.now()) diff --git a/backend/routers/auth.py b/backend/routers/auth.py index 887fbee..45c6012 100644 --- a/backend/routers/auth.py +++ b/backend/routers/auth.py @@ -27,8 +27,13 @@ def get_me(current_user: User = Depends(get_current_user)): username=current_user.username, name=current_user.name, phase_group=current_user.phase_group.value if hasattr(current_user.phase_group, 'value') else current_user.phase_group, - role=current_user.role.value if hasattr(current_user.role, 'value') else current_user.role, + role_id=current_user.role_id, + role_name=current_user.role_name, + permissions=current_user.permissions, monthly_salary=current_user.monthly_salary, + bonus=current_user.bonus or 0, + social_insurance=current_user.social_insurance or 0, + monthly_total_cost=current_user.monthly_total_cost, daily_cost=current_user.daily_cost, is_active=current_user.is_active, created_at=current_user.created_at, diff --git a/backend/routers/costs.py b/backend/routers/costs.py index 86e2326..a9d4b8b 100644 --- a/backend/routers/costs.py +++ b/backend/routers/costs.py @@ -5,14 +5,15 @@ from typing import List, Optional from datetime import date from database import get_db from models import ( - User, UserRole, AIToolCost, AIToolCostAllocation, OutsourceCost, - CostOverride, SubscriptionPeriod, CostAllocationType, OutsourceType + User, AIToolCost, AIToolCostAllocation, OutsourceCost, + CostOverride, OverheadCost, SubscriptionPeriod, CostAllocationType, + OutsourceType, OverheadCostType ) from schemas import ( AIToolCostCreate, AIToolCostOut, OutsourceCostCreate, OutsourceCostOut, - CostOverrideCreate + CostOverrideCreate, OverheadCostCreate, OverheadCostOut ) -from auth import get_current_user, require_role +from auth import get_current_user, require_permission router = APIRouter(prefix="/api/costs", tags=["成本管理"]) @@ -22,7 +23,7 @@ router = APIRouter(prefix="/api/costs", tags=["成本管理"]) @router.get("/ai-tools", response_model=List[AIToolCostOut]) def list_ai_tool_costs( db: Session = Depends(get_db), - current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR, UserRole.LEADER)) + current_user: User = Depends(require_permission("cost:view")) ): costs = db.query(AIToolCost).order_by(AIToolCost.record_date.desc()).all() return [ @@ -44,7 +45,7 @@ def list_ai_tool_costs( def create_ai_tool_cost( req: AIToolCostCreate, db: Session = Depends(get_db), - current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR, UserRole.LEADER)) + current_user: User = Depends(require_permission("cost:create")) ): cost = AIToolCost( tool_name=req.tool_name, @@ -84,7 +85,7 @@ def create_ai_tool_cost( def delete_ai_tool_cost( cost_id: int, db: Session = Depends(get_db), - current_user: User = Depends(require_role(UserRole.OWNER)) + current_user: User = Depends(require_permission("cost:delete")) ): cost = db.query(AIToolCost).filter(AIToolCost.id == cost_id).first() if not cost: @@ -101,7 +102,7 @@ def delete_ai_tool_cost( def list_outsource_costs( project_id: Optional[int] = Query(None), db: Session = Depends(get_db), - current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR, UserRole.LEADER)) + current_user: User = Depends(require_permission("cost:view")) ): q = db.query(OutsourceCost) if project_id: @@ -123,7 +124,7 @@ def list_outsource_costs( def create_outsource_cost( req: OutsourceCostCreate, db: Session = Depends(get_db), - current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR, UserRole.LEADER)) + current_user: User = Depends(require_permission("cost:create")) ): cost = OutsourceCost( project_id=req.project_id, @@ -150,7 +151,7 @@ def create_outsource_cost( def delete_outsource_cost( cost_id: int, db: Session = Depends(get_db), - current_user: User = Depends(require_role(UserRole.OWNER)) + current_user: User = Depends(require_permission("cost:delete")) ): cost = db.query(OutsourceCost).filter(OutsourceCost.id == cost_id).first() if not cost: @@ -166,7 +167,7 @@ def delete_outsource_cost( def create_cost_override( req: CostOverrideCreate, db: Session = Depends(get_db), - current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR)) + current_user: User = Depends(require_permission("cost:create")) ): override = CostOverride( user_id=req.user_id, @@ -186,7 +187,7 @@ def list_cost_overrides( user_id: Optional[int] = Query(None), project_id: Optional[int] = Query(None), db: Session = Depends(get_db), - current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR)) + current_user: User = Depends(require_permission("cost:view")) ): q = db.query(CostOverride) if user_id: @@ -203,3 +204,66 @@ def list_cost_overrides( } for r in records ] + + +# ──────────────────── 固定开支(办公室租金、水电费) ──────────────────── + +@router.get("/overhead", response_model=List[OverheadCostOut]) +def list_overhead_costs( + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("cost:view")) +): + costs = db.query(OverheadCost).order_by(OverheadCost.record_month.desc()).all() + return [ + OverheadCostOut( + id=c.id, + cost_type=c.cost_type.value if hasattr(c.cost_type, 'value') else c.cost_type, + amount=c.amount, + record_month=c.record_month, + note=c.note, + recorded_by=c.recorded_by, + created_at=c.created_at, + ) + for c in costs + ] + + +@router.post("/overhead", response_model=OverheadCostOut) +def create_overhead_cost( + req: OverheadCostCreate, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("cost:create")) +): + cost = OverheadCost( + cost_type=OverheadCostType(req.cost_type), + amount=req.amount, + record_month=req.record_month, + note=req.note, + recorded_by=current_user.id, + ) + db.add(cost) + db.commit() + db.refresh(cost) + return OverheadCostOut( + id=cost.id, + cost_type=cost.cost_type.value, + amount=cost.amount, + record_month=cost.record_month, + note=cost.note, + recorded_by=cost.recorded_by, + created_at=cost.created_at, + ) + + +@router.delete("/overhead/{cost_id}") +def delete_overhead_cost( + cost_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("cost:delete")) +): + cost = db.query(OverheadCost).filter(OverheadCost.id == cost_id).first() + if not cost: + raise HTTPException(status_code=404, detail="记录不存在") + db.delete(cost) + db.commit() + return {"message": "已删除"} diff --git a/backend/routers/dashboard.py b/backend/routers/dashboard.py index ad0a6ff..69d4590 100644 --- a/backend/routers/dashboard.py +++ b/backend/routers/dashboard.py @@ -5,14 +5,15 @@ from sqlalchemy import func as sa_func from datetime import date, timedelta from database import get_db from models import ( - User, UserRole, Project, Submission, AIToolCost, + User, Project, Submission, AIToolCost, ProjectStatus, ProjectType, WorkType ) -from auth import get_current_user, require_role +from auth import get_current_user, require_permission from calculations import ( calc_project_settlement, calc_waste_for_project, calc_labor_cost_for_project, calc_ai_tool_cost_for_project, - calc_outsource_cost_for_project, calc_team_efficiency + calc_outsource_cost_for_project, calc_overhead_cost_for_project, + calc_team_efficiency ) router = APIRouter(prefix="/api", tags=["仪表盘与结算"]) @@ -21,7 +22,7 @@ router = APIRouter(prefix="/api", tags=["仪表盘与结算"]) @router.get("/dashboard") def get_dashboard( db: Session = Depends(get_db), - current_user: User = Depends(require_role(UserRole.OWNER)) + current_user: User = Depends(require_permission("dashboard:view")) ): """全局仪表盘数据""" # 项目概览 @@ -133,15 +134,18 @@ def get_dashboard( total_labor_all = 0.0 total_ai_all = 0.0 total_outsource_all = 0.0 + total_overhead_all = 0.0 for p in active + completed: total_labor_all += calc_labor_cost_for_project(p.id, db) total_ai_all += calc_ai_tool_cost_for_project(p.id, db) total_outsource_all += calc_outsource_cost_for_project(p.id, db) + total_overhead_all += calc_overhead_cost_for_project(p.id, db) cost_breakdown = [ {"name": "人力成本", "value": round(total_labor_all, 0)}, {"name": "AI工具", "value": round(total_ai_all, 0)}, {"name": "外包", "value": round(total_outsource_all, 0)}, + {"name": "固定开支", "value": round(total_overhead_all, 0)}, ] # ── 图表数据:各项目产出对比(进行中项目) ── @@ -157,6 +161,43 @@ def get_dashboard( "target": p.target_total_seconds, }) + # ── 盈利概览 ── + total_contract = 0.0 + total_cost_completed = 0.0 + for s in settled: + if s.get("contract_amount"): + total_contract += s["contract_amount"] + total_cost_completed += s.get("total_cost", 0) + total_profit = total_contract - total_cost_completed + profit_rate = round(total_profit / total_contract * 100, 1) if total_contract > 0 else 0 + + # 进行中项目的合同额和当前成本 + in_progress_contract = 0.0 + in_progress_cost = 0.0 + for p in active: + if p.contract_amount: + in_progress_contract += p.contract_amount + in_progress_cost += calc_labor_cost_for_project(p.id, db) + calc_ai_tool_cost_for_project(p.id, db) + calc_outsource_cost_for_project(p.id, db) + calc_overhead_cost_for_project(p.id, db) + + # 每个项目的盈亏(用于柱状图) + profit_by_project = [] + for s in settled: + if s.get("contract_amount") and s["contract_amount"] > 0: + profit_by_project.append({ + "name": s["project_name"], + "profit": round((s.get("contract_amount", 0) or 0) - s.get("total_cost", 0), 0), + }) + + profitability = { + "total_contract": round(total_contract, 0), + "total_cost": round(total_cost_completed, 0), + "total_profit": round(total_profit, 0), + "profit_rate": profit_rate, + "in_progress_contract": round(in_progress_contract, 0), + "in_progress_cost": round(in_progress_cost, 0), + "profit_by_project": profit_by_project, + } + return { "active_projects": len(active), "completed_projects": len(completed), @@ -167,6 +208,7 @@ def get_dashboard( "projects": project_summaries, "waste_ranking": waste_ranking, "settled_projects": settled, + "profitability": profitability, # 图表数据 "daily_trend": daily_trend, "cost_breakdown": cost_breakdown, @@ -178,7 +220,7 @@ def get_dashboard( def get_settlement( project_id: int, db: Session = Depends(get_db), - current_user: User = Depends(require_role(UserRole.OWNER)) + current_user: User = Depends(require_permission("settlement:view")) ): """项目结算报告""" project = db.query(Project).filter(Project.id == project_id).first() @@ -191,7 +233,7 @@ def get_settlement( def get_efficiency( project_id: int, db: Session = Depends(get_db), - current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR, UserRole.LEADER)) + current_user: User = Depends(require_permission("efficiency:view")) ): """项目团队效率数据""" return calc_team_efficiency(project_id, db) diff --git a/backend/routers/projects.py b/backend/routers/projects.py index 92c7a6a..dfad0f7 100644 --- a/backend/routers/projects.py +++ b/backend/routers/projects.py @@ -5,11 +5,11 @@ from sqlalchemy import func as sa_func from typing import List, Optional from database import get_db from models import ( - User, Project, Submission, UserRole, ProjectType, + User, Project, Submission, ProjectType, ProjectStatus, PhaseGroup, WorkType ) from schemas import ProjectCreate, ProjectUpdate, ProjectOut -from auth import get_current_user, require_role +from auth import get_current_user, require_permission router = APIRouter(prefix="/api/projects", tags=["项目管理"]) @@ -76,7 +76,7 @@ def list_projects( def create_project( req: ProjectCreate, db: Session = Depends(get_db), - current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR)) + current_user: User = Depends(require_permission("project:create")) ): project = Project( name=req.name, @@ -111,7 +111,7 @@ def update_project( project_id: int, req: ProjectUpdate, db: Session = Depends(get_db), - current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR)) + current_user: User = Depends(require_permission("project:edit")) ): p = db.query(Project).filter(Project.id == project_id).first() if not p: @@ -141,11 +141,36 @@ def update_project( return enrich_project(p, db) +@router.delete("/{project_id}") +def delete_project( + project_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("project:delete")) +): + """删除项目及其关联数据""" + from models import OutsourceCost, AIToolCostAllocation, CostOverride, SubmissionHistory + p = db.query(Project).filter(Project.id == project_id).first() + if not p: + raise HTTPException(status_code=404, detail="项目不存在") + + # 删除关联数据 + subs = db.query(Submission).filter(Submission.project_id == project_id).all() + for s in subs: + db.query(SubmissionHistory).filter(SubmissionHistory.submission_id == s.id).delete() + db.query(Submission).filter(Submission.project_id == project_id).delete() + db.query(OutsourceCost).filter(OutsourceCost.project_id == project_id).delete() + db.query(AIToolCostAllocation).filter(AIToolCostAllocation.project_id == project_id).delete() + db.query(CostOverride).filter(CostOverride.project_id == project_id).delete() + db.delete(p) + db.commit() + return {"message": "项目已删除"} + + @router.post("/{project_id}/complete") def complete_project( project_id: int, db: Session = Depends(get_db), - current_user: User = Depends(require_role(UserRole.OWNER)) + current_user: User = Depends(require_permission("project:complete")) ): """Owner 手动确认项目完成""" p = db.query(Project).filter(Project.id == project_id).first() diff --git a/backend/routers/roles.py b/backend/routers/roles.py new file mode 100644 index 0000000..12ead9a --- /dev/null +++ b/backend/routers/roles.py @@ -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": "角色已删除"} diff --git a/backend/routers/submissions.py b/backend/routers/submissions.py index 7d937da..6d78605 100644 --- a/backend/routers/submissions.py +++ b/backend/routers/submissions.py @@ -5,11 +5,11 @@ from typing import List, Optional from datetime import date from database import get_db from models import ( - User, Submission, SubmissionHistory, Project, UserRole, + User, Submission, SubmissionHistory, Project, PhaseGroup, WorkType, ContentType, SubmitTo ) from schemas import SubmissionCreate, SubmissionUpdate, SubmissionOut -from auth import get_current_user, require_role +from auth import get_current_user, require_permission router = APIRouter(prefix="/api/submissions", tags=["内容提交"]) @@ -44,8 +44,8 @@ def list_submissions( current_user: User = Depends(get_current_user) ): q = db.query(Submission) - # 成员只能看自己的 - if current_user.role == UserRole.MEMBER: + # 没有 user:view 权限的只能看自己的 + if not current_user.has_permission("user:view"): q = q.filter(Submission.user_id == current_user.id) elif user_id: q = q.filter(Submission.user_id == user_id) @@ -98,7 +98,7 @@ def update_submission( submission_id: int, req: SubmissionUpdate, db: Session = Depends(get_db), - current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR, UserRole.LEADER)) + current_user: User = Depends(require_permission("submission:create")) ): """高权限修改提交记录(需填写原因)""" sub = db.query(Submission).filter(Submission.id == submission_id).first() @@ -174,7 +174,7 @@ def update_submission( def get_submission_history( submission_id: int, db: Session = Depends(get_db), - current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR, UserRole.LEADER)) + current_user: User = Depends(require_permission("submission:view")) ): """查看提交的修改历史""" records = db.query(SubmissionHistory).filter( diff --git a/backend/routers/users.py b/backend/routers/users.py index 5435975..99ab23e 100644 --- a/backend/routers/users.py +++ b/backend/routers/users.py @@ -3,9 +3,9 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session from typing import List from database import get_db -from models import User, UserRole, PhaseGroup +from models import User, Role, PhaseGroup from schemas import UserCreate, UserUpdate, UserOut -from auth import get_current_user, hash_password, require_role +from auth import get_current_user, hash_password, require_permission router = APIRouter(prefix="/api/users", tags=["用户管理"]) @@ -14,8 +14,12 @@ def user_to_out(u: User) -> UserOut: return UserOut( id=u.id, username=u.username, name=u.name, phase_group=u.phase_group.value if hasattr(u.phase_group, 'value') else u.phase_group, - role=u.role.value if hasattr(u.role, 'value') else u.role, - monthly_salary=u.monthly_salary, daily_cost=u.daily_cost, + role_id=u.role_id, role_name=u.role_name, permissions=u.permissions, + monthly_salary=u.monthly_salary, + bonus=u.bonus or 0, + social_insurance=u.social_insurance or 0, + monthly_total_cost=u.monthly_total_cost, + daily_cost=u.daily_cost, is_active=u.is_active, created_at=u.created_at, ) @@ -23,7 +27,7 @@ def user_to_out(u: User) -> UserOut: @router.get("/", response_model=List[UserOut]) def list_users( db: Session = Depends(get_db), - current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR, UserRole.LEADER)) + current_user: User = Depends(require_permission("user:view")) ): users = db.query(User).order_by(User.created_at.desc()).all() return [user_to_out(u) for u in users] @@ -33,7 +37,7 @@ def list_users( def create_user( req: UserCreate, db: Session = Depends(get_db), - current_user: User = Depends(require_role(UserRole.OWNER)) + current_user: User = Depends(require_permission("user:manage")) ): if db.query(User).filter(User.username == req.username).first(): raise HTTPException(status_code=400, detail="用户名已存在") @@ -42,8 +46,10 @@ def create_user( password_hash=hash_password(req.password), name=req.name, phase_group=PhaseGroup(req.phase_group), - role=UserRole(req.role), + role_id=req.role_id, monthly_salary=req.monthly_salary, + bonus=req.bonus, + social_insurance=req.social_insurance, ) db.add(user) db.commit() @@ -56,7 +62,7 @@ def update_user( user_id: int, req: UserUpdate, db: Session = Depends(get_db), - current_user: User = Depends(require_role(UserRole.OWNER)) + current_user: User = Depends(require_permission("user:manage")) ): user = db.query(User).filter(User.id == user_id).first() if not user: @@ -65,10 +71,14 @@ def update_user( user.name = req.name if req.phase_group is not None: user.phase_group = PhaseGroup(req.phase_group) - if req.role is not None: - user.role = UserRole(req.role) + if req.role_id is not None: + user.role_id = req.role_id if req.monthly_salary is not None: user.monthly_salary = req.monthly_salary + if req.bonus is not None: + user.bonus = req.bonus + if req.social_insurance is not None: + user.social_insurance = req.social_insurance if req.is_active is not None: user.is_active = req.is_active db.commit() diff --git a/backend/schemas.py b/backend/schemas.py index 9fef907..c90c0c1 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -23,15 +23,19 @@ class UserCreate(BaseModel): password: str name: str phase_group: str # 前期/制作/后期 - role: str = "成员" + role_id: int monthly_salary: float = 0 + bonus: float = 0 + social_insurance: float = 0 class UserUpdate(BaseModel): name: Optional[str] = None phase_group: Optional[str] = None - role: Optional[str] = None + role_id: Optional[int] = None monthly_salary: Optional[float] = None + bonus: Optional[float] = None + social_insurance: Optional[float] = None is_active: Optional[int] = None @@ -40,8 +44,13 @@ class UserOut(BaseModel): username: str name: str phase_group: str - role: str + role_id: int + role_name: str + permissions: List[str] = [] monthly_salary: float + bonus: float + social_insurance: float + monthly_total_cost: float daily_cost: float is_active: int created_at: Optional[datetime] = None @@ -214,6 +223,28 @@ class CostOverrideCreate(BaseModel): reason: Optional[str] = None +# ──────────────────────────── 固定开支 ──────────────────────────── + +class OverheadCostCreate(BaseModel): + cost_type: str # 办公室租金/水电费 + amount: float + record_month: str # YYYY-MM + note: Optional[str] = None + + +class OverheadCostOut(BaseModel): + id: int + cost_type: str + amount: float + record_month: str + note: Optional[str] = None + recorded_by: int + created_at: Optional[datetime] = None + + class Config: + from_attributes = True + + # ──────────────────────────── 仪表盘 ──────────────────────────── class DashboardSummary(BaseModel): diff --git a/backend/seed.py b/backend/seed.py index cd722ae..3032b77 100644 --- a/backend/seed.py +++ b/backend/seed.py @@ -14,22 +14,35 @@ def seed(): db.execute(table.delete()) db.commit() + # ── 初始化内置角色 ── + roles = {} + for role_name, role_def in BUILTIN_ROLES.items(): + role = Role( + name=role_name, + description=role_def["description"], + permissions=role_def["permissions"], + is_system=1, + ) + db.add(role) + roles[role_name] = role + db.flush() + # ── 用户 ── users = [ User(username="admin", password_hash=hash_password("admin123"), - name="老板", phase_group=PhaseGroup.PRODUCTION, role=UserRole.OWNER, monthly_salary=0), + name="老板", phase_group=PhaseGroup.PRODUCTION, role_id=roles["超级管理员"].id, monthly_salary=0), User(username="zhangsan", password_hash=hash_password("123456"), - name="张三", phase_group=PhaseGroup.PRE, role=UserRole.LEADER, monthly_salary=15000), + name="张三", phase_group=PhaseGroup.PRE, role_id=roles["组长"].id, monthly_salary=15000), User(username="lisi", password_hash=hash_password("123456"), - name="李四", phase_group=PhaseGroup.PRODUCTION, role=UserRole.LEADER, monthly_salary=18000), + name="李四", phase_group=PhaseGroup.PRODUCTION, role_id=roles["组长"].id, monthly_salary=18000), User(username="wangwu", password_hash=hash_password("123456"), - name="王五", phase_group=PhaseGroup.PRODUCTION, role=UserRole.MEMBER, monthly_salary=12000), + name="王五", phase_group=PhaseGroup.PRODUCTION, role_id=roles["成员"].id, monthly_salary=12000), User(username="zhaoliu", password_hash=hash_password("123456"), - name="赵六", phase_group=PhaseGroup.PRODUCTION, role=UserRole.MEMBER, monthly_salary=12000), + name="赵六", phase_group=PhaseGroup.PRODUCTION, role_id=roles["成员"].id, monthly_salary=12000), User(username="sunqi", password_hash=hash_password("123456"), - name="孙七", phase_group=PhaseGroup.POST, role=UserRole.MEMBER, monthly_salary=13000), + name="孙七", phase_group=PhaseGroup.POST, role_id=roles["成员"].id, monthly_salary=13000), User(username="producer", password_hash=hash_password("123456"), - name="陈制片", phase_group=PhaseGroup.PRODUCTION, role=UserRole.SUPERVISOR, monthly_salary=20000), + name="陈制片", phase_group=PhaseGroup.PRODUCTION, role_id=roles["主管"].id, monthly_salary=20000), ] db.add_all(users) db.flush() @@ -69,7 +82,6 @@ def seed(): base_date = date.today() - timedelta(days=14) submissions = [] - # 张三(前期组)给项目 A 和 D 做前期 for i in range(5): d = base_date + timedelta(days=i) submissions.append(Submission( @@ -89,10 +101,9 @@ def seed(): submit_date=d, )) - # 李四(制作组组长)主要做项目 A for i in range(10): d = base_date + timedelta(days=i) - secs = 45 + (i % 3) * 15 # 45-75秒 + secs = 45 + (i % 3) * 15 wt = WorkType.TEST if i < 2 else WorkType.PRODUCTION submissions.append(Submission( user_id=lisi.id, project_id=proj_a.id, @@ -103,10 +114,9 @@ def seed(): submit_date=d, )) - # 王五(制作组)做项目 A 和 B for i in range(8): d = base_date + timedelta(days=i) - secs = 30 + (i % 4) * 20 # 30-90秒 + secs = 30 + (i % 4) * 20 submissions.append(Submission( user_id=wangwu.id, project_id=proj_a.id, project_phase=PhaseGroup.PRODUCTION, work_type=WorkType.PRODUCTION, @@ -127,10 +137,9 @@ def seed(): submit_date=d, )) - # 赵六(制作组)做项目 A for i in range(10): d = base_date + timedelta(days=i) - secs = 50 + (i % 2) * 30 # 50-80秒 + secs = 50 + (i % 2) * 30 wt = WorkType.TEST if i < 1 else WorkType.PRODUCTION submissions.append(Submission( user_id=zhaoliu.id, project_id=proj_a.id, @@ -141,7 +150,6 @@ def seed(): submit_date=d, )) - # 孙七(后期组)剪辑 for i in range(3): d = base_date + timedelta(days=i + 10) submissions.append(Submission( @@ -151,7 +159,6 @@ def seed(): submit_to=SubmitTo.PRODUCER, description=f"第{i+1}集粗剪完成", submit_date=d, )) - # 后期补拍 submissions.append(Submission( user_id=sunqi.id, project_id=proj_a.id, project_phase=PhaseGroup.POST, work_type=WorkType.PRODUCTION, @@ -163,7 +170,6 @@ def seed(): db.add_all(submissions) - # ── AI 工具成本 ── db.add(AIToolCost( tool_name="Midjourney", subscription_period=SubscriptionPeriod.MONTHLY, amount=200, allocation_type=CostAllocationType.TEAM, @@ -176,7 +182,6 @@ def seed(): recorded_by=producer.id, record_date=date.today().replace(day=1), )) - # ── 外包成本 ── db.add(OutsourceCost( project_id=proj_a.id, outsource_type=OutsourceType.ANIMATION, episode_start=10, episode_end=13, amount=20000, @@ -185,6 +190,7 @@ def seed(): db.commit() print("[OK] seed data generated") + print(f" - roles: {len(roles)}") print(f" - users: {len(users)}") print(f" - projects: 4") print(f" - submissions: {len(submissions)}") diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 9d489fb..ee862f0 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -57,6 +57,7 @@ export const projectApi = { create: (data) => api.post('/projects/', data), update: (id, data) => api.put(`/projects/${id}`, data), get: (id) => api.get(`/projects/${id}`), + delete: (id) => api.delete(`/projects/${id}`), complete: (id) => api.post(`/projects/${id}/complete`), settlement: (id) => api.get(`/projects/${id}/settlement`), efficiency: (id) => api.get(`/projects/${id}/efficiency`), @@ -80,6 +81,18 @@ export const costApi = { deleteOutsource: (id) => api.delete(`/costs/outsource/${id}`), createOverride: (data) => api.post('/costs/overrides', data), listOverrides: (params) => api.get('/costs/overrides', { params }), + listOverhead: () => api.get('/costs/overhead'), + createOverhead: (data) => api.post('/costs/overhead', data), + deleteOverhead: (id) => api.delete(`/costs/overhead/${id}`), +} + +// ── 角色 ── +export const roleApi = { + list: () => api.get('/roles/'), + create: (data) => api.post('/roles/', data), + update: (id, data) => api.put(`/roles/${id}`, data), + delete: (id) => api.delete(`/roles/${id}`), + permissions: () => api.get('/roles/permissions'), } // ── 仪表盘 ── diff --git a/frontend/src/components/Layout.vue b/frontend/src/components/Layout.vue index dc8692d..93d370a 100644 --- a/frontend/src/components/Layout.vue +++ b/frontend/src/components/Layout.vue @@ -19,7 +19,7 @@ :to="item.path" class="nav-item" :class="{ active: isActive(item.path) }" - v-show="!item.role || hasRole(item.role)" + v-show="!item.perm || authStore.hasPermission(item.perm)" > {{ item.label }} @@ -32,7 +32,7 @@
{{ authStore.user?.name?.[0] || '?' }}
{{ authStore.user?.name }}
-
{{ authStore.user?.role }}
+
{{ authStore.user?.role_name }}
@@ -69,11 +69,12 @@ const authStore = useAuthStore() const isCollapsed = ref(false) const menuItems = [ - { path: '/dashboard', label: '仪表盘', icon: 'Odometer', role: 'Owner' }, - { path: '/projects', label: '项目管理', icon: 'FolderOpened' }, - { path: '/submissions', label: '内容提交', icon: 'EditPen' }, - { path: '/costs', label: '成本管理', icon: 'Money', role: 'leader+' }, - { path: '/users', label: '用户管理', icon: 'User', role: 'Owner' }, + { path: '/dashboard', label: '仪表盘', icon: 'Odometer', perm: 'dashboard:view' }, + { path: '/projects', label: '项目管理', icon: 'FolderOpened', perm: 'project:view' }, + { path: '/submissions', label: '内容提交', icon: 'EditPen', perm: 'submission:view' }, + { path: '/costs', label: '成本管理', icon: 'Money', perm: 'cost:view' }, + { path: '/users', label: '用户管理', icon: 'User', perm: 'user:manage' }, + { path: '/roles', label: '角色管理', icon: 'Lock', perm: 'role:manage' }, ] const titleMap = { @@ -82,6 +83,7 @@ const titleMap = { '/submissions': '内容提交', '/costs': '成本管理', '/users': '用户管理', + '/roles': '角色管理', } const currentTitle = computed(() => { @@ -94,12 +96,6 @@ function isActive(path) { return route.path === path || route.path.startsWith(path + '/') } -function hasRole(role) { - if (role === 'Owner') return authStore.isOwner() - if (role === 'leader+') return authStore.isLeaderOrAbove() - return true -} - onMounted(async () => { if (authStore.token && !authStore.user) { await authStore.fetchUser() diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index ca6b2ae..c39436c 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -5,15 +5,17 @@ const routes = [ { path: '/', component: () => import('../components/Layout.vue'), - redirect: '/dashboard', + redirect: '/projects', children: [ - { path: 'dashboard', name: 'Dashboard', component: () => import('../views/Dashboard.vue'), meta: { roles: ['Owner'] } }, - { path: 'projects', name: 'Projects', component: () => import('../views/Projects.vue') }, - { path: 'projects/:id', name: 'ProjectDetail', component: () => import('../views/ProjectDetail.vue') }, - { path: 'submissions', name: 'Submissions', component: () => import('../views/Submissions.vue') }, - { path: 'costs', name: 'Costs', component: () => import('../views/Costs.vue'), meta: { roles: ['Owner', '主管', '组长'] } }, - { path: 'users', name: 'Users', component: () => import('../views/Users.vue'), meta: { roles: ['Owner'] } }, - { path: 'settlement/:id', name: 'Settlement', component: () => import('../views/Settlement.vue'), meta: { roles: ['Owner'] } }, + { path: 'dashboard', name: 'Dashboard', component: () => import('../views/Dashboard.vue'), meta: { perm: 'dashboard:view' } }, + { path: 'projects', name: 'Projects', component: () => import('../views/Projects.vue'), meta: { perm: 'project:view' } }, + { path: 'projects/:id', name: 'ProjectDetail', component: () => import('../views/ProjectDetail.vue'), meta: { perm: 'project:view' } }, + { path: 'submissions', name: 'Submissions', component: () => import('../views/Submissions.vue'), meta: { perm: 'submission:view' } }, + { path: 'costs', name: 'Costs', component: () => import('../views/Costs.vue'), meta: { perm: 'cost:view' } }, + { path: 'users', name: 'Users', component: () => import('../views/Users.vue'), meta: { perm: 'user:manage' } }, + { path: 'users/:id/detail', name: 'MemberDetail', component: () => import('../views/MemberDetail.vue'), meta: { perm: 'user:view' } }, + { path: 'roles', name: 'Roles', component: () => import('../views/Roles.vue'), meta: { perm: 'role:manage' } }, + { path: 'settlement/:id', name: 'Settlement', component: () => import('../views/Settlement.vue'), meta: { perm: 'settlement:view' } }, ], }, ] @@ -26,18 +28,43 @@ const router = createRouter({ // 路由守卫 router.beforeEach(async (to, from, next) => { const token = localStorage.getItem('token') + if (to.meta.public) { - // 已登录时访问登录页,直接跳首页 if (to.path === '/login' && token) { next('/') } else { next() } - } else if (!token) { - next('/login') - } else { - next() + return } + + if (!token) { + next('/login') + return + } + + // 有 token 但还没加载用户信息(刷新页面时),先加载 + const { useAuthStore } = await import('../stores/auth') + const authStore = useAuthStore() + if (!authStore.user) { + await authStore.fetchUser() + } + + // fetchUser 失败会 logout 清 token + if (!localStorage.getItem('token')) { + next('/login') + return + } + + // 权限校验:如果路由要求特定权限,且用户没有,跳到第一个有权限的页面 + if (to.meta.perm && !authStore.hasPermission(to.meta.perm)) { + // 找到第一个有权限的页面 + const fallback = routes[1].children.find(r => !r.meta?.perm || authStore.hasPermission(r.meta.perm)) + next(fallback ? '/' + fallback.path : '/login') + return + } + + next() }) export default router diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js index ad10cf6..e4bb509 100644 --- a/frontend/src/stores/auth.js +++ b/frontend/src/stores/auth.js @@ -10,7 +10,6 @@ export const useAuthStore = defineStore('auth', () => { const res = await authApi.login({ username, password }) token.value = res.access_token localStorage.setItem('token', res.access_token) - // 登录后立即获取用户信息,失败不影响登录流程 try { user.value = await authApi.me() } catch (e) { @@ -33,9 +32,13 @@ export const useAuthStore = defineStore('auth', () => { localStorage.removeItem('token') } - const isOwner = () => user.value?.role === 'Owner' - const isSupervisor = () => ['Owner', '主管'].includes(user.value?.role) - const isLeaderOrAbove = () => ['Owner', '主管', '组长'].includes(user.value?.role) + /** + * 核心权限判断方法 + * @param {string} perm - 权限标识符,如 'dashboard:view' + */ + function hasPermission(perm) { + return (user.value?.permissions || []).includes(perm) + } - return { user, token, login, fetchUser, logout, isOwner, isSupervisor, isLeaderOrAbove } + return { user, token, login, fetchUser, logout, hasPermission } }) diff --git a/frontend/src/views/Costs.vue b/frontend/src/views/Costs.vue index e51d96c..89b5bf9 100644 --- a/frontend/src/views/Costs.vue +++ b/frontend/src/views/Costs.vue @@ -16,7 +16,7 @@ - + @@ -39,11 +39,29 @@ - + + + + +
+ 新增 +
+ + + + + + + + + + + +
@@ -112,6 +130,31 @@ 保存 + + + + + + + + + + + + + + + + + + + + + + @@ -125,22 +168,28 @@ const authStore = useAuthStore() const activeTab = ref('ai') const loadingAI = ref(false) const loadingOut = ref(false) +const loadingOH = ref(false) const aiCosts = ref([]) const outCosts = ref([]) +const overheadCosts = ref([]) const projects = ref([]) const showAIForm = ref(false) const showOutForm = ref(false) +const showOHForm = ref(false) const projectMap = computed(() => { const m = {}; projects.value.forEach(p => m[p.id] = p.name); return m }) const today = new Date().toISOString().split('T')[0] +const currentMonth = today.slice(0, 7) const aiForm = reactive({ tool_name: '', subscription_period: '月', amount: 0, allocation_type: '内容组整体', project_id: null, record_date: today }) const outForm = reactive({ project_id: null, outsource_type: '动画', episode_start: 1, episode_end: 1, amount: 0, record_date: today }) +const ohForm = reactive({ cost_type: '办公室租金', amount: 0, record_month: currentMonth, note: '' }) async function loadAI() { loadingAI.value = true; try { aiCosts.value = await costApi.listAITools() } finally { loadingAI.value = false } } async function loadOut() { loadingOut.value = true; try { outCosts.value = await costApi.listOutsource({}) } finally { loadingOut.value = false } } +async function loadOH() { loadingOH.value = true; try { overheadCosts.value = await costApi.listOverhead() } finally { loadingOH.value = false } } async function createAI() { await costApi.createAITool(aiForm); ElMessage.success('已添加'); showAIForm.value = false; loadAI() @@ -148,15 +197,21 @@ async function createAI() { async function createOut() { await costApi.createOutsource(outForm); ElMessage.success('已添加'); showOutForm.value = false; loadOut() } +async function createOH() { + await costApi.createOverhead(ohForm); ElMessage.success('已添加'); showOHForm.value = false; loadOH() +} async function deleteAI(id) { await ElMessageBox.confirm('确认删除?'); await costApi.deleteAITool(id); ElMessage.success('已删除'); loadAI() } async function deleteOut(id) { await ElMessageBox.confirm('确认删除?'); await costApi.deleteOutsource(id); ElMessage.success('已删除'); loadOut() } +async function deleteOH(id) { + await ElMessageBox.confirm('确认删除?'); await costApi.deleteOverhead(id); ElMessage.success('已删除'); loadOH() +} onMounted(async () => { - loadAI(); loadOut() + loadAI(); loadOut(); loadOH() try { projects.value = await projectApi.list({}) } catch {} }) diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue index 0bf6cd5..ada6c3b 100644 --- a/frontend/src/views/Dashboard.vue +++ b/frontend/src/views/Dashboard.vue @@ -1,7 +1,7 @@