feat: 飞书报告卡片化 + 报告权限系统 + 产出过滤优化
Some checks failed
Build and Deploy Web / build-and-deploy (push) Has been cancelled
Build and Deploy Backend / build-and-deploy (push) Has been cancelled

- 日报/周报/月报改为结构化卡片推送(column_set布局)
- 新增 report:daily/weekly/monthly 权限到角色管理
- 产出统计只算中期制作阶段动画秒数
- 效率之星改为跨项目加权通过率
- AI点评补充风险数据源
- 禁用多余admin账号,股东角色加报告权限

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
seaislee1209 2026-03-02 20:43:35 +08:00
parent ac350e763b
commit 530f02a66a
16 changed files with 892 additions and 252 deletions

Binary file not shown.

View File

@ -167,6 +167,56 @@ def calc_overhead_cost_for_project(project_id: int, db: Session) -> float:
return 0.0 return 0.0
# ──────────────────────────── 管理成本分摊 ────────────────────────────
def calc_management_cost_for_project(project_id: int, db: Session) -> float:
"""
计算某项目分摊的管理成本豁免提交角色的人员日薪
规则
- 只算豁免提交exempt_submission=1角色下有工资的活跃用户
- 按有提交记录的工作日数 × 每人日薪 计算总池
- 总池按各项目产出秒数比例分摊
"""
from models import Role
# 找出豁免角色 ID
exempt_role_ids = set(
r.id for r in db.query(Role).filter(Role.exempt_submission == 1).all()
)
if not exempt_role_ids:
return 0.0
# 豁免角色下有工资的活跃用户
exempt_users = db.query(User).filter(
User.is_active == 1,
User.monthly_salary > 0,
User.role_id.in_(exempt_role_ids),
).all()
if not exempt_users:
return 0.0
# 有提交记录的工作日数(代表公司运营天数)
working_days = db.query(Submission.submit_date).distinct().count()
if working_days == 0:
return 0.0
# 总管理成本池 = 每人日薪 × 工作日数
total_pool = sum(u.daily_cost * working_days for u in exempt_users)
# 按产出秒数比例分摊
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:
return round(total_pool * proj_secs / all_secs, 2)
return 0.0
# ──────────────────────────── 工作日计算工具 ──────────────────────────── # ──────────────────────────── 工作日计算工具 ────────────────────────────
def _working_days_between(start_date, end_date) -> int: def _working_days_between(start_date, end_date) -> int:
@ -446,7 +496,8 @@ def calc_project_settlement(project_id: int, db: Session) -> dict:
ai_tool = calc_ai_tool_cost_for_project(project_id, db) ai_tool = calc_ai_tool_cost_for_project(project_id, db)
outsource = calc_outsource_cost_for_project(project_id, db) outsource = calc_outsource_cost_for_project(project_id, db)
overhead = calc_overhead_cost_for_project(project_id, db) overhead = calc_overhead_cost_for_project(project_id, db)
total_cost = labor + ai_tool + outsource + overhead management = calc_management_cost_for_project(project_id, db)
total_cost = labor + ai_tool + outsource + overhead + management
waste = calc_waste_for_project(project_id, db) waste = calc_waste_for_project(project_id, db)
efficiency = calc_team_efficiency(project_id, db) efficiency = calc_team_efficiency(project_id, db)
@ -458,6 +509,7 @@ def calc_project_settlement(project_id: int, db: Session) -> dict:
"ai_tool_cost": ai_tool, "ai_tool_cost": ai_tool,
"outsource_cost": outsource, "outsource_cost": outsource,
"overhead_cost": overhead, "overhead_cost": overhead,
"management_cost": management,
"total_cost": round(total_cost, 2), "total_cost": round(total_cost, 2),
**waste, **waste,
"team_efficiency": efficiency, "team_efficiency": efficiency,

View File

@ -33,6 +33,11 @@ if _db_url.startswith("sqlite"):
conn.execute(sqlalchemy.text("ALTER TABLE users ADD COLUMN role_id INTEGER")) conn.execute(sqlalchemy.text("ALTER TABLE users ADD COLUMN role_id INTEGER"))
conn.commit() conn.commit()
logger.info("[MIGRATE] added column users.role_id") logger.info("[MIGRATE] added column users.role_id")
role_cols = [r[1] for r in conn.execute(sqlalchemy.text("PRAGMA table_info('roles')"))]
if "exempt_submission" not in role_cols:
conn.execute(sqlalchemy.text("ALTER TABLE roles ADD COLUMN exempt_submission INTEGER NOT NULL DEFAULT 0"))
conn.commit()
logger.info("[MIGRATE] added column roles.exempt_submission")
app = FastAPI(title="AirLabs Project", version="1.0.0") app = FastAPI(title="AirLabs Project", version="1.0.0")
@ -118,6 +123,7 @@ def init_roles_and_admin():
inspector = inspect(engine) inspector = inspect(engine)
ms_cols = [c['name'] for c in inspector.get_columns('project_milestones')] ms_cols = [c['name'] for c in inspector.get_columns('project_milestones')]
sub_cols = [c['name'] for c in inspector.get_columns('submissions')] sub_cols = [c['name'] for c in inspector.get_columns('submissions')]
role_cols = [c['name'] for c in inspector.get_columns('roles')]
with engine.connect() as conn: with engine.connect() as conn:
# ProjectMilestone 新字段 # ProjectMilestone 新字段
@ -139,6 +145,12 @@ def init_roles_and_admin():
conn.commit() conn.commit()
print("[MIGRATE] added episode_number to submissions") print("[MIGRATE] added episode_number to submissions")
# Role: exempt_submission 字段
if 'exempt_submission' not in role_cols:
conn.execute(text("ALTER TABLE roles ADD COLUMN exempt_submission INT NOT NULL DEFAULT 0"))
conn.commit()
print("[MIGRATE] added exempt_submission to roles")
# MySQL: 扩展 content_type 枚举(使用 Python enum 名称)+ 旧值迁移 # MySQL: 扩展 content_type 枚举(使用 Python enum 名称)+ 旧值迁移
from config import DATABASE_URL from config import DATABASE_URL
if not DATABASE_URL.startswith("sqlite"): if not DATABASE_URL.startswith("sqlite"):

View File

@ -50,6 +50,10 @@ ALL_PERMISSIONS = [
# 结算与效率 # 结算与效率
("settlement:view", "查看结算报告", "结算与效率"), ("settlement:view", "查看结算报告", "结算与效率"),
("efficiency:view", "查看团队效率", "结算与效率"), ("efficiency:view", "查看团队效率", "结算与效率"),
# 报告推送
("report:daily", "触发/查看日报", "报告推送"),
("report:weekly", "触发/查看周报(含成本)", "报告推送"),
("report:monthly", "触发/查看月报(含成本+盈亏)", "报告推送"),
] ]
PERMISSION_KEYS = [p[0] for p in ALL_PERMISSIONS] PERMISSION_KEYS = [p[0] for p in ALL_PERMISSIONS]
@ -80,6 +84,7 @@ BUILTIN_ROLES = {
"cost_outsource:view", "cost_outsource:create", "cost_outsource:delete", "cost_outsource:view", "cost_outsource:create", "cost_outsource:delete",
"user:view", "user:view",
"efficiency:view", "efficiency:view",
"report:daily", "report:weekly", "report:monthly",
], ],
}, },
"组长": { "组长": {
@ -89,6 +94,7 @@ BUILTIN_ROLES = {
"submission:view", "submission:create", "submission:view", "submission:create",
"cost_ai:view", "cost_ai:create", "cost_ai:view", "cost_ai:create",
"efficiency:view", "efficiency:view",
"report:daily",
], ],
}, },
"成员": { "成员": {
@ -194,6 +200,7 @@ class Role(Base):
description = Column(String(200), nullable=True) description = Column(String(200), nullable=True)
permissions = Column(JSON, nullable=False, default=[]) # 权限标识符列表 permissions = Column(JSON, nullable=False, default=[]) # 权限标识符列表
is_system = Column(Integer, nullable=False, default=0) # 1=内置角色不可删 is_system = Column(Integer, nullable=False, default=0) # 1=内置角色不可删
exempt_submission = Column(Integer, nullable=False, default=0) # 1=豁免提交
created_at = Column(DateTime, server_default=func.now()) created_at = Column(DateTime, server_default=func.now())
users = relationship("User", back_populates="role_ref") users = relationship("User", back_populates="role_ref")

View File

@ -6,14 +6,14 @@ from datetime import date, timedelta
from database import get_db from database import get_db
from models import ( from models import (
User, Project, Submission, AIToolCost, User, Project, Submission, AIToolCost,
ProjectStatus, ProjectType, WorkType ProjectStatus, ProjectType, WorkType, PhaseGroup
) )
from auth import get_current_user, require_permission from auth import get_current_user, require_permission
from calculations import ( from calculations import (
calc_project_settlement, calc_waste_for_project, calc_project_settlement, calc_waste_for_project,
calc_labor_cost_for_project, calc_ai_tool_cost_for_project, calc_labor_cost_for_project, calc_ai_tool_cost_for_project,
calc_outsource_cost_for_project, calc_overhead_cost_for_project, calc_outsource_cost_for_project, calc_overhead_cost_for_project,
calc_team_efficiency calc_management_cost_for_project, calc_team_efficiency
) )
router = APIRouter(prefix="/api", tags=["仪表盘与结算"]) router = APIRouter(prefix="/api", tags=["仪表盘与结算"])
@ -56,11 +56,12 @@ def get_dashboard(
AIToolCost.record_date <= today, AIToolCost.record_date <= today,
).scalar() or 0 ).scalar() or 0
# 本月总产出秒数 # 本月总产出秒数(只算中期动画制作)
monthly_secs = db.query(sa_func.sum(Submission.total_seconds)).filter( monthly_secs = db.query(sa_func.sum(Submission.total_seconds)).filter(
Submission.submit_date >= month_start, Submission.submit_date >= month_start,
Submission.submit_date <= today, Submission.submit_date <= today,
Submission.total_seconds > 0, Submission.total_seconds > 0,
Submission.project_phase == PhaseGroup.PRODUCTION,
).scalar() or 0 ).scalar() or 0
# 活跃人数 # 活跃人数
@ -70,13 +71,18 @@ def get_dashboard(
working_days = max(1, (today - month_start).days + 1) working_days = max(1, (today - month_start).days + 1)
avg_daily = round(monthly_secs / max(1, active_users) / working_days, 1) avg_daily = round(monthly_secs / max(1, active_users) / working_days, 1)
# 各项目摘要 # 各项目摘要(进度只算中期动画制作)
project_summaries = [] project_summaries = []
for p in active: for p in active:
waste = calc_waste_for_project(p.id, db) waste = calc_waste_for_project(p.id, db)
total_secs = waste.get("total_submitted_seconds", 0) # 只用中期制作产出算进度,不把后期算进去
production_secs = db.query(sa_func.sum(Submission.total_seconds)).filter(
Submission.project_id == p.id,
Submission.total_seconds > 0,
Submission.project_phase == PhaseGroup.PRODUCTION,
).scalar() or 0
target = p.target_total_seconds target = p.target_total_seconds
progress = round(total_secs / target * 100, 1) if target > 0 else 0 progress = round(production_secs / target * 100, 1) if target > 0 else 0
is_overdue = ( is_overdue = (
p.estimated_completion_date and today > p.estimated_completion_date p.estimated_completion_date and today > p.estimated_completion_date
) )
@ -86,7 +92,7 @@ def get_dashboard(
"project_type": p.project_type.value if hasattr(p.project_type, 'value') else p.project_type, "project_type": p.project_type.value if hasattr(p.project_type, 'value') else p.project_type,
"progress_percent": progress, "progress_percent": progress,
"target_seconds": target, "target_seconds": target,
"submitted_seconds": total_secs, "submitted_seconds": round(production_secs, 1),
"waste_rate": waste.get("waste_rate", 0), "waste_rate": waste.get("waste_rate", 0),
"waste_hours": waste.get("total_waste_hours", 0), "waste_hours": waste.get("total_waste_hours", 0),
"is_overdue": bool(is_overdue), "is_overdue": bool(is_overdue),
@ -127,13 +133,14 @@ def get_dashboard(
"profit_loss": settlement.get("profit_loss"), "profit_loss": settlement.get("profit_loss"),
}) })
# ── 图表数据近30天每日产出趋势 ── # ── 图表数据近30天每日产出趋势(只算中期动画制作) ──
daily_trend = [] daily_trend = []
for i in range(29, -1, -1): for i in range(29, -1, -1):
d = today - timedelta(days=i) d = today - timedelta(days=i)
day_secs = db.query(sa_func.sum(Submission.total_seconds)).filter( day_secs = db.query(sa_func.sum(Submission.total_seconds)).filter(
Submission.submit_date == d, Submission.submit_date == d,
Submission.total_seconds > 0, Submission.total_seconds > 0,
Submission.project_phase == PhaseGroup.PRODUCTION,
).scalar() or 0 ).scalar() or 0
daily_trend.append({ daily_trend.append({
"date": str(d), "date": str(d),
@ -145,25 +152,29 @@ def get_dashboard(
total_ai_all = 0.0 total_ai_all = 0.0
total_outsource_all = 0.0 total_outsource_all = 0.0
total_overhead_all = 0.0 total_overhead_all = 0.0
total_management_all = 0.0
for p in active + completed + abandoned: for p in active + completed + abandoned:
total_labor_all += calc_labor_cost_for_project(p.id, db) total_labor_all += calc_labor_cost_for_project(p.id, db)
total_ai_all += calc_ai_tool_cost_for_project(p.id, db) total_ai_all += calc_ai_tool_cost_for_project(p.id, db)
total_outsource_all += calc_outsource_cost_for_project(p.id, db) total_outsource_all += calc_outsource_cost_for_project(p.id, db)
total_overhead_all += calc_overhead_cost_for_project(p.id, db) total_overhead_all += calc_overhead_cost_for_project(p.id, db)
total_management_all += calc_management_cost_for_project(p.id, db)
cost_breakdown = [ cost_breakdown = [
{"name": "人力成本", "value": round(total_labor_all, 0)}, {"name": "人力成本", "value": round(total_labor_all, 0)},
{"name": "AI工具", "value": round(total_ai_all, 0)}, {"name": "AI工具", "value": round(total_ai_all, 0)},
{"name": "外包", "value": round(total_outsource_all, 0)}, {"name": "外包", "value": round(total_outsource_all, 0)},
{"name": "固定开支", "value": round(total_overhead_all, 0)}, {"name": "固定开支", "value": round(total_overhead_all, 0)},
{"name": "管理成本", "value": round(total_management_all, 0)},
] ]
# ── 图表数据:各项目产出对比(进行中项目 ── # ── 图表数据:各项目产出对比(进行中项目,只算中期动画制作 ──
project_comparison = [] project_comparison = []
for p in active: for p in active:
total_secs_p = db.query(sa_func.sum(Submission.total_seconds)).filter( total_secs_p = db.query(sa_func.sum(Submission.total_seconds)).filter(
Submission.project_id == p.id, Submission.project_id == p.id,
Submission.total_seconds > 0, Submission.total_seconds > 0,
Submission.project_phase == PhaseGroup.PRODUCTION,
).scalar() or 0 ).scalar() or 0
project_comparison.append({ project_comparison.append({
"name": p.name, "name": p.name,
@ -187,7 +198,7 @@ def get_dashboard(
for p in active: for p in active:
if p.contract_amount: if p.contract_amount:
in_progress_contract += 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) 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) + calc_management_cost_for_project(p.id, db)
# 每个项目的盈亏(用于柱状图) # 每个项目的盈亏(用于柱状图)
profit_by_project = [] profit_by_project = []
@ -212,6 +223,41 @@ def get_dashboard(
from services.report_service import analyze_project_risks from services.report_service import analyze_project_risks
risk_alerts = analyze_project_risks(db) risk_alerts = analyze_project_risks(db)
# ── 今日提交情况(排除豁免角色) ──
from models import Role
exempt_role_ids = set(
r.id for r in db.query(Role).filter(Role.exempt_submission == 1).all()
)
all_active_users = [
u for u in db.query(User).filter(User.is_active == 1).all()
if u.role_id not in exempt_role_ids
]
submitted_user_ids = set(
uid for (uid,) in db.query(Submission.user_id).filter(
Submission.submit_date == today,
).distinct().all()
)
submitted_users = []
not_submitted_users = []
for u in all_active_users:
info = {"id": u.id, "name": u.name}
if u.id in submitted_user_ids:
hours = db.query(sa_func.sum(Submission.hours_spent)).filter(
Submission.user_id == u.id,
Submission.submit_date == today,
).scalar() or 0
info["hours"] = round(hours, 1)
submitted_users.append(info)
else:
not_submitted_users.append(info)
daily_attendance = {
"total": len(all_active_users),
"submitted_count": len(submitted_users),
"not_submitted_count": len(not_submitted_users),
"submitted": submitted_users,
"not_submitted": not_submitted_users,
}
return { return {
"active_projects": len(active), "active_projects": len(active),
"completed_projects": len(completed), "completed_projects": len(completed),
@ -227,6 +273,7 @@ def get_dashboard(
"settled_projects": settled, "settled_projects": settled,
"profitability": profitability, "profitability": profitability,
"risk_alerts": risk_alerts, "risk_alerts": risk_alerts,
"daily_attendance": daily_attendance,
# 图表数据 # 图表数据
"daily_trend": daily_trend, "daily_trend": daily_trend,
"cost_breakdown": cost_breakdown, "cost_breakdown": cost_breakdown,

View File

@ -1,5 +1,6 @@
"""AI 报告路由 —— 手动触发报告生成与飞书推送""" """AI 报告路由 —— 手动触发报告生成与飞书推送"""
from fastapi import APIRouter, Depends from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from database import get_db from database import get_db
from models import User from models import User
@ -8,22 +9,38 @@ from auth import require_permission
router = APIRouter(prefix="/api/reports", tags=["AI报告"]) router = APIRouter(prefix="/api/reports", tags=["AI报告"])
async def _push_card(card: dict, test_mobile: Optional[str] = None) -> dict:
"""推送卡片,支持 test_mobile 只推单人"""
from services.feishu_service import feishu
if test_mobile:
user_id = await feishu.get_user_id_by_mobile(test_mobile)
if not user_id:
return {"success": [], "failed": [{"mobile": test_mobile, "reason": "未找到用户"}]}
ok = await feishu.send_card(user_id, card)
if ok:
return {"success": [test_mobile], "failed": []}
return {"success": [], "failed": [{"mobile": test_mobile, "reason": "发送失败"}]}
return await feishu.send_report_card_to_all(card)
@router.post("/daily") @router.post("/daily")
async def trigger_daily_report( async def trigger_daily_report(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_permission("dashboard:view")), current_user: User = Depends(require_permission("dashboard:view")),
test_mobile: Optional[str] = Query(None, description="测试手机号,只推这一个人"),
): ):
"""手动触发日报生成并推送飞书""" """手动触发日报生成并推送飞书"""
from services.report_service import generate_daily_report from services.report_service import generate_daily_report
from services.feishu_service import feishu from services.feishu_service import build_daily_card
report = generate_daily_report(db) report = generate_daily_report(db)
push_result = await feishu.send_report_to_all(report["title"], report["content"]) card = build_daily_card(report["title"], report["card_data"])
push_result = await _push_card(card, test_mobile)
return { return {
"message": "日报生成并推送完成", "message": "日报生成并推送完成",
"title": report["title"], "title": report["title"],
"content": report["content"], "card_data": report["card_data"],
"push_result": push_result, "push_result": push_result,
} }
@ -32,18 +49,20 @@ async def trigger_daily_report(
async def trigger_weekly_report( async def trigger_weekly_report(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_permission("dashboard:view")), current_user: User = Depends(require_permission("dashboard:view")),
test_mobile: Optional[str] = Query(None, description="测试手机号,只推这一个人"),
): ):
"""手动触发周报生成并推送飞书""" """手动触发周报生成并推送飞书"""
from services.report_service import generate_weekly_report from services.report_service import generate_weekly_report
from services.feishu_service import feishu from services.feishu_service import build_weekly_card
report = generate_weekly_report(db) report = generate_weekly_report(db)
push_result = await feishu.send_report_to_all(report["title"], report["content"]) card = build_weekly_card(report["title"], report["card_data"])
push_result = await _push_card(card, test_mobile)
return { return {
"message": "周报生成并推送完成", "message": "周报生成并推送完成",
"title": report["title"], "title": report["title"],
"content": report["content"], "card_data": report["card_data"],
"push_result": push_result, "push_result": push_result,
} }
@ -52,18 +71,20 @@ async def trigger_weekly_report(
async def trigger_monthly_report( async def trigger_monthly_report(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_permission("dashboard:view")), current_user: User = Depends(require_permission("dashboard:view")),
test_mobile: Optional[str] = Query(None, description="测试手机号,只推这一个人"),
): ):
"""手动触发月报生成并推送飞书""" """手动触发月报生成并推送飞书"""
from services.report_service import generate_monthly_report from services.report_service import generate_monthly_report
from services.feishu_service import feishu from services.feishu_service import build_monthly_card
report = generate_monthly_report(db) report = generate_monthly_report(db)
push_result = await feishu.send_report_to_all(report["title"], report["content"]) card = build_monthly_card(report["title"], report["card_data"])
push_result = await _push_card(card, test_mobile)
return { return {
"message": "月报生成并推送完成", "message": "月报生成并推送完成",
"title": report["title"], "title": report["title"],
"content": report["content"], "card_data": report["card_data"],
"push_result": push_result, "push_result": push_result,
} }
@ -74,26 +95,32 @@ async def preview_report(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_permission("dashboard:view")), current_user: User = Depends(require_permission("dashboard:view")),
): ):
"""预览报告内容(不推送飞书)""" """预览报告内容(不推送飞书),返回结构化数据 + 卡片 JSON"""
from services.report_service import ( from services.report_service import (
generate_daily_report, generate_weekly_report, generate_monthly_report, generate_daily_report, generate_weekly_report, generate_monthly_report,
) )
from services.feishu_service import (
build_daily_card, build_weekly_card, build_monthly_card,
)
generators = { generators = {
"daily": generate_daily_report, "daily": (generate_daily_report, build_daily_card),
"weekly": generate_weekly_report, "weekly": (generate_weekly_report, build_weekly_card),
"monthly": generate_monthly_report, "monthly": (generate_monthly_report, build_monthly_card),
} }
generator = generators.get(report_type) entry = generators.get(report_type)
if not generator: if not entry:
return {"error": f"不支持的报告类型: {report_type},可选: daily, weekly, monthly"} return {"error": f"不支持的报告类型: {report_type},可选: daily, weekly, monthly"}
report = generator(db) gen_fn, build_fn = entry
report = gen_fn(db)
card = build_fn(report["title"], report["card_data"])
return { return {
"title": report["title"], "title": report["title"],
"content": report["content"], "card_data": report["card_data"],
"data": report.get("data"), "card_json": card,
} }

View File

@ -33,6 +33,7 @@ def list_roles(
"description": r.description, "description": r.description,
"permissions": r.permissions or [], "permissions": r.permissions or [],
"is_system": bool(r.is_system), "is_system": bool(r.is_system),
"exempt_submission": bool(r.exempt_submission),
"user_count": db.query(User).filter(User.role_id == r.id).count(), "user_count": db.query(User).filter(User.role_id == r.id).count(),
"created_at": r.created_at, "created_at": r.created_at,
} }
@ -58,6 +59,7 @@ def create_role(
description=req.get("description", ""), description=req.get("description", ""),
permissions=perms, permissions=perms,
is_system=0, is_system=0,
exempt_submission=1 if req.get("exempt_submission") else 0,
) )
db.add(role) db.add(role)
db.commit() db.commit()
@ -90,6 +92,9 @@ def update_role(
if "permissions" in req: if "permissions" in req:
role.permissions = [p for p in req["permissions"] if p in PERMISSION_KEYS] role.permissions = [p for p in req["permissions"] if p in PERMISSION_KEYS]
if "exempt_submission" in req:
role.exempt_submission = 1 if req["exempt_submission"] else 0
db.commit() db.commit()
return {"message": "角色已更新"} return {"message": "角色已更新"}

View File

@ -33,13 +33,14 @@ def generate_report_summary(data_context: str, report_type: str) -> str:
system_prompt = ( system_prompt = (
"你是 AirLabs 动画团队的项目管理助手。" "你是 AirLabs 动画团队的项目管理助手。"
"请根据提供的数据,用简洁的中文生成一段项目管理{label}总结。" "请根据提供的数据,用简洁的中文生成一段{label}点评。\n"
"要求:\n" "重要规则:\n"
"1. 语言简练专业,适合管理层阅读\n" "1. 不要重复罗列原始数据(项目数、提交人次、未提交人员等已在报告上方展示)\n"
"2. 先总结关键数据,再给出分析和建议\n" "2. 直接给出分析洞察和可执行建议\n"
"3. 如果有风险项目,重点提醒\n" "3. 如果有风险项目,重点提醒并给出具体改进方向\n"
"4. 使用 markdown 格式\n" "4. 语言简练专业,适合管理层阅读\n"
"5. 总字数控制在 300 字以内" "5. 不要使用标题(如 ## 核心数据概览),直接写正文\n"
"6. 总字数控制在 200 字以内"
).format(label=label) ).format(label=label)
try: try:

View File

@ -10,6 +10,275 @@ logger = logging.getLogger(__name__)
FEISHU_BASE = "https://open.feishu.cn/open-apis" FEISHU_BASE = "https://open.feishu.cn/open-apis"
# ──────────────────────── 卡片构建工具 ────────────────────────
def _col(weight: int, content: str) -> dict:
"""快捷构建一个 column"""
return {
"tag": "column",
"width": "weighted",
"weight": weight,
"vertical_align": "top",
"elements": [{"tag": "markdown", "content": content}],
}
def _column_set(columns: list, bg: str = "grey") -> dict:
"""构建 column_set 多列布局"""
return {
"tag": "column_set",
"flex_mode": "none",
"background_style": bg,
"columns": columns,
}
def _hr() -> dict:
return {"tag": "hr"}
def _md(content: str) -> dict:
return {"tag": "markdown", "content": content}
def _progress_bar(pct: float) -> str:
"""用 unicode 方块生成进度条文本"""
filled = min(int(pct / 10), 10)
empty = 10 - filled
bar = "" * filled + "" * empty
return bar
def build_daily_card(title: str, data: dict) -> dict:
"""从结构化数据构建日报卡片"""
is_weekend = data.get("is_weekend", False)
has_risks = bool(data.get("risks"))
elements = []
# ── 顶部 KPI 指标栏3列灰底 ──
if is_weekend:
elements.append(_column_set([
_col(1, f"**进行中项目**\n{data['active_project_count']}"),
_col(1, f"**周末加班**\n{data['submitter_count']} 人提交"),
_col(1, f"**加班产出**\n{data['total_output']}"),
]))
else:
elements.append(_column_set([
_col(1, f"**进行中项目**\n{data['active_project_count']}"),
_col(1, f"**今日提交**\n{data['submitter_count']} 人次"),
_col(1, f"**总产出**\n{data['total_output']}"),
]))
# ── 各项目进展(表格式对齐) ──
elements.append(_hr())
projects = data.get("projects", [])
if projects:
# 表头
elements.append(_column_set([
_col(3, "**项目名称**"),
_col(2, "**进度**"),
_col(1, "**今日产出**"),
], bg="default"))
# 每个项目一行
for p in projects:
pct = p['progress']
bar = _progress_bar(pct)
elements.append(_column_set([
_col(3, f"{p['name']}"),
_col(2, f"{bar} {pct}%"),
_col(1, f"{p['today_output']}"),
], bg="default"))
else:
elements.append(_md("暂无进行中项目"))
# ── 风险预警(始终显示) ──
elements.append(_hr())
risks = data.get("risks", [])
if risks:
level_icon = {"high": "🔴", "medium": "🟡", "low": "🟢"}
risk_lines = "\n".join(
f"{level_icon.get(r.get('level','medium'), '⚠️')} **{r['name']}**{r['detail']}"
for r in risks
)
elements.append(_md(f"**🚨 风险预警({len(risks)}项)**\n{risk_lines}"))
else:
elements.append(_md("**✅ 风险预警**\n当前无风险项目"))
# ── 未提交人员(仅工作日显示) ──
not_submitted = data.get("not_submitted", [])
if not is_weekend and not_submitted:
elements.append(_hr())
names = "".join(not_submitted)
elements.append(_md(f"**📝 未提交({len(not_submitted)}人)**\n{names}"))
elif not is_weekend:
elements.append(_hr())
elements.append(_md("**✅ 提交情况**\n今日全员已提交"))
# ── AI 点评 ──
ai_summary = data.get("ai_summary")
if ai_summary:
elements.append(_hr())
elements.append(_md(f"**💡 AI 点评**\n{ai_summary}"))
return {
"config": {"wide_screen_mode": True},
"header": {
"title": {"tag": "plain_text", "content": title},
"template": "orange" if has_risks else "blue",
},
"elements": elements,
}
def build_weekly_card(title: str, data: dict) -> dict:
"""从结构化数据构建周报卡片"""
elements = []
# ── 顶部 KPI3列灰底 ──
top_cols = [
_col(1, f"**总产出**\n{data['total_output']}"),
_col(1, f"**人均日产出**\n{data['avg_daily_output']}"),
]
if data.get("top_producer"):
top_cols.append(_col(1, f"**效率之星**\n{data['top_producer']}"))
elements.append(_column_set(top_cols))
# ── 项目进展(表格式) ──
elements.append(_hr())
projects = data.get("projects", [])
if projects:
elements.append(_column_set([
_col(3, "**项目名称**"),
_col(2, "**进度**"),
_col(1, "**本周产出**"),
], bg="default"))
for p in projects:
pct = p['progress']
bar = _progress_bar(pct)
elements.append(_column_set([
_col(3, f"{p['name']}"),
_col(2, f"{bar} {pct}%"),
_col(1, f"{p['week_output']}"),
], bg="default"))
else:
elements.append(_md("暂无进行中项目"))
# ── 成本概览2列灰底 ──
elements.append(_hr())
elements.append(_column_set([
_col(1, f"**人力成本**\n{data['labor_cost']}"),
_col(1, f"**AI 工具**\n{data['ai_tool_cost']}"),
]))
# ── 损耗排行 ──
waste_ranking = data.get("waste_ranking", [])
if waste_ranking:
elements.append(_hr())
elements.append(_column_set([
_col(3, "**项目**"),
_col(2, "**损耗率**"),
], bg="default"))
for i, w in enumerate(waste_ranking):
elements.append(_column_set([
_col(3, f"{i+1}. {w['name']}"),
_col(2, f"{w['rate']}%"),
], bg="default"))
# ── AI 分析 ──
ai_summary = data.get("ai_summary")
if ai_summary:
elements.append(_hr())
elements.append(_md(f"**💡 AI 分析与建议**\n{ai_summary}"))
return {
"config": {"wide_screen_mode": True},
"header": {
"title": {"tag": "plain_text", "content": title},
"template": "blue",
},
"elements": elements,
}
def build_monthly_card(title: str, data: dict) -> dict:
"""从结构化数据构建月报卡片"""
elements = []
# ── 顶部 KPI4列灰底 ──
elements.append(_column_set([
_col(1, f"**进行中**\n{data['active_count']}"),
_col(1, f"**已完成**\n{data['completed_count']}"),
_col(1, f"**总产出**\n{data['total_output']}"),
_col(1, f"**总成本**\n{data['total_cost']}"),
]))
# ── 各项目成本明细(表格式) ──
project_costs = data.get("project_costs", [])
if project_costs:
elements.append(_hr())
elements.append(_column_set([
_col(2, "**项目**"),
_col(1, "**人力**"),
_col(1, "**AI**"),
_col(1, "**外包**"),
_col(1, "**合计**"),
], bg="default"))
for pc in project_costs:
elements.append(_column_set([
_col(2, f"{pc['name']}"),
_col(1, f"{pc['labor']}"),
_col(1, f"{pc['ai_tool']}"),
_col(1, f"{pc['outsource']}"),
_col(1, f"**{pc['total']}**"),
], bg="default"))
# ── 盈亏概览 ──
profit_items = data.get("profit_items", [])
if profit_items:
elements.append(_hr())
elements.append(_column_set([
_col(2, "**项目**"),
_col(1, "**回款**"),
_col(1, "**成本**"),
_col(1, "**利润**"),
], bg="default"))
for pi in profit_items:
sign = "+" if pi['is_positive'] else ""
elements.append(_column_set([
_col(2, f"{pi['name']}"),
_col(1, f"{pi['contract']}"),
_col(1, f"{pi['cost']}"),
_col(1, f"{sign}{pi['profit']}"),
], bg="default"))
if data.get("profit_rate") is not None:
elements.append(_md(f"总利润率:**{data['profit_rate']}%**"))
# ── 损耗 + 人均2列灰底 ──
elements.append(_hr())
elements.append(_column_set([
_col(1, f"**总损耗**\n{data['waste_total']}{data['waste_rate']}%"),
_col(1, f"**人均产出**\n{data['avg_per_person']}{data['participant_count']}人)"),
]))
# ── AI 深度分析 ──
ai_summary = data.get("ai_summary")
if ai_summary:
elements.append(_hr())
elements.append(_md(f"**💡 AI 深度分析**\n{ai_summary}"))
return {
"config": {"wide_screen_mode": True},
"header": {
"title": {"tag": "plain_text", "content": title},
"template": "blue",
},
"elements": elements,
}
# ──────────────────────── 飞书 API 服务 ────────────────────────
class FeishuService: class FeishuService:
def __init__(self): def __init__(self):
self.app_id = FEISHU_APP_ID self.app_id = FEISHU_APP_ID
@ -77,22 +346,12 @@ class FeishuService:
logger.warning(f"未找到手机号 {mobile} 对应的飞书用户") logger.warning(f"未找到手机号 {mobile} 对应的飞书用户")
return "" return ""
async def send_card_message(self, user_id: str, title: str, content: str): async def send_card(self, user_id: str, card: dict) -> bool:
"""发送飞书交互式卡片消息给个人""" """发送构建好的卡片 JSON 给个人"""
token = await self._get_tenant_token() token = await self._get_tenant_token()
if not token: if not token:
return False return False
card = {
"header": {
"title": {"tag": "plain_text", "content": title},
"template": "blue",
},
"elements": [
{"tag": "markdown", "content": content},
],
}
payload = { payload = {
"receive_id": user_id, "receive_id": user_id,
"msg_type": "interactive", "msg_type": "interactive",
@ -115,11 +374,44 @@ class FeishuService:
logger.info(f"飞书消息发送成功: {user_id}") logger.info(f"飞书消息发送成功: {user_id}")
return True return True
async def send_card_message(self, user_id: str, title: str, content: str):
"""兼容旧接口:发送简单 title+content 卡片"""
card = {
"header": {
"title": {"tag": "plain_text", "content": title},
"template": "blue",
},
"elements": [
{"tag": "markdown", "content": content},
],
}
return await self.send_card(user_id, card)
async def send_report_card_to_all(self, card: dict) -> dict:
"""给所有配置的接收人发送卡片报告"""
results = {"success": [], "failed": []}
if not REPORT_RECEIVERS:
logger.warning("未配置报告接收人")
return results
for mobile in REPORT_RECEIVERS:
user_id = await self.get_user_id_by_mobile(mobile)
if not user_id:
results["failed"].append({"mobile": mobile, "reason": "未找到用户"})
continue
ok = await self.send_card(user_id, card)
if ok:
results["success"].append(mobile)
else:
results["failed"].append({"mobile": mobile, "reason": "发送失败"})
logger.info(f"报告推送完成: 成功 {len(results['success'])},失败 {len(results['failed'])}")
return results
async def send_report_to_all(self, title: str, content: str) -> dict: async def send_report_to_all(self, title: str, content: str) -> dict:
""" """兼容旧接口"""
给所有配置的接收人发送报告
返回 {"success": [...], "failed": [...]}
"""
results = {"success": [], "failed": []} results = {"success": [], "failed": []}
if not REPORT_RECEIVERS: if not REPORT_RECEIVERS:

View File

@ -5,8 +5,8 @@ from sqlalchemy.orm import Session
from sqlalchemy import func as sa_func from sqlalchemy import func as sa_func
from models import ( from models import (
User, Project, Submission, AIToolCost, User, Project, Submission, AIToolCost, Role,
ProjectStatus, WorkType, ProjectStatus, WorkType, PhaseGroup,
) )
from calculations import ( from calculations import (
calc_waste_for_project, calc_waste_for_project,
@ -23,11 +23,14 @@ logger = logging.getLogger(__name__)
def _fmt_seconds(secs: float) -> str: def _fmt_seconds(secs: float) -> str:
"""秒数格式化为 Xm Xs""" """秒数格式化为 Xh Xm 或 Xm Xs"""
if secs <= 0: if secs <= 0:
return "0s" return "0s"
m = int(secs) // 60 h = int(secs) // 3600
m = (int(secs) % 3600) // 60
s = int(secs) % 60 s = int(secs) % 60
if h > 0:
return f"{h}h {m}m" if m > 0 else f"{h}h"
if m > 0: if m > 0:
return f"{m}m {s}s" if s > 0 else f"{m}m" return f"{m}m {s}s" if s > 0 else f"{m}m"
return f"{s}s" return f"{s}s"
@ -40,14 +43,21 @@ def _fmt_money(amount: float) -> str:
return f"¥{amount:,.0f}" return f"¥{amount:,.0f}"
def _is_weekend(d: date) -> bool:
"""判断是否为周末(周六=5, 周日=6"""
return d.weekday() >= 5
# ──────────────────────────── 日报 ──────────────────────────── # ──────────────────────────── 日报 ────────────────────────────
def generate_daily_report(db: Session) -> dict: def generate_daily_report(db: Session) -> dict:
""" """
生成日报 生成日报返回结构化数据供飞书卡片使用
返回 {"title": str, "content": str, "data": dict}
""" """
today = date.today() today = date.today()
is_weekend = _is_weekend(today)
weekday_names = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
weekday_label = weekday_names[today.weekday()]
# 今日提交 # 今日提交
today_subs = db.query(Submission).filter( today_subs = db.query(Submission).filter(
@ -55,119 +65,131 @@ def generate_daily_report(db: Session) -> dict:
).all() ).all()
today_submitter_ids = set(s.user_id for s in today_subs) today_submitter_ids = set(s.user_id for s in today_subs)
today_total_secs = sum(s.total_seconds for s in today_subs if s.total_seconds > 0) # 产出只算中期制作阶段的动画秒数
today_total_secs = sum(
# 所有活跃用户(有提交记录的) s.total_seconds for s in today_subs
all_active_user_ids = set( if s.total_seconds > 0 and s.project_phase == PhaseGroup.PRODUCTION
uid for (uid,) in db.query(Submission.user_id).distinct().all()
) )
# 未提交人员(仅工作日统计,且排除豁免角色)
not_submitted = [] not_submitted = []
for uid in all_active_user_ids: if not is_weekend:
if uid not in today_submitter_ids: exempt_role_ids = set(
user = db.query(User).filter(User.id == uid).first() r.id for r in db.query(Role).filter(Role.exempt_submission == 1).all()
if user and user.is_active: )
not_submitted.append(user.name) all_active_user_ids = set(
uid for (uid,) in db.query(Submission.user_id).distinct().all()
)
for uid in all_active_user_ids:
if uid not in today_submitter_ids:
user = db.query(User).filter(User.id == uid).first()
if user and user.is_active and user.role_id not in exempt_role_ids:
not_submitted.append(user.name)
# 进行中项目 # 进行中项目
active_projects = db.query(Project).filter( active_projects = db.query(Project).filter(
Project.status == ProjectStatus.IN_PROGRESS Project.status == ProjectStatus.IN_PROGRESS
).all() ).all()
project_lines = [] projects_data = []
risk_lines = []
for p in active_projects: for p in active_projects:
waste = calc_waste_for_project(p.id, db) waste = calc_waste_for_project(p.id, db)
total_secs = waste.get("total_submitted_seconds", 0) total_secs = waste.get("total_submitted_seconds", 0)
target = p.target_total_seconds target = p.target_total_seconds
progress = round(total_secs / target * 100, 1) if target > 0 else 0 progress = round(total_secs / target * 100, 1) if target > 0 else 0
# 今日该项目产出
proj_today_secs = sum( proj_today_secs = sum(
s.total_seconds for s in today_subs s.total_seconds for s in today_subs
if s.project_id == p.id and s.total_seconds > 0 if s.project_id == p.id and s.total_seconds > 0
and s.project_phase == PhaseGroup.PRODUCTION
) )
project_lines.append( projects_data.append({
f"- **{p.name}**:进度 {progress}%,今日产出 {_fmt_seconds(proj_today_secs)}" "name": p.name,
) "progress": progress,
"today_output": _fmt_seconds(proj_today_secs),
})
# 风险检测 # 风险检测(使用完整风险引擎)
if p.estimated_completion_date: full_risks = analyze_project_risks(db)
days_left = (p.estimated_completion_date - today).days risks_data = [
if days_left < 0: {
risk_lines.append(f"- **{p.name}**:已超期 {-days_left} 天,进度 {progress}%") "name": r["project_name"],
elif days_left <= 7 and progress < 80: "level": r["risk_level"],
risk_lines.append(f"- **{p.name}**:距截止仅剩 {days_left} 天,进度仅 {progress}%") "detail": "".join(r["risk_factors"]),
}
for r in full_risks
]
# AI 数据上下文
project_lines = [
f"- {p['name']}:进度 {p['progress']}%,今日产出 {p['today_output']}"
for p in projects_data
]
risk_lines = [f"- {r['name']}{r['detail']}" for r in risks_data]
# 组装数据上下文(供 AI 使用)
data_context = ( data_context = (
f"日期:{today}\n" f"日期:{today}{weekday_label}{',周末' if is_weekend else ''}\n"
f"进行中项目:{len(active_projects)}\n" f"进行中项目:{len(active_projects)}\n"
f"今日提交人次:{len(today_submitter_ids)}\n" f"今日提交人次:{len(today_submitter_ids)}\n"
f"今日总产出:{_fmt_seconds(today_total_secs)}\n" f"今日总产出:{_fmt_seconds(today_total_secs)}\n"
f"今日未提交人员:{', '.join(not_submitted) if not_submitted else ''}\n" )
if not is_weekend:
data_context += f"今日未提交人员:{', '.join(not_submitted) if not_submitted else ''}\n"
else:
data_context += "今天是周末,不统计未提交\n"
data_context += (
f"各项目情况:\n" + "\n".join(project_lines) + "\n" f"各项目情况:\n" + "\n".join(project_lines) + "\n"
f"风险项目:\n" + ("\n".join(risk_lines) if risk_lines else "") f"风险项目:\n" + ("\n".join(risk_lines) if risk_lines else "")
) )
# 调用 AI 生成摘要
ai_summary = generate_report_summary(data_context, "daily") ai_summary = generate_report_summary(data_context, "daily")
# 组装飞书 markdown 内容 title = f"AirLabs 日报 — {today}{weekday_label}"
title = f"AirLabs 日报 — {today}"
lines = [
f"**【今日概览】**",
f"- 进行中项目:{len(active_projects)}",
f"- 今日提交:{len(today_submitter_ids)} 人次,总产出 {_fmt_seconds(today_total_secs)}",
]
if not_submitted:
lines.append(f"- 今日未提交:{', '.join(not_submitted)}")
lines.append("") return {
lines.append("**【各项目进展】**") "title": title,
lines.extend(project_lines if project_lines else ["- 暂无进行中项目"]) "report_type": "daily",
"card_data": {
if risk_lines: "is_weekend": is_weekend,
lines.append("") "weekday_label": weekday_label,
lines.append("**【风险提醒】**") "active_project_count": len(active_projects),
lines.extend(risk_lines) "submitter_count": len(today_submitter_ids),
"total_output": _fmt_seconds(today_total_secs),
if ai_summary: "not_submitted": not_submitted,
lines.append("") "projects": projects_data,
lines.append("**【AI 点评】**") "risks": risks_data,
lines.append(ai_summary) "ai_summary": ai_summary,
},
content = "\n".join(lines) }
return {"title": title, "content": content, "data": {"date": str(today)}}
# ──────────────────────────── 周报 ──────────────────────────── # ──────────────────────────── 周报 ────────────────────────────
def generate_weekly_report(db: Session) -> dict: def generate_weekly_report(db: Session) -> dict:
"""生成周报(本周一到当天的数据)""" """生成周报(本周一到当天的数据),返回结构化数据"""
today = date.today() today = date.today()
# 本周一
monday = today - timedelta(days=today.weekday()) monday = today - timedelta(days=today.weekday())
# 本周提交
week_subs = db.query(Submission).filter( week_subs = db.query(Submission).filter(
Submission.submit_date >= monday, Submission.submit_date >= monday,
Submission.submit_date <= today, Submission.submit_date <= today,
).all() ).all()
week_submitter_ids = set(s.user_id for s in week_subs) week_submitter_ids = set(s.user_id for s in week_subs)
week_total_secs = sum(s.total_seconds for s in week_subs if s.total_seconds > 0) # 产出只算中期制作阶段的动画秒数
week_total_secs = sum(
s.total_seconds for s in week_subs
if s.total_seconds > 0 and s.project_phase == PhaseGroup.PRODUCTION
)
working_days = min((today - monday).days + 1, 5) working_days = min((today - monday).days + 1, 5)
avg_daily = round(week_total_secs / max(1, len(week_submitter_ids)) / max(1, working_days), 1) avg_daily = round(week_total_secs / max(1, len(week_submitter_ids)) / max(1, working_days), 1)
# 进行中项目
active_projects = db.query(Project).filter( active_projects = db.query(Project).filter(
Project.status == ProjectStatus.IN_PROGRESS Project.status == ProjectStatus.IN_PROGRESS
).all() ).all()
# 各项目周报数据 projects_data = []
project_lines = []
for p in active_projects: for p in active_projects:
waste = calc_waste_for_project(p.id, db) waste = calc_waste_for_project(p.id, db)
total_secs = waste.get("total_submitted_seconds", 0) total_secs = waste.get("total_submitted_seconds", 0)
@ -177,12 +199,15 @@ def generate_weekly_report(db: Session) -> dict:
proj_week_secs = sum( proj_week_secs = sum(
s.total_seconds for s in week_subs s.total_seconds for s in week_subs
if s.project_id == p.id and s.total_seconds > 0 if s.project_id == p.id and s.total_seconds > 0
and s.project_phase == PhaseGroup.PRODUCTION
) )
project_lines.append( projects_data.append({
f"- **{p.name}**:当前进度 {progress}%,本周产出 {_fmt_seconds(proj_week_secs)}" "name": p.name,
) "progress": progress,
"week_output": _fmt_seconds(proj_week_secs),
})
# 本周成本(简化:统计提交人的日成本) # 本周成本
week_labor = 0.0 week_labor = 0.0
processed = set() processed = set()
for s in week_subs: for s in week_subs:
@ -203,27 +228,48 @@ def generate_weekly_report(db: Session) -> dict:
for p in active_projects: for p in active_projects:
w = calc_waste_for_project(p.id, db) w = calc_waste_for_project(p.id, db)
if w.get("total_waste_seconds", 0) > 0: if w.get("total_waste_seconds", 0) > 0:
waste_ranking.append({ waste_ranking.append({"name": p.name, "rate": w["waste_rate"]})
"name": p.name,
"rate": w["waste_rate"],
})
waste_ranking.sort(key=lambda x: x["rate"], reverse=True) waste_ranking.sort(key=lambda x: x["rate"], reverse=True)
# 效率排行(找产出最高的人) # 效率之星 —— 跨项目加权通过率(有效产出 / 总产出,扣除返工)
user_week_secs = {} from collections import defaultdict
user_prod_secs = defaultdict(float) # 制作秒数
user_rev_secs = defaultdict(float) # 返工秒数
for s in week_subs: for s in week_subs:
if s.total_seconds > 0: if s.total_seconds > 0 and s.project_phase == PhaseGroup.PRODUCTION:
user_week_secs[s.user_id] = user_week_secs.get(s.user_id, 0) + s.total_seconds if s.work_type == WorkType.PRODUCTION:
user_prod_secs[s.user_id] += s.total_seconds
elif s.work_type == WorkType.REVISION:
user_rev_secs[s.user_id] += s.total_seconds
top_producer = None top_producer = None
if user_week_secs: best_rate = -1
top_uid = max(user_week_secs, key=user_week_secs.get) best_uid = None
top_user = db.query(User).filter(User.id == top_uid).first() for uid, prod in user_prod_secs.items():
if prod < 60: # 至少 1 分钟制作产出才参与排名
continue
rev = user_rev_secs.get(uid, 0)
rate = (prod - rev) / prod if prod > 0 else 0
if rate > best_rate:
best_rate = rate
best_uid = uid
if best_uid is not None and best_rate >= 0:
top_user = db.query(User).filter(User.id == best_uid).first()
if top_user: if top_user:
top_daily = round(user_week_secs[top_uid] / max(1, working_days), 1) top_producer = f"{top_user.name}(通过率 {round(best_rate * 100, 1)}%"
top_producer = f"{top_user.name}(日均 {_fmt_seconds(top_daily)}"
# AI 数据上下文 # AI 数据上下文
project_lines = [
f"- {p['name']}:当前进度 {p['progress']}%,本周产出 {p['week_output']}"
for p in projects_data
]
# 风险检测
full_risks = analyze_project_risks(db)
risk_lines = [
f"- {r['project_name']}{''.join(r['risk_factors'])}"
for r in full_risks
]
data_context = ( data_context = (
f"周期:{monday} ~ {today}\n" f"周期:{monday} ~ {today}\n"
f"进行中项目:{len(active_projects)}\n" f"进行中项目:{len(active_projects)}\n"
@ -233,74 +279,62 @@ def generate_weekly_report(db: Session) -> dict:
f"本周人力成本:{_fmt_money(week_labor)}\n" f"本周人力成本:{_fmt_money(week_labor)}\n"
f"本周AI工具成本{_fmt_money(week_ai_cost)}\n" f"本周AI工具成本{_fmt_money(week_ai_cost)}\n"
f"各项目:\n" + "\n".join(project_lines) + "\n" f"各项目:\n" + "\n".join(project_lines) + "\n"
f"损耗排行:\n" + "\n".join( f"损耗排行:\n" + ("\n".join(
f"- {w['name']}{w['rate']}%" for w in waste_ranking[:5] f"- {w['name']}{w['rate']}%" for w in waste_ranking[:5]
) if waste_ranking else "损耗排行:无" ) if waste_ranking else "") + "\n"
f"风险项目:\n" + ("\n".join(risk_lines) if risk_lines else "")
) )
ai_summary = generate_report_summary(data_context, "weekly") ai_summary = generate_report_summary(data_context, "weekly")
# 组装内容 week_num = today.isocalendar()[1]
title = f"AirLabs 周报 — 第{today.isocalendar()[1]}周({monday} ~ {today}" title = f"AirLabs 周报 — 第{week_num}周({monday} ~ {today}"
lines = [
"**【项目进展】**",
]
lines.extend(project_lines if project_lines else ["- 暂无进行中项目"])
lines.append("") return {
lines.append("**【团队产出】**") "title": title,
lines.append(f"- 本周总产出:{_fmt_seconds(week_total_secs)}") "report_type": "weekly",
lines.append(f"- 人均日产出:{_fmt_seconds(avg_daily)}") "card_data": {
if top_producer: "week_range": f"{monday} ~ {today}",
lines.append(f"- 效率最高:{top_producer}") "week_num": week_num,
"total_output": _fmt_seconds(week_total_secs),
lines.append("") "avg_daily_output": _fmt_seconds(avg_daily),
lines.append("**【成本概览】**") "top_producer": top_producer,
lines.append(f"- 本周人力成本:{_fmt_money(week_labor)}") "projects": projects_data,
lines.append(f"- 本周 AI 工具支出:{_fmt_money(week_ai_cost)}") "labor_cost": _fmt_money(week_labor),
"ai_tool_cost": _fmt_money(week_ai_cost),
if waste_ranking: "waste_ranking": waste_ranking[:5],
lines.append("") "ai_summary": ai_summary,
lines.append("**【损耗排行】**") },
for w in waste_ranking[:5]: }
lines.append(f"- {w['name']}:损耗率 {w['rate']}%")
if ai_summary:
lines.append("")
lines.append("**【AI 分析与建议】**")
lines.append(ai_summary)
content = "\n".join(lines)
return {"title": title, "content": content, "data": {"week_start": str(monday), "week_end": str(today)}}
# ──────────────────────────── 月报 ──────────────────────────── # ──────────────────────────── 月报 ────────────────────────────
def generate_monthly_report(db: Session) -> dict: def generate_monthly_report(db: Session) -> dict:
"""生成月报(上月完整数据在每月1号调用""" """生成月报(上月完整数据),返回结构化数据"""
today = date.today() today = date.today()
# 上月日期范围
first_of_this_month = today.replace(day=1) first_of_this_month = today.replace(day=1)
last_of_prev_month = first_of_this_month - timedelta(days=1) last_of_prev_month = first_of_this_month - timedelta(days=1)
first_of_prev_month = last_of_prev_month.replace(day=1) first_of_prev_month = last_of_prev_month.replace(day=1)
month_label = f"{last_of_prev_month.year}{last_of_prev_month.month}" month_label = f"{last_of_prev_month.year}{last_of_prev_month.month}"
# 上月提交
month_subs = db.query(Submission).filter( month_subs = db.query(Submission).filter(
Submission.submit_date >= first_of_prev_month, Submission.submit_date >= first_of_prev_month,
Submission.submit_date <= last_of_prev_month, Submission.submit_date <= last_of_prev_month,
).all() ).all()
month_total_secs = sum(s.total_seconds for s in month_subs if s.total_seconds > 0) # 产出只算中期制作阶段的动画秒数
month_total_secs = sum(
s.total_seconds for s in month_subs
if s.total_seconds > 0 and s.project_phase == PhaseGroup.PRODUCTION
)
month_submitters = set(s.user_id for s in month_subs) month_submitters = set(s.user_id for s in month_subs)
# 所有项目(含进行中和上月完成的)
all_projects = db.query(Project).filter( all_projects = db.query(Project).filter(
Project.status.in_([ProjectStatus.IN_PROGRESS, ProjectStatus.COMPLETED]) Project.status.in_([ProjectStatus.IN_PROGRESS, ProjectStatus.COMPLETED])
).all() ).all()
# 上月完成的项目
completed_this_month = [ completed_this_month = [
p for p in all_projects p for p in all_projects
if p.status == ProjectStatus.COMPLETED if p.status == ProjectStatus.COMPLETED
@ -311,7 +345,7 @@ def generate_monthly_report(db: Session) -> dict:
active_projects = [p for p in all_projects if p.status == ProjectStatus.IN_PROGRESS] active_projects = [p for p in all_projects if p.status == ProjectStatus.IN_PROGRESS]
# 各项目成本 # 各项目成本
project_cost_lines = [] project_costs = []
total_all_cost = 0.0 total_all_cost = 0.0
for p in active_projects + completed_this_month: for p in active_projects + completed_this_month:
labor = calc_labor_cost_for_project(p.id, db) labor = calc_labor_cost_for_project(p.id, db)
@ -320,13 +354,17 @@ def generate_monthly_report(db: Session) -> dict:
overhead = calc_overhead_cost_for_project(p.id, db) overhead = calc_overhead_cost_for_project(p.id, db)
total = labor + ai_tool + outsource + overhead total = labor + ai_tool + outsource + overhead
total_all_cost += total total_all_cost += total
project_cost_lines.append( project_costs.append({
f"- **{p.name}**:人力 {_fmt_money(labor)} / AI工具 {_fmt_money(ai_tool)} / " "name": p.name,
f"外包 {_fmt_money(outsource)} / 固定 {_fmt_money(overhead)} → 总计 {_fmt_money(total)}" "labor": _fmt_money(labor),
) "ai_tool": _fmt_money(ai_tool),
"outsource": _fmt_money(outsource),
"overhead": _fmt_money(overhead),
"total": _fmt_money(total),
})
# 盈亏概览(已结算的客户正式项目) # 盈亏
profit_lines = [] profit_items = []
total_profit = 0.0 total_profit = 0.0
total_contract = 0.0 total_contract = 0.0
for p in completed_this_month: for p in completed_this_month:
@ -335,11 +373,15 @@ def generate_monthly_report(db: Session) -> dict:
pl = settlement.get("profit_loss", 0) pl = settlement.get("profit_loss", 0)
total_profit += pl total_profit += pl
total_contract += settlement["contract_amount"] total_contract += settlement["contract_amount"]
sign = "+" if pl >= 0 else "" profit_items.append({
profit_lines.append( "name": p.name,
f"- **{p.name}**:回款 {_fmt_money(settlement['contract_amount'])}" "contract": _fmt_money(settlement["contract_amount"]),
f"成本 {_fmt_money(settlement['total_cost'])},利润 {sign}{_fmt_money(pl)}" "cost": _fmt_money(settlement["total_cost"]),
) "profit": _fmt_money(pl),
"is_positive": pl >= 0,
})
profit_rate = round(total_profit / total_contract * 100, 1) if total_contract > 0 else None
# 损耗汇总 # 损耗汇总
total_waste_secs = 0.0 total_waste_secs = 0.0
@ -350,11 +392,25 @@ def generate_monthly_report(db: Session) -> dict:
total_target_secs += p.target_total_seconds or 0 total_target_secs += p.target_total_seconds or 0
waste_rate = round(total_waste_secs / total_target_secs * 100, 1) if total_target_secs > 0 else 0 waste_rate = round(total_waste_secs / total_target_secs * 100, 1) if total_target_secs > 0 else 0
# 人均产出
working_days_month = 22
avg_per_person = round(month_total_secs / max(1, len(month_submitters)), 1) avg_per_person = round(month_total_secs / max(1, len(month_submitters)), 1)
# AI 数据上下文 # AI 数据上下文
project_cost_lines = [
f"- {pc['name']}:人力 {pc['labor']} / AI工具 {pc['ai_tool']} / "
f"外包 {pc['outsource']} / 固定 {pc['overhead']} → 总计 {pc['total']}"
for pc in project_costs
]
profit_lines = [
f"- {pi['name']}:回款 {pi['contract']},成本 {pi['cost']},利润 {'+'if pi['is_positive'] else ''}{pi['profit']}"
for pi in profit_items
]
# 风险检测
monthly_risks = analyze_project_risks(db)
monthly_risk_lines = [
f"- {r['project_name']}{''.join(r['risk_factors'])}"
for r in monthly_risks
]
data_context = ( data_context = (
f"月份:{month_label}\n" f"月份:{month_label}\n"
f"进行中项目:{len(active_projects)}\n" f"进行中项目:{len(active_projects)}\n"
@ -365,53 +421,32 @@ def generate_monthly_report(db: Session) -> dict:
f"参与人数:{len(month_submitters)}\n" f"参与人数:{len(month_submitters)}\n"
f"人均产出:{_fmt_seconds(avg_per_person)}\n" f"人均产出:{_fmt_seconds(avg_per_person)}\n"
f"各项目成本:\n" + "\n".join(project_cost_lines) + "\n" f"各项目成本:\n" + "\n".join(project_cost_lines) + "\n"
f"盈亏:\n" + ("\n".join(profit_lines) if profit_lines else "本月无结算项目") f"盈亏:\n" + ("\n".join(profit_lines) if profit_lines else "本月无结算项目") + "\n"
f"风险项目:\n" + ("\n".join(monthly_risk_lines) if monthly_risk_lines else "")
) )
ai_summary = generate_report_summary(data_context, "monthly") ai_summary = generate_report_summary(data_context, "monthly")
# 组装内容
title = f"AirLabs 月报 — {month_label}" title = f"AirLabs 月报 — {month_label}"
lines = [
"**【月度总览】**",
f"- 进行中项目:{len(active_projects)}",
f"- 本月完成项目:{len(completed_this_month)}",
f"- 月度总产出:{_fmt_seconds(month_total_secs)}",
f"- 月度总成本:{_fmt_money(total_all_cost)}",
]
if project_cost_lines:
lines.append("")
lines.append("**【各项目成本明细】**")
lines.extend(project_cost_lines)
if profit_lines:
lines.append("")
lines.append("**【盈亏概览】**")
lines.extend(profit_lines)
if total_contract > 0:
profit_rate = round(total_profit / total_contract * 100, 1)
lines.append(f"- 总利润率:{profit_rate}%")
lines.append("")
lines.append("**【月度损耗】**")
lines.append(f"- 总损耗:{_fmt_seconds(total_waste_secs)}(损耗率 {waste_rate}%")
lines.append("")
lines.append("**【人均产出】**")
lines.append(f"- 参与人数:{len(month_submitters)}")
lines.append(f"- 月度人均产出:{_fmt_seconds(avg_per_person)}")
if ai_summary:
lines.append("")
lines.append("**【AI 深度分析】**")
lines.append(ai_summary)
content = "\n".join(lines)
return { return {
"title": title, "title": title,
"content": content, "report_type": "monthly",
"data": {"month": month_label, "start": str(first_of_prev_month), "end": str(last_of_prev_month)}, "card_data": {
"month_label": month_label,
"active_count": len(active_projects),
"completed_count": len(completed_this_month),
"total_output": _fmt_seconds(month_total_secs),
"total_cost": _fmt_money(total_all_cost),
"project_costs": project_costs,
"profit_items": profit_items,
"profit_rate": profit_rate,
"waste_total": _fmt_seconds(total_waste_secs),
"waste_rate": waste_rate,
"participant_count": len(month_submitters),
"avg_per_person": _fmt_seconds(avg_per_person),
"ai_summary": ai_summary,
},
} }
@ -430,9 +465,14 @@ def analyze_project_risks(db: Session) -> list:
risks = [] risks = []
for p in active_projects: for p in active_projects:
waste = calc_waste_for_project(p.id, db) waste = calc_waste_for_project(p.id, db)
total_secs = waste.get("total_submitted_seconds", 0) # 进度只算中期制作产出,不把后期算进去
production_secs = db.query(sa_func.sum(Submission.total_seconds)).filter(
Submission.project_id == p.id,
Submission.total_seconds > 0,
Submission.project_phase == PhaseGroup.PRODUCTION,
).scalar() or 0
target = p.target_total_seconds target = p.target_total_seconds
progress = round(total_secs / target * 100, 1) if target > 0 else 0 progress = round(production_secs / target * 100, 1) if target > 0 else 0
risk_factors = [] risk_factors = []
risk_level = "low" risk_level = "low"
@ -452,7 +492,6 @@ def analyze_project_risks(db: Session) -> list:
# 进度落后于时间线 # 进度落后于时间线
if p.estimated_completion_date and days_left > 0: if p.estimated_completion_date and days_left > 0:
# 估算已用天数
if hasattr(p, 'created_at') and p.created_at: if hasattr(p, 'created_at') and p.created_at:
created = p.created_at.date() if hasattr(p.created_at, 'date') else p.created_at created = p.created_at.date() if hasattr(p.created_at, 'date') else p.created_at
total_days = (p.estimated_completion_date - created).days total_days = (p.estimated_completion_date - created).days
@ -487,7 +526,7 @@ def analyze_project_risks(db: Session) -> list:
Submission.project_id == p.id, Submission.project_id == p.id,
Submission.submit_date >= week_ago, Submission.submit_date >= week_ago,
).count() ).count()
if recent_subs == 0 and total_secs > 0: if recent_subs == 0 and production_secs > 0:
risk_factors.append("近 7 天无提交,产出停滞") risk_factors.append("近 7 天无提交,产出停滞")
if risk_level != "high": if risk_level != "high":
risk_level = "medium" risk_level = "medium"

View File

@ -14,23 +14,28 @@ async def _run_report_job(report_type: str):
from services.report_service import ( from services.report_service import (
generate_daily_report, generate_weekly_report, generate_monthly_report, generate_daily_report, generate_weekly_report, generate_monthly_report,
) )
from services.feishu_service import feishu from services.feishu_service import (
feishu, build_daily_card, build_weekly_card, build_monthly_card,
)
logger.info(f"[定时任务] 开始生成{report_type}...") logger.info(f"[定时任务] 开始生成{report_type}...")
db = SessionLocal() db = SessionLocal()
try: try:
if report_type == "日报": if report_type == "日报":
result = generate_daily_report(db) result = generate_daily_report(db)
card = build_daily_card(result["title"], result["card_data"])
elif report_type == "周报": elif report_type == "周报":
result = generate_weekly_report(db) result = generate_weekly_report(db)
card = build_weekly_card(result["title"], result["card_data"])
elif report_type == "月报": elif report_type == "月报":
result = generate_monthly_report(db) result = generate_monthly_report(db)
card = build_monthly_card(result["title"], result["card_data"])
else: else:
logger.error(f"未知报告类型: {report_type}") logger.error(f"未知报告类型: {report_type}")
return return
logger.info(f"[定时任务] {report_type}生成完成,开始推送飞书...") logger.info(f"[定时任务] {report_type}生成完成,开始推送飞书...")
push_result = await feishu.send_report_to_all(result["title"], result["content"]) push_result = await feishu.send_report_card_to_all(card)
logger.info(f"[定时任务] {report_type}推送完成: {push_result}") logger.info(f"[定时任务] {report_type}推送完成: {push_result}")
except Exception as e: except Exception as e:
@ -52,7 +57,12 @@ async def monthly_report_job():
def setup_scheduler(): def setup_scheduler():
"""配置并启动定时任务""" """配置并启动定时任务(通过 DISABLE_SCHEDULER=1 可关闭)"""
import os
if os.environ.get("DISABLE_SCHEDULER") == "1":
logger.info("[定时任务] 已通过 DISABLE_SCHEDULER=1 禁用")
return
# 日报:每天 20:00 # 日报:每天 20:00
scheduler.add_job( scheduler.add_job(
daily_report_job, "cron", daily_report_job, "cron",

View File

@ -1,8 +1,11 @@
<template> <template>
<el-container class="layout"> <el-container class="layout">
<!-- 手机端遮罩 -->
<div v-if="isMobile && mobileMenuOpen" class="menu-overlay" @click="mobileMenuOpen = false"></div>
<!-- 侧边栏 --> <!-- 侧边栏 -->
<el-aside :width="isCollapsed ? '64px' : '240px'" class="aside"> <el-aside :width="isMobile ? '240px' : (isCollapsed ? '64px' : '240px')" class="aside" :class="{ 'mobile-open': isMobile && mobileMenuOpen, 'mobile-hidden': isMobile && !mobileMenuOpen }">
<div class="logo" @click="isCollapsed = !isCollapsed"> <div class="logo" @click="isMobile ? (mobileMenuOpen = false) : (isCollapsed = !isCollapsed)">
<div class="logo-icon">A</div> <div class="logo-icon">A</div>
<transition name="fade"> <transition name="fade">
<div v-show="!isCollapsed" class="logo-info"> <div v-show="!isCollapsed" class="logo-info">
@ -20,9 +23,10 @@
class="nav-item" class="nav-item"
:class="{ active: isActive(item.path) }" :class="{ active: isActive(item.path) }"
v-show="!item.perm || (Array.isArray(item.perm) ? item.perm.some(p => authStore.hasPermission(p)) : authStore.hasPermission(item.perm))" v-show="!item.perm || (Array.isArray(item.perm) ? item.perm.some(p => authStore.hasPermission(p)) : authStore.hasPermission(item.perm))"
@click="isMobile && (mobileMenuOpen = false)"
> >
<el-icon :size="18"><component :is="item.icon" /></el-icon> <el-icon :size="18"><component :is="item.icon" /></el-icon>
<span v-show="!isCollapsed" class="nav-label">{{ item.label }}</span> <span v-show="isMobile || !isCollapsed" class="nav-label">{{ item.label }}</span>
</router-link> </router-link>
</nav> </nav>
@ -47,6 +51,9 @@
<el-container class="main-container"> <el-container class="main-container">
<el-header class="header"> <el-header class="header">
<div class="header-left"> <div class="header-left">
<el-button v-if="isMobile" text class="hamburger-btn" @click="mobileMenuOpen = !mobileMenuOpen">
<el-icon :size="20"><Fold v-if="mobileMenuOpen" /><Expand v-else /></el-icon>
</el-button>
<h3 class="page-route-title">{{ currentTitle }}</h3> <h3 class="page-route-title">{{ currentTitle }}</h3>
</div> </div>
<div class="header-right"> <div class="header-right">
@ -83,7 +90,7 @@
</template> </template>
<script setup> <script setup>
import { ref, reactive, computed, onMounted } from 'vue' import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { userApi } from '../api' import { userApi } from '../api'
@ -93,6 +100,10 @@ const router = useRouter()
const route = useRoute() const route = useRoute()
const authStore = useAuthStore() const authStore = useAuthStore()
const isCollapsed = ref(false) const isCollapsed = ref(false)
const isMobile = ref(window.innerWidth <= 768)
const mobileMenuOpen = ref(false)
function onResize() { isMobile.value = window.innerWidth <= 768; if (!isMobile.value) mobileMenuOpen.value = false }
const menuItems = [ const menuItems = [
{ path: '/dashboard', label: '仪表盘', icon: 'Odometer', perm: 'dashboard:view' }, { path: '/dashboard', label: '仪表盘', icon: 'Odometer', perm: 'dashboard:view' },
@ -123,11 +134,16 @@ function isActive(path) {
} }
onMounted(async () => { onMounted(async () => {
window.addEventListener('resize', onResize)
if (authStore.token && !authStore.user) { if (authStore.token && !authStore.user) {
await authStore.fetchUser() await authStore.fetchUser()
} }
}) })
onUnmounted(() => { window.removeEventListener('resize', onResize) })
watch(() => route.path, () => { if (isMobile.value) mobileMenuOpen.value = false })
function handleLogout() { function handleLogout() {
authStore.logout() authStore.logout()
router.push('/login') router.push('/login')
@ -275,4 +291,26 @@ async function handleChangePwd() {
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s; } .fade-enter-active, .fade-leave-active { transition: opacity 0.2s; }
.fade-enter-from, .fade-leave-to { opacity: 0; } .fade-enter-from, .fade-leave-to { opacity: 0; }
/* ── 手机端汉堡按钮 ── */
.hamburger-btn { padding: 4px !important; margin-right: 4px; }
.header-left { display: flex; align-items: center; gap: 4px; }
/* ── 手机端遮罩 + 侧边栏 ── */
.menu-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.3);
z-index: 99; animation: fadeIn 0.2s;
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@media (max-width: 768px) {
.aside {
position: fixed !important; z-index: 100; height: 100vh; top: 0; left: 0;
transition: transform 0.25s ease;
}
.aside.mobile-hidden { transform: translateX(-100%); }
.aside.mobile-open { transform: translateX(0); }
.header { padding: 0 12px; }
.main { padding: 12px; }
}
</style> </style>

View File

@ -66,6 +66,35 @@
</div> </div>
</div> </div>
<!-- 今日提交情况 -->
<div class="card attendance-card" v-if="data.daily_attendance">
<div class="card-header" style="cursor:pointer" @click="attendanceExpanded = !attendanceExpanded">
<span class="card-title">
<el-icon :size="16" style="color:#3370FF;margin-right:6px;vertical-align:-2px"><User /></el-icon>
今日提交情况
</span>
<div class="attendance-summary">
<span class="att-tag submitted">已提交 {{ data.daily_attendance.submitted_count }}</span>
<span class="att-tag not-submitted" v-if="data.daily_attendance.not_submitted_count > 0">未提交 {{ data.daily_attendance.not_submitted_count }}</span>
<el-icon :size="14" style="color:var(--text-secondary);margin-left:4px;transition:transform 0.2s" :style="{transform: attendanceExpanded ? 'rotate(180deg)' : ''}"><ArrowDown /></el-icon>
</div>
</div>
<div class="card-body attendance-body" v-show="attendanceExpanded">
<div v-if="data.daily_attendance.not_submitted?.length" class="att-section">
<div class="att-section-title not-submitted-title">未提交{{ data.daily_attendance.not_submitted.length }}</div>
<div class="att-user-list">
<span v-for="u in data.daily_attendance.not_submitted" :key="u.id" class="att-user not-submitted">{{ u.name }}</span>
</div>
</div>
<div v-if="data.daily_attendance.submitted?.length" class="att-section">
<div class="att-section-title">已提交{{ data.daily_attendance.submitted.length }}</div>
<div class="att-user-list">
<span v-for="u in data.daily_attendance.submitted" :key="u.id" class="att-user submitted">{{ u.name }} <small>{{ u.hours }}h</small></span>
</div>
</div>
</div>
</div>
<!-- 风险预警 --> <!-- 风险预警 -->
<div class="card risk-card" v-if="data.risk_alerts?.length && authStore.hasPermission('dashboard:view_risk')"> <div class="card risk-card" v-if="data.risk_alerts?.length && authStore.hasPermission('dashboard:view_risk')">
<div class="card-header"> <div class="card-header">
@ -206,6 +235,7 @@ const authStore = useAuthStore()
const loading = ref(false) const loading = ref(false)
const data = ref({}) const data = ref({})
const attendanceExpanded = ref(false)
const typeTagMap = { '客户正式项目': 'success', '客户测试项目': 'warning', '内部原创项目': '', '内部测试项目': 'info' } const typeTagMap = { '客户正式项目': 'success', '客户测试项目': 'warning', '内部原创项目': '', '内部测试项目': 'info' }
const trendChartRef = ref(null) const trendChartRef = ref(null)
@ -456,6 +486,25 @@ onUnmounted(() => {
.profit-text.loss { color: #FF3B30; } .profit-text.loss { color: #FF3B30; }
.text-muted { color: var(--text-secondary); } .text-muted { color: var(--text-secondary); }
/* 今日提交情况 */
.attendance-card { margin-bottom: 16px; }
.attendance-summary { display: flex; align-items: center; gap: 8px; }
.att-tag { font-size: 13px; font-weight: 600; padding: 2px 10px; border-radius: 10px; }
.att-tag.submitted { color: #34C759; background: #E8F8EE; }
.att-tag.not-submitted { color: #FF3B30; background: #FFE8E7; }
.attendance-body { padding-top: 8px !important; padding-bottom: 12px !important; }
.att-section { margin-bottom: 12px; }
.att-section:last-child { margin-bottom: 0; }
.att-section-title { font-size: 12px; color: var(--text-secondary); margin-bottom: 8px; font-weight: 500; }
.att-section-title.not-submitted-title { color: #FF3B30; }
.att-user-list { display: flex; flex-wrap: wrap; gap: 6px; }
.att-user {
font-size: 13px; padding: 4px 12px; border-radius: 6px; white-space: nowrap;
}
.att-user.not-submitted { background: #FFF5F5; color: #CC2D25; font-weight: 500; }
.att-user.submitted { background: #F7F8FA; color: var(--text-regular); }
.att-user.submitted small { color: var(--text-secondary); margin-left: 2px; }
/* 风险预警 */ /* 风险预警 */
.risk-card { margin-bottom: 16px; } .risk-card { margin-bottom: 16px; }
.risk-item { .risk-item {

View File

@ -529,12 +529,32 @@ let progressChart = null
function initProgressChart() { function initProgressChart() {
if (!progressChartRef.value) return if (!progressChartRef.value) return
const p = project.value const p = project.value
const target = p.target_total_seconds || 1
const animSecs = p.animation_seconds || 0
const w = prodWaste.value
const testSecs = w.test_waste_seconds || 0
const overSecs = w.overproduction_waste_seconds || 0
const netSecs = Math.max(0, animSecs - testSecs - overSecs)
const pct = p.progress_percent || 0 const pct = p.progress_percent || 0
const isOver = pct > 100
// 100%
const netPct = Math.min(netSecs / target * 100, 100)
const testPct = testSecs / target * 100
const overPct = overSecs / target * 100
const remaining = Math.max(0, 100 - netPct - testPct - overPct)
if (progressChart) progressChart.dispose() if (progressChart) progressChart.dispose()
progressChart = echarts.init(progressChartRef.value) progressChart = echarts.init(progressChartRef.value)
const data = [
{ value: netPct, name: '有效产出', itemStyle: { color: '#3370FF' } },
]
if (testPct > 0) data.push({ value: testPct, name: '测试损耗', itemStyle: { color: '#FF9500' } })
if (overPct > 0) data.push({ value: overPct, name: '超产损耗', itemStyle: { color: '#FF3B30' } })
if (remaining > 0) data.push({ value: remaining, name: '剩余', itemStyle: { color: '#E5E6EB' } })
const centerColor = pct > 100 ? '#FF9500' : '#3370FF'
progressChart.setOption({ progressChart.setOption({
series: [{ series: [{
type: 'pie', type: 'pie',
@ -542,10 +562,7 @@ function initProgressChart() {
center: ['50%', '50%'], center: ['50%', '50%'],
silent: true, silent: true,
label: { show: false }, label: { show: false },
data: [ data,
{ value: Math.min(pct, 100), itemStyle: { color: isOver ? '#FF9500' : '#3370FF' } },
{ value: Math.max(0, 100 - pct), itemStyle: { color: '#E5E6EB' } },
],
}], }],
graphic: [{ graphic: [{
type: 'text', type: 'text',
@ -553,7 +570,7 @@ function initProgressChart() {
style: { style: {
text: pct + '%', text: pct + '%',
fontSize: 22, fontWeight: 700, fontSize: 22, fontWeight: 700,
fill: isOver ? '#FF9500' : '#3370FF', fill: centerColor,
textAlign: 'center', textAlign: 'center',
}, },
}], }],
@ -1161,4 +1178,35 @@ onUnmounted(() => {
.drawer-sub-top { display: flex; align-items: center; gap: 8px; font-size: 13px; } .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-secs { font-weight: 600; color: var(--text-primary); margin-left: auto; }
.drawer-sub-desc { font-size: 12px; color: #8F959E; margin-top: 4px; } .drawer-sub-desc { font-size: 12px; color: #8F959E; margin-top: 4px; }
/* ── 手机端适配 ── */
@media (max-width: 768px) {
.page-header { flex-wrap: wrap; gap: 8px; }
.page-header-left h2 { font-size: 15px; }
.page-header .el-space { flex-wrap: wrap; }
.info-grid { grid-template-columns: 1fr 1fr; }
.info-item:nth-last-child(-n+3) { border-bottom: 1px solid var(--border-light, #f0f1f2); }
.info-item:last-child { border-bottom: none; }
.stat-grid { grid-template-columns: repeat(2, 1fr); gap: 10px; }
.stat-card { padding: 14px; }
.stat-value { font-size: 18px; }
.post-card { padding: 12px 16px; }
.post-value { font-size: 16px; }
.segmented-bar { flex-direction: column; gap: 8px; }
.milestone-columns { grid-template-columns: 1fr; }
.production-ring-layout { flex-direction: column; align-items: center; }
.production-info { max-width: none; }
.ep-bar-wrapper { flex-wrap: wrap; gap: 4px 12px; }
.ep-bar-track { flex: 1 1 100%; }
.ep-bar-info { min-width: auto; }
.heatmap-body { padding: 10px !important; }
.card-body { padding: 12px; }
}
</style> </style>

View File

@ -18,6 +18,11 @@
<span class="perm-count">{{ row.permissions.length }}</span> <span class="perm-count">{{ row.permissions.length }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="豁免提交" width="100" align="center">
<template #default="{row}">
<el-tag v-if="row.exempt_submission" size="small" type="warning">豁免</el-tag>
</template>
</el-table-column>
<el-table-column label="用户数" width="90" align="center"> <el-table-column label="用户数" width="90" align="center">
<template #default="{row}">{{ row.user_count }}</template> <template #default="{row}">{{ row.user_count }}</template>
</el-table-column> </el-table-column>
@ -38,6 +43,10 @@
<el-form-item label="描述"> <el-form-item label="描述">
<el-input v-model="form.description" placeholder="(选填)角色职责说明" /> <el-input v-model="form.description" placeholder="(选填)角色职责说明" />
</el-form-item> </el-form-item>
<el-form-item label="豁免提交">
<el-switch v-model="form.exempt_submission" active-text="豁免" inactive-text="" />
<span style="margin-left:8px;font-size:12px;color:#909399">开启后该角色成员不纳入每日提交统计</span>
</el-form-item>
</el-form> </el-form>
<div class="perm-panel"> <div class="perm-panel">
@ -85,7 +94,7 @@ const editingSystem = ref(false)
const roles = ref([]) const roles = ref([])
const permGroups = ref([]) const permGroups = ref([])
const form = ref({ name: '', description: '', permissions: [] }) const form = ref({ name: '', description: '', permissions: [], exempt_submission: false })
const allPermKeys = computed(() => permGroups.value.flatMap(g => g.permissions.map(p => p.key))) 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))) const isAllSelected = computed(() => allPermKeys.value.length > 0 && allPermKeys.value.every(k => form.value.permissions.includes(k)))
@ -123,7 +132,7 @@ function toggleAll() {
function openCreate() { function openCreate() {
editingId.value = null editingId.value = null
editingSystem.value = false editingSystem.value = false
form.value = { name: '', description: '', permissions: [] } form.value = { name: '', description: '', permissions: [], exempt_submission: false }
showDialog.value = true showDialog.value = true
} }
@ -134,6 +143,7 @@ function openEdit(role) {
name: role.name, name: role.name,
description: role.description || '', description: role.description || '',
permissions: [...role.permissions], permissions: [...role.permissions],
exempt_submission: !!role.exempt_submission,
} }
showDialog.value = true showDialog.value = true
} }

View File

@ -18,19 +18,22 @@
<!-- 成本汇总 --> <!-- 成本汇总 -->
<el-row :gutter="16" class="stat-row"> <el-row :gutter="16" class="stat-row">
<el-col :span="5"> <el-col :span="4">
<el-card shadow="hover"><div class="stat-label">人力成本</div><div class="stat-value">¥{{ fmt(data.labor_cost) }}</div></el-card> <el-card shadow="hover"><div class="stat-label">人力成本</div><div class="stat-value">¥{{ fmt(data.labor_cost) }}</div></el-card>
</el-col> </el-col>
<el-col :span="5"> <el-col :span="4">
<el-card shadow="hover"><div class="stat-label">AI 工具成本</div><div class="stat-value">¥{{ fmt(data.ai_tool_cost) }}</div></el-card> <el-card shadow="hover"><div class="stat-label">AI 工具</div><div class="stat-value">¥{{ fmt(data.ai_tool_cost) }}</div></el-card>
</el-col> </el-col>
<el-col :span="4"> <el-col :span="4">
<el-card shadow="hover"><div class="stat-label">外包成本</div><div class="stat-value">¥{{ fmt(data.outsource_cost) }}</div></el-card> <el-card shadow="hover"><div class="stat-label">外包成本</div><div class="stat-value">¥{{ fmt(data.outsource_cost) }}</div></el-card>
</el-col> </el-col>
<el-col :span="5"> <el-col :span="4">
<el-card shadow="hover"><div class="stat-label">固定开支</div><div class="stat-value">¥{{ fmt(data.overhead_cost) }}</div></el-card> <el-card shadow="hover"><div class="stat-label">固定开支</div><div class="stat-value">¥{{ fmt(data.overhead_cost) }}</div></el-card>
</el-col> </el-col>
<el-col :span="5"> <el-col :span="4">
<el-card shadow="hover"><div class="stat-label">管理成本</div><div class="stat-value">¥{{ fmt(data.management_cost) }}</div></el-card>
</el-col>
<el-col :span="4">
<el-card shadow="hover"> <el-card shadow="hover">
<div class="stat-label">项目总成本</div> <div class="stat-label">项目总成本</div>
<div class="stat-value" style="color:#e6a23c">¥{{ fmt(data.total_cost) }}</div> <div class="stat-value" style="color:#e6a23c">¥{{ fmt(data.total_cost) }}</div>