feat: 椤圭洰璇︽儏澧炲己+鎴愬憳璇︽儏椤?浠〃鐩樼泩鍒╂瑙?瑙掕壊鏉冮檺绯荤粺
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
f7b9db6f42
commit
6ac44d47fb
Binary file not shown.
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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": "已删除"}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -5,11 +5,11 @@ from sqlalchemy import func as sa_func
|
||||
from typing import List, Optional
|
||||
from database import get_db
|
||||
from models import (
|
||||
User, Project, Submission, UserRole, ProjectType,
|
||||
User, Project, Submission, ProjectType,
|
||||
ProjectStatus, PhaseGroup, WorkType
|
||||
)
|
||||
from schemas import ProjectCreate, ProjectUpdate, ProjectOut
|
||||
from auth import get_current_user, require_role
|
||||
from auth import get_current_user, require_permission
|
||||
|
||||
router = APIRouter(prefix="/api/projects", tags=["项目管理"])
|
||||
|
||||
@ -76,7 +76,7 @@ def list_projects(
|
||||
def create_project(
|
||||
req: ProjectCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR))
|
||||
current_user: User = Depends(require_permission("project:create"))
|
||||
):
|
||||
project = Project(
|
||||
name=req.name,
|
||||
@ -111,7 +111,7 @@ def update_project(
|
||||
project_id: int,
|
||||
req: ProjectUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR))
|
||||
current_user: User = Depends(require_permission("project:edit"))
|
||||
):
|
||||
p = db.query(Project).filter(Project.id == project_id).first()
|
||||
if not p:
|
||||
@ -141,11 +141,36 @@ def update_project(
|
||||
return enrich_project(p, db)
|
||||
|
||||
|
||||
@router.delete("/{project_id}")
|
||||
def delete_project(
|
||||
project_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("project:delete"))
|
||||
):
|
||||
"""删除项目及其关联数据"""
|
||||
from models import OutsourceCost, AIToolCostAllocation, CostOverride, SubmissionHistory
|
||||
p = db.query(Project).filter(Project.id == project_id).first()
|
||||
if not p:
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
|
||||
# 删除关联数据
|
||||
subs = db.query(Submission).filter(Submission.project_id == project_id).all()
|
||||
for s in subs:
|
||||
db.query(SubmissionHistory).filter(SubmissionHistory.submission_id == s.id).delete()
|
||||
db.query(Submission).filter(Submission.project_id == project_id).delete()
|
||||
db.query(OutsourceCost).filter(OutsourceCost.project_id == project_id).delete()
|
||||
db.query(AIToolCostAllocation).filter(AIToolCostAllocation.project_id == project_id).delete()
|
||||
db.query(CostOverride).filter(CostOverride.project_id == project_id).delete()
|
||||
db.delete(p)
|
||||
db.commit()
|
||||
return {"message": "项目已删除"}
|
||||
|
||||
|
||||
@router.post("/{project_id}/complete")
|
||||
def complete_project(
|
||||
project_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_role(UserRole.OWNER))
|
||||
current_user: User = Depends(require_permission("project:complete"))
|
||||
):
|
||||
"""Owner 手动确认项目完成"""
|
||||
p = db.query(Project).filter(Project.id == project_id).first()
|
||||
|
||||
113
backend/routers/roles.py
Normal file
113
backend/routers/roles.py
Normal 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": "角色已删除"}
|
||||
@ -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(
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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)}")
|
||||
|
||||
@ -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'),
|
||||
}
|
||||
|
||||
// ── 仪表盘 ──
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
:to="item.path"
|
||||
class="nav-item"
|
||||
:class="{ active: isActive(item.path) }"
|
||||
v-show="!item.role || hasRole(item.role)"
|
||||
v-show="!item.perm || authStore.hasPermission(item.perm)"
|
||||
>
|
||||
<el-icon :size="18"><component :is="item.icon" /></el-icon>
|
||||
<span v-show="!isCollapsed" class="nav-label">{{ item.label }}</span>
|
||||
@ -32,7 +32,7 @@
|
||||
<div class="user-avatar">{{ authStore.user?.name?.[0] || '?' }}</div>
|
||||
<div class="user-meta">
|
||||
<div class="user-name">{{ authStore.user?.name }}</div>
|
||||
<div class="user-role">{{ authStore.user?.role }}</div>
|
||||
<div class="user-role">{{ authStore.user?.role_name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -69,11 +69,12 @@ const authStore = useAuthStore()
|
||||
const isCollapsed = ref(false)
|
||||
|
||||
const menuItems = [
|
||||
{ path: '/dashboard', label: '仪表盘', icon: 'Odometer', role: 'Owner' },
|
||||
{ path: '/projects', label: '项目管理', icon: 'FolderOpened' },
|
||||
{ path: '/submissions', label: '内容提交', icon: 'EditPen' },
|
||||
{ path: '/costs', label: '成本管理', icon: 'Money', role: 'leader+' },
|
||||
{ path: '/users', label: '用户管理', icon: 'User', role: 'Owner' },
|
||||
{ path: '/dashboard', label: '仪表盘', icon: 'Odometer', perm: 'dashboard:view' },
|
||||
{ path: '/projects', label: '项目管理', icon: 'FolderOpened', perm: 'project:view' },
|
||||
{ path: '/submissions', label: '内容提交', icon: 'EditPen', perm: 'submission:view' },
|
||||
{ path: '/costs', label: '成本管理', icon: 'Money', perm: 'cost:view' },
|
||||
{ path: '/users', label: '用户管理', icon: 'User', perm: 'user:manage' },
|
||||
{ path: '/roles', label: '角色管理', icon: 'Lock', perm: 'role:manage' },
|
||||
]
|
||||
|
||||
const titleMap = {
|
||||
@ -82,6 +83,7 @@ const titleMap = {
|
||||
'/submissions': '内容提交',
|
||||
'/costs': '成本管理',
|
||||
'/users': '用户管理',
|
||||
'/roles': '角色管理',
|
||||
}
|
||||
|
||||
const currentTitle = computed(() => {
|
||||
@ -94,12 +96,6 @@ function isActive(path) {
|
||||
return route.path === path || route.path.startsWith(path + '/')
|
||||
}
|
||||
|
||||
function hasRole(role) {
|
||||
if (role === 'Owner') return authStore.isOwner()
|
||||
if (role === 'leader+') return authStore.isLeaderOrAbove()
|
||||
return true
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (authStore.token && !authStore.user) {
|
||||
await authStore.fetchUser()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 }
|
||||
})
|
||||
|
||||
@ -16,7 +16,7 @@
|
||||
</el-table-column>
|
||||
<el-table-column prop="allocation_type" label="归属方式" width="120" />
|
||||
<el-table-column prop="record_date" label="录入日期" width="110" />
|
||||
<el-table-column label="操作" width="80" v-if="authStore.isOwner()">
|
||||
<el-table-column label="操作" width="80" v-if="authStore.hasPermission('cost:delete')">
|
||||
<template #default="{row}"><el-button text type="danger" size="small" @click="deleteAI(row.id)">删除</el-button></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@ -39,11 +39,29 @@
|
||||
<template #default="{row}">¥{{ row.amount.toLocaleString() }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="record_date" label="录入日期" width="110" />
|
||||
<el-table-column label="操作" width="80" v-if="authStore.isOwner()">
|
||||
<el-table-column label="操作" width="80" v-if="authStore.hasPermission('cost:delete')">
|
||||
<template #default="{row}"><el-button text type="danger" size="small" @click="deleteOut(row.id)">删除</el-button></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 固定开支 -->
|
||||
<el-tab-pane label="固定开支" name="overhead">
|
||||
<div class="tab-header">
|
||||
<el-button type="primary" size="small" @click="showOHForm = true"><el-icon><Plus /></el-icon> 新增</el-button>
|
||||
</div>
|
||||
<el-table :data="overheadCosts" v-loading="loadingOH" stripe size="small">
|
||||
<el-table-column prop="cost_type" label="费用类型" width="140" />
|
||||
<el-table-column label="金额" width="120" align="right">
|
||||
<template #default="{row}">¥{{ row.amount.toLocaleString() }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="record_month" label="所属月份" width="110" />
|
||||
<el-table-column prop="note" label="备注" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="80" v-if="authStore.hasPermission('cost:delete')">
|
||||
<template #default="{row}"><el-button text type="danger" size="small" @click="deleteOH(row.id)">删除</el-button></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- AI 工具新增弹窗 -->
|
||||
@ -112,6 +130,31 @@
|
||||
<el-button type="primary" @click="createOut">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 固定开支新增弹窗 -->
|
||||
<el-dialog v-model="showOHForm" title="新增固定开支" width="480px" destroy-on-close>
|
||||
<el-form :model="ohForm" label-width="100px">
|
||||
<el-form-item label="费用类型">
|
||||
<el-select v-model="ohForm.cost_type" placeholder="选择开支类型" style="width:100%">
|
||||
<el-option label="办公室租金 — 每月办公室租赁费用" value="办公室租金" />
|
||||
<el-option label="水电费 — 每月水电物业等费用" value="水电费" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="金额">
|
||||
<el-input-number v-model="ohForm.amount" :min="0" :controls="false" placeholder="当月费用(元)" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="所属月份">
|
||||
<el-date-picker v-model="ohForm.record_month" type="month" value-format="YYYY-MM" placeholder="选择月份" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="ohForm.note" placeholder="(选填)补充说明" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showOHForm = false">取消</el-button>
|
||||
<el-button type="primary" @click="createOH">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -125,22 +168,28 @@ const authStore = useAuthStore()
|
||||
const activeTab = ref('ai')
|
||||
const loadingAI = ref(false)
|
||||
const loadingOut = ref(false)
|
||||
const loadingOH = ref(false)
|
||||
const aiCosts = ref([])
|
||||
const outCosts = ref([])
|
||||
const overheadCosts = ref([])
|
||||
const projects = ref([])
|
||||
const showAIForm = ref(false)
|
||||
const showOutForm = ref(false)
|
||||
const showOHForm = ref(false)
|
||||
|
||||
const projectMap = computed(() => {
|
||||
const m = {}; projects.value.forEach(p => m[p.id] = p.name); return m
|
||||
})
|
||||
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const currentMonth = today.slice(0, 7)
|
||||
const aiForm = reactive({ tool_name: '', subscription_period: '月', amount: 0, allocation_type: '内容组整体', project_id: null, record_date: today })
|
||||
const outForm = reactive({ project_id: null, outsource_type: '动画', episode_start: 1, episode_end: 1, amount: 0, record_date: today })
|
||||
const ohForm = reactive({ cost_type: '办公室租金', amount: 0, record_month: currentMonth, note: '' })
|
||||
|
||||
async function loadAI() { loadingAI.value = true; try { aiCosts.value = await costApi.listAITools() } finally { loadingAI.value = false } }
|
||||
async function loadOut() { loadingOut.value = true; try { outCosts.value = await costApi.listOutsource({}) } finally { loadingOut.value = false } }
|
||||
async function loadOH() { loadingOH.value = true; try { overheadCosts.value = await costApi.listOverhead() } finally { loadingOH.value = false } }
|
||||
|
||||
async function createAI() {
|
||||
await costApi.createAITool(aiForm); ElMessage.success('已添加'); showAIForm.value = false; loadAI()
|
||||
@ -148,15 +197,21 @@ async function createAI() {
|
||||
async function createOut() {
|
||||
await costApi.createOutsource(outForm); ElMessage.success('已添加'); showOutForm.value = false; loadOut()
|
||||
}
|
||||
async function createOH() {
|
||||
await costApi.createOverhead(ohForm); ElMessage.success('已添加'); showOHForm.value = false; loadOH()
|
||||
}
|
||||
async function deleteAI(id) {
|
||||
await ElMessageBox.confirm('确认删除?'); await costApi.deleteAITool(id); ElMessage.success('已删除'); loadAI()
|
||||
}
|
||||
async function deleteOut(id) {
|
||||
await ElMessageBox.confirm('确认删除?'); await costApi.deleteOutsource(id); ElMessage.success('已删除'); loadOut()
|
||||
}
|
||||
async function deleteOH(id) {
|
||||
await ElMessageBox.confirm('确认删除?'); await costApi.deleteOverhead(id); ElMessage.success('已删除'); loadOH()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loadAI(); loadOut()
|
||||
loadAI(); loadOut(); loadOH()
|
||||
try { projects.value = await projectApi.list({}) } catch {}
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="dashboard" v-loading="loading">
|
||||
<!-- 顶部统计卡片 -->
|
||||
<div class="stat-grid">
|
||||
<div class="stat-grid six">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon blue"><el-icon :size="20"><FolderOpened /></el-icon></div>
|
||||
<div class="stat-body">
|
||||
@ -30,6 +30,28 @@
|
||||
<div class="stat-label">人均日产出</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card" v-if="data.profitability">
|
||||
<div class="stat-icon" :class="data.profitability.total_profit >= 0 ? 'green' : 'red'">
|
||||
<el-icon :size="20"><Coin /></el-icon>
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<div class="stat-value" :class="{ profit: data.profitability.total_profit >= 0, loss: data.profitability.total_profit < 0 }">
|
||||
{{ data.profitability.total_profit >= 0 ? '+' : '' }}¥{{ formatNum(data.profitability.total_profit) }}
|
||||
</div>
|
||||
<div class="stat-label">已结算利润</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card" v-if="data.profitability">
|
||||
<div class="stat-icon" :class="data.profitability.profit_rate >= 0 ? 'green' : 'red'">
|
||||
<el-icon :size="20"><DataAnalysis /></el-icon>
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<div class="stat-value" :class="{ profit: data.profitability.profit_rate >= 0, loss: data.profitability.profit_rate < 0 }">
|
||||
{{ data.profitability.profit_rate }}%
|
||||
</div>
|
||||
<div class="stat-label">利润率</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图表行:产出趋势 + 成本构成 -->
|
||||
@ -82,18 +104,37 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 盈利分析图表 -->
|
||||
<div class="chart-row" v-if="data.profitability?.profit_by_project?.length">
|
||||
<div class="card chart-card full-width">
|
||||
<div class="card-header">
|
||||
<span class="card-title">项目盈亏分析</span>
|
||||
<span class="card-count" v-if="data.profitability">
|
||||
合同总额 ¥{{ formatNum(data.profitability.total_contract) }} · 总成本 ¥{{ formatNum(data.profitability.total_cost) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body"><div ref="profitChartRef" class="chart-container"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 已结算项目 -->
|
||||
<div class="card" v-if="data.settled_projects?.length">
|
||||
<div class="card-header"><span class="card-title">已结算项目</span></div>
|
||||
<div class="card-body">
|
||||
<el-table :data="data.settled_projects" size="small">
|
||||
<el-table-column prop="project_name" label="项目" />
|
||||
<el-table-column label="合同金额" align="right" width="120">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.contract_amount">¥{{ formatNum(row.contract_amount) }}</span>
|
||||
<span v-else class="text-muted">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="总成本" align="right" width="120">
|
||||
<template #default="{ row }">¥{{ formatNum(row.total_cost) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="盈亏" align="right" width="120">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.profit_loss != null" class="profit" :class="{ loss: row.profit_loss < 0 }">
|
||||
<span v-if="row.profit_loss != null" class="profit-text" :class="{ loss: row.profit_loss < 0 }">
|
||||
{{ row.profit_loss >= 0 ? '+' : '' }}¥{{ formatNum(row.profit_loss) }}
|
||||
</span>
|
||||
<span v-else class="text-muted">—</span>
|
||||
@ -118,8 +159,9 @@ const trendChartRef = ref(null)
|
||||
const costChartRef = ref(null)
|
||||
const comparisonChartRef = ref(null)
|
||||
const wasteChartRef = ref(null)
|
||||
const profitChartRef = ref(null)
|
||||
|
||||
let trendChart, costChart, comparisonChart, wasteChart
|
||||
let trendChart, costChart, comparisonChart, wasteChart, profitChart
|
||||
|
||||
function formatNum(n) { return (n || 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 }) }
|
||||
function formatSecs(s) {
|
||||
@ -226,11 +268,34 @@ function initWasteChart(ranking) {
|
||||
})
|
||||
}
|
||||
|
||||
function initProfitChart(profitData) {
|
||||
if (!profitChartRef.value || !profitData?.length) return
|
||||
profitChart = echarts.init(profitChartRef.value)
|
||||
const names = profitData.map(p => p.name.length > 10 ? p.name.slice(0, 10) + '…' : p.name)
|
||||
profitChart.setOption({
|
||||
tooltip: { trigger: 'axis', formatter: p => `${p[0].name}<br/>盈亏 <b>${p[0].value >= 0 ? '+' : ''}¥${p[0].value.toLocaleString()}</b>` },
|
||||
grid: { left: 12, right: 24, top: 16, bottom: 16, containLabel: true },
|
||||
xAxis: { type: 'value', axisLabel: { fontSize: 11, color: '#8F959E', formatter: v => v >= 1000 ? (v/1000) + 'k' : v }, splitLine: { lineStyle: { color: '#F0F1F2' } } },
|
||||
yAxis: { type: 'category', data: names, axisLabel: { fontSize: 12, color: '#3B3F46' }, axisLine: { show: false }, axisTick: { show: false } },
|
||||
series: [{
|
||||
type: 'bar', barWidth: 16,
|
||||
data: profitData.map(p => ({
|
||||
value: p.profit,
|
||||
itemStyle: {
|
||||
color: p.profit >= 0 ? '#34C759' : '#FF3B30',
|
||||
borderRadius: p.profit >= 0 ? [0, 4, 4, 0] : [4, 0, 0, 4],
|
||||
},
|
||||
})),
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
function handleResize() {
|
||||
trendChart?.resize()
|
||||
costChart?.resize()
|
||||
comparisonChart?.resize()
|
||||
wasteChart?.resize()
|
||||
profitChart?.resize()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@ -242,6 +307,9 @@ onMounted(async () => {
|
||||
initCostChart(data.value.cost_breakdown)
|
||||
initComparisonChart(data.value.project_comparison)
|
||||
initWasteChart(data.value.waste_ranking)
|
||||
if (data.value.profitability?.profit_by_project?.length) {
|
||||
initProfitChart(data.value.profitability.profit_by_project)
|
||||
}
|
||||
window.addEventListener('resize', handleResize)
|
||||
} finally {
|
||||
loading.value = false
|
||||
@ -254,12 +322,15 @@ onUnmounted(() => {
|
||||
costChart?.dispose()
|
||||
comparisonChart?.dispose()
|
||||
wasteChart?.dispose()
|
||||
profitChart?.dispose()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 统计网格 */
|
||||
.stat-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 20px; }
|
||||
.stat-grid.six { grid-template-columns: repeat(6, 1fr); }
|
||||
@media (max-width: 1200px) { .stat-grid.six { grid-template-columns: repeat(3, 1fr); } }
|
||||
.stat-card {
|
||||
background: var(--bg-card); border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md); padding: 20px; display: flex; align-items: center; gap: 16px;
|
||||
@ -269,6 +340,7 @@ onUnmounted(() => {
|
||||
.stat-icon.orange { background: #FFF3E0; color: #FF9500; }
|
||||
.stat-icon.green { background: #E8F8EE; color: #34C759; }
|
||||
.stat-icon.purple { background: #F0E8FE; color: #9B59B6; }
|
||||
.stat-icon.red { background: #FFE8E7; color: #FF3B30; }
|
||||
.stat-body { flex: 1; }
|
||||
.stat-value { font-size: 22px; font-weight: 700; color: var(--text-primary); line-height: 1.2; }
|
||||
.stat-label { font-size: 12px; color: var(--text-secondary); margin-top: 4px; }
|
||||
@ -279,6 +351,7 @@ onUnmounted(() => {
|
||||
.chart-card.wide { flex: 2; }
|
||||
.chart-card.narrow { flex: 1; }
|
||||
.chart-card.half { flex: 1; }
|
||||
.chart-card.full-width { flex: 1; }
|
||||
.chart-container { width: 100%; height: 260px; }
|
||||
|
||||
/* 卡片 */
|
||||
@ -307,7 +380,9 @@ onUnmounted(() => {
|
||||
.progress-pct { font-size: 14px; font-weight: 600; color: var(--primary); }
|
||||
.progress-meta { display: flex; justify-content: space-between; font-size: 12px; color: var(--text-secondary); margin-top: 6px; }
|
||||
|
||||
.profit { font-weight: 600; color: #34C759; }
|
||||
.profit.loss { color: #FF3B30; }
|
||||
.stat-value.profit { color: #34C759; }
|
||||
.stat-value.loss { color: #FF3B30; }
|
||||
.profit-text { font-weight: 600; color: #34C759; }
|
||||
.profit-text.loss { color: #FF3B30; }
|
||||
.text-muted { color: var(--text-secondary); }
|
||||
</style>
|
||||
|
||||
@ -23,7 +23,7 @@
|
||||
<el-form-item>
|
||||
<el-input v-model="form.password" placeholder="密码" type="password" size="large" show-password />
|
||||
</el-form-item>
|
||||
<el-button type="primary" size="large" :loading="loading" @click="handleLogin" class="login-btn">
|
||||
<el-button type="primary" size="large" :loading="loading" native-type="submit" class="login-btn">
|
||||
登 录
|
||||
</el-button>
|
||||
</el-form>
|
||||
|
||||
280
frontend/src/views/MemberDetail.vue
Normal file
280
frontend/src/views/MemberDetail.vue
Normal 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>
|
||||
@ -8,11 +8,42 @@
|
||||
<el-tag :type="project.status === '已完成' ? 'success' : 'info'" size="small">{{ project.status }}</el-tag>
|
||||
</div>
|
||||
<el-space>
|
||||
<el-button v-if="authStore.isOwner() && project.status === '制作中'" type="danger" plain @click="handleComplete">确认完成</el-button>
|
||||
<el-button v-if="authStore.isOwner() && project.status === '已完成'" type="primary" @click="$router.push(`/settlement/${project.id}`)">查看结算</el-button>
|
||||
<el-button v-if="authStore.hasPermission('project:delete')" type="danger" text @click="handleDelete">删除项目</el-button>
|
||||
<el-button v-if="authStore.hasPermission('project:complete') && project.status === '制作中'" type="danger" plain @click="handleComplete">确认完成</el-button>
|
||||
<el-button v-if="authStore.hasPermission('settlement:view') && project.status === '已完成'" type="primary" @click="$router.push(`/settlement/${project.id}`)">查看结算</el-button>
|
||||
</el-space>
|
||||
</div>
|
||||
|
||||
<!-- 项目信息 -->
|
||||
<div class="card info-card">
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<span class="info-label">合同金额</span>
|
||||
<span class="info-value price">{{ project.contract_amount ? '¥' + project.contract_amount.toLocaleString() : '未设置' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">负责人</span>
|
||||
<span class="info-value">{{ project.leader_name || '未指定' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">当前阶段</span>
|
||||
<span class="info-value">{{ project.current_phase || '—' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">创建时间</span>
|
||||
<span class="info-value">{{ project.created_at ? project.created_at.slice(0,10) : '—' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">预计完成</span>
|
||||
<span class="info-value">{{ project.estimated_completion_date || '未设置' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">目标时长</span>
|
||||
<span class="info-value">{{ project.episode_count }}集 × {{ project.episode_duration_minutes }}分 = {{ formatSecs(project.target_total_seconds) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 概览卡片 -->
|
||||
<div class="stat-grid">
|
||||
<div class="stat-card">
|
||||
@ -33,21 +64,47 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 进度条 -->
|
||||
<!-- 项目进度时间轴 -->
|
||||
<div class="card">
|
||||
<div class="card-header"><span class="card-title">项目进度</span></div>
|
||||
<div class="card-header">
|
||||
<span class="card-title">项目进度</span>
|
||||
<span class="card-count" v-if="remainingDays !== null">
|
||||
{{ remainingDays > 0 ? `剩余 ${remainingDays} 天` : remainingDays === 0 ? '今天截止' : `已超期 ${Math.abs(remainingDays)} 天` }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<el-progress :percentage="Math.min(project.progress_percent, 100)" :stroke-width="8" :show-text="false"
|
||||
:color="progressColor" style="margin-bottom:12px" />
|
||||
<div class="meta-row">
|
||||
<span>目标:{{ project.episode_count }}集 × {{ project.episode_duration_minutes }}分 = {{ formatSecs(project.target_total_seconds) }}</span>
|
||||
<span v-if="project.estimated_completion_date">预估完成:{{ project.estimated_completion_date }}</span>
|
||||
<!-- 产出进度条 -->
|
||||
<div class="progress-section">
|
||||
<div class="progress-label-row">
|
||||
<span>产出进度</span>
|
||||
<span :style="{color: progressColor, fontWeight: 600}">{{ project.progress_percent }}%</span>
|
||||
</div>
|
||||
<el-progress :percentage="Math.min(project.progress_percent, 100)" :stroke-width="8" :show-text="false" :color="progressColor" />
|
||||
</div>
|
||||
<!-- 时间轴 -->
|
||||
<div class="timeline-section" v-if="project.created_at && project.estimated_completion_date">
|
||||
<div class="progress-label-row">
|
||||
<span>时间进度</span>
|
||||
<span :style="{color: timePercent > 100 ? '#FF3B30' : '#8F959E', fontWeight: 600}">{{ timePercent }}%</span>
|
||||
</div>
|
||||
<div class="timeline-bar-wrapper">
|
||||
<div class="timeline-bar">
|
||||
<div class="timeline-elapsed" :style="{width: Math.min(timePercent, 100) + '%'}"></div>
|
||||
<div class="timeline-today-marker" :style="{left: Math.min(timePercent, 100) + '%'}" v-if="timePercent <= 100">
|
||||
<span class="timeline-today-label">今天</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="timeline-labels">
|
||||
<span>{{ project.created_at ? project.created_at.slice(0,10) : '' }}</span>
|
||||
<span>{{ project.estimated_completion_date }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 团队效率 -->
|
||||
<div v-if="authStore.isLeaderOrAbove() && efficiency.length" class="card">
|
||||
<div v-if="authStore.hasPermission('efficiency:view') && efficiency.length" class="card">
|
||||
<div class="card-header"><span class="card-title">团队效率</span></div>
|
||||
<div class="card-body">
|
||||
<el-table :data="efficiency" size="small">
|
||||
@ -70,6 +127,87 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 提交概览热力图 -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">提交概览</span>
|
||||
<div class="heatmap-controls">
|
||||
<el-radio-group v-model="heatmapRange" size="small">
|
||||
<el-radio-button label="30">最近30天</el-radio-button>
|
||||
<el-radio-button label="all">项目全周期</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body heatmap-body" v-if="heatmapUsers.length">
|
||||
<div class="heatmap-scroll">
|
||||
<table class="heatmap-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="heatmap-name-col">成员</th>
|
||||
<th v-for="d in heatmapDates" :key="d" class="heatmap-date-col" :class="{ today: d === todayStr }">
|
||||
{{ d.slice(5) }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="user in heatmapUsers" :key="user.id">
|
||||
<td class="heatmap-name-col">
|
||||
<a class="member-link" @click="openMemberDrawer(user)">{{ user.name }}</a>
|
||||
</td>
|
||||
<td v-for="d in heatmapDates" :key="d" class="heatmap-cell"
|
||||
:class="getCellClass(user.id, d)"
|
||||
@mouseenter="showCellTooltip($event, user.id, d)"
|
||||
@mouseleave="hideCellTooltip">
|
||||
<span v-if="getCellData(user.id, d)">{{ getCellData(user.id, d).totalSecs }}s</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- 图例 -->
|
||||
<div class="heatmap-legend">
|
||||
<span class="legend-item"><span class="legend-dot type-制作"></span>制作</span>
|
||||
<span class="legend-item"><span class="legend-dot type-测试"></span>测试</span>
|
||||
<span class="legend-item"><span class="legend-dot type-方案"></span>方案</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body" v-else>
|
||||
<el-empty description="暂无提交记录" :image-size="60" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 悬浮提示 -->
|
||||
<div class="cell-tooltip" v-if="tooltip.visible" :style="{ top: tooltip.y + 'px', left: tooltip.x + 'px' }">
|
||||
<div v-for="(s, i) in tooltip.items" :key="i" class="tooltip-row">
|
||||
<el-tag :type="s.work_type === '测试' ? 'warning' : s.work_type === '方案' ? 'info' : ''" size="small">{{ s.work_type }}</el-tag>
|
||||
<span class="tooltip-type">{{ s.content_type }}</span>
|
||||
<span class="tooltip-secs" v-if="s.total_seconds > 0">{{ formatSecs(s.total_seconds) }}</span>
|
||||
<span class="tooltip-desc" v-if="s.description">{{ s.description }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 成员提交抽屉 -->
|
||||
<el-drawer v-model="drawerVisible" :title="drawerUser?.name + ' 的提交记录'" size="480px">
|
||||
<div v-if="drawerUser" class="drawer-content">
|
||||
<div class="drawer-summary">
|
||||
<span>总提交 <strong>{{ drawerSubmissions.length }}</strong> 次</span>
|
||||
<span>总产出 <strong>{{ formatSecs(drawerTotalSecs) }}</strong></span>
|
||||
<router-link :to="`/users/${drawerUser.id}/detail`" class="drawer-link">查看完整详情 →</router-link>
|
||||
</div>
|
||||
<div v-for="(group, date) in drawerGrouped" :key="date" class="drawer-date-group">
|
||||
<div class="drawer-date-header">{{ date }}</div>
|
||||
<div v-for="s in group" :key="s.id" class="drawer-sub-item">
|
||||
<div class="drawer-sub-top">
|
||||
<el-tag :type="s.work_type === '测试' ? 'warning' : s.work_type === '方案' ? 'info' : ''" size="small">{{ s.work_type }}</el-tag>
|
||||
<span>{{ s.content_type }}</span>
|
||||
<span v-if="s.total_seconds > 0" class="drawer-sub-secs">{{ formatSecs(s.total_seconds) }}</span>
|
||||
</div>
|
||||
<div v-if="s.description" class="drawer-sub-desc">{{ s.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
|
||||
<!-- 提交记录 -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
@ -98,7 +236,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, reactive, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { projectApi, submissionApi } from '../api'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
@ -115,6 +253,146 @@ const typeTagMap = { '客户正式项目': 'success', '客户测试项目': 'war
|
||||
|
||||
const progressColor = computed(() => project.value.progress_percent > 100 ? '#FF9500' : '#3370FF')
|
||||
|
||||
// ── 时间轴计算 ──
|
||||
const todayStr = new Date().toISOString().slice(0, 10)
|
||||
|
||||
const remainingDays = computed(() => {
|
||||
if (!project.value.estimated_completion_date) return null
|
||||
const end = new Date(project.value.estimated_completion_date)
|
||||
const now = new Date()
|
||||
return Math.ceil((end - now) / (1000 * 60 * 60 * 24))
|
||||
})
|
||||
|
||||
const timePercent = computed(() => {
|
||||
const p = project.value
|
||||
if (!p.created_at || !p.estimated_completion_date) return 0
|
||||
const start = new Date(p.created_at)
|
||||
const end = new Date(p.estimated_completion_date)
|
||||
const now = new Date()
|
||||
const total = end - start
|
||||
if (total <= 0) return 100
|
||||
return Math.round((now - start) / total * 100)
|
||||
})
|
||||
|
||||
// ── 热力图 ──
|
||||
const heatmapRange = ref('30')
|
||||
|
||||
const heatmapDates = computed(() => {
|
||||
const subs = submissions.value
|
||||
if (!subs.length) return []
|
||||
if (heatmapRange.value === 'all') {
|
||||
// 项目全周期:从最早提交到今天
|
||||
const dates = subs.map(s => s.submit_date).sort()
|
||||
const start = new Date(dates[0])
|
||||
const end = new Date(todayStr)
|
||||
const result = []
|
||||
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
|
||||
result.push(d.toISOString().slice(0, 10))
|
||||
}
|
||||
return result
|
||||
} else {
|
||||
// 最近30天
|
||||
const result = []
|
||||
const today = new Date()
|
||||
for (let i = 29; i >= 0; i--) {
|
||||
const d = new Date(today)
|
||||
d.setDate(d.getDate() - i)
|
||||
result.push(d.toISOString().slice(0, 10))
|
||||
}
|
||||
return result
|
||||
}
|
||||
})
|
||||
|
||||
const heatmapUsers = computed(() => {
|
||||
const subs = submissions.value
|
||||
const userMap = new Map()
|
||||
subs.forEach(s => {
|
||||
if (!userMap.has(s.user_id)) {
|
||||
userMap.set(s.user_id, { id: s.user_id, name: s.user_name })
|
||||
}
|
||||
})
|
||||
return Array.from(userMap.values())
|
||||
})
|
||||
|
||||
// 按 userId + date 索引提交数据
|
||||
const heatmapIndex = computed(() => {
|
||||
const idx = {}
|
||||
submissions.value.forEach(s => {
|
||||
const key = `${s.user_id}_${s.submit_date}`
|
||||
if (!idx[key]) idx[key] = { items: [], totalSecs: 0, mainType: '' }
|
||||
idx[key].items.push(s)
|
||||
idx[key].totalSecs += s.total_seconds || 0
|
||||
})
|
||||
// 确定主要工作类型(按产出秒数最多的类型)
|
||||
Object.values(idx).forEach(cell => {
|
||||
const typeMap = {}
|
||||
cell.items.forEach(s => {
|
||||
typeMap[s.work_type] = (typeMap[s.work_type] || 0) + (s.total_seconds || 1)
|
||||
})
|
||||
cell.mainType = Object.entries(typeMap).sort((a, b) => b[1] - a[1])[0]?.[0] || '制作'
|
||||
cell.totalSecs = Math.round(cell.totalSecs)
|
||||
})
|
||||
return idx
|
||||
})
|
||||
|
||||
function getCellData(userId, date) {
|
||||
return heatmapIndex.value[`${userId}_${date}`] || null
|
||||
}
|
||||
|
||||
function getCellClass(userId, date) {
|
||||
const cell = getCellData(userId, date)
|
||||
if (!cell) return ''
|
||||
return `has-data type-${cell.mainType}`
|
||||
}
|
||||
|
||||
// ── 悬浮提示 ──
|
||||
const tooltip = reactive({ visible: false, x: 0, y: 0, items: [] })
|
||||
|
||||
function showCellTooltip(e, userId, date) {
|
||||
const cell = getCellData(userId, date)
|
||||
if (!cell) return
|
||||
const rect = e.target.getBoundingClientRect()
|
||||
tooltip.x = rect.left
|
||||
tooltip.y = rect.bottom + 4
|
||||
tooltip.items = cell.items
|
||||
tooltip.visible = true
|
||||
}
|
||||
|
||||
function hideCellTooltip() {
|
||||
tooltip.visible = false
|
||||
}
|
||||
|
||||
// ── 成员抽屉 ──
|
||||
const drawerVisible = ref(false)
|
||||
const drawerUser = ref(null)
|
||||
|
||||
const drawerSubmissions = computed(() => {
|
||||
if (!drawerUser.value) return []
|
||||
return submissions.value.filter(s => s.user_id === drawerUser.value.id)
|
||||
})
|
||||
|
||||
const drawerTotalSecs = computed(() => {
|
||||
return drawerSubmissions.value.reduce((sum, s) => sum + (s.total_seconds || 0), 0)
|
||||
})
|
||||
|
||||
const drawerGrouped = computed(() => {
|
||||
const groups = {}
|
||||
drawerSubmissions.value.forEach(s => {
|
||||
if (!groups[s.submit_date]) groups[s.submit_date] = []
|
||||
groups[s.submit_date].push(s)
|
||||
})
|
||||
// 按日期降序
|
||||
const sorted = {}
|
||||
Object.keys(groups).sort((a, b) => b.localeCompare(a)).forEach(k => { sorted[k] = groups[k] })
|
||||
return sorted
|
||||
})
|
||||
|
||||
function openMemberDrawer(user) {
|
||||
drawerUser.value = user
|
||||
drawerVisible.value = true
|
||||
}
|
||||
|
||||
// ── 工具函数 ──
|
||||
function formatSecs(s) {
|
||||
if (!s) return '0秒'
|
||||
const abs = Math.abs(s)
|
||||
@ -130,12 +408,25 @@ async function load() {
|
||||
const id = route.params.id
|
||||
project.value = await projectApi.get(id)
|
||||
submissions.value = await submissionApi.list({ project_id: id })
|
||||
if (authStore.isLeaderOrAbove()) {
|
||||
if (authStore.hasPermission('efficiency:view')) {
|
||||
try { efficiency.value = await projectApi.efficiency(id) } catch {}
|
||||
}
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'删除后项目及其所有提交记录、成本数据将被永久清除,此操作不可撤销。',
|
||||
'确认删除项目',
|
||||
{ type: 'error', confirmButtonText: '确认删除', cancelButtonText: '取消' }
|
||||
)
|
||||
await projectApi.delete(route.params.id)
|
||||
ElMessage.success('项目已删除')
|
||||
router.push('/projects')
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function handleComplete() {
|
||||
try {
|
||||
await ElMessageBox.confirm('确认将此项目标记为完成并进行结算?此操作不可撤销。', '确认完成', { type: 'warning' })
|
||||
@ -154,6 +445,21 @@ onMounted(load)
|
||||
.page-header-left h2 { font-size: 18px; font-weight: 600; }
|
||||
.back-btn { font-size: 16px !important; padding: 4px !important; }
|
||||
|
||||
/* 项目信息卡片 */
|
||||
.info-card { margin-bottom: 16px; }
|
||||
.info-grid {
|
||||
display: grid; grid-template-columns: repeat(3, 1fr); gap: 0;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
.info-item {
|
||||
display: flex; flex-direction: column; padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border-light, #f0f1f2);
|
||||
}
|
||||
.info-item:nth-last-child(-n+3) { border-bottom: none; }
|
||||
.info-label { font-size: 12px; color: var(--text-secondary); margin-bottom: 4px; }
|
||||
.info-value { font-size: 14px; color: var(--text-primary); font-weight: 500; }
|
||||
.info-value.price { color: #FF9500; font-weight: 700; font-size: 16px; }
|
||||
|
||||
.stat-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 16px; }
|
||||
.stat-card {
|
||||
background: var(--bg-card); border: 1px solid var(--border-color);
|
||||
@ -173,10 +479,113 @@ onMounted(load)
|
||||
.card-title { font-size: 14px; font-weight: 600; }
|
||||
.card-count { font-size: 12px; color: var(--text-secondary); }
|
||||
.card-body { padding: 20px; }
|
||||
.meta-row { display: flex; justify-content: space-between; font-size: 13px; color: var(--text-secondary); }
|
||||
.rate-badge {
|
||||
font-size: 12px; font-weight: 600; color: var(--text-secondary);
|
||||
background: var(--bg-hover); padding: 2px 8px; border-radius: 4px; display: inline-block;
|
||||
}
|
||||
.rate-badge.danger { background: #FFE8E7; color: #FF3B30; }
|
||||
|
||||
/* 进度时间轴 */
|
||||
.progress-section { margin-bottom: 20px; }
|
||||
.progress-label-row {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
font-size: 13px; color: var(--text-secondary); margin-bottom: 8px;
|
||||
}
|
||||
.timeline-section { margin-top: 4px; }
|
||||
.timeline-bar-wrapper { position: relative; }
|
||||
.timeline-bar {
|
||||
height: 8px; background: #E5E6EB; border-radius: 4px; position: relative; overflow: visible;
|
||||
}
|
||||
.timeline-elapsed {
|
||||
height: 100%; background: #3370FF; border-radius: 4px 0 0 4px;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
.timeline-today-marker {
|
||||
position: absolute; top: -4px; transform: translateX(-50%);
|
||||
width: 16px; height: 16px; background: #3370FF; border: 2px solid #fff;
|
||||
border-radius: 50%; box-shadow: 0 1px 4px rgba(0,0,0,0.15);
|
||||
}
|
||||
.timeline-today-label {
|
||||
position: absolute; top: -20px; left: 50%; transform: translateX(-50%);
|
||||
font-size: 11px; color: #3370FF; font-weight: 600; white-space: nowrap;
|
||||
}
|
||||
.timeline-labels {
|
||||
display: flex; justify-content: space-between; font-size: 12px; color: var(--text-secondary); margin-top: 8px;
|
||||
}
|
||||
|
||||
/* 热力图 */
|
||||
.heatmap-controls { display: flex; gap: 8px; }
|
||||
.heatmap-body { padding: 16px 20px !important; }
|
||||
.heatmap-scroll { overflow-x: auto; margin-bottom: 12px; }
|
||||
.heatmap-table {
|
||||
border-collapse: collapse; width: max-content; min-width: 100%;
|
||||
}
|
||||
.heatmap-table th, .heatmap-table td {
|
||||
border: 1px solid var(--border-light, #f0f1f2); padding: 0; text-align: center;
|
||||
font-size: 11px; height: 32px; min-width: 44px;
|
||||
}
|
||||
.heatmap-table thead th {
|
||||
background: #F7F8FA; color: var(--text-secondary); font-weight: 500;
|
||||
position: sticky; top: 0; z-index: 1;
|
||||
}
|
||||
.heatmap-table thead th.today { background: #E8F0FE; color: #3370FF; font-weight: 600; }
|
||||
.heatmap-name-col {
|
||||
position: sticky; left: 0; z-index: 2; background: #fff;
|
||||
min-width: 80px; max-width: 80px; text-align: left; padding: 0 8px !important;
|
||||
font-weight: 500; font-size: 12px; color: var(--text-primary);
|
||||
}
|
||||
.heatmap-table thead .heatmap-name-col { background: #F7F8FA; }
|
||||
.heatmap-cell { cursor: default; transition: background 0.15s; }
|
||||
.heatmap-cell:hover { filter: brightness(0.92); }
|
||||
.heatmap-cell.has-data { font-weight: 600; color: #fff; }
|
||||
.heatmap-cell.type-制作 { background: #3370FF; }
|
||||
.heatmap-cell.type-测试 { background: #FF9500; }
|
||||
.heatmap-cell.type-方案 { background: #8F959E; }
|
||||
.member-link {
|
||||
color: #3370FF; cursor: pointer; text-decoration: none;
|
||||
}
|
||||
.member-link:hover { text-decoration: underline; }
|
||||
|
||||
.heatmap-legend { display: flex; gap: 16px; font-size: 12px; color: var(--text-secondary); }
|
||||
.legend-item { display: flex; align-items: center; gap: 4px; }
|
||||
.legend-dot { width: 10px; height: 10px; border-radius: 2px; display: inline-block; }
|
||||
.legend-dot.type-制作 { background: #3370FF; }
|
||||
.legend-dot.type-测试 { background: #FF9500; }
|
||||
.legend-dot.type-方案 { background: #8F959E; }
|
||||
|
||||
/* 悬浮提示 */
|
||||
.cell-tooltip {
|
||||
position: fixed; z-index: 9999;
|
||||
background: #fff; border: 1px solid var(--border-color); border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.12); padding: 10px 14px;
|
||||
max-width: 320px; pointer-events: none;
|
||||
}
|
||||
.tooltip-row { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; flex-wrap: wrap; }
|
||||
.tooltip-row:last-child { margin-bottom: 0; }
|
||||
.tooltip-type { font-size: 12px; color: var(--text-secondary); }
|
||||
.tooltip-secs { font-size: 12px; font-weight: 600; color: var(--text-primary); }
|
||||
.tooltip-desc { font-size: 11px; color: #8F959E; width: 100%; margin-top: 2px; }
|
||||
|
||||
/* 成员抽屉 */
|
||||
.drawer-content { padding: 0 4px; }
|
||||
.drawer-summary {
|
||||
display: flex; gap: 16px; align-items: center; flex-wrap: wrap;
|
||||
padding: 12px 16px; background: #F7F8FA; border-radius: 8px;
|
||||
font-size: 13px; color: var(--text-secondary); margin-bottom: 16px;
|
||||
}
|
||||
.drawer-summary strong { color: var(--text-primary); }
|
||||
.drawer-link { color: #3370FF; font-size: 13px; margin-left: auto; }
|
||||
.drawer-date-group { margin-bottom: 16px; }
|
||||
.drawer-date-header {
|
||||
font-size: 13px; font-weight: 600; color: var(--text-primary);
|
||||
padding-bottom: 8px; border-bottom: 1px solid var(--border-light, #f0f1f2);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.drawer-sub-item {
|
||||
padding: 6px 0; border-bottom: 1px solid var(--border-light, #f0f1f2);
|
||||
}
|
||||
.drawer-sub-item:last-child { border-bottom: none; }
|
||||
.drawer-sub-top { display: flex; align-items: center; gap: 8px; font-size: 13px; }
|
||||
.drawer-sub-secs { font-weight: 600; color: var(--text-primary); margin-left: auto; }
|
||||
.drawer-sub-desc { font-size: 12px; color: #8F959E; margin-top: 4px; }
|
||||
</style>
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<div></div>
|
||||
<el-button v-if="authStore.isSupervisor()" type="primary" @click="showCreate = true">
|
||||
<el-button v-if="authStore.hasPermission('project:create')" type="primary" @click="showCreate = true">
|
||||
<el-icon><Plus /></el-icon> 新建项目
|
||||
</el-button>
|
||||
</div>
|
||||
@ -142,7 +142,7 @@ async function handleCreate() {
|
||||
|
||||
onMounted(async () => {
|
||||
load()
|
||||
if (authStore.isLeaderOrAbove()) {
|
||||
if (authStore.hasPermission('user:view')) {
|
||||
try { users.value = await userApi.list() } catch {}
|
||||
}
|
||||
})
|
||||
|
||||
196
frontend/src/views/Roles.vue
Normal file
196
frontend/src/views/Roles.vue
Normal 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>
|
||||
@ -18,16 +18,19 @@
|
||||
|
||||
<!-- 成本汇总 -->
|
||||
<el-row :gutter="16" class="stat-row">
|
||||
<el-col :span="6">
|
||||
<el-col :span="5">
|
||||
<el-card shadow="hover"><div class="stat-label">人力成本</div><div class="stat-value">¥{{ fmt(data.labor_cost) }}</div></el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-col :span="5">
|
||||
<el-card shadow="hover"><div class="stat-label">AI 工具成本</div><div class="stat-value">¥{{ fmt(data.ai_tool_cost) }}</div></el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-col :span="4">
|
||||
<el-card shadow="hover"><div class="stat-label">外包成本</div><div class="stat-value">¥{{ fmt(data.outsource_cost) }}</div></el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-col :span="5">
|
||||
<el-card shadow="hover"><div class="stat-label">固定开支</div><div class="stat-value">¥{{ fmt(data.overhead_cost) }}</div></el-card>
|
||||
</el-col>
|
||||
<el-col :span="5">
|
||||
<el-card shadow="hover">
|
||||
<div class="stat-label">项目总成本</div>
|
||||
<div class="stat-value" style="color:#e6a23c">¥{{ fmt(data.total_cost) }}</div>
|
||||
|
||||
@ -6,24 +6,39 @@
|
||||
</div>
|
||||
|
||||
<el-table :data="users" v-loading="loading" stripe>
|
||||
<el-table-column prop="name" label="姓名" width="100" />
|
||||
<el-table-column prop="username" label="用户名" width="120" />
|
||||
<el-table-column prop="phase_group" label="阶段组" width="80" />
|
||||
<el-table-column label="角色" width="90">
|
||||
<el-table-column label="姓名" width="100">
|
||||
<template #default="{row}">
|
||||
<el-tag :type="roleMap[row.role]" size="small">{{ row.role }}</el-tag>
|
||||
<router-link :to="`/users/${row.id}/detail`" class="user-link">{{ row.name }}</router-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="月薪" width="110" align="right">
|
||||
<el-table-column prop="username" label="用户名" width="120" />
|
||||
<el-table-column prop="phase_group" label="阶段" width="70" />
|
||||
<el-table-column label="角色" width="80">
|
||||
<template #default="{row}">
|
||||
<el-tag size="small">{{ row.role_name }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="月薪" width="100" align="right">
|
||||
<template #default="{row}">¥{{ row.monthly_salary.toLocaleString() }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="日成本" width="100" align="right">
|
||||
<el-table-column label="奖金" width="90" align="right">
|
||||
<template #default="{row}">{{ row.bonus > 0 ? '¥' + row.bonus.toLocaleString() : '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="五险一金" width="100" align="right">
|
||||
<template #default="{row}">{{ row.social_insurance > 0 ? '¥' + row.social_insurance.toLocaleString() : '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="月总成本" width="110" align="right">
|
||||
<template #default="{row}">
|
||||
<span style="font-weight:600;color:var(--text-primary)">¥{{ row.monthly_total_cost.toLocaleString() }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="日成本" width="90" align="right">
|
||||
<template #default="{row}">¥{{ row.daily_cost.toLocaleString() }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="80">
|
||||
<el-table-column label="状态" width="70">
|
||||
<template #default="{row}"><el-tag :type="row.is_active ? 'success' : 'danger'" size="small">{{ row.is_active ? '启用' : '停用' }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100">
|
||||
<el-table-column label="操作" width="80">
|
||||
<template #default="{row}">
|
||||
<el-button text size="small" @click="editUser(row)">编辑</el-button>
|
||||
</template>
|
||||
@ -31,8 +46,8 @@
|
||||
</el-table>
|
||||
|
||||
<!-- 新增/编辑对话框 -->
|
||||
<el-dialog v-model="showCreate" :title="editingId ? '编辑用户' : '新增用户'" width="480px" destroy-on-close>
|
||||
<el-form :model="form" label-width="80px">
|
||||
<el-dialog v-model="showCreate" :title="editingId ? '编辑用户' : '新增用户'" width="520px" destroy-on-close>
|
||||
<el-form :model="form" label-width="90px">
|
||||
<el-form-item label="姓名"><el-input v-model="form.name" /></el-form-item>
|
||||
<el-form-item v-if="!editingId" label="用户名"><el-input v-model="form.username" /></el-form-item>
|
||||
<el-form-item v-if="!editingId" label="密码"><el-input v-model="form.password" type="password" /></el-form-item>
|
||||
@ -44,17 +59,25 @@
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="角色">
|
||||
<el-select v-model="form.role" placeholder="系统权限角色" style="width:100%">
|
||||
<el-option label="成员 — 仅提交内容" value="成员" />
|
||||
<el-option label="组长 — 管理本组提交" value="组长" />
|
||||
<el-option label="主管 — 管理项目和人员" value="主管" />
|
||||
<el-option label="Owner — 全部权限" value="Owner" />
|
||||
<el-select v-model="form.role_id" placeholder="系统权限角色" style="width:100%">
|
||||
<el-option v-for="r in roles" :key="r.id" :label="r.name" :value="r.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-divider content-position="left">成本信息</el-divider>
|
||||
<el-form-item label="月薪">
|
||||
<el-input-number v-model="form.monthly_salary" :min="0" :step="1000" :controls="false" placeholder="月薪(元),日成本=月薪÷22" style="width:100%" />
|
||||
<el-input-number v-model="form.monthly_salary" :min="0" :step="1000" :controls="false" placeholder="基本月薪(元)" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="editingId" label="状态">
|
||||
<el-form-item label="奖金">
|
||||
<el-input-number v-model="form.bonus" :min="0" :step="500" :controls="false" placeholder="月度奖金(元),无则填0" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="五险一金">
|
||||
<el-input-number v-model="form.social_insurance" :min="0" :step="500" :controls="false" placeholder="公司承担的五险一金(元/月)" style="width:100%" />
|
||||
</el-form-item>
|
||||
<div class="cost-summary" v-if="form.monthly_salary || form.bonus || form.social_insurance">
|
||||
月总成本 = ¥{{ (form.monthly_salary || 0).toLocaleString() }} + ¥{{ (form.bonus || 0).toLocaleString() }} + ¥{{ (form.social_insurance || 0).toLocaleString() }} = <strong>¥{{ ((form.monthly_salary || 0) + (form.bonus || 0) + (form.social_insurance || 0)).toLocaleString() }}</strong>
|
||||
→ 日成本 <strong>¥{{ (((form.monthly_salary || 0) + (form.bonus || 0) + (form.social_insurance || 0)) / 22).toFixed(0) }}</strong>
|
||||
</div>
|
||||
<el-form-item v-if="editingId" label="状态" style="margin-top:16px">
|
||||
<el-switch v-model="form.is_active" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="停用" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
@ -68,27 +91,38 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { userApi } from '../api'
|
||||
import { userApi, roleApi } from '../api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const loading = ref(false)
|
||||
const showCreate = ref(false)
|
||||
const editingId = ref(null)
|
||||
const users = ref([])
|
||||
const roleMap = { 'Owner': 'danger', '主管': 'warning', '组长': '', '成员': 'info' }
|
||||
const form = reactive({ username: '', password: '', name: '', phase_group: '制作', role: '成员', monthly_salary: 0, is_active: 1 })
|
||||
const roles = ref([])
|
||||
const form = reactive({
|
||||
username: '', password: '', name: '', phase_group: '制作', role_id: null,
|
||||
monthly_salary: 0, bonus: 0, social_insurance: 0, is_active: 1,
|
||||
})
|
||||
|
||||
async function load() { loading.value = true; try { users.value = await userApi.list() } finally { loading.value = false } }
|
||||
|
||||
function editUser(u) {
|
||||
editingId.value = u.id
|
||||
Object.assign(form, { name: u.name, phase_group: u.phase_group, role: u.role, monthly_salary: u.monthly_salary, is_active: u.is_active })
|
||||
Object.assign(form, {
|
||||
name: u.name, phase_group: u.phase_group, role_id: u.role_id,
|
||||
monthly_salary: u.monthly_salary, bonus: u.bonus || 0,
|
||||
social_insurance: u.social_insurance || 0, is_active: u.is_active,
|
||||
})
|
||||
showCreate.value = true
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (editingId.value) {
|
||||
await userApi.update(editingId.value, { name: form.name, phase_group: form.phase_group, role: form.role, monthly_salary: form.monthly_salary, is_active: form.is_active })
|
||||
await userApi.update(editingId.value, {
|
||||
name: form.name, phase_group: form.phase_group, role_id: form.role_id,
|
||||
monthly_salary: form.monthly_salary, bonus: form.bonus,
|
||||
social_insurance: form.social_insurance, is_active: form.is_active,
|
||||
})
|
||||
} else {
|
||||
await userApi.create(form)
|
||||
}
|
||||
@ -98,10 +132,21 @@ async function handleSave() {
|
||||
load()
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
onMounted(async () => {
|
||||
load()
|
||||
try { roles.value = await roleApi.list() } catch {}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||
.page-header h2 { font-size: 18px; font-weight: 600; }
|
||||
.cost-summary {
|
||||
background: #F7F8FA; border-radius: 6px; padding: 10px 16px;
|
||||
font-size: 13px; color: var(--text-secondary); margin: -4px 0 8px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.cost-summary strong { color: var(--text-primary); }
|
||||
.user-link { color: #3370FF; text-decoration: none; font-weight: 500; }
|
||||
.user-link:hover { text-decoration: underline; }
|
||||
</style>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user