feat: 飞书报告卡片化 + 报告权限系统 + 产出过滤优化
- 日报/周报/月报改为结构化卡片推送(column_set布局) - 新增 report:daily/weekly/monthly 权限到角色管理 - 产出统计只算中期制作阶段动画秒数 - 效率之星改为跨项目加权通过率 - AI点评补充风险数据源 - 禁用多余admin账号,股东角色加报告权限 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ac350e763b
commit
530f02a66a
Binary file not shown.
@ -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,
|
||||||
|
|||||||
@ -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"):
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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": "角色已更新"}
|
||||||
|
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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 = []
|
||||||
|
|
||||||
|
# ── 顶部 KPI(3列灰底) ──
|
||||||
|
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 = []
|
||||||
|
|
||||||
|
# ── 顶部 KPI(4列灰底) ──
|
||||||
|
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:
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user