feat: V2功能增强 — 里程碑系统+圆环进度图+损耗修复+AI服务+报告系统
All checks were successful
Build and Deploy Backend / build-and-deploy (push) Successful in 1m27s
Build and Deploy Web / build-and-deploy (push) Successful in 54s

- 项目详情页三阶段里程碑管理(前期/制作/后期)
- 制作卡片改用180px ECharts圆环进度图+右侧数据列表
- 修复损耗率双重计算bug(测试秒数不再重复计入超产)
- 新增飞书推送服务、豆包AI风险分析、APScheduler定时报告
- 项目列表页增强(筛选/排序/批量操作/废弃功能)
- 成员详情页产出时间轴+效率对比
- 成本页固定开支管理

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
seaislee1209 2026-02-13 18:36:44 +08:00
parent 0dbe00a8b4
commit 90707005ed
30 changed files with 2820 additions and 107 deletions

View File

@ -0,0 +1,335 @@
# AirLabs · AI 能力需求文档
> V2 功能模块 | 更新日期2026-02-13
---
## 1. 功能概述
为 AirLabs 项目管理系统接入 AI 能力,实现:
1. **自动报告生成** — 系统定时汇总项目、产出、成本数据,由 AI 大模型生成自然语言总结
2. **飞书私聊推送** — 通过飞书自建应用将报告以卡片消息形式私聊推送给指定管理人员
3. **项目风险预警** — 基于规则引擎 + AI 分析,识别项目风险并在仪表盘展示
**本版本不包含**
- 前端 AI 聊天助手页面
- 自然语言数据问答
- 按权限分级推送(所有接收人看到相同完整报告)
以上功能列入后续迭代计划。
---
## 2. AI 模型选型
| 项目 | 选型 |
|------|------|
| 模型 | 豆包Doubao— 字节跳动旗下大语言模型 |
| 平台 | 火山引擎 ARK |
| 模型版本 | doubao-seed-1-8-251228 |
| API 协议 | OpenAI 兼容(使用 openai Python SDK |
| API 地址 | https://ark.cn-beijing.volces.com/api/v3 |
### 调用方式
```python
from openai import OpenAI
client = OpenAI(
api_key="<ARK_API_KEY>",
base_url="https://ark.cn-beijing.volces.com/api/v3"
)
response = client.chat.completions.create(
model="doubao-seed-1-8-251228",
messages=[
{"role": "system", "content": "你是 AirLabs 项目管理助手..."},
{"role": "user", "content": "<数据上下文>"}
]
)
```
### 降级策略
- API Key 未配置 → 报告仅包含数据模板,不含 AI 摘要
- API 调用失败 → 自动降级为纯数据报告,记录错误日志
- 超时设置:单次调用 30 秒超时
---
## 3. 自动报告功能
### 3.1 报告类型与推送时间
| 报告类型 | 推送时间 | 数据范围 |
|----------|----------|----------|
| 日报 | 每天 20:00 | 当天数据 |
| 周报 | 每周五 20:00 | 本周一至周五 |
| 月报 | 每月1日 10:00 | 上月完整数据 |
时区Asia/Shanghai北京时间
### 3.2 日报内容
```
📊 AirLabs 日报 — 2026-02-13
【今日概览】
• 进行中项目X 个
• 今日提交X 人次,总产出 Xm Xs
• 今日未提交:张三、李四
【各项目进展】
• 项目A进度 XX%,今日产出 Xs
• 项目B进度 XX%,今日产出 Xs
【风险提醒】
⚠️ 项目C距截止仅剩 X 天,进度仅 XX%
【AI 点评】
(豆包生成的自然语言总结)
```
### 3.3 周报内容
```
📋 AirLabs 周报 — 第X周MM/DD - MM/DD
【项目进展】
• 项目A周初 XX% → 周末 XX%,本周产出 Xs
• 项目B周初 XX% → 周末 XX%,本周产出 Xs
【团队产出】
• 本周总产出Xm Xs
• 人均日产出Xs
• 效率最高XXX日均 Xs
【成本概览】
• 本周人力成本¥X
• 本周 AI 工具支出¥X
• 本周外包支出¥X
【损耗排行】
• 项目A损耗率 XX%
• 项目B损耗率 XX%
【AI 分析与建议】
(豆包生成的深度分析)
```
### 3.4 月报内容
```
📈 AirLabs 月报 — 2026年X月
【月度总览】
• 进行中项目X 个
• 本月完成项目X 个
• 月度总产出Xm Xs
• 月度总成本¥X
【各项目成本明细】
| 项目 | 人力 | AI工具 | 外包 | 固定开支 | 总成本 |
| ... |
【盈亏概览】(仅客户正式项目)
• 项目A回款 ¥X成本 ¥X利润 ¥X
• 总利润率XX%
【月度损耗】
• 总损耗Xs损耗率 XX%
• 测试损耗Xs
• 超产损耗Xs
【人均产出统计】
• 月度人均产出Xs
• 较上月变化:+XX% / -XX%
【AI 深度分析】
(豆包生成的月度总结、趋势分析、改进建议)
```
---
## 4. 飞书推送机制
### 4.1 接入方式
- **类型**:飞书自建应用(非 Webhook 群机器人)
- **能力**:私聊发送消息给指定个人
- **消息格式**飞书交互式卡片Interactive Card
### 4.2 所需权限
| 权限 | 权限标识 | 用途 |
|------|----------|------|
| 以应用身份发消息 | im:message:send_as_bot | 发送私聊消息 |
| 通过手机号获取用户ID | contact:user.id:readonly | 查找接收人 |
### 4.3 接收人配置
通过 `.env` 文件配置接收人手机号,逗号分隔:
```
REPORT_RECEIVERS=18002277047,13811803069,13636518028,13811126887
```
| 姓名 | 手机号 |
|------|--------|
| 沈海川 | 18002277047 |
| 李海 | 13811803069 |
| 曾恺 | 13636518028 |
| 黄雪婷 | 13811126887 |
### 4.4 推送流程
```
定时触发 / 手动触发
report_service 汇总数据库数据
ai_service 调用豆包生成摘要
组装飞书卡片 markdown
feishu_service 获取 tenant_access_token
通过手机号查询每个接收人的 user_id
逐个发送私聊卡片消息
```
---
## 5. 项目风险预警
### 5.1 风险评估维度
| 维度 | 规则 | 风险等级 |
|------|------|----------|
| 进度风险 | 实际进度 < 预期进度按时间线性估算 | / |
| 超期风险 | 距截止不足 7 天且进度 < 80% | |
| 损耗风险 | 损耗率 > 50% | 中;> 80% 高 |
| 产出停滞 | 近 7 天无提交或产出骤降 > 50% | 高 |
### 5.2 展示位置
- **仪表盘**:新增风险预警卡片区域,展示所有中/高风险项目
- **日报**:风险提醒段落
- **周报/月报**:风险汇总段落
### 5.3 AI 增强
对标记为中/高风险的项目,调用豆包生成:
- 风险原因分析
- 改进建议
- 预估影响
---
## 6. 技术架构
```
┌──────────────────────────────────────────────────┐
│ FastAPI 后端 │
│ │
│ ┌─────────────┐ ┌────────────────────┐ │
│ │ APScheduler │──→│ report_service.py │ │
│ │ 定时调度 │ │ 数据汇总 │ │
│ │ • 日报 20:00 │ │ ↓ │ │
│ │ • 周报 五20:00│ │ ai_service.py │ │
│ │ • 月报 1日10 │ │ 调用豆包 AI │ │
│ └─────────────┘ │ ↓ │ │
│ │ feishu_service.py │ │
│ ┌─────────────┐ │ 飞书私聊推送 │ │
│ │ reports.py │──→│ │ │
│ │ 手动触发 API │ └────────────────────┘ │
│ └─────────────┘ │
│ │
│ ┌─────────────┐ ┌────────────────────┐ │
│ │ dashboard.py │──→│ ai_service.py │ │
│ │ 仪表盘 API │ │ 风险预警分析 │ │
│ └─────────────┘ └────────────────────┘ │
└──────────────────────────────────────────────────┘
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ SQLite 数据库 │ │ 豆包 AI API │
│ airlabs.db │ │ ARK 平台 │
└──────────────┘ └──────────────┘
┌──────────────┐
│ 飞书开放平台 │
│ 私聊消息推送 │
└──────────────┘
```
### 新增依赖
| 包名 | 用途 |
|------|------|
| openai | 调用豆包 AIOpenAI 兼容协议) |
| httpx | 异步 HTTP 请求(飞书 API |
| apscheduler | 定时任务调度 |
| python-dotenv | 读取 .env 配置 |
---
## 7. 新增文件说明
| 文件路径 | 用途 |
|----------|------|
| backend/.env | 环境变量API Key、飞书凭证、接收人 |
| backend/services/__init__.py | services 包初始化 |
| backend/services/ai_service.py | 豆包模型调用封装 |
| backend/services/report_service.py | 报告数据汇总 + AI 摘要 |
| backend/services/feishu_service.py | 飞书应用消息推送 |
| backend/services/scheduler_service.py | APScheduler 定时任务 |
| backend/routers/reports.py | 手动触发报告 API |
---
## 8. API 接口
| 方法 | 路径 | 说明 | 权限 |
|------|------|------|------|
| POST | /api/reports/daily | 手动触发日报 | 超级管理员 |
| POST | /api/reports/weekly | 手动触发周报 | 超级管理员 |
| POST | /api/reports/monthly | 手动触发月报 | 超级管理员 |
---
## 9. 后续迭代计划
| 优先级 | 功能 | 说明 |
|--------|------|------|
| P0 | AI 智能问答助手 | 前端聊天页面,自然语言查询系统数据 |
| P1 | 按权限分级推送 | 不同角色收到不同内容的报告 |
| P1 | 报告历史存储 | 保存历史报告,支持回看 |
| P2 | 飞书交互式操作 | 在飞书卡片上直接查看详情、跳转系统 |
| P2 | 团队效率 AI 分析 | AI 深度分析成员效率趋势和分配建议 |
---
## 10. 配置清单
### 已获取
| 配置项 | 值 |
|--------|-----|
| 豆包 API Key | `846b6981-9954-4c58-bb39-63079393bdb8` |
| 豆包模型 | `doubao-seed-1-8-251228` |
| 飞书 App ID | `cli_a90478156bf85bd7` |
| 飞书 App Secret | `87N2nnx6Yv56TPjl2GraLdKOjFiGOSGp` |
| 接收人 | 沈海川、李海、曾恺、黄雪婷 |
### 飞书应用已开通权限
- `im:message:send_as_bot` — 以应用身份发消息
- `contact:user.id:readonly` — 通过手机号获取用户 ID
---
*本文档为 AirLabs V2 AI 能力的完整需求说明,配合主 PRD 使用。*

View File

@ -581,6 +581,61 @@ V1 不做逐条通过率标记(太复杂),采用**人均基准对比法**
--- ---
## 17. AI 能力V2
### 17.1 概述
V2 接入 AI 大模型能力,为管理层提供自动化报告和智能分析:
- **AI 模型**豆包Doubao— 字节跳动大语言模型,火山引擎 ARK 平台
- **报告推送**:飞书自建应用,私聊推送给指定管理人员
- **定时调度**APScheduler 集成
### 17.2 自动报告
| 报告类型 | 推送时间 | 数据范围 |
|----------|----------|----------|
| 日报 | 每天 20:00 | 当天数据 |
| 周报 | 每周五 20:00 | 本周一至周五 |
| 月报 | 每月1日 10:00 | 上月完整数据 |
报告内容由系统汇总数据 + AI 生成自然语言摘要与建议。
### 17.3 项目风险预警
基于规则引擎自动识别风险项目,在仪表盘展示预警:
| 风险维度 | 判定规则 |
|----------|----------|
| 超期风险 | 距截止 ≤7天且进度 <80% |
| 进度风险 | 实际进度严重落后于时间线 |
| 损耗风险 | 损耗率 >50% |
| 产出停滞 | 近7天无提交 |
### 17.4 后续迭代V3
- AI 智能问答助手(自然语言查询系统数据)
- 按权限分级推送(不同角色看到不同内容)
- 团队效率 AI 深度分析
---
## 18. 飞书集成
### 接入方式
- 飞书自建应用(非 Webhook 群机器人)
- 私聊发送消息给指定管理人员
### 所需权限
- `im:message:send_as_bot` — 以应用身份发消息
- `contact:user.id:readonly` — 通过手机号获取用户 ID
### 消息格式
- 飞书交互式卡片Interactive Card
- 内容为 markdown 格式
---
## 最终定义 ## 最终定义
> **这是一个让内容生产过程保持自由, > **这是一个让内容生产过程保持自由,

Binary file not shown.

View File

@ -173,30 +173,38 @@ def calc_waste_for_project(project_id: int, db: Session) -> dict:
""" """
计算项目损耗 计算项目损耗
返回: {test_waste, overproduction_waste, total_waste, waste_rate, target_seconds} 返回: {test_waste, overproduction_waste, total_waste, waste_rate, target_seconds}
废弃项目全部产出直接记为损耗
""" """
from models import ProjectStatus
project = db.query(Project).filter(Project.id == project_id).first() project = db.query(Project).filter(Project.id == project_id).first()
if not project: if not project:
return {} return {}
target = project.target_total_seconds target = project.target_total_seconds
# 测试损耗:工作类型为"测试"的全部秒数
test_waste = db.query(sa_func.sum(Submission.total_seconds)).filter(
Submission.project_id == project_id,
Submission.work_type == WorkType.TEST,
).scalar() or 0
# 全部有秒数的提交总量 # 全部有秒数的提交总量
total_submitted = db.query(sa_func.sum(Submission.total_seconds)).filter( total_submitted = db.query(sa_func.sum(Submission.total_seconds)).filter(
Submission.project_id == project_id, Submission.project_id == project_id,
Submission.total_seconds > 0, Submission.total_seconds > 0,
).scalar() or 0 ).scalar() or 0
# 超产损耗 # 废弃项目:全部产出记为损耗
overproduction_waste = max(0, total_submitted - target) if project.status == ProjectStatus.ABANDONED:
total_waste = total_submitted
total_waste = test_waste + overproduction_waste test_waste = 0.0
waste_rate = round(total_waste / target * 100, 1) if target > 0 else 0 overproduction_waste = total_submitted
waste_rate = 100.0 if total_submitted > 0 else 0.0
else:
# 测试损耗:工作类型为"测试"的全部秒数
test_waste = db.query(sa_func.sum(Submission.total_seconds)).filter(
Submission.project_id == project_id,
Submission.work_type == WorkType.TEST,
).scalar() or 0
# 超产损耗(仅计算生产性提交超出目标的部分,排除测试秒数避免双重计数)
production_submitted = total_submitted - test_waste
overproduction_waste = max(0, production_submitted - target)
total_waste = test_waste + overproduction_waste
waste_rate = round(total_waste / target * 100, 1) if target > 0 else 0
return { return {
"target_seconds": target, "target_seconds": target,

View File

@ -1,5 +1,8 @@
"""应用配置""" """应用配置"""
import os import os
from dotenv import load_dotenv
load_dotenv()
# 数据库 # 数据库
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./airlabs.db") DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./airlabs.db")
@ -14,3 +17,15 @@ CORS_ORIGINS = os.getenv("CORS_ORIGINS", "*").split(",")
# 成本计算 # 成本计算
WORKING_DAYS_PER_MONTH = 22 WORKING_DAYS_PER_MONTH = 22
# 豆包 AI火山引擎 ARK
ARK_API_KEY = os.getenv("ARK_API_KEY", "")
ARK_MODEL = os.getenv("ARK_MODEL", "doubao-seed-1-8-251228")
ARK_BASE_URL = os.getenv("ARK_BASE_URL", "https://ark.cn-beijing.volces.com/api/v3")
# 飞书自建应用
FEISHU_APP_ID = os.getenv("FEISHU_APP_ID", "")
FEISHU_APP_SECRET = os.getenv("FEISHU_APP_SECRET", "")
# 报告接收人手机号
REPORT_RECEIVERS = [p.strip() for p in os.getenv("REPORT_RECEIVERS", "").split(",") if p.strip()]

View File

@ -1,12 +1,22 @@
"""AirLabs Project —— 主入口""" """AirLabs Project —— 主入口"""
from dotenv import load_dotenv
load_dotenv()
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from database import engine, Base from database import engine, Base
from models import User, Role, PhaseGroup, BUILTIN_ROLES from models import (
User, Role, PhaseGroup, BUILTIN_ROLES, COST_PERM_MIGRATION,
Project, ProjectMilestone, DEFAULT_MILESTONES
)
from auth import hash_password from auth import hash_password
import os import os
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# 创建所有表 # 创建所有表
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
@ -31,6 +41,7 @@ from routers.submissions import router as submissions_router
from routers.costs import router as costs_router from routers.costs import router as costs_router
from routers.dashboard import router as dashboard_router from routers.dashboard import router as dashboard_router
from routers.roles import router as roles_router from routers.roles import router as roles_router
from routers.reports import router as reports_router
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(users_router) app.include_router(users_router)
@ -39,6 +50,7 @@ app.include_router(submissions_router)
app.include_router(costs_router) app.include_router(costs_router)
app.include_router(dashboard_router) app.include_router(dashboard_router)
app.include_router(roles_router) app.include_router(roles_router)
app.include_router(reports_router)
# 前端静态文件 # 前端静态文件
frontend_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "frontend", "dist") frontend_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "frontend", "dist")
@ -53,6 +65,21 @@ if os.path.exists(frontend_dir):
return FileResponse(os.path.join(frontend_dir, "index.html")) return FileResponse(os.path.join(frontend_dir, "index.html"))
@app.on_event("startup")
async def start_scheduler():
"""启动定时任务调度器"""
from services.scheduler_service import setup_scheduler
setup_scheduler()
@app.on_event("shutdown")
async def stop_scheduler():
"""关闭定时任务调度器"""
from services.scheduler_service import scheduler
scheduler.shutdown(wait=False)
logger.info("[定时任务] 已关闭")
@app.on_event("startup") @app.on_event("startup")
def init_roles_and_admin(): def init_roles_and_admin():
"""首次启动时创建内置角色和默认管理员""" """首次启动时创建内置角色和默认管理员"""
@ -73,6 +100,39 @@ def init_roles_and_admin():
print(f"[OK] created role: {role_name}") print(f"[OK] created role: {role_name}")
db.commit() db.commit()
# 迁移旧成本权限 → 细分权限
old_cost_perms = set(COST_PERM_MIGRATION.keys())
for role in db.query(Role).all():
perms = list(role.permissions or [])
changed = False
for old_perm, new_perms in COST_PERM_MIGRATION.items():
if old_perm in perms:
perms.remove(old_perm)
for np in new_perms:
if np not in perms:
perms.append(np)
changed = True
if changed:
role.permissions = perms
print(f"[MIGRATE] upgraded cost permissions for role: {role.name}")
db.commit()
# 为已有项目补充默认里程碑
for proj in db.query(Project).all():
has_ms = db.query(ProjectMilestone).filter(
ProjectMilestone.project_id == proj.id
).first()
if not has_ms:
for ms in DEFAULT_MILESTONES:
db.add(ProjectMilestone(
project_id=proj.id,
name=ms["name"],
phase=PhaseGroup(ms["phase"]),
sort_order=ms.get("sort_order", 0),
))
print(f"[MIGRATE] added default milestones for project: {proj.name}")
db.commit()
# 创建默认管理员(关联超级管理员角色) # 创建默认管理员(关联超级管理员角色)
admin_role = db.query(Role).filter(Role.name == "超级管理员").first() admin_role = db.query(Role).filter(Role.name == "超级管理员").first()
if admin_role and not db.query(User).filter(User.username == "admin").first(): if admin_role and not db.query(User).filter(User.username == "admin").first():

View File

@ -23,10 +23,18 @@ ALL_PERMISSIONS = [
# 内容提交 # 内容提交
("submission:view", "查看提交记录", "内容提交"), ("submission:view", "查看提交记录", "内容提交"),
("submission:create", "新增提交", "内容提交"), ("submission:create", "新增提交", "内容提交"),
# 成本管理 # 成本管理 —— 按类型细分
("cost:view", "查看成本", "成本管理"), ("cost_ai:view", "查看AI工具成本", "成本管理"),
("cost:create", "录入成本", "成本管理"), ("cost_ai:create", "录入AI工具成本", "成本管理"),
("cost:delete", "删除成本", "成本管理"), ("cost_ai:delete", "删除AI工具成本", "成本管理"),
("cost_outsource:view", "查看外包成本", "成本管理"),
("cost_outsource:create", "录入外包成本", "成本管理"),
("cost_outsource:delete", "删除外包成本", "成本管理"),
("cost_overhead:view", "查看固定开支", "成本管理"),
("cost_overhead:create", "录入固定开支", "成本管理"),
("cost_overhead:delete", "删除固定开支", "成本管理"),
("cost_labor:view", "查看人力调整", "成本管理"),
("cost_labor:create", "录入人力调整", "成本管理"),
# 用户与角色 # 用户与角色
("user:view", "查看用户列表", "用户与角色"), ("user:view", "查看用户列表", "用户与角色"),
("user:manage", "管理用户", "用户与角色"), ("user:manage", "管理用户", "用户与角色"),
@ -38,6 +46,16 @@ ALL_PERMISSIONS = [
PERMISSION_KEYS = [p[0] for p in ALL_PERMISSIONS] PERMISSION_KEYS = [p[0] for p in ALL_PERMISSIONS]
# 成本查看权限集合(用于判断是否有任一成本查看权限)
COST_VIEW_PERMS = ["cost_ai:view", "cost_outsource:view", "cost_overhead:view", "cost_labor:view"]
# 旧权限 → 新权限映射(用于数据库迁移)
COST_PERM_MIGRATION = {
"cost:view": ["cost_ai:view", "cost_outsource:view", "cost_overhead:view", "cost_labor:view"],
"cost:create": ["cost_ai:create", "cost_outsource:create", "cost_overhead:create", "cost_labor:create"],
"cost:delete": ["cost_ai:delete", "cost_outsource:delete", "cost_overhead:delete"],
}
# 内置角色定义 # 内置角色定义
BUILTIN_ROLES = { BUILTIN_ROLES = {
"超级管理员": { "超级管理员": {
@ -50,7 +68,10 @@ BUILTIN_ROLES = {
"dashboard:view", "dashboard:view",
"project:view", "project:create", "project:edit", "project:complete", "project:view", "project:create", "project:edit", "project:complete",
"submission:view", "submission:create", "submission:view", "submission:create",
"cost:view", "cost:create", "cost:delete", "cost_ai:view", "cost_ai:create", "cost_ai:delete",
"cost_outsource:view", "cost_outsource:create", "cost_outsource:delete",
"cost_overhead:view", "cost_overhead:create", "cost_overhead:delete",
"cost_labor:view", "cost_labor:create",
"user:view", "user:view",
"settlement:view", "efficiency:view", "settlement:view", "efficiency:view",
], ],
@ -60,7 +81,7 @@ BUILTIN_ROLES = {
"permissions": [ "permissions": [
"project:view", "project:create", "project:view", "project:create",
"submission:view", "submission:create", "submission:view", "submission:create",
"cost:view", "cost:create", "cost_ai:view", "cost_ai:create",
"efficiency:view", "efficiency:view",
], ],
}, },
@ -86,6 +107,7 @@ class ProjectType(str, enum.Enum):
class ProjectStatus(str, enum.Enum): class ProjectStatus(str, enum.Enum):
IN_PROGRESS = "制作中" IN_PROGRESS = "制作中"
COMPLETED = "已完成" COMPLETED = "已完成"
ABANDONED = "废弃"
class PhaseGroup(str, enum.Enum): class PhaseGroup(str, enum.Enum):
@ -219,6 +241,7 @@ class Project(Base):
submissions = relationship("Submission", back_populates="project") submissions = relationship("Submission", back_populates="project")
outsource_costs = relationship("OutsourceCost", back_populates="project") outsource_costs = relationship("OutsourceCost", back_populates="project")
ai_tool_allocations = relationship("AIToolCostAllocation", back_populates="project") ai_tool_allocations = relationship("AIToolCostAllocation", back_populates="project")
milestones = relationship("ProjectMilestone", back_populates="project", cascade="all, delete-orphan")
@property @property
def target_total_seconds(self): def target_total_seconds(self):
@ -341,3 +364,35 @@ class OverheadCost(Base):
note = Column(Text, nullable=True) note = Column(Text, nullable=True)
recorded_by = Column(Integer, ForeignKey("users.id"), nullable=False) recorded_by = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime, server_default=func.now()) created_at = Column(DateTime, server_default=func.now())
# ──────────────────────────── 项目里程碑 ────────────────────────────
class ProjectMilestone(Base):
__tablename__ = "project_milestones"
id = Column(Integer, primary_key=True, index=True)
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
name = Column(String(100), nullable=False)
phase = Column(SAEnum(PhaseGroup), nullable=False)
is_completed = Column(Integer, nullable=False, default=0) # 0/1
completed_at = Column(DateTime, nullable=True)
sort_order = Column(Integer, nullable=False, default=0)
project = relationship("Project", back_populates="milestones")
# 默认里程碑模板
DEFAULT_MILESTONES = [
# 前期
{"name": "策划案", "phase": "前期", "sort_order": 1},
{"name": "剧本", "phase": "前期", "sort_order": 2},
{"name": "分镜", "phase": "前期", "sort_order": 3},
{"name": "人设图", "phase": "前期", "sort_order": 4},
{"name": "场景图", "phase": "前期", "sort_order": 5},
# 后期
{"name": "配音", "phase": "后期", "sort_order": 1},
{"name": "音效", "phase": "后期", "sort_order": 2},
{"name": "修补镜头", "phase": "后期", "sort_order": 3},
{"name": "杂项", "phase": "后期", "sort_order": 4},
]

View File

@ -6,3 +6,7 @@ python-jose[cryptography]
passlib[bcrypt] passlib[bcrypt]
bcrypt==4.0.1 bcrypt==4.0.1
python-multipart python-multipart
openai
httpx
apscheduler
python-dotenv

View File

@ -23,7 +23,7 @@ router = APIRouter(prefix="/api/costs", tags=["成本管理"])
@router.get("/ai-tools", response_model=List[AIToolCostOut]) @router.get("/ai-tools", response_model=List[AIToolCostOut])
def list_ai_tool_costs( def list_ai_tool_costs(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_permission("cost:view")) current_user: User = Depends(require_permission("cost_ai:view"))
): ):
costs = db.query(AIToolCost).order_by(AIToolCost.record_date.desc()).all() costs = db.query(AIToolCost).order_by(AIToolCost.record_date.desc()).all()
return [ return [
@ -45,7 +45,7 @@ def list_ai_tool_costs(
def create_ai_tool_cost( def create_ai_tool_cost(
req: AIToolCostCreate, req: AIToolCostCreate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_permission("cost:create")) current_user: User = Depends(require_permission("cost_ai:create"))
): ):
cost = AIToolCost( cost = AIToolCost(
tool_name=req.tool_name, tool_name=req.tool_name,
@ -85,7 +85,7 @@ def create_ai_tool_cost(
def delete_ai_tool_cost( def delete_ai_tool_cost(
cost_id: int, cost_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_permission("cost:delete")) current_user: User = Depends(require_permission("cost_ai:delete"))
): ):
cost = db.query(AIToolCost).filter(AIToolCost.id == cost_id).first() cost = db.query(AIToolCost).filter(AIToolCost.id == cost_id).first()
if not cost: if not cost:
@ -102,7 +102,7 @@ def delete_ai_tool_cost(
def list_outsource_costs( def list_outsource_costs(
project_id: Optional[int] = Query(None), project_id: Optional[int] = Query(None),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_permission("cost:view")) current_user: User = Depends(require_permission("cost_outsource:view"))
): ):
q = db.query(OutsourceCost) q = db.query(OutsourceCost)
if project_id: if project_id:
@ -124,7 +124,7 @@ def list_outsource_costs(
def create_outsource_cost( def create_outsource_cost(
req: OutsourceCostCreate, req: OutsourceCostCreate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_permission("cost:create")) current_user: User = Depends(require_permission("cost_outsource:create"))
): ):
cost = OutsourceCost( cost = OutsourceCost(
project_id=req.project_id, project_id=req.project_id,
@ -151,7 +151,7 @@ def create_outsource_cost(
def delete_outsource_cost( def delete_outsource_cost(
cost_id: int, cost_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_permission("cost:delete")) current_user: User = Depends(require_permission("cost_outsource:delete"))
): ):
cost = db.query(OutsourceCost).filter(OutsourceCost.id == cost_id).first() cost = db.query(OutsourceCost).filter(OutsourceCost.id == cost_id).first()
if not cost: if not cost:
@ -167,7 +167,7 @@ def delete_outsource_cost(
def create_cost_override( def create_cost_override(
req: CostOverrideCreate, req: CostOverrideCreate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_permission("cost:create")) current_user: User = Depends(require_permission("cost_labor:create"))
): ):
override = CostOverride( override = CostOverride(
user_id=req.user_id, user_id=req.user_id,
@ -187,7 +187,7 @@ def list_cost_overrides(
user_id: Optional[int] = Query(None), user_id: Optional[int] = Query(None),
project_id: Optional[int] = Query(None), project_id: Optional[int] = Query(None),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_permission("cost:view")) current_user: User = Depends(require_permission("cost_labor:view"))
): ):
q = db.query(CostOverride) q = db.query(CostOverride)
if user_id: if user_id:
@ -211,7 +211,7 @@ def list_cost_overrides(
@router.get("/overhead", response_model=List[OverheadCostOut]) @router.get("/overhead", response_model=List[OverheadCostOut])
def list_overhead_costs( def list_overhead_costs(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_permission("cost:view")) current_user: User = Depends(require_permission("cost_overhead:view"))
): ):
costs = db.query(OverheadCost).order_by(OverheadCost.record_month.desc()).all() costs = db.query(OverheadCost).order_by(OverheadCost.record_month.desc()).all()
return [ return [
@ -232,7 +232,7 @@ def list_overhead_costs(
def create_overhead_cost( def create_overhead_cost(
req: OverheadCostCreate, req: OverheadCostCreate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_permission("cost:create")) current_user: User = Depends(require_permission("cost_overhead:create"))
): ):
cost = OverheadCost( cost = OverheadCost(
cost_type=OverheadCostType(req.cost_type), cost_type=OverheadCostType(req.cost_type),
@ -259,7 +259,7 @@ def create_overhead_cost(
def delete_overhead_cost( def delete_overhead_cost(
cost_id: int, cost_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_permission("cost:delete")) current_user: User = Depends(require_permission("cost_overhead:delete"))
): ):
cost = db.query(OverheadCost).filter(OverheadCost.id == cost_id).first() cost = db.query(OverheadCost).filter(OverheadCost.id == cost_id).first()
if not cost: if not cost:

View File

@ -28,6 +28,7 @@ def get_dashboard(
# 项目概览 # 项目概览
active = db.query(Project).filter(Project.status == ProjectStatus.IN_PROGRESS).all() active = db.query(Project).filter(Project.status == ProjectStatus.IN_PROGRESS).all()
completed = db.query(Project).filter(Project.status == ProjectStatus.COMPLETED).all() completed = db.query(Project).filter(Project.status == ProjectStatus.COMPLETED).all()
abandoned = db.query(Project).filter(Project.status == ProjectStatus.ABANDONED).all()
# 当月日期范围 # 当月日期范围
today = date.today() today = date.today()
@ -91,11 +92,11 @@ def get_dashboard(
"estimated_completion_date": str(p.estimated_completion_date) if p.estimated_completion_date else None, "estimated_completion_date": str(p.estimated_completion_date) if p.estimated_completion_date else None,
}) })
# 损耗排行 # 损耗排行(含废弃项目,废弃项目全部产出记为损耗)
waste_ranking = [] waste_ranking = []
total_waste_seconds_all = 0.0 total_waste_seconds_all = 0.0
total_target_seconds_all = 0.0 total_target_seconds_all = 0.0
for p in active + completed: for p in active + completed + abandoned:
w = calc_waste_for_project(p.id, db) w = calc_waste_for_project(p.id, db)
total_waste_seconds_all += w.get("total_waste_seconds", 0) total_waste_seconds_all += w.get("total_waste_seconds", 0)
total_target_seconds_all += p.target_total_seconds or 0 total_target_seconds_all += p.target_total_seconds or 0
@ -140,7 +141,7 @@ 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
for p in active + completed: 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)
@ -203,6 +204,10 @@ def get_dashboard(
"profit_by_project": profit_by_project, "profit_by_project": profit_by_project,
} }
# ── 风险预警 ──
from services.report_service import analyze_project_risks
risk_alerts = analyze_project_risks(db)
return { return {
"active_projects": len(active), "active_projects": len(active),
"completed_projects": len(completed), "completed_projects": len(completed),
@ -216,6 +221,7 @@ def get_dashboard(
"waste_ranking": waste_ranking, "waste_ranking": waste_ranking,
"settled_projects": settled, "settled_projects": settled,
"profitability": profitability, "profitability": profitability,
"risk_alerts": risk_alerts,
# 图表数据 # 图表数据
"daily_trend": daily_trend, "daily_trend": daily_trend,
"cost_breakdown": cost_breakdown, "cost_breakdown": cost_breakdown,

View File

@ -3,12 +3,17 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import func as sa_func from sqlalchemy import func as sa_func
from typing import List, Optional from typing import List, Optional
from datetime import datetime
from database import get_db from database import get_db
from models import ( from models import (
User, Project, Submission, ProjectType, User, Project, Submission, ProjectType,
ProjectStatus, PhaseGroup, WorkType ProjectStatus, PhaseGroup, WorkType,
ProjectMilestone, DEFAULT_MILESTONES
)
from schemas import (
ProjectCreate, ProjectUpdate, ProjectOut,
MilestoneOut, MilestoneCreate
) )
from schemas import ProjectCreate, ProjectUpdate, ProjectOut
from auth import get_current_user, require_permission from auth import get_current_user, require_permission
router = APIRouter(prefix="/api/projects", tags=["项目管理"]) router = APIRouter(prefix="/api/projects", tags=["项目管理"])
@ -25,17 +30,60 @@ def enrich_project(p: Project, db: Session) -> ProjectOut:
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
# 损耗 = 测试损耗 + 超产损耗 # 损耗 = 测试损耗 + 超产损耗(排除测试秒数避免双重计数)
test_secs = db.query(sa_func.sum(Submission.total_seconds)).filter( test_secs = db.query(sa_func.sum(Submission.total_seconds)).filter(
Submission.project_id == p.id, Submission.project_id == p.id,
Submission.work_type == WorkType.TEST Submission.work_type == WorkType.TEST
).scalar() or 0 ).scalar() or 0
overproduction = max(0, total_secs - target) production_secs = total_secs - test_secs
overproduction = max(0, production_secs - target)
waste = test_secs + overproduction waste = test_secs + overproduction
waste_rate = round(waste / target * 100, 1) if target > 0 else 0 waste_rate = round(waste / target * 100, 1) if target > 0 else 0
leader_name = p.leader.name if p.leader else None leader_name = p.leader.name if p.leader else None
# 里程碑数据
ms_rows = db.query(ProjectMilestone).filter(
ProjectMilestone.project_id == p.id
).order_by(ProjectMilestone.phase, ProjectMilestone.sort_order).all()
milestones_out = [
MilestoneOut(
id=m.id, name=m.name,
phase=m.phase.value if hasattr(m.phase, 'value') else m.phase,
is_completed=bool(m.is_completed),
completed_at=m.completed_at,
sort_order=m.sort_order,
)
for m in ms_rows
]
# 阶段摘要
pre_ms = [m for m in ms_rows if (m.phase.value if hasattr(m.phase, 'value') else m.phase) == "前期"]
post_ms = [m for m in ms_rows if (m.phase.value if hasattr(m.phase, 'value') else m.phase) == "后期"]
pre_completed = sum(1 for m in pre_ms if m.is_completed)
post_completed = sum(1 for m in post_ms if m.is_completed)
phase_summary = {
"pre": {"total": len(pre_ms), "completed": pre_completed},
"production": {
"progress_percent": progress,
"submitted_seconds": round(total_secs, 1),
"target_seconds": target,
},
"post": {"total": len(post_ms), "completed": post_completed},
}
# 自动推断当前阶段
if len(pre_ms) > 0 and pre_completed < len(pre_ms):
current_stage = "前期"
elif progress < 100:
current_stage = "制作"
elif len(post_ms) > 0 and post_completed < len(post_ms):
current_stage = "后期"
else:
current_stage = "已完成"
return ProjectOut( return ProjectOut(
id=p.id, name=p.name, id=p.id, name=p.name,
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,
@ -53,6 +101,9 @@ def enrich_project(p: Project, db: Session) -> ProjectOut:
progress_percent=progress, progress_percent=progress,
waste_seconds=round(waste, 1), waste_seconds=round(waste, 1),
waste_rate=waste_rate, waste_rate=waste_rate,
milestones=milestones_out,
phase_summary=phase_summary,
current_stage=current_stage,
) )
@ -89,6 +140,17 @@ def create_project(
contract_amount=req.contract_amount, contract_amount=req.contract_amount,
) )
db.add(project) db.add(project)
db.flush()
# 创建里程碑
ms_list = req.milestones if req.milestones else DEFAULT_MILESTONES
for ms in ms_list:
db.add(ProjectMilestone(
project_id=project.id,
name=ms["name"],
phase=PhaseGroup(ms["phase"]),
sort_order=ms.get("sort_order", 0),
))
db.commit() db.commit()
db.refresh(project) db.refresh(project)
return enrich_project(project, db) return enrich_project(project, db)
@ -121,7 +183,10 @@ def update_project(
if req.project_type is not None: if req.project_type is not None:
p.project_type = ProjectType(req.project_type) p.project_type = ProjectType(req.project_type)
if req.status is not None: if req.status is not None:
p.status = ProjectStatus(req.status) new_status = ProjectStatus(req.status)
p.status = new_status
if new_status in (ProjectStatus.IN_PROGRESS, ProjectStatus.ABANDONED):
p.actual_completion_date = None
if req.leader_id is not None: if req.leader_id is not None:
p.leader_id = req.leader_id p.leader_id = req.leader_id
if req.current_phase is not None: if req.current_phase is not None:
@ -161,6 +226,7 @@ def delete_project(
db.query(OutsourceCost).filter(OutsourceCost.project_id == project_id).delete() db.query(OutsourceCost).filter(OutsourceCost.project_id == project_id).delete()
db.query(AIToolCostAllocation).filter(AIToolCostAllocation.project_id == project_id).delete() db.query(AIToolCostAllocation).filter(AIToolCostAllocation.project_id == project_id).delete()
db.query(CostOverride).filter(CostOverride.project_id == project_id).delete() db.query(CostOverride).filter(CostOverride.project_id == project_id).delete()
db.query(ProjectMilestone).filter(ProjectMilestone.project_id == project_id).delete()
db.delete(p) db.delete(p)
db.commit() db.commit()
return {"message": "项目已删除"} return {"message": "项目已删除"}
@ -176,8 +242,91 @@ def complete_project(
p = db.query(Project).filter(Project.id == project_id).first() p = db.query(Project).filter(Project.id == project_id).first()
if not p: if not p:
raise HTTPException(status_code=404, detail="项目不存在") raise HTTPException(status_code=404, detail="项目不存在")
from datetime import date from datetime import date as date_today
p.status = ProjectStatus.COMPLETED p.status = ProjectStatus.COMPLETED
p.actual_completion_date = date.today() p.actual_completion_date = date_today.today()
db.commit() db.commit()
return {"message": "项目已标记为完成", "project_id": project_id} return {"message": "项目已标记为完成", "project_id": project_id}
# ──────────────────── 里程碑管理 ────────────────────
@router.get("/{project_id}/milestones", response_model=List[MilestoneOut])
def list_milestones(
project_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
ms = db.query(ProjectMilestone).filter(
ProjectMilestone.project_id == project_id
).order_by(ProjectMilestone.phase, ProjectMilestone.sort_order).all()
return [
MilestoneOut(
id=m.id, name=m.name,
phase=m.phase.value if hasattr(m.phase, 'value') else m.phase,
is_completed=bool(m.is_completed),
completed_at=m.completed_at,
sort_order=m.sort_order,
)
for m in ms
]
@router.post("/{project_id}/milestones", response_model=MilestoneOut)
def add_milestone(
project_id: int,
req: MilestoneCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("project:edit"))
):
p = db.query(Project).filter(Project.id == project_id).first()
if not p:
raise HTTPException(status_code=404, detail="项目不存在")
# 找到同阶段最大 sort_order
max_order = db.query(sa_func.max(ProjectMilestone.sort_order)).filter(
ProjectMilestone.project_id == project_id,
ProjectMilestone.phase == PhaseGroup(req.phase),
).scalar() or 0
m = ProjectMilestone(
project_id=project_id,
name=req.name,
phase=PhaseGroup(req.phase),
sort_order=max_order + 1,
)
db.add(m)
db.commit()
db.refresh(m)
return MilestoneOut(
id=m.id, name=m.name,
phase=m.phase.value, is_completed=False,
completed_at=None, sort_order=m.sort_order,
)
@router.put("/milestones/{milestone_id}/toggle")
def toggle_milestone(
milestone_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("project:edit"))
):
m = db.query(ProjectMilestone).filter(ProjectMilestone.id == milestone_id).first()
if not m:
raise HTTPException(status_code=404, detail="里程碑不存在")
m.is_completed = 0 if m.is_completed else 1
m.completed_at = datetime.now() if m.is_completed else None
db.commit()
return {"id": m.id, "is_completed": bool(m.is_completed)}
@router.delete("/milestones/{milestone_id}")
def delete_milestone(
milestone_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("project:edit"))
):
m = db.query(ProjectMilestone).filter(ProjectMilestone.id == milestone_id).first()
if not m:
raise HTTPException(status_code=404, detail="里程碑不存在")
db.delete(m)
db.commit()
return {"message": "已删除"}

107
backend/routers/reports.py Normal file
View File

@ -0,0 +1,107 @@
"""AI 报告路由 —— 手动触发报告生成与飞书推送"""
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from database import get_db
from models import User
from auth import require_permission
router = APIRouter(prefix="/api/reports", tags=["AI报告"])
@router.post("/daily")
async def trigger_daily_report(
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("dashboard:view")),
):
"""手动触发日报生成并推送飞书"""
from services.report_service import generate_daily_report
from services.feishu_service import feishu
report = generate_daily_report(db)
push_result = await feishu.send_report_to_all(report["title"], report["content"])
return {
"message": "日报生成并推送完成",
"title": report["title"],
"content": report["content"],
"push_result": push_result,
}
@router.post("/weekly")
async def trigger_weekly_report(
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("dashboard:view")),
):
"""手动触发周报生成并推送飞书"""
from services.report_service import generate_weekly_report
from services.feishu_service import feishu
report = generate_weekly_report(db)
push_result = await feishu.send_report_to_all(report["title"], report["content"])
return {
"message": "周报生成并推送完成",
"title": report["title"],
"content": report["content"],
"push_result": push_result,
}
@router.post("/monthly")
async def trigger_monthly_report(
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("dashboard:view")),
):
"""手动触发月报生成并推送飞书"""
from services.report_service import generate_monthly_report
from services.feishu_service import feishu
report = generate_monthly_report(db)
push_result = await feishu.send_report_to_all(report["title"], report["content"])
return {
"message": "月报生成并推送完成",
"title": report["title"],
"content": report["content"],
"push_result": push_result,
}
@router.post("/preview/{report_type}")
async def preview_report(
report_type: str,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("dashboard:view")),
):
"""预览报告内容(不推送飞书)"""
from services.report_service import (
generate_daily_report, generate_weekly_report, generate_monthly_report,
)
generators = {
"daily": generate_daily_report,
"weekly": generate_weekly_report,
"monthly": generate_monthly_report,
}
generator = generators.get(report_type)
if not generator:
return {"error": f"不支持的报告类型: {report_type},可选: daily, weekly, monthly"}
report = generator(db)
return {
"title": report["title"],
"content": report["content"],
"data": report.get("data"),
}
@router.get("/risks")
def get_project_risks(
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("dashboard:view")),
):
"""获取当前所有项目风险预警"""
from services.report_service import analyze_project_risks
return analyze_project_risks(db)

View File

@ -44,8 +44,11 @@ def list_submissions(
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
q = db.query(Submission) q = db.query(Submission)
# 没有 user:view 权限的只能看自己的 # 查看项目内提交时,所有人都可见(方便横向对比)
if not current_user.has_permission("user:view"): # 全局提交列表时,没有 user:view 权限只能看自己的
if project_id:
pass # 项目内提交不做用户过滤
elif not current_user.has_permission("user:view"):
q = q.filter(Submission.user_id == current_user.id) q = q.filter(Submission.user_id == current_user.id)
elif user_id: elif user_id:
q = q.filter(Submission.user_id == user_id) q = q.filter(Submission.user_id == user_id)

View File

@ -61,6 +61,23 @@ class UserOut(BaseModel):
# ──────────────────────────── 项目 ──────────────────────────── # ──────────────────────────── 项目 ────────────────────────────
class MilestoneOut(BaseModel):
id: int
name: str
phase: str
is_completed: bool
completed_at: Optional[datetime] = None
sort_order: int
class Config:
from_attributes = True
class MilestoneCreate(BaseModel):
name: str
phase: str # 前期/后期
class ProjectCreate(BaseModel): class ProjectCreate(BaseModel):
name: str name: str
project_type: str project_type: str
@ -70,6 +87,7 @@ class ProjectCreate(BaseModel):
episode_count: int episode_count: int
estimated_completion_date: Optional[date] = None estimated_completion_date: Optional[date] = None
contract_amount: Optional[float] = None contract_amount: Optional[float] = None
milestones: Optional[List[dict]] = None # [{"name", "phase", "sort_order"}]
class ProjectUpdate(BaseModel): class ProjectUpdate(BaseModel):
@ -105,6 +123,10 @@ class ProjectOut(BaseModel):
progress_percent: Optional[float] = 0 progress_percent: Optional[float] = 0
waste_seconds: Optional[float] = 0 waste_seconds: Optional[float] = 0
waste_rate: Optional[float] = 0 waste_rate: Optional[float] = 0
# 里程碑
milestones: Optional[List[MilestoneOut]] = []
phase_summary: Optional[dict] = None
current_stage: Optional[str] = None
class Config: class Config:
from_attributes = True from_attributes = True

View File

View File

@ -0,0 +1,96 @@
"""豆包大模型服务 —— 通过火山引擎 ARK 平台调用"""
import logging
from config import ARK_API_KEY, ARK_MODEL, ARK_BASE_URL
logger = logging.getLogger(__name__)
# 延迟初始化,避免无 Key 时报错
_client = None
def _get_client():
global _client
if _client is None and ARK_API_KEY:
from openai import OpenAI
_client = OpenAI(api_key=ARK_API_KEY, base_url=ARK_BASE_URL)
return _client
def generate_report_summary(data_context: str, report_type: str) -> str:
"""
调用豆包生成报告摘要
:param data_context: 数据库汇总数据纯文本
:param report_type: daily / weekly / monthly
:return: AI 生成的 markdown 摘要失败时返回空字符串
"""
client = _get_client()
if not client:
logger.warning("豆包 AI 未配置 API Key跳过摘要生成")
return ""
type_labels = {"daily": "日报", "weekly": "周报", "monthly": "月报"}
label = type_labels.get(report_type, "报告")
system_prompt = (
"你是 AirLabs 动画团队的项目管理助手。"
"请根据提供的数据,用简洁的中文生成一段项目管理{label}总结。"
"要求:\n"
"1. 语言简练专业,适合管理层阅读\n"
"2. 先总结关键数据,再给出分析和建议\n"
"3. 如果有风险项目,重点提醒\n"
"4. 使用 markdown 格式\n"
"5. 总字数控制在 300 字以内"
).format(label=label)
try:
response = client.chat.completions.create(
model=ARK_MODEL,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": f"以下是{label}数据,请生成总结:\n\n{data_context}"},
],
temperature=0.7,
max_tokens=1024,
timeout=30,
)
return response.choices[0].message.content.strip()
except Exception as e:
logger.error(f"豆包 AI 调用失败: {e}")
return ""
def generate_risk_analysis(project_data: str) -> str:
"""
调用豆包分析项目风险
:param project_data: 项目数据文本
:return: AI 生成的风险分析失败时返回空字符串
"""
client = _get_client()
if not client:
return ""
system_prompt = (
"你是 AirLabs 动画团队的项目风险分析专家。"
"请根据项目数据,分析风险原因并给出改进建议。"
"要求:\n"
"1. 分析要具体,基于数据说话\n"
"2. 建议要可执行\n"
"3. 使用中文,简练专业\n"
"4. 总字数控制在 150 字以内"
)
try:
response = client.chat.completions.create(
model=ARK_MODEL,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": f"项目数据:\n\n{project_data}"},
],
temperature=0.7,
max_tokens=512,
timeout=30,
)
return response.choices[0].message.content.strip()
except Exception as e:
logger.error(f"豆包 AI 风险分析失败: {e}")
return ""

View File

@ -0,0 +1,146 @@
"""飞书自建应用消息推送服务"""
import time
import json
import logging
import httpx
from config import FEISHU_APP_ID, FEISHU_APP_SECRET, REPORT_RECEIVERS
logger = logging.getLogger(__name__)
FEISHU_BASE = "https://open.feishu.cn/open-apis"
class FeishuService:
def __init__(self):
self.app_id = FEISHU_APP_ID
self.app_secret = FEISHU_APP_SECRET
self._tenant_token: str = ""
self._token_expires: float = 0
self._user_id_cache: dict[str, str] = {}
async def _get_tenant_token(self) -> str:
"""获取 tenant_access_token有效期 2 小时,自动缓存)"""
if self._tenant_token and time.time() < self._token_expires:
return self._tenant_token
if not self.app_id or not self.app_secret:
logger.warning("飞书 App ID/Secret 未配置")
return ""
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(
f"{FEISHU_BASE}/auth/v3/tenant_access_token/internal",
json={
"app_id": self.app_id,
"app_secret": self.app_secret,
},
)
data = resp.json()
if data.get("code") != 0:
logger.error(f"获取飞书 token 失败: {data}")
return ""
self._tenant_token = data["tenant_access_token"]
self._token_expires = time.time() + data.get("expire", 7200) - 60
logger.info("飞书 tenant_access_token 获取成功")
return self._tenant_token
async def get_user_id_by_mobile(self, mobile: str) -> str:
"""通过手机号查飞书 user_id"""
if mobile in self._user_id_cache:
return self._user_id_cache[mobile]
token = await self._get_tenant_token()
if not token:
return ""
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(
f"{FEISHU_BASE}/contact/v3/users/batch_get_id",
headers={"Authorization": f"Bearer {token}"},
json={"mobiles": [mobile]},
params={"user_id_type": "open_id"},
)
data = resp.json()
if data.get("code") != 0:
logger.error(f"查询用户 {mobile} 失败: {data}")
return ""
user_list = data.get("data", {}).get("user_list", [])
if user_list and user_list[0].get("user_id"):
uid = user_list[0]["user_id"]
self._user_id_cache[mobile] = uid
return uid
logger.warning(f"未找到手机号 {mobile} 对应的飞书用户")
return ""
async def send_card_message(self, user_id: str, title: str, content: str):
"""发送飞书交互式卡片消息给个人"""
token = await self._get_tenant_token()
if not token:
return False
card = {
"header": {
"title": {"tag": "plain_text", "content": title},
"template": "blue",
},
"elements": [
{"tag": "markdown", "content": content},
],
}
payload = {
"receive_id": user_id,
"msg_type": "interactive",
"content": json.dumps(card),
}
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.post(
f"{FEISHU_BASE}/im/v1/messages",
headers={"Authorization": f"Bearer {token}"},
params={"receive_id_type": "open_id"},
json=payload,
)
data = resp.json()
if data.get("code") != 0:
logger.error(f"发送消息给 {user_id} 失败: {data}")
return False
logger.info(f"飞书消息发送成功: {user_id}")
return True
async def send_report_to_all(self, title: str, content: str) -> dict:
"""
给所有配置的接收人发送报告
返回 {"success": [...], "failed": [...]}
"""
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_message(user_id, title, content)
if ok:
results["success"].append(mobile)
else:
results["failed"].append({"mobile": mobile, "reason": "发送失败"})
logger.info(f"报告推送完成: 成功 {len(results['success'])},失败 {len(results['failed'])}")
return results
# 全局单例
feishu = FeishuService()

View File

@ -0,0 +1,507 @@
"""报告生成服务 —— 汇总数据库数据 + 调用 AI 生成摘要"""
import logging
from datetime import date, timedelta
from sqlalchemy.orm import Session
from sqlalchemy import func as sa_func
from models import (
User, Project, Submission, AIToolCost,
ProjectStatus, WorkType,
)
from calculations import (
calc_waste_for_project,
calc_labor_cost_for_project,
calc_ai_tool_cost_for_project,
calc_outsource_cost_for_project,
calc_overhead_cost_for_project,
calc_team_efficiency,
calc_project_settlement,
)
from services.ai_service import generate_report_summary
logger = logging.getLogger(__name__)
def _fmt_seconds(secs: float) -> str:
"""秒数格式化为 Xm Xs"""
if secs <= 0:
return "0s"
m = int(secs) // 60
s = int(secs) % 60
if m > 0:
return f"{m}m {s}s" if s > 0 else f"{m}m"
return f"{s}s"
def _fmt_money(amount: float) -> str:
"""金额格式化"""
if amount >= 10000:
return f"¥{amount/10000:.1f}"
return f"¥{amount:,.0f}"
# ──────────────────────────── 日报 ────────────────────────────
def generate_daily_report(db: Session) -> dict:
"""
生成日报
返回 {"title": str, "content": str, "data": dict}
"""
today = date.today()
# 今日提交
today_subs = db.query(Submission).filter(
Submission.submit_date == today
).all()
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)
# 所有活跃用户(有提交记录的)
all_active_user_ids = set(
uid for (uid,) in db.query(Submission.user_id).distinct().all()
)
not_submitted = []
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:
not_submitted.append(user.name)
# 进行中项目
active_projects = db.query(Project).filter(
Project.status == ProjectStatus.IN_PROGRESS
).all()
project_lines = []
risk_lines = []
for p in active_projects:
waste = calc_waste_for_project(p.id, db)
total_secs = waste.get("total_submitted_seconds", 0)
target = p.target_total_seconds
progress = round(total_secs / target * 100, 1) if target > 0 else 0
# 今日该项目产出
proj_today_secs = sum(
s.total_seconds for s in today_subs
if s.project_id == p.id and s.total_seconds > 0
)
project_lines.append(
f"- **{p.name}**:进度 {progress}%,今日产出 {_fmt_seconds(proj_today_secs)}"
)
# 风险检测
if p.estimated_completion_date:
days_left = (p.estimated_completion_date - today).days
if days_left < 0:
risk_lines.append(f"- **{p.name}**:已超期 {-days_left} 天,进度 {progress}%")
elif days_left <= 7 and progress < 80:
risk_lines.append(f"- **{p.name}**:距截止仅剩 {days_left} 天,进度仅 {progress}%")
# 组装数据上下文(供 AI 使用)
data_context = (
f"日期:{today}\n"
f"进行中项目:{len(active_projects)}\n"
f"今日提交人次:{len(today_submitter_ids)}\n"
f"今日总产出:{_fmt_seconds(today_total_secs)}\n"
f"今日未提交人员:{', '.join(not_submitted) if not_submitted else ''}\n"
f"各项目情况:\n" + "\n".join(project_lines) + "\n"
f"风险项目:\n" + ("\n".join(risk_lines) if risk_lines else "")
)
# 调用 AI 生成摘要
ai_summary = generate_report_summary(data_context, "daily")
# 组装飞书 markdown 内容
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("")
lines.append("**【各项目进展】**")
lines.extend(project_lines if project_lines else ["- 暂无进行中项目"])
if risk_lines:
lines.append("")
lines.append("**【风险提醒】**")
lines.extend(risk_lines)
if ai_summary:
lines.append("")
lines.append("**【AI 点评】**")
lines.append(ai_summary)
content = "\n".join(lines)
return {"title": title, "content": content, "data": {"date": str(today)}}
# ──────────────────────────── 周报 ────────────────────────────
def generate_weekly_report(db: Session) -> dict:
"""生成周报(本周一到当天的数据)"""
today = date.today()
# 本周一
monday = today - timedelta(days=today.weekday())
# 本周提交
week_subs = db.query(Submission).filter(
Submission.submit_date >= monday,
Submission.submit_date <= today,
).all()
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)
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)
# 进行中项目
active_projects = db.query(Project).filter(
Project.status == ProjectStatus.IN_PROGRESS
).all()
# 各项目周报数据
project_lines = []
for p in active_projects:
waste = calc_waste_for_project(p.id, db)
total_secs = waste.get("total_submitted_seconds", 0)
target = p.target_total_seconds
progress = round(total_secs / target * 100, 1) if target > 0 else 0
proj_week_secs = sum(
s.total_seconds for s in week_subs
if s.project_id == p.id and s.total_seconds > 0
)
project_lines.append(
f"- **{p.name}**:当前进度 {progress}%,本周产出 {_fmt_seconds(proj_week_secs)}"
)
# 本周成本(简化:统计提交人的日成本)
week_labor = 0.0
processed = set()
for s in week_subs:
key = (s.user_id, s.submit_date)
if key not in processed:
processed.add(key)
user = db.query(User).filter(User.id == s.user_id).first()
if user:
week_labor += user.daily_cost
week_ai_cost = db.query(sa_func.sum(AIToolCost.amount)).filter(
AIToolCost.record_date >= monday,
AIToolCost.record_date <= today,
).scalar() or 0
# 损耗排行
waste_ranking = []
for p in active_projects:
w = calc_waste_for_project(p.id, db)
if w.get("total_waste_seconds", 0) > 0:
waste_ranking.append({
"name": p.name,
"rate": w["waste_rate"],
})
waste_ranking.sort(key=lambda x: x["rate"], reverse=True)
# 效率排行(找产出最高的人)
user_week_secs = {}
for s in week_subs:
if s.total_seconds > 0:
user_week_secs[s.user_id] = user_week_secs.get(s.user_id, 0) + s.total_seconds
top_producer = None
if user_week_secs:
top_uid = max(user_week_secs, key=user_week_secs.get)
top_user = db.query(User).filter(User.id == top_uid).first()
if top_user:
top_daily = round(user_week_secs[top_uid] / max(1, working_days), 1)
top_producer = f"{top_user.name}(日均 {_fmt_seconds(top_daily)}"
# AI 数据上下文
data_context = (
f"周期:{monday} ~ {today}\n"
f"进行中项目:{len(active_projects)}\n"
f"本周总产出:{_fmt_seconds(week_total_secs)}\n"
f"人均日产出:{_fmt_seconds(avg_daily)}\n"
f"效率最高:{top_producer or '暂无'}\n"
f"本周人力成本:{_fmt_money(week_labor)}\n"
f"本周AI工具成本{_fmt_money(week_ai_cost)}\n"
f"各项目:\n" + "\n".join(project_lines) + "\n"
f"损耗排行:\n" + "\n".join(
f"- {w['name']}{w['rate']}%" for w in waste_ranking[:5]
) if waste_ranking else "损耗排行:无"
)
ai_summary = generate_report_summary(data_context, "weekly")
# 组装内容
title = f"AirLabs 周报 — 第{today.isocalendar()[1]}周({monday} ~ {today}"
lines = [
"**【项目进展】**",
]
lines.extend(project_lines if project_lines else ["- 暂无进行中项目"])
lines.append("")
lines.append("**【团队产出】**")
lines.append(f"- 本周总产出:{_fmt_seconds(week_total_secs)}")
lines.append(f"- 人均日产出:{_fmt_seconds(avg_daily)}")
if top_producer:
lines.append(f"- 效率最高:{top_producer}")
lines.append("")
lines.append("**【成本概览】**")
lines.append(f"- 本周人力成本:{_fmt_money(week_labor)}")
lines.append(f"- 本周 AI 工具支出:{_fmt_money(week_ai_cost)}")
if waste_ranking:
lines.append("")
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:
"""生成月报上月完整数据在每月1号调用"""
today = date.today()
# 上月日期范围
first_of_this_month = today.replace(day=1)
last_of_prev_month = first_of_this_month - timedelta(days=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_subs = db.query(Submission).filter(
Submission.submit_date >= first_of_prev_month,
Submission.submit_date <= last_of_prev_month,
).all()
month_total_secs = sum(s.total_seconds for s in month_subs if s.total_seconds > 0)
month_submitters = set(s.user_id for s in month_subs)
# 所有项目(含进行中和上月完成的)
all_projects = db.query(Project).filter(
Project.status.in_([ProjectStatus.IN_PROGRESS, ProjectStatus.COMPLETED])
).all()
# 上月完成的项目
completed_this_month = [
p for p in all_projects
if p.status == ProjectStatus.COMPLETED
and p.actual_completion_date
and first_of_prev_month <= p.actual_completion_date <= last_of_prev_month
]
active_projects = [p for p in all_projects if p.status == ProjectStatus.IN_PROGRESS]
# 各项目成本
project_cost_lines = []
total_all_cost = 0.0
for p in active_projects + completed_this_month:
labor = calc_labor_cost_for_project(p.id, db)
ai_tool = calc_ai_tool_cost_for_project(p.id, db)
outsource = calc_outsource_cost_for_project(p.id, db)
overhead = calc_overhead_cost_for_project(p.id, db)
total = labor + ai_tool + outsource + overhead
total_all_cost += total
project_cost_lines.append(
f"- **{p.name}**:人力 {_fmt_money(labor)} / AI工具 {_fmt_money(ai_tool)} / "
f"外包 {_fmt_money(outsource)} / 固定 {_fmt_money(overhead)} → 总计 {_fmt_money(total)}"
)
# 盈亏概览(已结算的客户正式项目)
profit_lines = []
total_profit = 0.0
total_contract = 0.0
for p in completed_this_month:
settlement = calc_project_settlement(p.id, db)
if settlement.get("contract_amount"):
pl = settlement.get("profit_loss", 0)
total_profit += pl
total_contract += settlement["contract_amount"]
sign = "+" if pl >= 0 else ""
profit_lines.append(
f"- **{p.name}**:回款 {_fmt_money(settlement['contract_amount'])}"
f"成本 {_fmt_money(settlement['total_cost'])},利润 {sign}{_fmt_money(pl)}"
)
# 损耗汇总
total_waste_secs = 0.0
total_target_secs = 0.0
for p in active_projects + completed_this_month:
w = calc_waste_for_project(p.id, db)
total_waste_secs += w.get("total_waste_seconds", 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
# 人均产出
working_days_month = 22
avg_per_person = round(month_total_secs / max(1, len(month_submitters)), 1)
# AI 数据上下文
data_context = (
f"月份:{month_label}\n"
f"进行中项目:{len(active_projects)}\n"
f"本月完成项目:{len(completed_this_month)}\n"
f"月度总产出:{_fmt_seconds(month_total_secs)}\n"
f"月度总成本:{_fmt_money(total_all_cost)}\n"
f"总损耗率:{waste_rate}%\n"
f"参与人数:{len(month_submitters)}\n"
f"人均产出:{_fmt_seconds(avg_per_person)}\n"
f"各项目成本:\n" + "\n".join(project_cost_lines) + "\n"
f"盈亏:\n" + ("\n".join(profit_lines) if profit_lines else "本月无结算项目")
)
ai_summary = generate_report_summary(data_context, "monthly")
# 组装内容
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 {
"title": title,
"content": content,
"data": {"month": month_label, "start": str(first_of_prev_month), "end": str(last_of_prev_month)},
}
# ──────────────────────────── 风险预警 ────────────────────────────
def analyze_project_risks(db: Session) -> list:
"""
分析所有进行中项目的风险返回风险列表
纯规则引擎判断不依赖 AI
"""
today = date.today()
active_projects = db.query(Project).filter(
Project.status == ProjectStatus.IN_PROGRESS
).all()
risks = []
for p in active_projects:
waste = calc_waste_for_project(p.id, db)
total_secs = waste.get("total_submitted_seconds", 0)
target = p.target_total_seconds
progress = round(total_secs / target * 100, 1) if target > 0 else 0
risk_factors = []
risk_level = "low"
# 1. 超期检测
if p.estimated_completion_date:
days_left = (p.estimated_completion_date - today).days
if days_left < 0:
risk_factors.append(f"已超期 {-days_left}")
risk_level = "high"
elif days_left <= 7 and progress < 80:
risk_factors.append(f"距截止仅剩 {days_left} 天,进度仅 {progress}%")
risk_level = "high"
elif days_left <= 14 and progress < 60:
risk_factors.append(f"距截止 {days_left} 天,进度 {progress}% 偏低")
risk_level = "medium"
# 进度落后于时间线
if p.estimated_completion_date and days_left > 0:
# 估算已用天数
if hasattr(p, 'created_at') and 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
elapsed_days = (today - created).days
if total_days > 0:
expected_progress = round(elapsed_days / total_days * 100, 1)
if progress < expected_progress * 0.7:
risk_factors.append(
f"预期进度 {expected_progress}%,实际 {progress}%,严重落后"
)
risk_level = "high"
elif progress < expected_progress * 0.85:
risk_factors.append(
f"预期进度 {expected_progress}%,实际 {progress}%"
)
if risk_level != "high":
risk_level = "medium"
# 2. 损耗率检测
waste_rate = waste.get("waste_rate", 0)
if waste_rate > 80:
risk_factors.append(f"损耗率 {waste_rate}%,严重偏高")
risk_level = "high"
elif waste_rate > 50:
risk_factors.append(f"损耗率 {waste_rate}%,偏高")
if risk_level != "high":
risk_level = "medium"
# 3. 近7天无提交
week_ago = today - timedelta(days=7)
recent_subs = db.query(Submission).filter(
Submission.project_id == p.id,
Submission.submit_date >= week_ago,
).count()
if recent_subs == 0 and total_secs > 0:
risk_factors.append("近 7 天无提交,产出停滞")
if risk_level != "high":
risk_level = "medium"
if risk_factors:
risks.append({
"project_id": p.id,
"project_name": p.name,
"risk_level": risk_level,
"progress": progress,
"risk_factors": risk_factors,
})
# 高风险排前面
level_order = {"high": 0, "medium": 1, "low": 2}
risks.sort(key=lambda x: level_order.get(x["risk_level"], 99))
return risks

View File

@ -0,0 +1,78 @@
"""APScheduler 定时任务 —— 自动生成报告并推送飞书"""
import asyncio
import logging
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from database import SessionLocal
logger = logging.getLogger(__name__)
scheduler = AsyncIOScheduler(timezone="Asia/Shanghai")
async def _run_report_job(report_type: str):
"""通用报告任务执行器"""
from services.report_service import (
generate_daily_report, generate_weekly_report, generate_monthly_report,
)
from services.feishu_service import feishu
logger.info(f"[定时任务] 开始生成{report_type}...")
db = SessionLocal()
try:
if report_type == "日报":
result = generate_daily_report(db)
elif report_type == "周报":
result = generate_weekly_report(db)
elif report_type == "月报":
result = generate_monthly_report(db)
else:
logger.error(f"未知报告类型: {report_type}")
return
logger.info(f"[定时任务] {report_type}生成完成,开始推送飞书...")
push_result = await feishu.send_report_to_all(result["title"], result["content"])
logger.info(f"[定时任务] {report_type}推送完成: {push_result}")
except Exception as e:
logger.error(f"[定时任务] {report_type}生成/推送失败: {e}", exc_info=True)
finally:
db.close()
async def daily_report_job():
await _run_report_job("日报")
async def weekly_report_job():
await _run_report_job("周报")
async def monthly_report_job():
await _run_report_job("月报")
def setup_scheduler():
"""配置并启动定时任务"""
# 日报:每天 20:00
scheduler.add_job(
daily_report_job, "cron",
hour=20, minute=0,
id="daily_report", replace_existing=True,
)
# 周报:每周五 20:00
scheduler.add_job(
weekly_report_job, "cron",
day_of_week="fri", hour=20, minute=0,
id="weekly_report", replace_existing=True,
)
# 月报每月1日 10:00
scheduler.add_job(
monthly_report_job, "cron",
day=1, hour=10, minute=0,
id="monthly_report", replace_existing=True,
)
scheduler.start()
logger.info(
"[定时任务] 已启动 — 日报:每天20:00 | 周报:周五20:00 | 月报:每月1日10:00"
)

View File

@ -63,6 +63,10 @@ export const projectApi = {
complete: (id) => api.post(`/projects/${id}/complete`), complete: (id) => api.post(`/projects/${id}/complete`),
settlement: (id) => api.get(`/projects/${id}/settlement`), settlement: (id) => api.get(`/projects/${id}/settlement`),
efficiency: (id) => api.get(`/projects/${id}/efficiency`), efficiency: (id) => api.get(`/projects/${id}/efficiency`),
milestones: (id) => api.get(`/projects/${id}/milestones`),
addMilestone: (id, data) => api.post(`/projects/${id}/milestones`, data),
toggleMilestone: (milestoneId) => api.put(`/projects/milestones/${milestoneId}/toggle`),
deleteMilestone: (milestoneId) => api.delete(`/projects/milestones/${milestoneId}`),
} }
// ── 内容提交 ── // ── 内容提交 ──

View File

@ -19,7 +19,7 @@
:to="item.path" :to="item.path"
class="nav-item" class="nav-item"
:class="{ active: isActive(item.path) }" :class="{ active: isActive(item.path) }"
v-show="!item.perm || authStore.hasPermission(item.perm)" v-show="!item.perm || (Array.isArray(item.perm) ? item.perm.some(p => authStore.hasPermission(p)) : authStore.hasPermission(item.perm))"
> >
<el-icon :size="18"><component :is="item.icon" /></el-icon> <el-icon :size="18"><component :is="item.icon" /></el-icon>
<span v-show="!isCollapsed" class="nav-label">{{ item.label }}</span> <span v-show="!isCollapsed" class="nav-label">{{ item.label }}</span>
@ -72,7 +72,7 @@ const menuItems = [
{ path: '/dashboard', label: '仪表盘', icon: 'Odometer', perm: 'dashboard:view' }, { path: '/dashboard', label: '仪表盘', icon: 'Odometer', perm: 'dashboard:view' },
{ path: '/projects', label: '项目管理', icon: 'FolderOpened', perm: 'project:view' }, { path: '/projects', label: '项目管理', icon: 'FolderOpened', perm: 'project:view' },
{ path: '/submissions', label: '内容提交', icon: 'EditPen', perm: 'submission:view' }, { path: '/submissions', label: '内容提交', icon: 'EditPen', perm: 'submission:view' },
{ path: '/costs', label: '成本管理', icon: 'Money', perm: 'cost:view' }, { path: '/costs', label: '成本管理', icon: 'Money', perm: ['cost_ai:view', 'cost_outsource:view', 'cost_overhead:view', 'cost_labor:view'] },
{ path: '/users', label: '用户管理', icon: 'User', perm: 'user:manage' }, { path: '/users', label: '用户管理', icon: 'User', perm: 'user:manage' },
{ path: '/roles', label: '角色管理', icon: 'Lock', perm: 'role:manage' }, { path: '/roles', label: '角色管理', icon: 'Lock', perm: 'role:manage' },
] ]

View File

@ -11,7 +11,7 @@ const routes = [
{ path: 'projects', name: 'Projects', component: () => import('../views/Projects.vue'), meta: { perm: 'project:view' } }, { path: 'projects', name: 'Projects', component: () => import('../views/Projects.vue'), meta: { perm: 'project:view' } },
{ path: 'projects/:id', name: 'ProjectDetail', component: () => import('../views/ProjectDetail.vue'), meta: { perm: 'project:view' } }, { path: 'projects/:id', name: 'ProjectDetail', component: () => import('../views/ProjectDetail.vue'), meta: { perm: 'project:view' } },
{ path: 'submissions', name: 'Submissions', component: () => import('../views/Submissions.vue'), meta: { perm: 'submission:view' } }, { path: 'submissions', name: 'Submissions', component: () => import('../views/Submissions.vue'), meta: { perm: 'submission:view' } },
{ path: 'costs', name: 'Costs', component: () => import('../views/Costs.vue'), meta: { perm: 'cost:view' } }, { path: 'costs', name: 'Costs', component: () => import('../views/Costs.vue'), meta: { perm: ['cost_ai:view', 'cost_outsource:view', 'cost_overhead:view', 'cost_labor:view'] } },
{ path: 'users', name: 'Users', component: () => import('../views/Users.vue'), meta: { perm: 'user:manage' } }, { path: 'users', name: 'Users', component: () => import('../views/Users.vue'), meta: { perm: 'user:manage' } },
{ path: 'users/:id/detail', name: 'MemberDetail', component: () => import('../views/MemberDetail.vue'), meta: { perm: 'user:view' } }, { path: 'users/:id/detail', name: 'MemberDetail', component: () => import('../views/MemberDetail.vue'), meta: { perm: 'user:view' } },
{ path: 'roles', name: 'Roles', component: () => import('../views/Roles.vue'), meta: { perm: 'role:manage' } }, { path: 'roles', name: 'Roles', component: () => import('../views/Roles.vue'), meta: { perm: 'role:manage' } },
@ -57,9 +57,13 @@ router.beforeEach(async (to, from, next) => {
} }
// 权限校验:如果路由要求特定权限,且用户没有,跳到第一个有权限的页面 // 权限校验:如果路由要求特定权限,且用户没有,跳到第一个有权限的页面
if (to.meta.perm && !authStore.hasPermission(to.meta.perm)) { const checkPerm = (perm) => {
// 找到第一个有权限的页面 if (!perm) return true
const fallback = routes[1].children.find(r => !r.meta?.perm || authStore.hasPermission(r.meta.perm)) if (Array.isArray(perm)) return perm.some(p => authStore.hasPermission(p))
return authStore.hasPermission(perm)
}
if (to.meta.perm && !checkPerm(to.meta.perm)) {
const fallback = routes[1].children.find(r => checkPerm(r.meta?.perm))
next(fallback ? '/' + fallback.path : '/login') next(fallback ? '/' + fallback.path : '/login')
return return
} }

View File

@ -4,9 +4,9 @@
<el-tabs v-model="activeTab"> <el-tabs v-model="activeTab">
<!-- AI 工具成本 --> <!-- AI 工具成本 -->
<el-tab-pane label="AI 工具成本" name="ai"> <el-tab-pane v-if="authStore.hasPermission('cost_ai:view')" label="AI 工具成本" name="ai">
<div class="tab-header"> <div class="tab-header">
<el-button type="primary" size="small" @click="showAIForm = true"><el-icon><Plus /></el-icon> 新增</el-button> <el-button v-if="authStore.hasPermission('cost_ai:create')" type="primary" size="small" @click="showAIForm = true"><el-icon><Plus /></el-icon> 新增</el-button>
</div> </div>
<el-table :data="aiCosts" v-loading="loadingAI" stripe size="small"> <el-table :data="aiCosts" v-loading="loadingAI" stripe size="small">
<el-table-column prop="tool_name" label="工具名称" width="140" /> <el-table-column prop="tool_name" label="工具名称" width="140" />
@ -16,16 +16,16 @@
</el-table-column> </el-table-column>
<el-table-column prop="allocation_type" label="归属方式" width="120" /> <el-table-column prop="allocation_type" label="归属方式" width="120" />
<el-table-column prop="record_date" label="录入日期" width="110" /> <el-table-column prop="record_date" label="录入日期" width="110" />
<el-table-column label="操作" width="80" v-if="authStore.hasPermission('cost:delete')"> <el-table-column label="操作" width="80" v-if="authStore.hasPermission('cost_ai:delete')">
<template #default="{row}"><el-button text type="danger" size="small" @click="deleteAI(row.id)">删除</el-button></template> <template #default="{row}"><el-button text type="danger" size="small" @click="deleteAI(row.id)">删除</el-button></template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</el-tab-pane> </el-tab-pane>
<!-- 外包成本 --> <!-- 外包成本 -->
<el-tab-pane label="外包成本" name="outsource"> <el-tab-pane v-if="authStore.hasPermission('cost_outsource:view')" label="外包成本" name="outsource">
<div class="tab-header"> <div class="tab-header">
<el-button type="primary" size="small" @click="showOutForm = true"><el-icon><Plus /></el-icon> 新增</el-button> <el-button v-if="authStore.hasPermission('cost_outsource:create')" type="primary" size="small" @click="showOutForm = true"><el-icon><Plus /></el-icon> 新增</el-button>
</div> </div>
<el-table :data="outCosts" v-loading="loadingOut" stripe size="small"> <el-table :data="outCosts" v-loading="loadingOut" stripe size="small">
<el-table-column label="项目" width="160"> <el-table-column label="项目" width="160">
@ -39,16 +39,16 @@
<template #default="{row}">¥{{ row.amount.toLocaleString() }}</template> <template #default="{row}">¥{{ row.amount.toLocaleString() }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="record_date" label="录入日期" width="110" /> <el-table-column prop="record_date" label="录入日期" width="110" />
<el-table-column label="操作" width="80" v-if="authStore.hasPermission('cost:delete')"> <el-table-column label="操作" width="80" v-if="authStore.hasPermission('cost_outsource:delete')">
<template #default="{row}"><el-button text type="danger" size="small" @click="deleteOut(row.id)">删除</el-button></template> <template #default="{row}"><el-button text type="danger" size="small" @click="deleteOut(row.id)">删除</el-button></template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</el-tab-pane> </el-tab-pane>
<!-- 固定开支 --> <!-- 固定开支 -->
<el-tab-pane label="固定开支" name="overhead"> <el-tab-pane v-if="authStore.hasPermission('cost_overhead:view')" label="固定开支" name="overhead">
<div class="tab-header"> <div class="tab-header">
<el-button type="primary" size="small" @click="showOHForm = true"><el-icon><Plus /></el-icon> 新增</el-button> <el-button v-if="authStore.hasPermission('cost_overhead:create')" type="primary" size="small" @click="showOHForm = true"><el-icon><Plus /></el-icon> 新增</el-button>
</div> </div>
<el-table :data="overheadCosts" v-loading="loadingOH" stripe size="small"> <el-table :data="overheadCosts" v-loading="loadingOH" stripe size="small">
<el-table-column prop="cost_type" label="费用类型" width="140" /> <el-table-column prop="cost_type" label="费用类型" width="140" />
@ -57,7 +57,7 @@
</el-table-column> </el-table-column>
<el-table-column prop="record_month" label="所属月份" width="110" /> <el-table-column prop="record_month" label="所属月份" width="110" />
<el-table-column prop="note" label="备注" show-overflow-tooltip /> <el-table-column prop="note" label="备注" show-overflow-tooltip />
<el-table-column label="操作" width="80" v-if="authStore.hasPermission('cost:delete')"> <el-table-column label="操作" width="80" v-if="authStore.hasPermission('cost_overhead:delete')">
<template #default="{row}"><el-button text type="danger" size="small" @click="deleteOH(row.id)">删除</el-button></template> <template #default="{row}"><el-button text type="danger" size="small" @click="deleteOH(row.id)">删除</el-button></template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@ -165,7 +165,13 @@ import { useAuthStore } from '../stores/auth'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
const authStore = useAuthStore() const authStore = useAuthStore()
const activeTab = ref('ai')
// tab
const tabOrder = ['ai', 'outsource', 'overhead']
const tabPermMap = { ai: 'cost_ai:view', outsource: 'cost_outsource:view', overhead: 'cost_overhead:view' }
const defaultTab = tabOrder.find(t => authStore.hasPermission(tabPermMap[t])) || 'ai'
const activeTab = ref(defaultTab)
const loadingAI = ref(false) const loadingAI = ref(false)
const loadingOut = ref(false) const loadingOut = ref(false)
const loadingOH = ref(false) const loadingOH = ref(false)
@ -211,7 +217,10 @@ async function deleteOH(id) {
} }
onMounted(async () => { onMounted(async () => {
loadAI(); loadOut(); loadOH() //
if (authStore.hasPermission('cost_ai:view')) loadAI()
if (authStore.hasPermission('cost_outsource:view')) loadOut()
if (authStore.hasPermission('cost_overhead:view')) loadOH()
try { projects.value = await projectApi.list({}) } catch {} try { projects.value = await projectApi.list({}) } catch {}
}) })
</script> </script>

View File

@ -65,6 +65,33 @@
</div> </div>
</div> </div>
<!-- 风险预警 -->
<div class="card risk-card" v-if="data.risk_alerts?.length">
<div class="card-header">
<span class="card-title">
<el-icon :size="16" style="color:#FF9500;margin-right:6px;vertical-align:-2px"><WarnTriangleFilled /></el-icon>
风险预警
</span>
<span class="card-count">{{ data.risk_alerts.length }} 个项目存在风险</span>
</div>
<div class="card-body">
<div v-for="risk in data.risk_alerts" :key="risk.project_id"
class="risk-item" :class="risk.risk_level"
@click="$router.push(`/projects/${risk.project_id}`)">
<div class="risk-header">
<span class="risk-name">{{ risk.project_name }}</span>
<el-tag size="small" :type="risk.risk_level === 'high' ? 'danger' : 'warning'" effect="dark">
{{ risk.risk_level === 'high' ? '高风险' : '中风险' }}
</el-tag>
<span class="risk-progress">进度 {{ risk.progress }}%</span>
</div>
<div class="risk-factors">
<span v-for="(factor, i) in risk.risk_factors" :key="i" class="risk-factor">{{ factor }}</span>
</div>
</div>
</div>
</div>
<!-- 图表行产出趋势 + 成本构成 --> <!-- 图表行产出趋势 + 成本构成 -->
<div class="chart-row"> <div class="chart-row">
<div class="card chart-card wide"> <div class="card chart-card wide">
@ -395,4 +422,24 @@ onUnmounted(() => {
.profit-text { font-weight: 600; color: #34C759; } .profit-text { font-weight: 600; color: #34C759; }
.profit-text.loss { color: #FF3B30; } .profit-text.loss { color: #FF3B30; }
.text-muted { color: var(--text-secondary); } .text-muted { color: var(--text-secondary); }
/* 风险预警 */
.risk-card { margin-bottom: 16px; }
.risk-item {
padding: 14px 0; border-bottom: 1px solid var(--border-light);
cursor: pointer; margin: 0 -20px; padding-left: 20px; padding-right: 20px;
transition: background 0.15s;
}
.risk-item:last-child { border-bottom: none; }
.risk-item:hover { background: #FFFBF5; }
.risk-item.high { border-left: 3px solid #FF3B30; }
.risk-item.medium { border-left: 3px solid #FF9500; }
.risk-header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.risk-name { font-size: 14px; font-weight: 600; color: var(--text-primary); }
.risk-progress { font-size: 12px; color: var(--text-secondary); margin-left: auto; }
.risk-factors { display: flex; flex-wrap: wrap; gap: 6px; }
.risk-factor {
font-size: 12px; color: #8B572A; background: #FFF8F0;
padding: 2px 8px; border-radius: 4px;
}
</style> </style>

View File

@ -42,15 +42,24 @@
<!-- 90天日历热力图 --> <!-- 90天日历热力图 -->
<div class="card"> <div class="card">
<div class="card-header"><span class="card-title"> 90 天提交热力图</span></div> <div class="card-header"><span class="card-title"> 90 天提交热力图</span></div>
<div class="card-body"> <div class="card-body heatmap-body">
<div class="calendar-heatmap" v-if="calendarData.length"> <div class="calendar-heatmap" v-if="calendarData.length">
<div class="calendar-row"> <div class="heatmap-summary"> 90 天内有 <strong>{{ activeDays }}</strong> 天有提交</div>
<div v-for="week in calendarWeeks" :key="week[0]" class="calendar-week"> <div class="calendar-wrapper">
<div v-for="day in week" :key="day.date" class="calendar-day" <div class="weekday-labels">
:class="getDayClass(day)" :title="getDayTitle(day)"> <span v-for="l in ['日','一','二','三','四','五','六']" :key="l">{{ l }}</span>
</div>
<div class="calendar-grid">
<div v-for="(week, wi) in calendarWeeks" :key="wi" class="calendar-week">
<div v-for="(day, di) in week" :key="`${wi}-${di}`" class="calendar-day"
:class="getDayClass(day)" :title="getDayTitle(day)">
</div>
</div> </div>
</div> </div>
</div> </div>
<div class="month-labels" v-if="monthLabels.length">
<span v-for="(m, i) in monthLabels" :key="i" class="month-label" :style="{ left: m.offset + 'px' }">{{ m.label }}</span>
</div>
<div class="calendar-legend"> <div class="calendar-legend">
<span class="legend-text"></span> <span class="legend-text"></span>
<span class="calendar-day level-0"></span> <span class="calendar-day level-0"></span>
@ -70,16 +79,16 @@
<div class="card-header"><span class="card-title">按项目统计</span></div> <div class="card-header"><span class="card-title">按项目统计</span></div>
<div class="card-body" v-if="projectGroups.length"> <div class="card-body" v-if="projectGroups.length">
<div v-for="pg in projectGroups" :key="pg.projectId" class="project-group"> <div v-for="pg in projectGroups" :key="pg.projectId" class="project-group">
<div class="project-group-header" @click="pg.expanded = !pg.expanded"> <div class="project-group-header" @click="toggleProjectExpand(pg.projectId)">
<div class="project-group-info"> <div class="project-group-info">
<span class="project-group-name">{{ pg.projectName }}</span> <span class="project-group-name">{{ pg.projectName }}</span>
<el-tag size="small" type="info">{{ pg.submissions.length }} 次提交</el-tag> <el-tag size="small" type="info">{{ pg.submissions.length }} 次提交</el-tag>
<span class="project-group-secs">总产出 {{ formatSecs(pg.totalSecs) }}</span> <span class="project-group-secs">总产出 {{ formatSecs(pg.totalSecs) }}</span>
</div> </div>
<el-icon :class="{ rotated: pg.expanded }"><ArrowRight /></el-icon> <el-icon :class="{ rotated: expandedProjectIds.has(pg.projectId) }"><ArrowRight /></el-icon>
</div> </div>
<transition name="expand"> <transition name="expand">
<div v-if="pg.expanded" class="project-group-body"> <div v-if="expandedProjectIds.has(pg.projectId)" class="project-group-body">
<el-table :data="pg.submissions" size="small"> <el-table :data="pg.submissions" size="small">
<el-table-column prop="submit_date" label="日期" width="110" /> <el-table-column prop="submit_date" label="日期" width="110" />
<el-table-column label="工作类型" width="80"> <el-table-column label="工作类型" width="80">
@ -105,7 +114,7 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, reactive, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { userApi, submissionApi } from '../api' import { userApi, submissionApi } from '../api'
@ -113,6 +122,7 @@ const route = useRoute()
const loading = ref(false) const loading = ref(false)
const member = ref({}) const member = ref({})
const allSubmissions = ref([]) const allSubmissions = ref([])
const expandedProjectIds = ref(new Set())
const totalOutputSecs = computed(() => allSubmissions.value.reduce((s, r) => s + (r.total_seconds || 0), 0)) const totalOutputSecs = computed(() => allSubmissions.value.reduce((s, r) => s + (r.total_seconds || 0), 0))
@ -151,7 +161,6 @@ const calendarWeeks = computed(() => {
const weeks = [] const weeks = []
const data = calendarData.value const data = calendarData.value
let week = [] let week = []
//
const firstDay = new Date(data[0]?.date).getDay() const firstDay = new Date(data[0]?.date).getDay()
for (let i = 0; i < firstDay; i++) week.push({ date: '', secs: 0, count: 0, empty: true }) for (let i = 0; i < firstDay; i++) week.push({ date: '', secs: 0, count: 0, empty: true })
data.forEach(d => { data.forEach(d => {
@ -162,6 +171,26 @@ const calendarWeeks = computed(() => {
return weeks return weeks
}) })
//
const cellWidth = 22
const monthLabels = computed(() => {
const data = calendarData.value
if (!data.length) return []
const firstDate = new Date(data[0].date)
const result = []
let prevMonth = -1
for (let i = 0; i < data.length; i++) {
if (data[i].empty) continue
const dt = new Date(data[i].date)
if (dt.getMonth() !== prevMonth) {
prevMonth = dt.getMonth()
const weekIdx = Math.floor((i + firstDate.getDay()) / 7)
result.push({ label: `${dt.getMonth() + 1}`, offset: weekIdx * cellWidth })
}
}
return result
})
function getDayClass(day) { function getDayClass(day) {
if (day.empty) return 'empty' if (day.empty) return 'empty'
if (day.count === 0) return 'level-0' if (day.count === 0) return 'level-0'
@ -182,13 +211,12 @@ const projectGroups = computed(() => {
const groups = {} const groups = {}
allSubmissions.value.forEach(s => { allSubmissions.value.forEach(s => {
if (!groups[s.project_id]) { if (!groups[s.project_id]) {
groups[s.project_id] = reactive({ groups[s.project_id] = {
projectId: s.project_id, projectId: s.project_id,
projectName: s.project_name, projectName: s.project_name,
submissions: [], submissions: [],
totalSecs: 0, totalSecs: 0,
expanded: false, }
})
} }
groups[s.project_id].submissions.push(s) groups[s.project_id].submissions.push(s)
groups[s.project_id].totalSecs += s.total_seconds || 0 groups[s.project_id].totalSecs += s.total_seconds || 0
@ -196,6 +224,13 @@ const projectGroups = computed(() => {
return Object.values(groups).sort((a, b) => b.totalSecs - a.totalSecs) return Object.values(groups).sort((a, b) => b.totalSecs - a.totalSecs)
}) })
function toggleProjectExpand(projectId) {
const next = new Set(expandedProjectIds.value)
if (next.has(projectId)) next.delete(projectId)
else next.add(projectId)
expandedProjectIds.value = next
}
function formatSecs(s) { function formatSecs(s) {
if (!s) return '0秒' if (!s) return '0秒'
const abs = Math.abs(s) const abs = Math.abs(s)
@ -242,23 +277,43 @@ onMounted(load)
.card-body { padding: 20px; } .card-body { padding: 20px; }
/* 日历热力图 */ /* 日历热力图 */
.calendar-heatmap { overflow-x: auto; } .heatmap-body { min-height: 160px; }
.calendar-row { display: flex; gap: 3px; } .heatmap-summary {
.calendar-week { display: flex; flex-direction: column; gap: 3px; } font-size: 13px; color: var(--text-secondary); margin-bottom: 12px;
.calendar-day {
width: 12px; height: 12px; border-radius: 2px; background: #EBEDF0;
} }
.calendar-day.empty { background: transparent; } .heatmap-summary strong { color: var(--text-primary); font-weight: 600; }
.calendar-heatmap { width: 100%; }
.calendar-wrapper { display: flex; gap: 6px; align-items: flex-start; margin-bottom: 8px; }
.weekday-labels {
display: flex; flex-direction: column; gap: 4px; flex-shrink: 0; width: 14px;
font-size: 11px; color: var(--text-secondary); line-height: 18px;
}
.weekday-labels span { height: 18px; text-align: center; }
.calendar-grid {
display: flex; gap: 4px; justify-content: flex-start;
}
.month-labels {
position: relative; height: 16px; margin-top: 4px; margin-left: 20px;
font-size: 11px; color: var(--text-secondary);
}
.month-label { position: absolute; }
.calendar-week { display: flex; flex-direction: column; gap: 4px; }
.calendar-day {
width: 18px; height: 18px; border-radius: 3px; background: #EBEDF0;
flex-shrink: 0;
}
.calendar-day.empty { background: transparent; min-width: 18px; min-height: 18px; }
.calendar-day.level-0 { background: #EBEDF0; } .calendar-day.level-0 { background: #EBEDF0; }
.calendar-day.level-1 { background: #9be9a8; } .calendar-day.level-1 { background: #9be9a8; }
.calendar-day.level-2 { background: #40c463; } .calendar-day.level-2 { background: #40c463; }
.calendar-day.level-3 { background: #30a14e; } .calendar-day.level-3 { background: #30a14e; }
.calendar-day.level-4 { background: #216e39; } .calendar-day.level-4 { background: #216e39; }
.calendar-day:hover:not(.empty) { outline: 2px solid #30a14e; outline-offset: 1px; }
.calendar-legend { .calendar-legend {
display: flex; align-items: center; gap: 3px; margin-top: 8px; display: flex; align-items: center; gap: 4px; margin-top: 12px;
justify-content: flex-end; justify-content: flex-end; font-size: 12px;
} }
.legend-text { font-size: 11px; color: var(--text-secondary); margin: 0 4px; } .legend-text { color: var(--text-secondary); margin: 0 6px; }
/* 项目分组 */ /* 项目分组 */
.project-group { border-bottom: 1px solid #f0f1f2; } .project-group { border-bottom: 1px solid #f0f1f2; }

View File

@ -5,12 +5,14 @@
<el-button text @click="$router.push('/projects')" class="back-btn"><el-icon><ArrowLeft /></el-icon></el-button> <el-button text @click="$router.push('/projects')" class="back-btn"><el-icon><ArrowLeft /></el-icon></el-button>
<h2>{{ project.name }}</h2> <h2>{{ project.name }}</h2>
<el-tag :type="typeTagMap[project.project_type]" size="small">{{ project.project_type }}</el-tag> <el-tag :type="typeTagMap[project.project_type]" size="small">{{ project.project_type }}</el-tag>
<el-tag :type="project.status === '已完成' ? 'success' : 'info'" size="small">{{ project.status }}</el-tag> <el-tag :type="project.status === '已完成' ? 'success' : project.status === '废弃' ? 'danger' : 'info'" size="small">{{ project.status }}</el-tag>
</div> </div>
<el-space> <el-space>
<el-button v-if="authStore.hasPermission('project:edit')" @click="openEdit">编辑项目</el-button>
<el-button v-if="authStore.hasPermission('project:delete')" type="danger" text @click="handleDelete">删除项目</el-button> <el-button v-if="authStore.hasPermission('project:delete')" type="danger" text @click="handleDelete">删除项目</el-button>
<el-button v-if="authStore.hasPermission('project:complete') && project.status === '制作中'" type="danger" plain @click="handleComplete">确认完成</el-button> <el-button v-if="authStore.hasPermission('project:complete') && project.status === '制作中'" type="danger" plain @click="handleComplete">确认完成</el-button>
<el-button v-if="authStore.hasPermission('settlement:view') && project.status === '已完成'" type="primary" @click="$router.push(`/settlement/${project.id}`)">查看结算</el-button> <el-button v-if="authStore.hasPermission('settlement:view') && project.status === '已完成'" type="primary" @click="$router.push(`/settlement/${project.id}`)">查看结算</el-button>
<el-button v-if="authStore.hasPermission('project:edit') && project.status === '制作中'" type="warning" plain @click="handleAbandon">标记废弃</el-button>
</el-space> </el-space>
</div> </div>
@ -55,8 +57,8 @@
<div class="stat-value">{{ formatSecs(project.total_submitted_seconds) }}</div> <div class="stat-value">{{ formatSecs(project.total_submitted_seconds) }}</div>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<div class="stat-label">完成进度</div> <div class="stat-label">当前阶段</div>
<div class="stat-value" :style="{color: project.progress_percent > 100 ? '#FF9500' : '#3370FF'}">{{ project.progress_percent }}%</div> <div class="stat-value" :style="{color: stageColor}">{{ project.current_stage || '—' }}</div>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<div class="stat-label">损耗率</div> <div class="stat-label">损耗率</div>
@ -64,7 +66,7 @@
</div> </div>
</div> </div>
<!-- 项目进度时间轴 --> <!-- 里程碑进度 -->
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<span class="card-title">项目进度</span> <span class="card-title">项目进度</span>
@ -73,14 +75,112 @@
</span> </span>
</div> </div>
<div class="card-body"> <div class="card-body">
<!-- 产出进度条 --> <!-- 三阶段分段进度条 -->
<div class="progress-section"> <div class="milestone-pipeline" v-if="project.phase_summary">
<div class="progress-label-row"> <div class="segmented-bar">
<span>产出进度</span> <div class="seg-group" style="flex:2">
<span :style="{color: progressColor, fontWeight: 600}">{{ project.progress_percent }}%</span> <div class="seg-track">
<div class="seg-fill pre" :style="{width: prePercent + '%'}"></div>
</div>
<div class="seg-info">
<span class="seg-name" :class="{active: project.current_stage === '前期'}">前期</span>
<span class="seg-stat">{{ phasePre.completed }}/{{ phasePre.total }}</span>
</div>
</div>
<div class="seg-group" style="flex:5">
<div class="seg-track">
<div class="seg-fill production" :style="{width: Math.min(project.progress_percent, 100) + '%'}"></div>
</div>
<div class="seg-info">
<span class="seg-name" :class="{active: project.current_stage === '制作'}">制作</span>
<span class="seg-stat">{{ project.progress_percent }}%</span>
</div>
</div>
<div class="seg-group" style="flex:2">
<div class="seg-track">
<div class="seg-fill post" :style="{width: postPercent + '%'}"></div>
</div>
<div class="seg-info">
<span class="seg-name" :class="{active: project.current_stage === '后期'}">后期</span>
<span class="seg-stat">{{ phasePost.completed }}/{{ phasePost.total }}</span>
</div>
</div>
</div>
<!-- 三列里程碑清单 -->
<div class="milestone-columns">
<!-- 前期 -->
<div class="milestone-col">
<div class="milestone-col-header">前期</div>
<div v-for="m in preMilestones" :key="m.id" class="milestone-item">
<el-checkbox
:model-value="m.is_completed"
@change="toggleMilestone(m)"
:disabled="!authStore.hasPermission('project:edit')"
/>
<span class="milestone-name" :class="{ completed: m.is_completed }">{{ m.name }}</span>
<el-button v-if="authStore.hasPermission('project:edit')" text type="danger" size="small" class="milestone-del"
@click="deleteMilestone(m.id)"><el-icon size="12"><Close /></el-icon></el-button>
</div>
<div v-if="authStore.hasPermission('project:edit')" class="milestone-add">
<el-input v-model="newMilestone.pre" size="small" placeholder="添加前期里程碑" @keyup.enter="addMilestone('前期', 'pre')">
<template #append>
<el-button @click="addMilestone('前期', 'pre')"><el-icon><Plus /></el-icon></el-button>
</template>
</el-input>
</div>
</div>
<!-- 制作 -->
<div class="milestone-col production-col">
<div class="milestone-col-header">制作</div>
<div class="production-ring-layout">
<div ref="progressChartRef" style="width:180px;height:180px;flex-shrink:0"></div>
<div class="production-info">
<div class="prod-info-row">
<span class="prod-info-label">已产出</span>
<span class="prod-info-value">{{ formatSecs(project.total_submitted_seconds) }}</span>
</div>
<div class="prod-info-row">
<span class="prod-info-label">目标</span>
<span class="prod-info-value">{{ formatSecs(project.target_total_seconds) }}</span>
</div>
<div class="prod-info-row">
<span class="prod-info-label">损耗</span>
<span class="prod-info-value" :style="{color: project.waste_rate > 30 ? '#FF3B30' : 'inherit'}">{{ formatSecs(project.waste_seconds) }}</span>
</div>
<div class="prod-info-row">
<span class="prod-info-label">损耗率</span>
<span class="prod-info-value" :style="{color: project.waste_rate > 30 ? '#FF3B30' : 'inherit'}">{{ project.waste_rate }}%</span>
</div>
</div>
</div>
</div>
<!-- 后期 -->
<div class="milestone-col">
<div class="milestone-col-header">后期</div>
<div v-for="m in postMilestones" :key="m.id" class="milestone-item">
<el-checkbox
:model-value="m.is_completed"
@change="toggleMilestone(m)"
:disabled="!authStore.hasPermission('project:edit')"
/>
<span class="milestone-name" :class="{ completed: m.is_completed }">{{ m.name }}</span>
<el-button v-if="authStore.hasPermission('project:edit')" text type="danger" size="small" class="milestone-del"
@click="deleteMilestone(m.id)"><el-icon size="12"><Close /></el-icon></el-button>
</div>
<div v-if="authStore.hasPermission('project:edit')" class="milestone-add">
<el-input v-model="newMilestone.post" size="small" placeholder="添加后期里程碑" @keyup.enter="addMilestone('后期', 'post')">
<template #append>
<el-button @click="addMilestone('后期', 'post')"><el-icon><Plus /></el-icon></el-button>
</template>
</el-input>
</div>
</div>
</div> </div>
<el-progress :percentage="Math.min(project.progress_percent, 100)" :stroke-width="8" :show-text="false" :color="progressColor" />
</div> </div>
<!-- 时间轴 --> <!-- 时间轴 -->
<div class="timeline-section" v-if="project.created_at && project.estimated_completion_date"> <div class="timeline-section" v-if="project.created_at && project.estimated_completion_date">
<div class="progress-label-row"> <div class="progress-label-row">
@ -208,6 +308,63 @@
</div> </div>
</el-drawer> </el-drawer>
<!-- 编辑项目对话框 -->
<el-dialog v-model="showEdit" title="编辑项目" width="560px" destroy-on-close>
<el-form :model="editForm" label-width="110px" label-position="left">
<el-form-item label="项目名称" required>
<el-input v-model="editForm.name" placeholder="输入项目名称" />
</el-form-item>
<el-form-item label="项目类型" required>
<el-select v-model="editForm.project_type" style="width:100%">
<el-option v-for="t in projectTypes" :key="t" :label="t" :value="t" />
</el-select>
</el-form-item>
<el-form-item label="项目状态">
<el-select v-model="editForm.status" style="width:100%">
<el-option label="制作中" value="制作中" />
<el-option label="已完成" value="已完成" />
<el-option label="废弃" value="废弃" />
</el-select>
<div class="field-hint" style="margin-top:4px">可将已完成项目改回制作中误触确认完成时可在此恢复</div>
</el-form-item>
<el-form-item label="负责人">
<el-select v-model="editForm.leader_id" placeholder="选择负责人" clearable style="width:100%">
<el-option v-for="u in users" :key="u.id" :label="u.name" :value="u.id" />
</el-select>
</el-form-item>
<el-form-item label="当前阶段">
<el-select v-model="editForm.current_phase" style="width:100%">
<el-option label="前期" value="前期" />
<el-option label="制作" value="制作" />
<el-option label="后期" value="后期" />
</el-select>
</el-form-item>
<el-form-item label="单集时长" required>
<div class="inline-field">
<el-input-number v-model="editForm.episode_duration_minutes" :min="0.1" :step="0.5" style="width:140px" />
<span class="field-unit">分钟/</span>
</div>
</el-form-item>
<el-form-item label="集数" required>
<div class="inline-field">
<el-input-number v-model="editForm.episode_count" :min="1" style="width:140px" />
<span class="field-unit"></span>
<span class="field-hint">目标总时长 = {{ editForm.episode_duration_minutes }} × {{ editForm.episode_count }} = {{ (editForm.episode_duration_minutes * editForm.episode_count).toFixed(1) }} 分钟</span>
</div>
</el-form-item>
<el-form-item label="预估完成日期">
<el-date-picker v-model="editForm.estimated_completion_date" value-format="YYYY-MM-DD" placeholder="选择预计交付日期" style="width:100%" />
</el-form-item>
<el-form-item v-if="editForm.project_type === '客户正式项目'" label="合同金额">
<el-input-number v-model="editForm.contract_amount" :min="0" :step="10000" :controls="false" placeholder="甲方合同总金额(元)" style="width:100%" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showEdit = false">取消</el-button>
<el-button type="primary" :loading="editing" @click="handleSaveEdit">保存</el-button>
</template>
</el-dialog>
<!-- 提交记录 --> <!-- 提交记录 -->
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
@ -236,11 +393,12 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, reactive, onMounted } from 'vue' import { ref, computed, reactive, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { projectApi, submissionApi } from '../api' import { projectApi, submissionApi, userApi } from '../api'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import * as echarts from 'echarts'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@ -249,9 +407,101 @@ const loading = ref(false)
const project = ref({}) const project = ref({})
const submissions = ref([]) const submissions = ref([])
const efficiency = ref([]) const efficiency = ref([])
const users = ref([])
const showEdit = ref(false)
const editing = ref(false)
const projectTypes = ['客户正式项目', '客户测试项目', '内部原创项目', '内部测试项目']
const typeTagMap = { '客户正式项目': 'success', '客户测试项目': 'warning', '内部原创项目': '', '内部测试项目': 'info' } const typeTagMap = { '客户正式项目': 'success', '客户测试项目': 'warning', '内部原创项目': '', '内部测试项目': 'info' }
//
const progressChartRef = ref(null)
let progressChart = null
function initProgressChart() {
if (!progressChartRef.value) return
const p = project.value
const pct = p.progress_percent || 0
const isOver = pct > 100
if (progressChart) progressChart.dispose()
progressChart = echarts.init(progressChartRef.value)
progressChart.setOption({
series: [{
type: 'pie',
radius: ['55%', '78%'],
center: ['50%', '50%'],
silent: true,
label: { show: false },
data: [
{ value: Math.min(pct, 100), itemStyle: { color: isOver ? '#FF9500' : '#3370FF' } },
{ value: Math.max(0, 100 - pct), itemStyle: { color: '#E5E6EB' } },
],
}],
graphic: [{
type: 'text',
left: 'center', top: 'center',
style: {
text: pct + '%',
fontSize: 22, fontWeight: 700,
fill: isOver ? '#FF9500' : '#3370FF',
textAlign: 'center',
},
}],
})
}
function handleProgressResize() { progressChart?.resize() }
const editForm = reactive({
name: '', project_type: '客户正式项目', status: '制作中', leader_id: null, current_phase: '制作',
episode_duration_minutes: 5, episode_count: 1,
estimated_completion_date: null, contract_amount: null,
})
const progressColor = computed(() => project.value.progress_percent > 100 ? '#FF9500' : '#3370FF') const progressColor = computed(() => project.value.progress_percent > 100 ? '#FF9500' : '#3370FF')
const stageColor = computed(() => {
const s = project.value.current_stage
if (s === '已完成') return '#34C759'
if (s === '前期') return '#8F959E'
if (s === '制作') return '#3370FF'
if (s === '后期') return '#FF9500'
return 'var(--text-primary)'
})
//
const phasePre = computed(() => project.value.phase_summary?.pre || { total: 0, completed: 0 })
const phasePost = computed(() => project.value.phase_summary?.post || { total: 0, completed: 0 })
const prePercent = computed(() => phasePre.value.total > 0 ? Math.round(phasePre.value.completed / phasePre.value.total * 100) : 0)
const postPercent = computed(() => phasePost.value.total > 0 ? Math.round(phasePost.value.completed / phasePost.value.total * 100) : 0)
const preMilestones = computed(() => (project.value.milestones || []).filter(m => m.phase === '前期'))
const postMilestones = computed(() => (project.value.milestones || []).filter(m => m.phase === '后期'))
const newMilestone = reactive({ pre: '', post: '' })
async function toggleMilestone(m) {
try {
await projectApi.toggleMilestone(m.id)
load()
} catch {}
}
async function addMilestone(phase, key) {
const name = newMilestone[key]?.trim()
if (!name) return
try {
await projectApi.addMilestone(route.params.id, { name, phase })
newMilestone[key] = ''
load()
} catch {}
}
async function deleteMilestone(id) {
try {
await ElMessageBox.confirm('确认删除此里程碑?', '删除', { type: 'warning' })
await projectApi.deleteMilestone(id)
load()
} catch {}
}
// //
const todayStr = new Date().toISOString().slice(0, 10) const todayStr = new Date().toISOString().slice(0, 10)
@ -402,6 +652,36 @@ function formatSecs(s) {
return m > 0 ? `${sign}${m}${sec > 0 ? sec + '秒' : ''}` : `${sign}${sec}` return m > 0 ? `${sign}${m}${sec > 0 ? sec + '秒' : ''}` : `${sign}${sec}`
} }
async function openEdit() {
const p = project.value
if (authStore.hasPermission('user:view') && !users.value.length) {
try { users.value = await userApi.list() } catch {}
}
Object.assign(editForm, {
name: p.name, project_type: p.project_type, status: p.status || '制作中', leader_id: p.leader_id,
current_phase: p.current_phase || '制作',
episode_duration_minutes: p.episode_duration_minutes, episode_count: p.episode_count,
estimated_completion_date: p.estimated_completion_date, contract_amount: p.contract_amount,
})
showEdit.value = true
}
async function handleSaveEdit() {
if (!editForm.name) { ElMessage.warning('请输入项目名称'); return }
editing.value = true
try {
await projectApi.update(route.params.id, {
name: editForm.name, project_type: editForm.project_type, status: editForm.status, leader_id: editForm.leader_id,
current_phase: editForm.current_phase,
episode_duration_minutes: editForm.episode_duration_minutes, episode_count: editForm.episode_count,
estimated_completion_date: editForm.estimated_completion_date, contract_amount: editForm.contract_amount,
})
ElMessage.success('项目已更新')
showEdit.value = false
load()
} finally { editing.value = false }
}
async function load() { async function load() {
loading.value = true loading.value = true
try { try {
@ -411,6 +691,12 @@ async function load() {
if (authStore.hasPermission('efficiency:view')) { if (authStore.hasPermission('efficiency:view')) {
try { efficiency.value = await projectApi.efficiency(id) } catch {} try { efficiency.value = await projectApi.efficiency(id) } catch {}
} }
if (authStore.hasPermission('user:view') && !users.value.length) {
try { users.value = await userApi.list() } catch {}
}
await nextTick()
initProgressChart()
window.addEventListener('resize', handleProgressResize)
} finally { loading.value = false } } finally { loading.value = false }
} }
@ -429,14 +715,32 @@ async function handleDelete() {
async function handleComplete() { async function handleComplete() {
try { try {
await ElMessageBox.confirm('确认将此项目标记为完成并进行结算?此操作不可撤销。', '确认完成', { type: 'warning' }) await ElMessageBox.confirm('确认将此项目标记为完成并进行结算?', '确认完成', { type: 'warning' })
await projectApi.complete(route.params.id) await projectApi.complete(route.params.id)
ElMessage.success('项目已完成') ElMessage.success('项目已完成')
load() load()
} catch {} } catch {}
} }
async function handleAbandon() {
try {
await ElMessageBox.confirm(
'确定将此项目标记为废弃?项目停止制作,全部产出将记为损耗。可在编辑中改回制作中。',
'标记废弃',
{ type: 'warning', confirmButtonText: '确定废弃', cancelButtonText: '取消' }
)
await projectApi.update(route.params.id, { status: '废弃' })
ElMessage.success('项目已标记为废弃')
load()
} catch {}
}
onMounted(load) onMounted(load)
onUnmounted(() => {
window.removeEventListener('resize', handleProgressResize)
progressChart?.dispose()
})
</script> </script>
<style scoped> <style scoped>
@ -484,15 +788,77 @@ onMounted(load)
background: var(--bg-hover); padding: 2px 8px; border-radius: 4px; display: inline-block; background: var(--bg-hover); padding: 2px 8px; border-radius: 4px; display: inline-block;
} }
.rate-badge.danger { background: #FFE8E7; color: #FF3B30; } .rate-badge.danger { background: #FFE8E7; color: #FF3B30; }
.inline-field { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.field-unit { font-size: 13px; color: var(--text-secondary); white-space: nowrap; }
.field-hint { font-size: 12px; color: var(--text-placeholder, #C0C4CC); margin-left: 4px; }
/* 分段进度条 */
.milestone-pipeline { margin-bottom: 24px; }
.segmented-bar { display: flex; gap: 6px; margin-bottom: 24px; }
.seg-group { display: flex; flex-direction: column; gap: 6px; }
.seg-track {
height: 8px; background: #E5E6EB; border-radius: 4px;
overflow: hidden; position: relative;
}
.seg-fill {
height: 100%; border-radius: 4px; transition: width 0.4s ease;
}
.seg-fill.pre { background: #8F959E; }
.seg-fill.production { background: #3370FF; }
.seg-fill.post { background: #FF9500; }
.seg-info {
display: flex; justify-content: space-between; align-items: center;
padding: 0 2px;
}
.seg-name {
font-size: 12px; font-weight: 500; color: var(--text-secondary);
transition: color 0.2s;
}
.seg-name.active { color: #3370FF; font-weight: 600; }
.seg-stat { font-size: 12px; color: var(--text-placeholder, #C0C4CC); font-weight: 600; }
.milestone-columns {
display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 20px;
}
.milestone-col {
background: #F7F8FA; border-radius: 8px; padding: 12px 14px;
}
.milestone-col-header {
font-size: 13px; font-weight: 600; color: var(--text-primary);
margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid var(--border-light, #f0f1f2);
}
.milestone-item {
display: flex; align-items: center; gap: 6px; padding: 4px 0;
}
.milestone-name { font-size: 13px; color: var(--text-primary); flex: 1; }
.milestone-name.completed { color: var(--text-placeholder, #C0C4CC); text-decoration: line-through; }
.milestone-del { opacity: 0; transition: opacity 0.15s; padding: 2px !important; }
.milestone-item:hover .milestone-del { opacity: 1; }
.milestone-add { margin-top: 8px; }
/* 制作阶段 — 圆环 + 数据 */
.production-col { display: flex; flex-direction: column; }
.production-col .milestone-col-header { margin-bottom: 4px; }
.production-ring-layout {
flex: 1; display: flex; align-items: center; gap: 12px;
}
.production-info {
flex: 1; display: flex; flex-direction: column; gap: 12px;
}
.prod-info-row {
display: flex; justify-content: space-between; align-items: baseline;
}
.prod-info-label { font-size: 12px; color: var(--text-secondary); white-space: nowrap; }
.prod-info-value { font-size: 14px; font-weight: 600; color: var(--text-primary); white-space: nowrap; }
.production-info { max-width: 140px; }
/* 进度时间轴 */ /* 进度时间轴 */
.progress-section { margin-bottom: 20px; }
.progress-label-row { .progress-label-row {
display: flex; justify-content: space-between; align-items: center; display: flex; justify-content: space-between; align-items: center;
font-size: 13px; color: var(--text-secondary); margin-bottom: 8px; font-size: 13px; color: var(--text-secondary); margin-bottom: 8px;
} }
.timeline-section { margin-top: 4px; } .timeline-section { margin-top: 4px; }
.timeline-bar-wrapper { position: relative; } .timeline-bar-wrapper { position: relative; margin-top: 24px; }
.timeline-bar { .timeline-bar {
height: 8px; background: #E5E6EB; border-radius: 4px; position: relative; overflow: visible; height: 8px; background: #E5E6EB; border-radius: 4px; position: relative; overflow: visible;
} }

View File

@ -12,6 +12,7 @@
<el-select v-model="filter.status" placeholder="状态" clearable style="width:120px" @change="load"> <el-select v-model="filter.status" placeholder="状态" clearable style="width:120px" @change="load">
<el-option label="制作中" value="制作中" /> <el-option label="制作中" value="制作中" />
<el-option label="已完成" value="已完成" /> <el-option label="已完成" value="已完成" />
<el-option label="废弃" value="废弃" />
</el-select> </el-select>
<el-select v-model="filter.project_type" placeholder="项目类型" clearable style="width:150px" @change="load"> <el-select v-model="filter.project_type" placeholder="项目类型" clearable style="width:150px" @change="load">
<el-option v-for="t in projectTypes" :key="t" :label="t" :value="t" /> <el-option v-for="t in projectTypes" :key="t" :label="t" :value="t" />
@ -34,7 +35,7 @@
</el-table-column> </el-table-column>
<el-table-column label="状态" width="90"> <el-table-column label="状态" width="90">
<template #default="{ row }"> <template #default="{ row }">
<el-tag size="small" :type="row.status === '已完成' ? 'success' : 'info'">{{ row.status }}</el-tag> <el-tag size="small" :type="row.status === '已完成' ? 'success' : row.status === '废弃' ? 'danger' : 'info'">{{ row.status }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="leader_name" label="负责人" width="90" /> <el-table-column prop="leader_name" label="负责人" width="90" />
@ -43,10 +44,8 @@
</el-table-column> </el-table-column>
<el-table-column label="进度" width="180"> <el-table-column label="进度" width="180">
<template #default="{ row }"> <template #default="{ row }">
<div class="cell-progress"> <div class="cell-stage">
<el-progress :percentage="Math.min(row.progress_percent, 100)" :stroke-width="6" :show-text="false" <span class="stage-tag" :class="'stage-' + (row.current_stage || '')">{{ stageLabel(row) }}</span>
:color="row.progress_percent > 100 ? '#FF9500' : '#3370FF'" />
<span class="progress-text">{{ row.progress_percent }}%</span>
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
@ -94,6 +93,36 @@
<el-form-item v-if="form.project_type === '客户正式项目'" label="合同金额"> <el-form-item v-if="form.project_type === '客户正式项目'" label="合同金额">
<el-input-number v-model="form.contract_amount" :min="0" :step="10000" :controls="false" placeholder="甲方合同总金额(元)" style="width:100%" /> <el-input-number v-model="form.contract_amount" :min="0" :step="10000" :controls="false" placeholder="甲方合同总金额(元)" style="width:100%" />
</el-form-item> </el-form-item>
<!-- 里程碑模板 -->
<el-form-item label="里程碑">
<div class="ms-template">
<div class="ms-phase-group">
<div class="ms-phase-label">前期</div>
<div v-for="(ms, i) in msTemplate.filter(m => m.phase === '前期')" :key="'pre'+i" class="ms-check-item">
<el-checkbox v-model="ms.checked" :label="ms.name" />
</div>
</div>
<div class="ms-phase-group">
<div class="ms-phase-label">后期</div>
<div v-for="(ms, i) in msTemplate.filter(m => m.phase === '后期')" :key="'post'+i" class="ms-check-item">
<el-checkbox v-model="ms.checked" :label="ms.name" />
</div>
</div>
<div class="ms-custom-add">
<el-input v-model="customMs.name" size="small" placeholder="自定义里程碑" style="flex:1">
<template #prepend>
<el-select v-model="customMs.phase" style="width:80px" size="small">
<el-option label="前期" value="前期" /><el-option label="后期" value="后期" />
</el-select>
</template>
<template #append>
<el-button @click="addCustomMs"><el-icon><Plus /></el-icon></el-button>
</template>
</el-input>
</div>
</div>
</el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="showCreate = false">取消</el-button> <el-button @click="showCreate = false">取消</el-button>
@ -124,6 +153,42 @@ const form = reactive({
estimated_completion_date: null, contract_amount: null, estimated_completion_date: null, contract_amount: null,
}) })
//
const defaultMsTemplate = [
{ name: '策划案', phase: '前期', sort_order: 1, checked: true },
{ name: '剧本', phase: '前期', sort_order: 2, checked: true },
{ name: '分镜', phase: '前期', sort_order: 3, checked: true },
{ name: '人设图', phase: '前期', sort_order: 4, checked: true },
{ name: '场景图', phase: '前期', sort_order: 5, checked: true },
{ name: '配音', phase: '后期', sort_order: 1, checked: true },
{ name: '音效', phase: '后期', sort_order: 2, checked: true },
{ name: '修补镜头', phase: '后期', sort_order: 3, checked: true },
{ name: '杂项', phase: '后期', sort_order: 4, checked: true },
]
const msTemplate = reactive([...defaultMsTemplate.map(m => ({...m}))])
const customMs = reactive({ name: '', phase: '前期' })
function addCustomMs() {
const name = customMs.name.trim()
if (!name) return
const phase = customMs.phase
const samePhase = msTemplate.filter(m => m.phase === phase)
const maxOrder = samePhase.length > 0 ? Math.max(...samePhase.map(m => m.sort_order)) : 0
msTemplate.push({ name, phase, sort_order: maxOrder + 1, checked: true })
customMs.name = ''
}
function stageLabel(row) {
const s = row.phase_summary
if (!s) return row.progress_percent + '%'
const stage = row.current_stage
if (stage === '前期') return `前期 ${s.pre.completed}/${s.pre.total}`
if (stage === '制作') return `制作 ${row.progress_percent}%`
if (stage === '后期') return `后期 ${s.post.completed}/${s.post.total}`
if (stage === '已完成') return '已完成'
return row.progress_percent + '%'
}
async function load() { async function load() {
loading.value = true loading.value = true
try { projects.value = await projectApi.list(filter) } finally { loading.value = false } try { projects.value = await projectApi.list(filter) } finally { loading.value = false }
@ -133,9 +198,14 @@ async function handleCreate() {
if (!form.name) { ElMessage.warning('请输入项目名称'); return } if (!form.name) { ElMessage.warning('请输入项目名称'); return }
creating.value = true creating.value = true
try { try {
await projectApi.create(form) const checkedMs = msTemplate.filter(m => m.checked).map(m => ({
name: m.name, phase: m.phase, sort_order: m.sort_order,
}))
await projectApi.create({ ...form, milestones: checkedMs })
ElMessage.success('项目已创建') ElMessage.success('项目已创建')
showCreate.value = false showCreate.value = false
// reset milestone template
msTemplate.splice(0, msTemplate.length, ...defaultMsTemplate.map(m => ({...m})))
load() load()
} finally { creating.value = false } } finally { creating.value = false }
} }
@ -157,9 +227,15 @@ onMounted(async () => {
} }
.card-body { padding: 4px 0; } .card-body { padding: 4px 0; }
.cell-bold { font-weight: 500; color: var(--text-primary); } .cell-bold { font-weight: 500; color: var(--text-primary); }
.cell-progress { display: flex; align-items: center; gap: 8px; } .cell-stage { display: flex; align-items: center; }
.cell-progress .el-progress { flex: 1; } .stage-tag {
.progress-text { font-size: 12px; font-weight: 600; color: var(--text-secondary); min-width: 36px; text-align: right; } font-size: 12px; font-weight: 600; padding: 2px 10px; border-radius: 4px;
background: var(--bg-hover); color: var(--text-secondary);
}
.stage-tag.stage-前期 { background: #F0F1F5; color: #8F959E; }
.stage-tag.stage-制作 { background: #EBF1FF; color: #3370FF; }
.stage-tag.stage-后期 { background: #FFF3E0; color: #FF9500; }
.stage-tag.stage-已完成 { background: #E8F8EE; color: #34C759; }
.rate-badge { .rate-badge {
font-size: 12px; font-weight: 600; color: var(--text-secondary); font-size: 12px; font-weight: 600; color: var(--text-secondary);
background: var(--bg-hover); padding: 2px 8px; border-radius: 4px; display: inline-block; background: var(--bg-hover); padding: 2px 8px; border-radius: 4px; display: inline-block;
@ -168,4 +244,11 @@ onMounted(async () => {
.inline-field { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .inline-field { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.field-unit { font-size: 13px; color: var(--text-secondary); white-space: nowrap; } .field-unit { font-size: 13px; color: var(--text-secondary); white-space: nowrap; }
.field-hint { font-size: 12px; color: var(--text-placeholder, #C0C4CC); margin-left: 4px; } .field-hint { font-size: 12px; color: var(--text-placeholder, #C0C4CC); margin-left: 4px; }
/* 里程碑模板 */
.ms-template { width: 100%; }
.ms-phase-group { margin-bottom: 12px; }
.ms-phase-label { font-size: 12px; font-weight: 600; color: var(--text-secondary); margin-bottom: 4px; }
.ms-check-item { padding: 2px 0; }
.ms-custom-add { margin-top: 8px; }
</style> </style>

View File

@ -5,7 +5,7 @@
<el-button type="primary" @click="showCreate = true"><el-icon><Plus /></el-icon> 新增用户</el-button> <el-button type="primary" @click="showCreate = true"><el-icon><Plus /></el-icon> 新增用户</el-button>
</div> </div>
<el-table :data="users" v-loading="loading" stripe> <el-table :data="sortedUsers" v-loading="loading" stripe>
<el-table-column label="姓名" width="100"> <el-table-column label="姓名" width="100">
<template #default="{row}"> <template #default="{row}">
<router-link :to="`/users/${row.id}/detail`" class="user-link">{{ row.name }}</router-link> <router-link :to="`/users/${row.id}/detail`" class="user-link">{{ row.name }}</router-link>
@ -90,7 +90,7 @@
</template> </template>
<script setup> <script setup>
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, computed, onMounted } from 'vue'
import { userApi, roleApi } from '../api' import { userApi, roleApi } from '../api'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
@ -99,6 +99,17 @@ const showCreate = ref(false)
const editingId = ref(null) const editingId = ref(null)
const users = ref([]) const users = ref([])
const roles = ref([]) const roles = ref([])
const ROLE_ORDER = ['超级管理员', '主管', '组长', '组员']
const sortedUsers = computed(() => {
return [...users.value].sort((a, b) => {
const ia = ROLE_ORDER.indexOf(a.role_name)
const ib = ROLE_ORDER.indexOf(b.role_name)
const ai = ia >= 0 ? ia : 999
const bi = ib >= 0 ? ib : 999
return ai - bi
})
})
const form = reactive({ const form = reactive({
username: '', password: '', name: '', phase_group: '制作', role_id: null, username: '', password: '', name: '', phase_group: '制作', role_id: null,
monthly_salary: 0, bonus: 0, social_insurance: 0, is_active: 1, monthly_salary: 0, bonus: 0, social_insurance: 0, is_active: 1,

181
之前的上下文.md Normal file
View File

@ -0,0 +1,181 @@
这个系统是否可以植入AI模型进去
如果植入AI模型AI模型可以为这个系统提供哪些功能植入AI模型的话也是以连接API的方式。
完全可以。你们的系统数据结构清晰,接入 AI API 非常适合。结合你们的业务场景(动画团队管理),以下是最有实用价值的 AI 功能方向:
一、数据分析与预测类
1. 项目风险预警
根据历史提交数据velocity、当前进度、剩余时间预测项目是否会延期
自动识别"产出骤降"等异常模式
在仪表盘展示风险等级
2. 成本预估与优化
基于历史项目数据,新建项目时自动预估总成本
分析 AI 工具 / 外包 / 人力成本结构,给出优化建议
月度成本趋势预测
3. 团队效率分析
分析每个成员的产出趋势、擅长阶段
项目人员分配建议(哪些人适合前期、哪些人制作效率高)
二、自然语言交互类
4. 智能问答助手
用自然语言查询数据:"本月哪个项目损耗率最高?"、"张三上周产出了多少?"
实现方式AI 读取系统数据后回答,不需要用户记菜单
5. 自动生成报告
周报/月报一键生成:"本月4个项目进展摘要、团队产出排名、成本概览"
项目结算时自动生成分析总结
三、流程辅助类
6. 里程碑智能建议
创建项目时根据项目类型和规模AI 建议里程碑模板
根据前期完成速度,预估中期/后期开始时间
7. 提交记录审核
检测异常提交(如单日产出异常高、重复提交)
自动分类和标注提交内容
技术实现架构
前端 ←→ 后端 API ←→ AI Service Layer ←→ Claude/GPT API
系统数据库(读取上下文)
核心是在后端加一个 AI 服务层:
# backend/routers/ai.py示意
@router.post("/api/ai/chat")
def ai_chat(question: str, db, current_user):
# 1. 根据问题意图,从数据库查出相关数据
# 2. 组装成 prompt + context 发给 AI API
# 3. 返回 AI 回答
@router.get("/api/ai/project/{id}/risk")
def project_risk(project_id, db):
# 1. 查出项目进度、提交历史、时间线
# 2. 让 AI 分析风险并返回评估
建议优先级
优先级 功能 价值 难度
P0 智能问答助手 高 中
P0 项目风险预警 高 中
P1 自动生成报告 高 低
P1 成本预估 中 中
P2 人员分配建议 中 高
P2 提交异常检测 低 中
最推荐先做智能问答 + 项目风险预警,这两个对你们团队日常管理帮助最大,且实现难度适中。要不要选一个方向开始实现?
1. 项目风险预警
根据历史提交数据velocity、当前进度、剩余时间预测项目是否会延期
在仪表盘展示风险等级
3. 团队效率分析
分析每个成员的产出趋势、擅长阶段
项目人员分配建议(哪些人适合前期、哪些人制作效率高)
4. 智能问答助手
5. 自动生成报告
我希望它每隔一段时间生成一次总结报告并自动推送到项目管理人员的手机上。你看看这个有什么比较好的实现方式吗比如说接入飞书bot
我整理了一下,这几个点是我认为比较实际且需要的功能。关于第五点,你来帮我分析一下。
自动报告 + 飞书推送 分析
你们团队用飞书的话,飞书机器人是最佳方案。有两种接入方式:
方案对比
群机器人 Webhook 自建飞书应用
接入难度 极低5分钟 中(需要飞书开放平台注册)
推送方式 只能发到群 可以私聊推送给个人
消息格式 富文本 / 卡片消息 富文本 / 卡片 / 交互式
适合场景 项目群定期播报 按角色分发不同报告
需要审核 不需要 需要企业管理员审批
推荐:先用 Webhook 群机器人 快速上线,后续再升级为自建应用。
整体架构
┌─────────────────────────────────────────────┐
│ FastAPI 后端 │
│ │
│ APScheduler (定时调度) │
│ ├── 每日 18:00 → 日报 │
│ ├── 每周五 18:00 → 周报 │
│ └── 每月1日 10:00 → 月报 │
│ │ │
│ ▼ │
│ report_service.py │
│ ├── 从数据库汇总项目/提交/成本数据 │
│ ├── 调用 AI API 生成自然语言总结 │
│ └── 格式化为飞书卡片消息 │
│ │ │
│ ▼ │
│ feishu_bot.py │
│ └── POST → 飞书 Webhook URL │
└─────────────────────────────────────────────┘
┌──────────────┐
│ 飞书项目群 │ ← 管理人员在这个群里收到推送
│ 📊 日报/周报 │
└──────────────┘
报告内容设计
日报(简短,卡片形式):
📊 AirLabs 日报 — 2026-02-13
进行中项目4 个
今日提交8 人次,总产出 3分42秒
风险项目:「品牌方 TVC」前期 2/5 未完成,距截止仅剩 19 天
👤 今日未提交:张三、李四
周报AI 生成摘要):
📋 AirLabs 周报 — 第7周
【项目进展】
• 品牌方 TVC制作阶段产出 213.9%,已超产,建议关注损耗
• AI 短剧原创 S1前期 3/5剧本和分镜已完成
• ...
【团队效率】
本周人均日产出 45秒较上周 +12%
效率最高:王五(日均 68秒
【成本概览】
本周 AI 工具支出 ¥2,400外包 ¥0
【AI 建议】
品牌方 TVC 损耗率达 113.9%,建议复盘测试流程...
关键技术选型
定时调度APScheduler — 直接集成进 FastAPI不需要额外部署 Celery/Redis
AI 生成报告:调用 Claude API — 把数据库汇总数据作为 context让 AI 生成自然语言总结 + 风险建议
飞书 Webhook一个 HTTP POST 就能发消息,核心代码非常简单:
# 示意(不是完整代码)
import httpx
FEISHU_WEBHOOK = "https://open.feishu.cn/open-apis/bot/v2/hook/xxx"
async def send_to_feishu(title, content):
card = {
"msg_type": "interactive",
"card": {
"header": {"title": {"tag": "plain_text", "content": title}},
"elements": [{"tag": "markdown", "content": content}]
}
}
async with httpx.AsyncClient() as client:
await client.post(FEISHU_WEBHOOK, json=card)
实现步骤
在飞书群创建 Webhook 机器人(飞书群设置 → 群机器人 → 自定义机器人),拿到 webhook URL
后端新增 report_service.py汇总数据 + 调 AI API 生成报告文本
后端新增 feishu_bot.py发送飞书卡片消息
main.py 集成 APScheduler配置定时任务
后端新增手动触发接口POST /api/reports/generate — 方便调试和手动生成
你觉得这个方案可以的话,我可以开始实现。你需要先在飞书群里创建一个 Webhook 机器人拿到 URL另外需要一个 AI API keyClaude 或其他)。
你现在手头有这些吗还是先让我把代码框架搭起来webhook 和 API key 后面再配?

307
项目总结文档.md Normal file
View File

@ -0,0 +1,307 @@
# AirLabs Project 项目总结文档
> 内容组 · 项目制周期 / 成本 / 产出管理系统
> 更新日期2025-02-11
---
## 1. 项目概述
### 1.1 定位
服务于**以 AI 动画/视觉内容为核心产出的内容团队**(约 30 人以内)的内部管理系统。
**核心价值**:以项目为核心,以内容提交为最小记录单位,自动汇总产出、进度、成本与损耗,支持项目完成后统一结算。
### 1.2 解决的核心问题
- 项目**实际投入成本**不可见
- 实际产出是否匹配项目目标
- 测试与损耗占比无法量化
- 项目超期难以追踪
- 项目结束后是否盈利
### 1.3 覆盖范围V1
- 内容组、内容类项目、动画/视觉内容生产
- PC 端 Web 应用预留响应式V2 适配移动端)
- **不包含**:产品研发周期、精细工时、人事绩效、审批验收、移动端专属适配
---
## 2. 技术架构
| 层级 | 技术选型 | 说明 |
|------|----------|------|
| 前端 | Vue 3 + Element Plus + Vite | 后台管理 UI |
| 后端 | Python FastAPI | 高性能 API |
| 数据库 | SQLite | 零配置MVP 适用 |
| 图表 | ECharts | 仪表盘可视化 |
| 认证 | JWT Token | 登录鉴权与权限控制 |
### 2.1 部署
- **CI/CD**Gitea Actions 自动构建
- **镜像**Docker 构建,推送到华为云 SWR
- **运行**K3s 集群,通过 Ingress 暴露服务
- **前端**:静态构建后由后端统一托管(`/` 路径返回 index.html
### 2.2 目录结构
```
AirLabs Project/
├── backend/ # FastAPI 后端
│ ├── main.py # 入口、静态文件托管、角色初始化
│ ├── models.py # ORM 模型、枚举、权限定义
│ ├── calculations.py # 成本分摊、损耗、效率、结算计算引擎
│ ├── config.py # 配置
│ ├── database.py # SQLAlchemy 引擎与会话
│ ├── auth.py # 密码哈希、JWT
│ ├── schemas.py # Pydantic 模型
│ └── routers/ # API 路由
│ ├── auth.py # 登录、me
│ ├── users.py # 用户 CRUD
│ ├── projects.py # 项目 CRUD、完成、结算、效率
│ ├── submissions.py# 内容提交 CRUD、历史
│ ├── costs.py # AI 工具、外包、人力调整、固定开支
│ ├── dashboard.py # 仪表盘
│ └── roles.py # 角色、权限
├── frontend/ # Vue 3 前端
│ └── src/
│ ├── views/ # 页面Dashboard, Projects, ProjectDetail, Submissions,
│ │ # Costs, Users, MemberDetail, Roles, Settlement, Login
│ ├── api/index.js # 接口封装
│ ├── stores/auth.js# 用户状态
│ └── router/ # 路由与权限守卫
├── k8s/ # K8s manifests
├── .gitea/workflows/ # 部署流程
└── 人员清单.md # 团队成员与薪资
```
---
## 3. 核心数据模型
### 3.1 项目Project
| 字段 | 说明 |
|------|------|
| name | 项目名称 |
| project_type | 客户正式/客户测试/内部原创/内部测试 |
| status | **制作中 / 已完成 / 废弃** |
| leader_id | 负责人 |
| current_phase | 前期/制作/后期 |
| episode_duration_minutes | 单集正片时长(分钟) |
| episode_count | 集数 |
| target_total_seconds | 目标总秒数(计算属性 = 单集×集数×60 |
| estimated_completion_date | 预估完成日期 |
| actual_completion_date | 实际完成日期 |
| contract_amount | 回款金额(客户正式项目) |
### 3.2 用户User
- 姓名、用户名、所属阶段组(前期/制作/后期)
- 角色、月薪、社保、奖金
- **日成本** = (月薪+奖金+社保) ÷ 22 个工作日
### 3.3 内容提交Submission
- 提交人、所属项目、项目阶段、工作类型(制作/测试/方案)
- 内容制作类型(内容制作/设定策划/剪辑后期/其他)
- 产出时长(分钟/秒,换算为 total_seconds
- 投入时长(小时,可选)
- 提交对象(组长/制片/内部/外部)
- 提交日期
---
## 4. 业务逻辑
### 4.1 成本结构
| 成本类型 | 计算方式 |
|----------|----------|
| **人力成本** | 按提交自动分摊:有秒数按秒数比例,无秒数按条数比例;支持 CostOverride 手动调整 |
| **AI 工具成本** | 指定项目 / 内容组整体(按产出秒数分摊)/ 手动分摊 |
| **外包成本** | 按项目直接归属 |
| **固定开支** | 办公室租金、水电费,按项目产出秒数比例分摊 |
### 4.2 损耗Waste与亏损Loss
**概念区分:**
- **损耗**:生产效率指标(测试试错 + 超产废片),盈利项目也有
- **亏损**:财务结果(总成本 > 回款),仅客户正式项目
**损耗计算:**
- **测试损耗**:工作类型为「测试」的提交秒数
- **超产损耗**:累计提交秒数 目标秒数(>0 部分)
- **废弃项目**:全部产出直接记为损耗,损耗率 100%
**损耗率** = 总损耗秒数 ÷ 目标秒数 × 100%
### 4.3 团队效率(人均基准对比法)
- 人均基准 = 目标秒数 ÷ 参与制作人数
- 超出比例 = (个人提交 人均基准) ÷ 人均基准 × 100%
### 4.4 进度计算
- 完成百分比 = 当前有秒数提交的累计秒数 ÷ 目标总秒数 × 100%
- 仅统计有产出秒数的提交(制作组 + 后期补拍)
---
## 5. 权限体系
| 角色 | 可见范围 | 典型权限 |
|------|----------|----------|
| 超级管理员 | 全量 | 全部 |
| 主管 | 所有项目 | dashboard, project CRUD+complete, submission, cost, settlement, efficiency |
| 组长 | 所负责项目 | project view+create, submission, cost, efficiency |
| 成员 | 本人提交、项目公开信息 | project view, submission |
**权限标识**`dashboard:view`, `project:view/create/edit/delete/complete`, `submission:view/create`, `cost:view/create/delete`, `user:view/manage`, `role:manage`, `settlement:view`, `efficiency:view`
---
## 6. 前端页面与路由
| 路径 | 页面 | 权限 |
|------|------|------|
| /login | 登录 | 公开 |
| /dashboard | 仪表盘 | dashboard:view |
| /projects | 项目列表 | project:view |
| /projects/:id | 项目详情、提交、损耗、进度 | project:view |
| /submissions | 提交记录 | submission:view |
| /costs | 成本管理AI/外包/固定开支/人力调整) | cost:view |
| /users | 用户管理 | user:manage |
| /users/:id/detail | 成员详情 | user:view |
| /roles | 角色管理 | role:manage |
| /settlement/:id | 项目结算 | settlement:view |
**项目详情页操作**:编辑、确认完成(制作中→已完成)、标记废弃(制作中→废弃)、查看结算(已完成)
---
## 7. 废弃项目特性(近期新增)
- **状态**`ProjectStatus.ABANDONED = "废弃"`
- **含义**:项目中途停止,不再继续制作
- **损耗**:全部产出记为损耗,损耗率 100%
- **操作**
- 项目详情页「标记废弃」按钮(仅制作中可见)
- 编辑表单中状态下拉增加「废弃」选项
- **仪表盘**:废弃项目纳入总损耗统计、成本构成统计、损耗排行
---
## 8. 人员与团队(来自 人员清单.md
- 22 人:主管 3、组长 4、组员 15、Owner 2
- 职位涵盖总导演、AI 导演、制片、动画制作、剪辑、编剧等
- 薪资与社保已录入,用于人力成本计算
---
## 9. PRD 与实现对照
| PRD 要求 | 实现情况 |
|----------|----------|
| 项目管理(创建/编辑/状态/目标) | ✅ |
| 内容提交表单 | ✅ |
| 项目详情(进度、提交、损耗) | ✅ |
| 人力/AI/外包/固定开支成本 | ✅ |
| 仪表盘 | ✅ |
| 项目结算、盈亏 | ✅ |
| 废弃项目 | ✅PRD 未写,实际补充) |
| 登录与 4 级权限 | ✅ |
| 历史修改记录(版本追溯、修改原因) | ⚠️ 部分(有 SubmissionHistory 表与 history 接口,前端展示待完善) |
| 用户管理(新增/编辑/薪资) | ✅ |
---
## 10. AI 能力模块V2 新增)
### 10.1 技术架构
```
FastAPI 后端
├── services/ai_service.py # 豆包模型调用OpenAI 兼容协议)
├── services/report_service.py # 报告数据汇总 + AI 摘要
├── services/feishu_service.py # 飞书自建应用消息推送
├── services/scheduler_service.py # APScheduler 定时任务
└── routers/reports.py # 手动触发报告 API
↓ ↓
豆包 AI (ARK) 飞书开放平台
生成报告摘要 私聊推送给管理员
```
### 10.2 功能清单
| 功能 | 说明 |
|------|------|
| 自动日报 | 每天 20:00今日提交、项目进展、风险提醒 |
| 自动周报 | 每周五 20:00项目进展、团队产出、成本概览 |
| 自动月报 | 每月1日 10:00上月完整数据、成本明细、盈亏 |
| 项目风险预警 | 仪表盘展示,规则引擎检测超期/进度/损耗/停滞 |
| 手动触发 | POST /api/reports/{daily,weekly,monthly},调试用 |
| 报告预览 | POST /api/reports/preview/{type},不推送仅预览 |
### 10.3 新增依赖
| 包 | 用途 |
|----|------|
| openai | 豆包 AI 调用(兼容协议) |
| httpx | 飞书 API 异步请求 |
| apscheduler | 定时任务调度 |
| python-dotenv | .env 配置文件 |
### 10.4 配置项
通过 `backend/.env` 配置:
- `ARK_API_KEY` — 豆包 API Key
- `ARK_MODEL` — 模型名称
- `FEISHU_APP_ID` / `FEISHU_APP_SECRET` — 飞书应用凭证
- `REPORT_RECEIVERS` — 接收人手机号(逗号分隔)
---
## 11. PRD 与实现对照(更新)
| PRD 要求 | 实现情况 |
|----------|----------|
| 项目管理(创建/编辑/状态/目标) | ✅ |
| 内容提交表单 | ✅ |
| 项目详情(进度、提交、损耗) | ✅ |
| 人力/AI/外包/固定开支成本 | ✅ |
| 仪表盘 | ✅ |
| 项目结算、盈亏 | ✅ |
| 废弃项目 | ✅PRD 未写,实际补充) |
| 登录与 4 级权限 | ✅ |
| 历史修改记录 | ⚠️ 部分 |
| 用户管理 | ✅ |
| AI 自动报告 + 飞书推送 | ✅ V2 新增 |
| 项目风险预警 | ✅ V2 新增 |
---
## 12. 已知待办 / 待完善
- **历史修改记录**:后端已有 `SubmissionHistory`,前端完整展示与 diff 对比可完善
- **数据导出**PRD 提到 V2 支持 Excel/PDF当前未实现
- **移动端适配**V2 计划
- **数据库升级**:若用户量增长,可迁移至 PostgreSQL
- **AI 智能问答**V3 计划,前端聊天页面 + 自然语言查询
- **按权限分级推送**V3 计划,不同角色收到不同报告内容
---
## 13. 默认账号
- 首次启动自动创建:`admin` / `admin123`(超级管理员)
---
*本文档用于项目交接、复盘与后续迭代参考。*