Compare commits

...

6 Commits

Author SHA1 Message Date
seaislee1209
90707005ed 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>
2026-02-13 18:36:44 +08:00
seaislee1209
0dbe00a8b4 feat: add seed_demo.py with demo data using real team members
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 18:46:22 +08:00
seaislee1209
e751418540 fix: login page now shows error message on wrong password
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 18:44:02 +08:00
seaislee1209
56ea51fb8b fix: remove 7 fake seed users, keep only 22 real team members
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 18:29:09 +08:00
seaislee1209
8aa8b805a4 feat: add total waste card to dashboard and import 22 real users
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 18:01:54 +08:00
seaislee1209
6ac44d47fb feat: 椤圭洰璇︽儏澧炲己+鎴愬憳璇︽儏椤?浠〃鐩樼泩鍒╂瑙?瑙掕壊鏉冮檺绯荤粺
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 17:41:27 +08:00
41 changed files with 4893 additions and 228 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,7 +581,62 @@ 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

@ -7,7 +7,6 @@ from jose import JWTError, jwt
from passlib.context import CryptContext
from sqlalchemy.orm import Session
from database import get_db
from models import User, UserRole
from config import SECRET_KEY, ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
@ -29,8 +28,9 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> User:
def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)):
"""从 JWT token 解析当前用户"""
from models import User
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="登录已过期,请重新登录",
@ -51,13 +51,13 @@ def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(
return user
def require_role(*roles: UserRole):
"""权限装饰器:要求当前用户具有指定角色之一"""
def role_checker(current_user: User = Depends(get_current_user)):
if current_user.role not in [r.value for r in roles] and current_user.role not in roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="权限不足"
)
def require_permission(*perms: str):
"""权限校验依赖:要求当前用户拥有指定权限中的至少一项"""
def perm_checker(current_user=Depends(get_current_user)):
if not current_user.role_ref:
raise HTTPException(status_code=403, detail="未分配角色")
user_perms = current_user.permissions or []
if not any(p in user_perms for p in perms):
raise HTTPException(status_code=403, detail="权限不足")
return current_user
return role_checker
return perm_checker

View File

@ -8,7 +8,7 @@ from collections import defaultdict
from datetime import date, timedelta
from models import (
User, Project, Submission, AIToolCost, AIToolCostAllocation,
OutsourceCost, CostOverride, WorkType, CostAllocationType
OutsourceCost, CostOverride, OverheadCost, WorkType, CostAllocationType
)
from config import WORKING_DAYS_PER_MONTH
@ -142,36 +142,69 @@ def calc_outsource_cost_for_project(project_id: int, db: Session) -> float:
return round(total, 2)
# ──────────────────────────── 固定开支分摊 ────────────────────────────
def calc_overhead_cost_for_project(project_id: int, db: Session) -> float:
"""
计算某项目分摊的固定开支办公室租金+水电费
规则按所有项目的产出秒数比例均摊
"""
total_overhead = db.query(sa_func.sum(OverheadCost.amount)).scalar() or 0
if total_overhead == 0:
return 0.0
all_secs = db.query(sa_func.sum(Submission.total_seconds)).filter(
Submission.total_seconds > 0
).scalar() or 0
proj_secs = db.query(sa_func.sum(Submission.total_seconds)).filter(
Submission.project_id == project_id,
Submission.total_seconds > 0,
).scalar() or 0
if all_secs > 0:
ratio = proj_secs / all_secs
return round(total_overhead * ratio, 2)
return 0.0
# ──────────────────────────── 损耗计算 ────────────────────────────
def calc_waste_for_project(project_id: int, db: Session) -> dict:
"""
计算项目损耗
返回: {test_waste, overproduction_waste, total_waste, waste_rate, target_seconds}
废弃项目全部产出直接记为损耗
"""
from models import ProjectStatus
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
return {}
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(
Submission.project_id == project_id,
Submission.total_seconds > 0,
).scalar() or 0
# 超产损耗
overproduction_waste = max(0, total_submitted - target)
total_waste = test_waste + overproduction_waste
waste_rate = round(total_waste / target * 100, 1) if target > 0 else 0
# 废弃项目:全部产出记为损耗
if project.status == ProjectStatus.ABANDONED:
total_waste = total_submitted
test_waste = 0.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 {
"target_seconds": target,
@ -243,7 +276,8 @@ def calc_project_settlement(project_id: int, db: Session) -> dict:
labor = calc_labor_cost_for_project(project_id, db)
ai_tool = calc_ai_tool_cost_for_project(project_id, db)
outsource = calc_outsource_cost_for_project(project_id, db)
total_cost = labor + ai_tool + outsource
overhead = calc_overhead_cost_for_project(project_id, db)
total_cost = labor + ai_tool + outsource + overhead
waste = calc_waste_for_project(project_id, db)
efficiency = calc_team_efficiency(project_id, db)
@ -254,6 +288,7 @@ def calc_project_settlement(project_id: int, db: Session) -> dict:
"labor_cost": labor,
"ai_tool_cost": ai_tool,
"outsource_cost": outsource,
"overhead_cost": overhead,
"total_cost": round(total_cost, 2),
**waste,
"team_efficiency": efficiency,

View File

@ -1,5 +1,8 @@
"""应用配置"""
import os
from dotenv import load_dotenv
load_dotenv()
# 数据库
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
# 豆包 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 —— 主入口"""
from dotenv import load_dotenv
load_dotenv()
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from database import engine, Base
from models import User, UserRole, PhaseGroup
from models import (
User, Role, PhaseGroup, BUILTIN_ROLES, COST_PERM_MIGRATION,
Project, ProjectMilestone, DEFAULT_MILESTONES
)
from auth import hash_password
import os
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# 创建所有表
Base.metadata.create_all(bind=engine)
@ -30,6 +40,8 @@ from routers.projects import router as projects_router
from routers.submissions import router as submissions_router
from routers.costs import router as costs_router
from routers.dashboard import router as dashboard_router
from routers.roles import router as roles_router
from routers.reports import router as reports_router
app.include_router(auth_router)
app.include_router(users_router)
@ -37,6 +49,8 @@ app.include_router(projects_router)
app.include_router(submissions_router)
app.include_router(costs_router)
app.include_router(dashboard_router)
app.include_router(roles_router)
app.include_router(reports_router)
# 前端静态文件
frontend_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "frontend", "dist")
@ -52,18 +66,82 @@ if os.path.exists(frontend_dir):
@app.on_event("startup")
def init_default_owner():
"""首次启动时创建默认 Owner 账号"""
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")
def init_roles_and_admin():
"""首次启动时创建内置角色和默认管理员"""
from database import SessionLocal
db = SessionLocal()
try:
if not db.query(User).filter(User.role == UserRole.OWNER).first():
# 初始化内置角色
for role_name, role_def in BUILTIN_ROLES.items():
existing = db.query(Role).filter(Role.name == role_name).first()
if not existing:
role = Role(
name=role_name,
description=role_def["description"],
permissions=role_def["permissions"],
is_system=1,
)
db.add(role)
print(f"[OK] created role: {role_name}")
db.commit()
# 迁移旧成本权限 → 细分权限
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()
if admin_role and not db.query(User).filter(User.username == "admin").first():
owner = User(
username="admin",
password_hash=hash_password("admin123"),
name="管理员",
phase_group=PhaseGroup.PRODUCTION,
role=UserRole.OWNER,
role_id=admin_role.id,
monthly_salary=0,
)
db.add(owner)

View File

@ -9,6 +9,92 @@ from database import Base
import enum
# ──────────────────────────── 权限标识符定义 ────────────────────────────
ALL_PERMISSIONS = [
# 仪表盘
("dashboard:view", "查看仪表盘", "仪表盘"),
# 项目管理
("project:view", "查看项目", "项目管理"),
("project:create", "创建项目", "项目管理"),
("project:edit", "编辑项目", "项目管理"),
("project:delete", "删除项目", "项目管理"),
("project:complete", "确认完成项目", "项目管理"),
# 内容提交
("submission:view", "查看提交记录", "内容提交"),
("submission:create", "新增提交", "内容提交"),
# 成本管理 —— 按类型细分
("cost_ai:view", "查看AI工具成本", "成本管理"),
("cost_ai:create", "录入AI工具成本", "成本管理"),
("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:manage", "管理用户", "用户与角色"),
("role:manage", "管理角色", "用户与角色"),
# 结算与效率
("settlement:view", "查看结算报告", "结算与效率"),
("efficiency:view", "查看团队效率", "结算与效率"),
]
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 = {
"超级管理员": {
"description": "系统最高权限,拥有全部功能",
"permissions": PERMISSION_KEYS[:], # 全部
},
"主管": {
"description": "管理项目和成本,不可管理用户和角色",
"permissions": [
"dashboard:view",
"project:view", "project:create", "project:edit", "project:complete",
"submission:view", "submission:create",
"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",
"settlement:view", "efficiency:view",
],
},
"组长": {
"description": "管理本组提交和查看成本",
"permissions": [
"project:view", "project:create",
"submission:view", "submission:create",
"cost_ai:view", "cost_ai:create",
"efficiency:view",
],
},
"成员": {
"description": "提交内容和查看项目",
"permissions": [
"project:view",
"submission:view", "submission:create",
],
},
}
# ──────────────────────────── 枚举定义 ────────────────────────────
class ProjectType(str, enum.Enum):
@ -21,6 +107,7 @@ class ProjectType(str, enum.Enum):
class ProjectStatus(str, enum.Enum):
IN_PROGRESS = "制作中"
COMPLETED = "已完成"
ABANDONED = "废弃"
class PhaseGroup(str, enum.Enum):
@ -29,13 +116,6 @@ class PhaseGroup(str, enum.Enum):
POST = "后期"
class UserRole(str, enum.Enum):
MEMBER = "成员"
LEADER = "组长"
SUPERVISOR = "主管"
OWNER = "Owner"
class WorkType(str, enum.Enum):
PRODUCTION = "制作"
TEST = "测试"
@ -73,6 +153,29 @@ class OutsourceType(str, enum.Enum):
FULL_EPISODE = "整集"
class OverheadCostType(str, enum.Enum):
OFFICE_RENT = "办公室租金"
UTILITIES = "水电费"
# ──────────────────────────── 角色 ────────────────────────────
class Role(Base):
__tablename__ = "roles"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(50), unique=True, nullable=False)
description = Column(String(200), nullable=True)
permissions = Column(JSON, nullable=False, default=[]) # 权限标识符列表
is_system = Column(Integer, nullable=False, default=0) # 1=内置角色不可删
created_at = Column(DateTime, server_default=func.now())
users = relationship("User", back_populates="role_ref")
def has_permission(self, perm: str) -> bool:
return perm in (self.permissions or [])
# ──────────────────────────── 用户 ────────────────────────────
class User(Base):
@ -83,20 +186,37 @@ class User(Base):
password_hash = Column(String(255), nullable=False)
name = Column(String(50), nullable=False)
phase_group = Column(SAEnum(PhaseGroup), nullable=False)
role = Column(SAEnum(UserRole), nullable=False, default=UserRole.MEMBER)
role_id = Column(Integer, ForeignKey("roles.id"), nullable=False)
monthly_salary = Column(Float, nullable=False, default=0)
bonus = Column(Float, nullable=False, default=0)
social_insurance = Column(Float, nullable=False, default=0)
is_active = Column(Integer, nullable=False, default=1)
created_at = Column(DateTime, server_default=func.now())
# 关系
role_ref = relationship("Role", back_populates="users")
submissions = relationship("Submission", back_populates="user")
led_projects = relationship("Project", back_populates="leader")
@property
def role_name(self):
return self.role_ref.name if self.role_ref else ""
@property
def permissions(self):
return self.role_ref.permissions if self.role_ref else []
def has_permission(self, perm: str) -> bool:
return self.role_ref.has_permission(perm) if self.role_ref else False
@property
def monthly_total_cost(self):
return (self.monthly_salary or 0) + (self.bonus or 0) + (self.social_insurance or 0)
@property
def daily_cost(self):
"""日成本 = 月薪 ÷ 22"""
from config import WORKING_DAYS_PER_MONTH
return round(self.monthly_salary / WORKING_DAYS_PER_MONTH, 2) if self.monthly_salary else 0
return round(self.monthly_total_cost / WORKING_DAYS_PER_MONTH, 2) if self.monthly_total_cost else 0
# ──────────────────────────── 项目 ────────────────────────────
@ -114,18 +234,17 @@ class Project(Base):
episode_count = Column(Integer, nullable=False)
estimated_completion_date = Column(Date, nullable=True)
actual_completion_date = Column(Date, nullable=True)
contract_amount = Column(Float, nullable=True) # 仅客户正式项目
contract_amount = Column(Float, nullable=True)
created_at = Column(DateTime, server_default=func.now())
# 关系
leader = relationship("User", back_populates="led_projects")
submissions = relationship("Submission", back_populates="project")
outsource_costs = relationship("OutsourceCost", back_populates="project")
ai_tool_allocations = relationship("AIToolCostAllocation", back_populates="project")
milestones = relationship("ProjectMilestone", back_populates="project", cascade="all, delete-orphan")
@property
def target_total_seconds(self):
"""目标总秒数 = 单集时长(分) × 60 × 集数"""
return int(self.episode_duration_minutes * 60 * self.episode_count)
@ -142,14 +261,13 @@ class Submission(Base):
content_type = Column(SAEnum(ContentType), nullable=False)
duration_minutes = Column(Float, nullable=True, default=0)
duration_seconds = Column(Float, nullable=True, default=0)
total_seconds = Column(Float, nullable=False, default=0) # 系统自动计算
hours_spent = Column(Float, nullable=True) # 可选:投入时长(小时)
total_seconds = Column(Float, nullable=False, default=0)
hours_spent = Column(Float, nullable=True)
submit_to = Column(SAEnum(SubmitTo), nullable=False)
description = Column(Text, nullable=True)
submit_date = Column(Date, nullable=False)
created_at = Column(DateTime, server_default=func.now())
# 关系
user = relationship("User", back_populates="submissions")
project = relationship("Project", back_populates="submissions")
history = relationship("SubmissionHistory", back_populates="submission")
@ -165,23 +283,21 @@ class AIToolCost(Base):
subscription_period = Column(SAEnum(SubscriptionPeriod), nullable=False)
amount = Column(Float, nullable=False)
allocation_type = Column(SAEnum(CostAllocationType), nullable=False)
project_id = Column(Integer, ForeignKey("projects.id"), nullable=True) # 指定项目时
project_id = Column(Integer, ForeignKey("projects.id"), nullable=True)
recorded_by = Column(Integer, ForeignKey("users.id"), nullable=False)
record_date = Column(Date, nullable=False)
created_at = Column(DateTime, server_default=func.now())
# 关系
allocations = relationship("AIToolCostAllocation", back_populates="ai_tool_cost")
class AIToolCostAllocation(Base):
"""AI 工具成本手动分摊明细"""
__tablename__ = "ai_tool_cost_allocations"
id = Column(Integer, primary_key=True, index=True)
ai_tool_cost_id = Column(Integer, ForeignKey("ai_tool_costs.id"), nullable=False)
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
percentage = Column(Float, nullable=False) # 0-100
percentage = Column(Float, nullable=False)
ai_tool_cost = relationship("AIToolCost", back_populates="allocations")
project = relationship("Project", back_populates="ai_tool_allocations")
@ -208,7 +324,6 @@ class OutsourceCost(Base):
# ──────────────────────────── 人力成本手动调整 ────────────────────────────
class CostOverride(Base):
"""管理员手动修改某人某天的成本分摊"""
__tablename__ = "cost_overrides"
id = Column(Integer, primary_key=True, index=True)
@ -224,7 +339,6 @@ class CostOverride(Base):
# ──────────────────────────── 提交历史版本 ────────────────────────────
class SubmissionHistory(Base):
"""内容提交的修改历史"""
__tablename__ = "submission_history"
id = Column(Integer, primary_key=True, index=True)
@ -236,3 +350,49 @@ class SubmissionHistory(Base):
created_at = Column(DateTime, server_default=func.now())
submission = relationship("Submission", back_populates="history")
# ──────────────────────────── 固定开支 ────────────────────────────
class OverheadCost(Base):
__tablename__ = "overhead_costs"
id = Column(Integer, primary_key=True, index=True)
cost_type = Column(SAEnum(OverheadCostType), nullable=False)
amount = Column(Float, nullable=False)
record_month = Column(String(7), nullable=False)
note = Column(Text, nullable=True)
recorded_by = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime, server_default=func.now())
# ──────────────────────────── 项目里程碑 ────────────────────────────
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]
bcrypt==4.0.1
python-multipart
openai
httpx
apscheduler
python-dotenv

View File

@ -27,8 +27,13 @@ def get_me(current_user: User = Depends(get_current_user)):
username=current_user.username,
name=current_user.name,
phase_group=current_user.phase_group.value if hasattr(current_user.phase_group, 'value') else current_user.phase_group,
role=current_user.role.value if hasattr(current_user.role, 'value') else current_user.role,
role_id=current_user.role_id,
role_name=current_user.role_name,
permissions=current_user.permissions,
monthly_salary=current_user.monthly_salary,
bonus=current_user.bonus or 0,
social_insurance=current_user.social_insurance or 0,
monthly_total_cost=current_user.monthly_total_cost,
daily_cost=current_user.daily_cost,
is_active=current_user.is_active,
created_at=current_user.created_at,

View File

@ -5,14 +5,15 @@ from typing import List, Optional
from datetime import date
from database import get_db
from models import (
User, UserRole, AIToolCost, AIToolCostAllocation, OutsourceCost,
CostOverride, SubscriptionPeriod, CostAllocationType, OutsourceType
User, AIToolCost, AIToolCostAllocation, OutsourceCost,
CostOverride, OverheadCost, SubscriptionPeriod, CostAllocationType,
OutsourceType, OverheadCostType
)
from schemas import (
AIToolCostCreate, AIToolCostOut, OutsourceCostCreate, OutsourceCostOut,
CostOverrideCreate
CostOverrideCreate, OverheadCostCreate, OverheadCostOut
)
from auth import get_current_user, require_role
from auth import get_current_user, require_permission
router = APIRouter(prefix="/api/costs", tags=["成本管理"])
@ -22,7 +23,7 @@ router = APIRouter(prefix="/api/costs", tags=["成本管理"])
@router.get("/ai-tools", response_model=List[AIToolCostOut])
def list_ai_tool_costs(
db: Session = Depends(get_db),
current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR, UserRole.LEADER))
current_user: User = Depends(require_permission("cost_ai:view"))
):
costs = db.query(AIToolCost).order_by(AIToolCost.record_date.desc()).all()
return [
@ -44,7 +45,7 @@ def list_ai_tool_costs(
def create_ai_tool_cost(
req: AIToolCostCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR, UserRole.LEADER))
current_user: User = Depends(require_permission("cost_ai:create"))
):
cost = AIToolCost(
tool_name=req.tool_name,
@ -84,7 +85,7 @@ def create_ai_tool_cost(
def delete_ai_tool_cost(
cost_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_role(UserRole.OWNER))
current_user: User = Depends(require_permission("cost_ai:delete"))
):
cost = db.query(AIToolCost).filter(AIToolCost.id == cost_id).first()
if not cost:
@ -101,7 +102,7 @@ def delete_ai_tool_cost(
def list_outsource_costs(
project_id: Optional[int] = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR, UserRole.LEADER))
current_user: User = Depends(require_permission("cost_outsource:view"))
):
q = db.query(OutsourceCost)
if project_id:
@ -123,7 +124,7 @@ def list_outsource_costs(
def create_outsource_cost(
req: OutsourceCostCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR, UserRole.LEADER))
current_user: User = Depends(require_permission("cost_outsource:create"))
):
cost = OutsourceCost(
project_id=req.project_id,
@ -150,7 +151,7 @@ def create_outsource_cost(
def delete_outsource_cost(
cost_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_role(UserRole.OWNER))
current_user: User = Depends(require_permission("cost_outsource:delete"))
):
cost = db.query(OutsourceCost).filter(OutsourceCost.id == cost_id).first()
if not cost:
@ -166,7 +167,7 @@ def delete_outsource_cost(
def create_cost_override(
req: CostOverrideCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR))
current_user: User = Depends(require_permission("cost_labor:create"))
):
override = CostOverride(
user_id=req.user_id,
@ -186,7 +187,7 @@ def list_cost_overrides(
user_id: Optional[int] = Query(None),
project_id: Optional[int] = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR))
current_user: User = Depends(require_permission("cost_labor:view"))
):
q = db.query(CostOverride)
if user_id:
@ -203,3 +204,66 @@ def list_cost_overrides(
}
for r in records
]
# ──────────────────── 固定开支(办公室租金、水电费) ────────────────────
@router.get("/overhead", response_model=List[OverheadCostOut])
def list_overhead_costs(
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("cost_overhead:view"))
):
costs = db.query(OverheadCost).order_by(OverheadCost.record_month.desc()).all()
return [
OverheadCostOut(
id=c.id,
cost_type=c.cost_type.value if hasattr(c.cost_type, 'value') else c.cost_type,
amount=c.amount,
record_month=c.record_month,
note=c.note,
recorded_by=c.recorded_by,
created_at=c.created_at,
)
for c in costs
]
@router.post("/overhead", response_model=OverheadCostOut)
def create_overhead_cost(
req: OverheadCostCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("cost_overhead:create"))
):
cost = OverheadCost(
cost_type=OverheadCostType(req.cost_type),
amount=req.amount,
record_month=req.record_month,
note=req.note,
recorded_by=current_user.id,
)
db.add(cost)
db.commit()
db.refresh(cost)
return OverheadCostOut(
id=cost.id,
cost_type=cost.cost_type.value,
amount=cost.amount,
record_month=cost.record_month,
note=cost.note,
recorded_by=cost.recorded_by,
created_at=cost.created_at,
)
@router.delete("/overhead/{cost_id}")
def delete_overhead_cost(
cost_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("cost_overhead:delete"))
):
cost = db.query(OverheadCost).filter(OverheadCost.id == cost_id).first()
if not cost:
raise HTTPException(status_code=404, detail="记录不存在")
db.delete(cost)
db.commit()
return {"message": "已删除"}

View File

@ -5,14 +5,15 @@ from sqlalchemy import func as sa_func
from datetime import date, timedelta
from database import get_db
from models import (
User, UserRole, Project, Submission, AIToolCost,
User, Project, Submission, AIToolCost,
ProjectStatus, ProjectType, WorkType
)
from auth import get_current_user, require_role
from auth import get_current_user, require_permission
from calculations import (
calc_project_settlement, calc_waste_for_project,
calc_labor_cost_for_project, calc_ai_tool_cost_for_project,
calc_outsource_cost_for_project, calc_team_efficiency
calc_outsource_cost_for_project, calc_overhead_cost_for_project,
calc_team_efficiency
)
router = APIRouter(prefix="/api", tags=["仪表盘与结算"])
@ -21,12 +22,13 @@ router = APIRouter(prefix="/api", tags=["仪表盘与结算"])
@router.get("/dashboard")
def get_dashboard(
db: Session = Depends(get_db),
current_user: User = Depends(require_role(UserRole.OWNER))
current_user: User = Depends(require_permission("dashboard:view"))
):
"""全局仪表盘数据"""
# 项目概览
active = db.query(Project).filter(Project.status == ProjectStatus.IN_PROGRESS).all()
completed = db.query(Project).filter(Project.status == ProjectStatus.COMPLETED).all()
abandoned = db.query(Project).filter(Project.status == ProjectStatus.ABANDONED).all()
# 当月日期范围
today = date.today()
@ -90,10 +92,14 @@ def get_dashboard(
"estimated_completion_date": str(p.estimated_completion_date) if p.estimated_completion_date else None,
})
# 损耗排行
# 损耗排行(含废弃项目,废弃项目全部产出记为损耗)
waste_ranking = []
for p in active + completed:
total_waste_seconds_all = 0.0
total_target_seconds_all = 0.0
for p in active + completed + abandoned:
w = calc_waste_for_project(p.id, db)
total_waste_seconds_all += w.get("total_waste_seconds", 0)
total_target_seconds_all += p.target_total_seconds or 0
if w.get("total_waste_seconds", 0) > 0:
waste_ranking.append({
"project_id": p.id,
@ -102,6 +108,7 @@ def get_dashboard(
"waste_rate": w["waste_rate"],
})
waste_ranking.sort(key=lambda x: x["waste_rate"], reverse=True)
total_waste_rate = round(total_waste_seconds_all / total_target_seconds_all * 100, 1) if total_target_seconds_all > 0 else 0
# 已结算项目
settled = []
@ -133,15 +140,18 @@ def get_dashboard(
total_labor_all = 0.0
total_ai_all = 0.0
total_outsource_all = 0.0
for p in active + completed:
total_overhead_all = 0.0
for p in active + completed + abandoned:
total_labor_all += calc_labor_cost_for_project(p.id, db)
total_ai_all += calc_ai_tool_cost_for_project(p.id, db)
total_outsource_all += calc_outsource_cost_for_project(p.id, db)
total_overhead_all += calc_overhead_cost_for_project(p.id, db)
cost_breakdown = [
{"name": "人力成本", "value": round(total_labor_all, 0)},
{"name": "AI工具", "value": round(total_ai_all, 0)},
{"name": "外包", "value": round(total_outsource_all, 0)},
{"name": "固定开支", "value": round(total_overhead_all, 0)},
]
# ── 图表数据:各项目产出对比(进行中项目) ──
@ -157,6 +167,47 @@ def get_dashboard(
"target": p.target_total_seconds,
})
# ── 盈利概览 ──
total_contract = 0.0
total_cost_completed = 0.0
for s in settled:
if s.get("contract_amount"):
total_contract += s["contract_amount"]
total_cost_completed += s.get("total_cost", 0)
total_profit = total_contract - total_cost_completed
profit_rate = round(total_profit / total_contract * 100, 1) if total_contract > 0 else 0
# 进行中项目的合同额和当前成本
in_progress_contract = 0.0
in_progress_cost = 0.0
for p in active:
if p.contract_amount:
in_progress_contract += p.contract_amount
in_progress_cost += calc_labor_cost_for_project(p.id, db) + calc_ai_tool_cost_for_project(p.id, db) + calc_outsource_cost_for_project(p.id, db) + calc_overhead_cost_for_project(p.id, db)
# 每个项目的盈亏(用于柱状图)
profit_by_project = []
for s in settled:
if s.get("contract_amount") and s["contract_amount"] > 0:
profit_by_project.append({
"name": s["project_name"],
"profit": round((s.get("contract_amount", 0) or 0) - s.get("total_cost", 0), 0),
})
profitability = {
"total_contract": round(total_contract, 0),
"total_cost": round(total_cost_completed, 0),
"total_profit": round(total_profit, 0),
"profit_rate": profit_rate,
"in_progress_contract": round(in_progress_contract, 0),
"in_progress_cost": round(in_progress_cost, 0),
"profit_by_project": profit_by_project,
}
# ── 风险预警 ──
from services.report_service import analyze_project_risks
risk_alerts = analyze_project_risks(db)
return {
"active_projects": len(active),
"completed_projects": len(completed),
@ -165,8 +216,12 @@ def get_dashboard(
"monthly_total_seconds": round(monthly_secs, 1),
"avg_daily_seconds_per_person": avg_daily,
"projects": project_summaries,
"total_waste_seconds": round(total_waste_seconds_all, 0),
"total_waste_rate": total_waste_rate,
"waste_ranking": waste_ranking,
"settled_projects": settled,
"profitability": profitability,
"risk_alerts": risk_alerts,
# 图表数据
"daily_trend": daily_trend,
"cost_breakdown": cost_breakdown,
@ -178,7 +233,7 @@ def get_dashboard(
def get_settlement(
project_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_role(UserRole.OWNER))
current_user: User = Depends(require_permission("settlement:view"))
):
"""项目结算报告"""
project = db.query(Project).filter(Project.id == project_id).first()
@ -191,7 +246,7 @@ def get_settlement(
def get_efficiency(
project_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR, UserRole.LEADER))
current_user: User = Depends(require_permission("efficiency:view"))
):
"""项目团队效率数据"""
return calc_team_efficiency(project_id, db)

View File

@ -3,13 +3,18 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import func as sa_func
from typing import List, Optional
from datetime import datetime
from database import get_db
from models import (
User, Project, Submission, UserRole, ProjectType,
ProjectStatus, PhaseGroup, WorkType
User, Project, Submission, ProjectType,
ProjectStatus, PhaseGroup, WorkType,
ProjectMilestone, DEFAULT_MILESTONES
)
from schemas import ProjectCreate, ProjectUpdate, ProjectOut
from auth import get_current_user, require_role
from schemas import (
ProjectCreate, ProjectUpdate, ProjectOut,
MilestoneOut, MilestoneCreate
)
from auth import get_current_user, require_permission
router = APIRouter(prefix="/api/projects", tags=["项目管理"])
@ -25,17 +30,60 @@ def enrich_project(p: Project, db: Session) -> ProjectOut:
target = p.target_total_seconds
progress = round(total_secs / target * 100, 1) if target > 0 else 0
# 损耗 = 测试损耗 + 超产损耗
# 损耗 = 测试损耗 + 超产损耗(排除测试秒数避免双重计数)
test_secs = db.query(sa_func.sum(Submission.total_seconds)).filter(
Submission.project_id == p.id,
Submission.work_type == WorkType.TEST
).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_rate = round(waste / target * 100, 1) if target > 0 else 0
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(
id=p.id, name=p.name,
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,
waste_seconds=round(waste, 1),
waste_rate=waste_rate,
milestones=milestones_out,
phase_summary=phase_summary,
current_stage=current_stage,
)
@ -76,7 +127,7 @@ def list_projects(
def create_project(
req: ProjectCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR))
current_user: User = Depends(require_permission("project:create"))
):
project = Project(
name=req.name,
@ -89,6 +140,17 @@ def create_project(
contract_amount=req.contract_amount,
)
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.refresh(project)
return enrich_project(project, db)
@ -111,7 +173,7 @@ def update_project(
project_id: int,
req: ProjectUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR))
current_user: User = Depends(require_permission("project:edit"))
):
p = db.query(Project).filter(Project.id == project_id).first()
if not p:
@ -121,7 +183,10 @@ def update_project(
if req.project_type is not None:
p.project_type = ProjectType(req.project_type)
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:
p.leader_id = req.leader_id
if req.current_phase is not None:
@ -141,18 +206,127 @@ def update_project(
return enrich_project(p, db)
@router.delete("/{project_id}")
def delete_project(
project_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("project:delete"))
):
"""删除项目及其关联数据"""
from models import OutsourceCost, AIToolCostAllocation, CostOverride, SubmissionHistory
p = db.query(Project).filter(Project.id == project_id).first()
if not p:
raise HTTPException(status_code=404, detail="项目不存在")
# 删除关联数据
subs = db.query(Submission).filter(Submission.project_id == project_id).all()
for s in subs:
db.query(SubmissionHistory).filter(SubmissionHistory.submission_id == s.id).delete()
db.query(Submission).filter(Submission.project_id == project_id).delete()
db.query(OutsourceCost).filter(OutsourceCost.project_id == project_id).delete()
db.query(AIToolCostAllocation).filter(AIToolCostAllocation.project_id == project_id).delete()
db.query(CostOverride).filter(CostOverride.project_id == project_id).delete()
db.query(ProjectMilestone).filter(ProjectMilestone.project_id == project_id).delete()
db.delete(p)
db.commit()
return {"message": "项目已删除"}
@router.post("/{project_id}/complete")
def complete_project(
project_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_role(UserRole.OWNER))
current_user: User = Depends(require_permission("project:complete"))
):
"""Owner 手动确认项目完成"""
p = db.query(Project).filter(Project.id == project_id).first()
if not p:
raise HTTPException(status_code=404, detail="项目不存在")
from datetime import date
from datetime import date as date_today
p.status = ProjectStatus.COMPLETED
p.actual_completion_date = date.today()
p.actual_completion_date = date_today.today()
db.commit()
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)

113
backend/routers/roles.py Normal file
View File

@ -0,0 +1,113 @@
"""角色管理路由"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List
from database import get_db
from models import Role, User, ALL_PERMISSIONS, PERMISSION_KEYS
from auth import get_current_user, require_permission
router = APIRouter(prefix="/api/roles", tags=["角色管理"])
@router.get("/permissions")
def get_all_permissions(current_user: User = Depends(get_current_user)):
"""获取系统全部权限定义(供前端勾选面板使用)"""
groups = {}
for key, label, group in ALL_PERMISSIONS:
if group not in groups:
groups[group] = []
groups[group].append({"key": key, "label": label})
return [{"group": g, "permissions": perms} for g, perms in groups.items()]
@router.get("/")
def list_roles(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
roles = db.query(Role).order_by(Role.is_system.desc(), Role.id).all()
return [
{
"id": r.id,
"name": r.name,
"description": r.description,
"permissions": r.permissions or [],
"is_system": bool(r.is_system),
"user_count": db.query(User).filter(User.role_id == r.id).count(),
"created_at": r.created_at,
}
for r in roles
]
@router.post("/")
def create_role(
req: dict,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("role:manage"))
):
name = req.get("name", "").strip()
if not name:
raise HTTPException(status_code=400, detail="角色名称不能为空")
if db.query(Role).filter(Role.name == name).first():
raise HTTPException(status_code=400, detail="角色名称已存在")
perms = [p for p in req.get("permissions", []) if p in PERMISSION_KEYS]
role = Role(
name=name,
description=req.get("description", ""),
permissions=perms,
is_system=0,
)
db.add(role)
db.commit()
db.refresh(role)
return {"id": role.id, "name": role.name, "message": "角色已创建"}
@router.put("/{role_id}")
def update_role(
role_id: int,
req: dict,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("role:manage"))
):
role = db.query(Role).filter(Role.id == role_id).first()
if not role:
raise HTTPException(status_code=404, detail="角色不存在")
name = req.get("name")
if name is not None:
name = name.strip()
existing = db.query(Role).filter(Role.name == name, Role.id != role_id).first()
if existing:
raise HTTPException(status_code=400, detail="角色名称已存在")
role.name = name
if "description" in req:
role.description = req["description"]
if "permissions" in req:
role.permissions = [p for p in req["permissions"] if p in PERMISSION_KEYS]
db.commit()
return {"message": "角色已更新"}
@router.delete("/{role_id}")
def delete_role(
role_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("role:manage"))
):
role = db.query(Role).filter(Role.id == role_id).first()
if not role:
raise HTTPException(status_code=404, detail="角色不存在")
if role.is_system:
raise HTTPException(status_code=400, detail="系统内置角色不可删除")
user_count = db.query(User).filter(User.role_id == role_id).count()
if user_count > 0:
raise HTTPException(status_code=400, detail=f"该角色下还有 {user_count} 个用户,请先转移用户再删除")
db.delete(role)
db.commit()
return {"message": "角色已删除"}

View File

@ -5,11 +5,11 @@ from typing import List, Optional
from datetime import date
from database import get_db
from models import (
User, Submission, SubmissionHistory, Project, UserRole,
User, Submission, SubmissionHistory, Project,
PhaseGroup, WorkType, ContentType, SubmitTo
)
from schemas import SubmissionCreate, SubmissionUpdate, SubmissionOut
from auth import get_current_user, require_role
from auth import get_current_user, require_permission
router = APIRouter(prefix="/api/submissions", tags=["内容提交"])
@ -44,8 +44,11 @@ def list_submissions(
current_user: User = Depends(get_current_user)
):
q = db.query(Submission)
# 成员只能看自己的
if current_user.role == UserRole.MEMBER:
# 查看项目内提交时,所有人都可见(方便横向对比)
# 全局提交列表时,没有 user:view 权限只能看自己的
if project_id:
pass # 项目内提交不做用户过滤
elif not current_user.has_permission("user:view"):
q = q.filter(Submission.user_id == current_user.id)
elif user_id:
q = q.filter(Submission.user_id == user_id)
@ -98,7 +101,7 @@ def update_submission(
submission_id: int,
req: SubmissionUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR, UserRole.LEADER))
current_user: User = Depends(require_permission("submission:create"))
):
"""高权限修改提交记录(需填写原因)"""
sub = db.query(Submission).filter(Submission.id == submission_id).first()
@ -174,7 +177,7 @@ def update_submission(
def get_submission_history(
submission_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR, UserRole.LEADER))
current_user: User = Depends(require_permission("submission:view"))
):
"""查看提交的修改历史"""
records = db.query(SubmissionHistory).filter(

View File

@ -3,9 +3,9 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List
from database import get_db
from models import User, UserRole, PhaseGroup
from models import User, Role, PhaseGroup
from schemas import UserCreate, UserUpdate, UserOut
from auth import get_current_user, hash_password, require_role
from auth import get_current_user, hash_password, require_permission
router = APIRouter(prefix="/api/users", tags=["用户管理"])
@ -14,8 +14,12 @@ def user_to_out(u: User) -> UserOut:
return UserOut(
id=u.id, username=u.username, name=u.name,
phase_group=u.phase_group.value if hasattr(u.phase_group, 'value') else u.phase_group,
role=u.role.value if hasattr(u.role, 'value') else u.role,
monthly_salary=u.monthly_salary, daily_cost=u.daily_cost,
role_id=u.role_id, role_name=u.role_name, permissions=u.permissions,
monthly_salary=u.monthly_salary,
bonus=u.bonus or 0,
social_insurance=u.social_insurance or 0,
monthly_total_cost=u.monthly_total_cost,
daily_cost=u.daily_cost,
is_active=u.is_active, created_at=u.created_at,
)
@ -23,7 +27,7 @@ def user_to_out(u: User) -> UserOut:
@router.get("/", response_model=List[UserOut])
def list_users(
db: Session = Depends(get_db),
current_user: User = Depends(require_role(UserRole.OWNER, UserRole.SUPERVISOR, UserRole.LEADER))
current_user: User = Depends(require_permission("user:view"))
):
users = db.query(User).order_by(User.created_at.desc()).all()
return [user_to_out(u) for u in users]
@ -33,7 +37,7 @@ def list_users(
def create_user(
req: UserCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_role(UserRole.OWNER))
current_user: User = Depends(require_permission("user:manage"))
):
if db.query(User).filter(User.username == req.username).first():
raise HTTPException(status_code=400, detail="用户名已存在")
@ -42,8 +46,10 @@ def create_user(
password_hash=hash_password(req.password),
name=req.name,
phase_group=PhaseGroup(req.phase_group),
role=UserRole(req.role),
role_id=req.role_id,
monthly_salary=req.monthly_salary,
bonus=req.bonus,
social_insurance=req.social_insurance,
)
db.add(user)
db.commit()
@ -56,7 +62,7 @@ def update_user(
user_id: int,
req: UserUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_role(UserRole.OWNER))
current_user: User = Depends(require_permission("user:manage"))
):
user = db.query(User).filter(User.id == user_id).first()
if not user:
@ -65,10 +71,14 @@ def update_user(
user.name = req.name
if req.phase_group is not None:
user.phase_group = PhaseGroup(req.phase_group)
if req.role is not None:
user.role = UserRole(req.role)
if req.role_id is not None:
user.role_id = req.role_id
if req.monthly_salary is not None:
user.monthly_salary = req.monthly_salary
if req.bonus is not None:
user.bonus = req.bonus
if req.social_insurance is not None:
user.social_insurance = req.social_insurance
if req.is_active is not None:
user.is_active = req.is_active
db.commit()

View File

@ -23,15 +23,19 @@ class UserCreate(BaseModel):
password: str
name: str
phase_group: str # 前期/制作/后期
role: str = "成员"
role_id: int
monthly_salary: float = 0
bonus: float = 0
social_insurance: float = 0
class UserUpdate(BaseModel):
name: Optional[str] = None
phase_group: Optional[str] = None
role: Optional[str] = None
role_id: Optional[int] = None
monthly_salary: Optional[float] = None
bonus: Optional[float] = None
social_insurance: Optional[float] = None
is_active: Optional[int] = None
@ -40,8 +44,13 @@ class UserOut(BaseModel):
username: str
name: str
phase_group: str
role: str
role_id: int
role_name: str
permissions: List[str] = []
monthly_salary: float
bonus: float
social_insurance: float
monthly_total_cost: float
daily_cost: float
is_active: int
created_at: Optional[datetime] = None
@ -52,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):
name: str
project_type: str
@ -61,6 +87,7 @@ class ProjectCreate(BaseModel):
episode_count: int
estimated_completion_date: Optional[date] = None
contract_amount: Optional[float] = None
milestones: Optional[List[dict]] = None # [{"name", "phase", "sort_order"}]
class ProjectUpdate(BaseModel):
@ -96,6 +123,10 @@ class ProjectOut(BaseModel):
progress_percent: Optional[float] = 0
waste_seconds: Optional[float] = 0
waste_rate: Optional[float] = 0
# 里程碑
milestones: Optional[List[MilestoneOut]] = []
phase_summary: Optional[dict] = None
current_stage: Optional[str] = None
class Config:
from_attributes = True
@ -214,6 +245,28 @@ class CostOverrideCreate(BaseModel):
reason: Optional[str] = None
# ──────────────────────────── 固定开支 ────────────────────────────
class OverheadCostCreate(BaseModel):
cost_type: str # 办公室租金/水电费
amount: float
record_month: str # YYYY-MM
note: Optional[str] = None
class OverheadCostOut(BaseModel):
id: int
cost_type: str
amount: float
record_month: str
note: Optional[str] = None
recorded_by: int
created_at: Optional[datetime] = None
class Config:
from_attributes = True
# ──────────────────────────── 仪表盘 ────────────────────────────
class DashboardSummary(BaseModel):

View File

@ -14,22 +14,35 @@ def seed():
db.execute(table.delete())
db.commit()
# ── 初始化内置角色 ──
roles = {}
for role_name, role_def in BUILTIN_ROLES.items():
role = Role(
name=role_name,
description=role_def["description"],
permissions=role_def["permissions"],
is_system=1,
)
db.add(role)
roles[role_name] = role
db.flush()
# ── 用户 ──
users = [
User(username="admin", password_hash=hash_password("admin123"),
name="老板", phase_group=PhaseGroup.PRODUCTION, role=UserRole.OWNER, monthly_salary=0),
name="老板", phase_group=PhaseGroup.PRODUCTION, role_id=roles["超级管理员"].id, monthly_salary=0),
User(username="zhangsan", password_hash=hash_password("123456"),
name="张三", phase_group=PhaseGroup.PRE, role=UserRole.LEADER, monthly_salary=15000),
name="张三", phase_group=PhaseGroup.PRE, role_id=roles["组长"].id, monthly_salary=15000),
User(username="lisi", password_hash=hash_password("123456"),
name="李四", phase_group=PhaseGroup.PRODUCTION, role=UserRole.LEADER, monthly_salary=18000),
name="李四", phase_group=PhaseGroup.PRODUCTION, role_id=roles["组长"].id, monthly_salary=18000),
User(username="wangwu", password_hash=hash_password("123456"),
name="王五", phase_group=PhaseGroup.PRODUCTION, role=UserRole.MEMBER, monthly_salary=12000),
name="王五", phase_group=PhaseGroup.PRODUCTION, role_id=roles["成员"].id, monthly_salary=12000),
User(username="zhaoliu", password_hash=hash_password("123456"),
name="赵六", phase_group=PhaseGroup.PRODUCTION, role=UserRole.MEMBER, monthly_salary=12000),
name="赵六", phase_group=PhaseGroup.PRODUCTION, role_id=roles["成员"].id, monthly_salary=12000),
User(username="sunqi", password_hash=hash_password("123456"),
name="孙七", phase_group=PhaseGroup.POST, role=UserRole.MEMBER, monthly_salary=13000),
name="孙七", phase_group=PhaseGroup.POST, role_id=roles["成员"].id, monthly_salary=13000),
User(username="producer", password_hash=hash_password("123456"),
name="陈制片", phase_group=PhaseGroup.PRODUCTION, role=UserRole.SUPERVISOR, monthly_salary=20000),
name="陈制片", phase_group=PhaseGroup.PRODUCTION, role_id=roles["主管"].id, monthly_salary=20000),
]
db.add_all(users)
db.flush()
@ -69,7 +82,6 @@ def seed():
base_date = date.today() - timedelta(days=14)
submissions = []
# 张三(前期组)给项目 A 和 D 做前期
for i in range(5):
d = base_date + timedelta(days=i)
submissions.append(Submission(
@ -89,10 +101,9 @@ def seed():
submit_date=d,
))
# 李四(制作组组长)主要做项目 A
for i in range(10):
d = base_date + timedelta(days=i)
secs = 45 + (i % 3) * 15 # 45-75秒
secs = 45 + (i % 3) * 15
wt = WorkType.TEST if i < 2 else WorkType.PRODUCTION
submissions.append(Submission(
user_id=lisi.id, project_id=proj_a.id,
@ -103,10 +114,9 @@ def seed():
submit_date=d,
))
# 王五(制作组)做项目 A 和 B
for i in range(8):
d = base_date + timedelta(days=i)
secs = 30 + (i % 4) * 20 # 30-90秒
secs = 30 + (i % 4) * 20
submissions.append(Submission(
user_id=wangwu.id, project_id=proj_a.id,
project_phase=PhaseGroup.PRODUCTION, work_type=WorkType.PRODUCTION,
@ -127,10 +137,9 @@ def seed():
submit_date=d,
))
# 赵六(制作组)做项目 A
for i in range(10):
d = base_date + timedelta(days=i)
secs = 50 + (i % 2) * 30 # 50-80秒
secs = 50 + (i % 2) * 30
wt = WorkType.TEST if i < 1 else WorkType.PRODUCTION
submissions.append(Submission(
user_id=zhaoliu.id, project_id=proj_a.id,
@ -141,7 +150,6 @@ def seed():
submit_date=d,
))
# 孙七(后期组)剪辑
for i in range(3):
d = base_date + timedelta(days=i + 10)
submissions.append(Submission(
@ -151,7 +159,6 @@ def seed():
submit_to=SubmitTo.PRODUCER, description=f"{i+1}集粗剪完成",
submit_date=d,
))
# 后期补拍
submissions.append(Submission(
user_id=sunqi.id, project_id=proj_a.id,
project_phase=PhaseGroup.POST, work_type=WorkType.PRODUCTION,
@ -163,7 +170,6 @@ def seed():
db.add_all(submissions)
# ── AI 工具成本 ──
db.add(AIToolCost(
tool_name="Midjourney", subscription_period=SubscriptionPeriod.MONTHLY,
amount=200, allocation_type=CostAllocationType.TEAM,
@ -176,7 +182,6 @@ def seed():
recorded_by=producer.id, record_date=date.today().replace(day=1),
))
# ── 外包成本 ──
db.add(OutsourceCost(
project_id=proj_a.id, outsource_type=OutsourceType.ANIMATION,
episode_start=10, episode_end=13, amount=20000,
@ -185,6 +190,7 @@ def seed():
db.commit()
print("[OK] seed data generated")
print(f" - roles: {len(roles)}")
print(f" - users: {len(users)}")
print(f" - projects: 4")
print(f" - submissions: {len(submissions)}")

362
backend/seed_demo.py Normal file
View File

@ -0,0 +1,362 @@
"""补充演示数据 - 只添加项目/提交/成本,不动用户和角色"""
from datetime import date, timedelta
from database import SessionLocal, engine
from models import *
db = SessionLocal()
def get_user(username):
u = db.query(User).filter(User.username == username).first()
if not u:
print(f" [WARN] user '{username}' not found")
return u
def seed_demo():
# 清除旧的项目相关数据(不动 users 和 roles
db.query(SubmissionHistory).delete()
db.query(Submission).delete()
db.query(OutsourceCost).delete()
db.query(CostOverride).delete()
db.query(AIToolCost).delete()
db.query(OverheadCost).delete()
db.query(Project).delete()
db.commit()
print("[1] Cleared old project data")
# 获取真实用户
huhaonan = get_user("huhaonan") # 主管/总导演
dengqingrui = get_user("dengqingrui") # 主管/AI导演
qiushaohui = get_user("qiushaohui") # 主管/制片
chenbaodan = get_user("chenbaodan") # 组长/动画制作
maruoqing = get_user("maruoqing") # 组长/AI导演
weichunli = get_user("weichunli") # 组长/AI导演
panziyan = get_user("panziyan") # 组长/剪辑
daixiaoqian = get_user("daixiaoqian") # 组员/动画制作
tanruping = get_user("tanruping") # 组员/动画制作
zhengyiqing = get_user("zhengyiqing") # 组员/动画制作
huangxuewen = get_user("huangxuewen") # 组员/动画制作
liushiqi = get_user("liushiqi") # 组员/动画制作
daiwei = get_user("daiwei") # 组员/动画制作
huangrongying = get_user("huangrongying") # 组员/编剧
jiahaozheng = get_user("jiahaozheng") # 组员/剪辑
wangyansen = get_user("wangyansen") # 组员/剪辑
huangqiuxia = get_user("huangqiuxia") # 组员/动画制作
lijing = get_user("lijing") # 组员/动画制作
yemeilian = get_user("yemeilian") # 组员/动画制作
chenxuanying = get_user("chenxuanying") # 组员/动画制作
# ── 项目 ──
proj_a = Project(
name="星际漫游 第一季", project_type=ProjectType.CLIENT_FORMAL,
leader_id=huhaonan.id, current_phase=PhaseGroup.PRODUCTION,
episode_duration_minutes=5, episode_count=13,
estimated_completion_date=date.today() + timedelta(days=60),
contract_amount=100000,
)
proj_b = Project(
name="品牌方 TVC 宣传片", project_type=ProjectType.CLIENT_FORMAL,
leader_id=dengqingrui.id, current_phase=PhaseGroup.PRODUCTION,
episode_duration_minutes=1, episode_count=3,
estimated_completion_date=date.today() + timedelta(days=20),
contract_amount=50000,
)
proj_c = Project(
name="甲方风格测试", project_type=ProjectType.CLIENT_TEST,
leader_id=maruoqing.id, current_phase=PhaseGroup.PRE,
episode_duration_minutes=1, episode_count=1,
)
proj_d = Project(
name="AI 短剧原创 S1", project_type=ProjectType.INTERNAL_ORIGINAL,
leader_id=weichunli.id, current_phase=PhaseGroup.PRE,
episode_duration_minutes=8, episode_count=6,
estimated_completion_date=date.today() + timedelta(days=90),
)
db.add_all([proj_a, proj_b, proj_c, proj_d])
db.flush()
print("[2] Created 4 projects")
# ── 内容提交(模拟近 20 天的数据) ──
base = date.today() - timedelta(days=20)
subs = []
# --- 项目A星际漫游 ---
# 黄溶莹 - 编剧 - 前期方案
for i in range(6):
d = base + timedelta(days=i)
subs.append(Submission(
user_id=huangrongying.id, project_id=proj_a.id,
project_phase=PhaseGroup.PRE, work_type=WorkType.PLAN,
content_type=ContentType.DESIGN, total_seconds=0,
submit_to=SubmitTo.INTERNAL, description=f"{i+1}集剧本初稿",
submit_date=d,
))
# 陈保丹 - 组长 - 动画制作
for i in range(12):
d = base + timedelta(days=i + 3)
secs = 55 + (i % 3) * 20
wt = WorkType.TEST if i < 2 else WorkType.PRODUCTION
subs.append(Submission(
user_id=chenbaodan.id, project_id=proj_a.id,
project_phase=PhaseGroup.PRODUCTION, work_type=wt,
content_type=ContentType.ANIMATION, total_seconds=secs,
duration_minutes=secs // 60, duration_seconds=secs % 60,
submit_to=SubmitTo.INTERNAL, description=f"第1集场景{i+1}动画",
submit_date=d,
))
# 代晓倩 - 动画制作
for i in range(10):
d = base + timedelta(days=i + 2)
secs = 40 + (i % 4) * 15
subs.append(Submission(
user_id=daixiaoqian.id, project_id=proj_a.id,
project_phase=PhaseGroup.PRODUCTION, work_type=WorkType.PRODUCTION,
content_type=ContentType.ANIMATION, total_seconds=secs,
duration_minutes=secs // 60, duration_seconds=secs % 60,
submit_to=SubmitTo.LEADER, description=f"第2集片段{i+1}",
submit_date=d,
))
# 谭如平 - 动画制作
for i in range(8):
d = base + timedelta(days=i + 4)
secs = 35 + (i % 3) * 25
wt = WorkType.TEST if i == 0 else WorkType.PRODUCTION
subs.append(Submission(
user_id=tanruping.id, project_id=proj_a.id,
project_phase=PhaseGroup.PRODUCTION, work_type=wt,
content_type=ContentType.ANIMATION, total_seconds=secs,
duration_minutes=secs // 60, duration_seconds=secs % 60,
submit_to=SubmitTo.LEADER, description=f"第3集镜头{i+1}",
submit_date=d,
))
# 郑奕晴 - 动画制作
for i in range(9):
d = base + timedelta(days=i + 3)
secs = 45 + (i % 2) * 30
subs.append(Submission(
user_id=zhengyiqing.id, project_id=proj_a.id,
project_phase=PhaseGroup.PRODUCTION, work_type=WorkType.PRODUCTION,
content_type=ContentType.ANIMATION, total_seconds=secs,
duration_minutes=secs // 60, duration_seconds=secs % 60,
submit_to=SubmitTo.LEADER, description=f"第4集场景动画{i+1}",
submit_date=d,
))
# 黄雪雯 - 动画制作
for i in range(7):
d = base + timedelta(days=i + 5)
secs = 30 + (i % 3) * 20
subs.append(Submission(
user_id=huangxuewen.id, project_id=proj_a.id,
project_phase=PhaseGroup.PRODUCTION, work_type=WorkType.PRODUCTION,
content_type=ContentType.ANIMATION, total_seconds=secs,
duration_minutes=secs // 60, duration_seconds=secs % 60,
submit_to=SubmitTo.LEADER, description=f"第5集片段{i+1}",
submit_date=d,
))
# 潘梓彦 - 剪辑 - 后期
for i in range(4):
d = base + timedelta(days=i + 14)
subs.append(Submission(
user_id=panziyan.id, project_id=proj_a.id,
project_phase=PhaseGroup.POST, work_type=WorkType.PRODUCTION,
content_type=ContentType.EDITING, total_seconds=0,
submit_to=SubmitTo.PRODUCER, description=f"{i+1}集粗剪",
submit_date=d,
))
# 贾浩正 - 剪辑
for i in range(3):
d = base + timedelta(days=i + 15)
subs.append(Submission(
user_id=jiahaozheng.id, project_id=proj_a.id,
project_phase=PhaseGroup.POST, work_type=WorkType.PRODUCTION,
content_type=ContentType.EDITING, total_seconds=0,
submit_to=SubmitTo.PRODUCER, description=f"{i+5}集粗剪",
submit_date=d,
))
# --- 项目B品牌方 TVC ---
# 马若情 - AI导演
for i in range(6):
d = base + timedelta(days=i + 5)
secs = 20 + i * 10
subs.append(Submission(
user_id=maruoqing.id, project_id=proj_b.id,
project_phase=PhaseGroup.PRODUCTION, work_type=WorkType.PRODUCTION,
content_type=ContentType.ANIMATION, total_seconds=secs,
duration_minutes=secs // 60, duration_seconds=secs % 60,
submit_to=SubmitTo.INTERNAL, description=f"TVC 片段{i+1}",
submit_date=d,
))
# 刘诗琪 - 动画制作
for i in range(5):
d = base + timedelta(days=i + 7)
secs = 15 + (i % 3) * 10
subs.append(Submission(
user_id=liushiqi.id, project_id=proj_b.id,
project_phase=PhaseGroup.PRODUCTION, work_type=WorkType.PRODUCTION,
content_type=ContentType.ANIMATION, total_seconds=secs,
duration_minutes=secs // 60, duration_seconds=secs % 60,
submit_to=SubmitTo.LEADER, description=f"TVC 补充镜头{i+1}",
submit_date=d,
))
# 王炎森 - 剪辑
for i in range(3):
d = base + timedelta(days=i + 13)
subs.append(Submission(
user_id=wangyansen.id, project_id=proj_b.id,
project_phase=PhaseGroup.POST, work_type=WorkType.PRODUCTION,
content_type=ContentType.EDITING, total_seconds=0,
submit_to=SubmitTo.PRODUCER, description=f"TVC 第{i+1}版剪辑",
submit_date=d,
))
# --- 项目C甲方风格测试 ---
for i in range(3):
d = base + timedelta(days=i + 1)
subs.append(Submission(
user_id=huangrongying.id, project_id=proj_c.id,
project_phase=PhaseGroup.PRE, work_type=WorkType.PLAN,
content_type=ContentType.DESIGN, total_seconds=0,
submit_to=SubmitTo.INTERNAL, description=f"风格方案{i+1}",
submit_date=d,
))
for i in range(4):
d = base + timedelta(days=i + 4)
secs = 10 + i * 5
subs.append(Submission(
user_id=daiwei.id, project_id=proj_c.id,
project_phase=PhaseGroup.PRODUCTION, work_type=WorkType.TEST,
content_type=ContentType.ANIMATION, total_seconds=secs,
duration_minutes=secs // 60, duration_seconds=secs % 60,
submit_to=SubmitTo.INTERNAL, description=f"风格测试片段{i+1}",
submit_date=d,
))
# --- 项目DAI 短剧原创 ---
for i in range(5):
d = base + timedelta(days=i)
subs.append(Submission(
user_id=huangrongying.id, project_id=proj_d.id,
project_phase=PhaseGroup.PRE, work_type=WorkType.PLAN,
content_type=ContentType.DESIGN, total_seconds=0,
submit_to=SubmitTo.INTERNAL, description=f"原创剧本第{i+1}集大纲",
submit_date=d,
))
for i in range(6):
d = base + timedelta(days=i + 8)
secs = 60 + (i % 3) * 25
subs.append(Submission(
user_id=weichunli.id, project_id=proj_d.id,
project_phase=PhaseGroup.PRODUCTION, work_type=WorkType.PRODUCTION,
content_type=ContentType.ANIMATION, total_seconds=secs,
duration_minutes=secs // 60, duration_seconds=secs % 60,
submit_to=SubmitTo.INTERNAL, description=f"原创第1集片段{i+1}",
submit_date=d,
))
for i in range(5):
d = base + timedelta(days=i + 10)
secs = 50 + (i % 2) * 35
subs.append(Submission(
user_id=huangqiuxia.id, project_id=proj_d.id,
project_phase=PhaseGroup.PRODUCTION, work_type=WorkType.PRODUCTION,
content_type=ContentType.ANIMATION, total_seconds=secs,
duration_minutes=secs // 60, duration_seconds=secs % 60,
submit_to=SubmitTo.LEADER, description=f"原创第2集动画{i+1}",
submit_date=d,
))
for i in range(4):
d = base + timedelta(days=i + 12)
secs = 45 + i * 15
subs.append(Submission(
user_id=lijing.id, project_id=proj_d.id,
project_phase=PhaseGroup.PRODUCTION, work_type=WorkType.PRODUCTION,
content_type=ContentType.ANIMATION, total_seconds=secs,
duration_minutes=secs // 60, duration_seconds=secs % 60,
submit_to=SubmitTo.LEADER, description=f"原创第3集片段{i+1}",
submit_date=d,
))
for i in range(3):
d = base + timedelta(days=i + 14)
secs = 40 + i * 20
subs.append(Submission(
user_id=yemeilian.id, project_id=proj_d.id,
project_phase=PhaseGroup.PRODUCTION, work_type=WorkType.PRODUCTION,
content_type=ContentType.ANIMATION, total_seconds=secs,
duration_minutes=secs // 60, duration_seconds=secs % 60,
submit_to=SubmitTo.LEADER, description=f"原创第4集动画{i+1}",
submit_date=d,
))
for i in range(3):
d = base + timedelta(days=i + 15)
secs = 55 + (i % 2) * 20
subs.append(Submission(
user_id=chenxuanying.id, project_id=proj_d.id,
project_phase=PhaseGroup.PRODUCTION, work_type=WorkType.PRODUCTION,
content_type=ContentType.ANIMATION, total_seconds=secs,
duration_minutes=secs // 60, duration_seconds=secs % 60,
submit_to=SubmitTo.LEADER, description=f"原创第5集场景{i+1}",
submit_date=d,
))
db.add_all(subs)
print(f"[3] Created {len(subs)} submissions")
# ── AI 工具成本 ──
db.add(AIToolCost(
tool_name="Midjourney", subscription_period=SubscriptionPeriod.MONTHLY,
amount=200, allocation_type=CostAllocationType.TEAM,
recorded_by=qiushaohui.id, record_date=date.today().replace(day=1),
))
db.add(AIToolCost(
tool_name="Runway", subscription_period=SubscriptionPeriod.MONTHLY,
amount=600, allocation_type=CostAllocationType.PROJECT,
project_id=proj_a.id,
recorded_by=qiushaohui.id, record_date=date.today().replace(day=1),
))
db.add(AIToolCost(
tool_name="ChatGPT Plus", subscription_period=SubscriptionPeriod.MONTHLY,
amount=150, allocation_type=CostAllocationType.TEAM,
recorded_by=qiushaohui.id, record_date=date.today().replace(day=1),
))
print("[4] Created 3 AI tool costs")
# ── 外包成本 ──
db.add(OutsourceCost(
project_id=proj_a.id, outsource_type=OutsourceType.ANIMATION,
episode_start=10, episode_end=13, amount=20000,
recorded_by=qiushaohui.id, record_date=date.today() - timedelta(days=5),
))
print("[5] Created 1 outsource cost")
# ── 固定开支 ──
db.add(OverheadCost(
cost_type=OverheadCostType.OFFICE_RENT,
amount=8000, record_month=date.today().strftime("%Y-%m"),
recorded_by=qiushaohui.id, note="办公室月租",
))
db.add(OverheadCost(
cost_type=OverheadCostType.UTILITIES,
amount=500, record_month=date.today().strftime("%Y-%m"),
recorded_by=qiushaohui.id, note="水电费",
))
print("[6] Created 2 overhead costs")
db.commit()
print("\n[DONE] Demo data seeded successfully!")
print(f" Projects: 4")
print(f" Submissions: {len(subs)}")
print(f" AI tools: 3, Outsource: 1, Overhead: 2")
if __name__ == "__main__":
seed_demo()

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

@ -24,7 +24,9 @@ api.interceptors.response.use(
if (err.response?.status === 401) {
const isOnLogin = window.location.pathname === '/login'
localStorage.removeItem('token')
if (!isOnLogin) {
if (isOnLogin) {
ElMessage.error(msg || '用户名或密码错误')
} else {
router.push('/login')
ElMessage.error('登录已过期,请重新登录')
}
@ -57,9 +59,14 @@ export const projectApi = {
create: (data) => api.post('/projects/', data),
update: (id, data) => api.put(`/projects/${id}`, data),
get: (id) => api.get(`/projects/${id}`),
delete: (id) => api.delete(`/projects/${id}`),
complete: (id) => api.post(`/projects/${id}/complete`),
settlement: (id) => api.get(`/projects/${id}/settlement`),
efficiency: (id) => api.get(`/projects/${id}/efficiency`),
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}`),
}
// ── 内容提交 ──
@ -80,6 +87,18 @@ export const costApi = {
deleteOutsource: (id) => api.delete(`/costs/outsource/${id}`),
createOverride: (data) => api.post('/costs/overrides', data),
listOverrides: (params) => api.get('/costs/overrides', { params }),
listOverhead: () => api.get('/costs/overhead'),
createOverhead: (data) => api.post('/costs/overhead', data),
deleteOverhead: (id) => api.delete(`/costs/overhead/${id}`),
}
// ── 角色 ──
export const roleApi = {
list: () => api.get('/roles/'),
create: (data) => api.post('/roles/', data),
update: (id, data) => api.put(`/roles/${id}`, data),
delete: (id) => api.delete(`/roles/${id}`),
permissions: () => api.get('/roles/permissions'),
}
// ── 仪表盘 ──

View File

@ -19,7 +19,7 @@
:to="item.path"
class="nav-item"
:class="{ active: isActive(item.path) }"
v-show="!item.role || hasRole(item.role)"
v-show="!item.perm || (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>
<span v-show="!isCollapsed" class="nav-label">{{ item.label }}</span>
@ -32,7 +32,7 @@
<div class="user-avatar">{{ authStore.user?.name?.[0] || '?' }}</div>
<div class="user-meta">
<div class="user-name">{{ authStore.user?.name }}</div>
<div class="user-role">{{ authStore.user?.role }}</div>
<div class="user-role">{{ authStore.user?.role_name }}</div>
</div>
</div>
</div>
@ -69,11 +69,12 @@ const authStore = useAuthStore()
const isCollapsed = ref(false)
const menuItems = [
{ path: '/dashboard', label: '仪表盘', icon: 'Odometer', role: 'Owner' },
{ path: '/projects', label: '项目管理', icon: 'FolderOpened' },
{ path: '/submissions', label: '内容提交', icon: 'EditPen' },
{ path: '/costs', label: '成本管理', icon: 'Money', role: 'leader+' },
{ path: '/users', label: '用户管理', icon: 'User', role: 'Owner' },
{ path: '/dashboard', label: '仪表盘', icon: 'Odometer', perm: 'dashboard:view' },
{ path: '/projects', label: '项目管理', icon: 'FolderOpened', perm: 'project:view' },
{ path: '/submissions', label: '内容提交', icon: 'EditPen', perm: 'submission:view' },
{ path: '/costs', label: '成本管理', icon: 'Money', perm: ['cost_ai:view', 'cost_outsource:view', 'cost_overhead:view', 'cost_labor:view'] },
{ path: '/users', label: '用户管理', icon: 'User', perm: 'user:manage' },
{ path: '/roles', label: '角色管理', icon: 'Lock', perm: 'role:manage' },
]
const titleMap = {
@ -82,6 +83,7 @@ const titleMap = {
'/submissions': '内容提交',
'/costs': '成本管理',
'/users': '用户管理',
'/roles': '角色管理',
}
const currentTitle = computed(() => {
@ -94,12 +96,6 @@ function isActive(path) {
return route.path === path || route.path.startsWith(path + '/')
}
function hasRole(role) {
if (role === 'Owner') return authStore.isOwner()
if (role === 'leader+') return authStore.isLeaderOrAbove()
return true
}
onMounted(async () => {
if (authStore.token && !authStore.user) {
await authStore.fetchUser()

View File

@ -5,15 +5,17 @@ const routes = [
{
path: '/',
component: () => import('../components/Layout.vue'),
redirect: '/dashboard',
redirect: '/projects',
children: [
{ path: 'dashboard', name: 'Dashboard', component: () => import('../views/Dashboard.vue'), meta: { roles: ['Owner'] } },
{ path: 'projects', name: 'Projects', component: () => import('../views/Projects.vue') },
{ path: 'projects/:id', name: 'ProjectDetail', component: () => import('../views/ProjectDetail.vue') },
{ path: 'submissions', name: 'Submissions', component: () => import('../views/Submissions.vue') },
{ path: 'costs', name: 'Costs', component: () => import('../views/Costs.vue'), meta: { roles: ['Owner', '主管', '组长'] } },
{ path: 'users', name: 'Users', component: () => import('../views/Users.vue'), meta: { roles: ['Owner'] } },
{ path: 'settlement/:id', name: 'Settlement', component: () => import('../views/Settlement.vue'), meta: { roles: ['Owner'] } },
{ path: 'dashboard', name: 'Dashboard', component: () => import('../views/Dashboard.vue'), meta: { perm: 'dashboard:view' } },
{ path: 'projects', name: 'Projects', component: () => import('../views/Projects.vue'), meta: { perm: 'project:view' } },
{ path: 'projects/:id', name: 'ProjectDetail', component: () => import('../views/ProjectDetail.vue'), meta: { perm: 'project:view' } },
{ path: 'submissions', name: 'Submissions', component: () => import('../views/Submissions.vue'), meta: { perm: 'submission:view' } },
{ path: 'costs', name: 'Costs', component: () => import('../views/Costs.vue'), meta: { perm: ['cost_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/:id/detail', name: 'MemberDetail', component: () => import('../views/MemberDetail.vue'), meta: { perm: 'user:view' } },
{ path: 'roles', name: 'Roles', component: () => import('../views/Roles.vue'), meta: { perm: 'role:manage' } },
{ path: 'settlement/:id', name: 'Settlement', component: () => import('../views/Settlement.vue'), meta: { perm: 'settlement:view' } },
],
},
]
@ -26,18 +28,47 @@ const router = createRouter({
// 路由守卫
router.beforeEach(async (to, from, next) => {
const token = localStorage.getItem('token')
if (to.meta.public) {
// 已登录时访问登录页,直接跳首页
if (to.path === '/login' && token) {
next('/')
} else {
next()
}
} else if (!token) {
next('/login')
} else {
next()
return
}
if (!token) {
next('/login')
return
}
// 有 token 但还没加载用户信息(刷新页面时),先加载
const { useAuthStore } = await import('../stores/auth')
const authStore = useAuthStore()
if (!authStore.user) {
await authStore.fetchUser()
}
// fetchUser 失败会 logout 清 token
if (!localStorage.getItem('token')) {
next('/login')
return
}
// 权限校验:如果路由要求特定权限,且用户没有,跳到第一个有权限的页面
const checkPerm = (perm) => {
if (!perm) return true
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')
return
}
next()
})
export default router

View File

@ -10,7 +10,6 @@ export const useAuthStore = defineStore('auth', () => {
const res = await authApi.login({ username, password })
token.value = res.access_token
localStorage.setItem('token', res.access_token)
// 登录后立即获取用户信息,失败不影响登录流程
try {
user.value = await authApi.me()
} catch (e) {
@ -33,9 +32,13 @@ export const useAuthStore = defineStore('auth', () => {
localStorage.removeItem('token')
}
const isOwner = () => user.value?.role === 'Owner'
const isSupervisor = () => ['Owner', '主管'].includes(user.value?.role)
const isLeaderOrAbove = () => ['Owner', '主管', '组长'].includes(user.value?.role)
/**
* 核心权限判断方法
* @param {string} perm - 权限标识符 'dashboard:view'
*/
function hasPermission(perm) {
return (user.value?.permissions || []).includes(perm)
}
return { user, token, login, fetchUser, logout, isOwner, isSupervisor, isLeaderOrAbove }
return { user, token, login, fetchUser, logout, hasPermission }
})

View File

@ -4,9 +4,9 @@
<el-tabs v-model="activeTab">
<!-- 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">
<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>
<el-table :data="aiCosts" v-loading="loadingAI" stripe size="small">
<el-table-column prop="tool_name" label="工具名称" width="140" />
@ -16,16 +16,16 @@
</el-table-column>
<el-table-column prop="allocation_type" label="归属方式" width="120" />
<el-table-column prop="record_date" label="录入日期" width="110" />
<el-table-column label="操作" width="80" v-if="authStore.isOwner()">
<el-table-column label="操作" width="80" v-if="authStore.hasPermission('cost_ai:delete')">
<template #default="{row}"><el-button text type="danger" size="small" @click="deleteAI(row.id)">删除</el-button></template>
</el-table-column>
</el-table>
</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">
<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>
<el-table :data="outCosts" v-loading="loadingOut" stripe size="small">
<el-table-column label="项目" width="160">
@ -39,11 +39,29 @@
<template #default="{row}">¥{{ row.amount.toLocaleString() }}</template>
</el-table-column>
<el-table-column prop="record_date" label="录入日期" width="110" />
<el-table-column label="操作" width="80" v-if="authStore.isOwner()">
<el-table-column label="操作" width="80" v-if="authStore.hasPermission('cost_outsource:delete')">
<template #default="{row}"><el-button text type="danger" size="small" @click="deleteOut(row.id)">删除</el-button></template>
</el-table-column>
</el-table>
</el-tab-pane>
<!-- 固定开支 -->
<el-tab-pane v-if="authStore.hasPermission('cost_overhead:view')" label="固定开支" name="overhead">
<div class="tab-header">
<el-button v-if="authStore.hasPermission('cost_overhead:create')" type="primary" size="small" @click="showOHForm = true"><el-icon><Plus /></el-icon> 新增</el-button>
</div>
<el-table :data="overheadCosts" v-loading="loadingOH" stripe size="small">
<el-table-column prop="cost_type" label="费用类型" width="140" />
<el-table-column label="金额" width="120" align="right">
<template #default="{row}">¥{{ row.amount.toLocaleString() }}</template>
</el-table-column>
<el-table-column prop="record_month" label="所属月份" width="110" />
<el-table-column prop="note" label="备注" show-overflow-tooltip />
<el-table-column label="操作" width="80" v-if="authStore.hasPermission('cost_overhead:delete')">
<template #default="{row}"><el-button text type="danger" size="small" @click="deleteOH(row.id)">删除</el-button></template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
<!-- AI 工具新增弹窗 -->
@ -112,6 +130,31 @@
<el-button type="primary" @click="createOut">保存</el-button>
</template>
</el-dialog>
<!-- 固定开支新增弹窗 -->
<el-dialog v-model="showOHForm" title="新增固定开支" width="480px" destroy-on-close>
<el-form :model="ohForm" label-width="100px">
<el-form-item label="费用类型">
<el-select v-model="ohForm.cost_type" placeholder="选择开支类型" style="width:100%">
<el-option label="办公室租金 — 每月办公室租赁费用" value="办公室租金" />
<el-option label="水电费 — 每月水电物业等费用" value="水电费" />
</el-select>
</el-form-item>
<el-form-item label="金额">
<el-input-number v-model="ohForm.amount" :min="0" :controls="false" placeholder="当月费用(元)" style="width:100%" />
</el-form-item>
<el-form-item label="所属月份">
<el-date-picker v-model="ohForm.record_month" type="month" value-format="YYYY-MM" placeholder="选择月份" style="width:100%" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="ohForm.note" placeholder="(选填)补充说明" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showOHForm = false">取消</el-button>
<el-button type="primary" @click="createOH">保存</el-button>
</template>
</el-dialog>
</div>
</template>
@ -122,25 +165,37 @@ import { useAuthStore } from '../stores/auth'
import { ElMessage, ElMessageBox } from 'element-plus'
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 loadingOut = ref(false)
const loadingOH = ref(false)
const aiCosts = ref([])
const outCosts = ref([])
const overheadCosts = ref([])
const projects = ref([])
const showAIForm = ref(false)
const showOutForm = ref(false)
const showOHForm = ref(false)
const projectMap = computed(() => {
const m = {}; projects.value.forEach(p => m[p.id] = p.name); return m
})
const today = new Date().toISOString().split('T')[0]
const currentMonth = today.slice(0, 7)
const aiForm = reactive({ tool_name: '', subscription_period: '月', amount: 0, allocation_type: '内容组整体', project_id: null, record_date: today })
const outForm = reactive({ project_id: null, outsource_type: '动画', episode_start: 1, episode_end: 1, amount: 0, record_date: today })
const ohForm = reactive({ cost_type: '办公室租金', amount: 0, record_month: currentMonth, note: '' })
async function loadAI() { loadingAI.value = true; try { aiCosts.value = await costApi.listAITools() } finally { loadingAI.value = false } }
async function loadOut() { loadingOut.value = true; try { outCosts.value = await costApi.listOutsource({}) } finally { loadingOut.value = false } }
async function loadOH() { loadingOH.value = true; try { overheadCosts.value = await costApi.listOverhead() } finally { loadingOH.value = false } }
async function createAI() {
await costApi.createAITool(aiForm); ElMessage.success('已添加'); showAIForm.value = false; loadAI()
@ -148,15 +203,24 @@ async function createAI() {
async function createOut() {
await costApi.createOutsource(outForm); ElMessage.success('已添加'); showOutForm.value = false; loadOut()
}
async function createOH() {
await costApi.createOverhead(ohForm); ElMessage.success('已添加'); showOHForm.value = false; loadOH()
}
async function deleteAI(id) {
await ElMessageBox.confirm('确认删除?'); await costApi.deleteAITool(id); ElMessage.success('已删除'); loadAI()
}
async function deleteOut(id) {
await ElMessageBox.confirm('确认删除?'); await costApi.deleteOutsource(id); ElMessage.success('已删除'); loadOut()
}
async function deleteOH(id) {
await ElMessageBox.confirm('确认删除?'); await costApi.deleteOverhead(id); ElMessage.success('已删除'); loadOH()
}
onMounted(async () => {
loadAI(); loadOut()
//
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 {}
})
</script>

View File

@ -30,6 +30,66 @@
<div class="stat-label">人均日产出</div>
</div>
</div>
<div class="stat-card" v-if="data.profitability">
<div class="stat-icon" :class="data.profitability.total_profit >= 0 ? 'green' : 'red'">
<el-icon :size="20"><Coin /></el-icon>
</div>
<div class="stat-body">
<div class="stat-value" :class="{ profit: data.profitability.total_profit >= 0, loss: data.profitability.total_profit < 0 }">
{{ data.profitability.total_profit >= 0 ? '+' : '' }}¥{{ formatNum(data.profitability.total_profit) }}
</div>
<div class="stat-label">已结算利润</div>
</div>
</div>
<div class="stat-card" v-if="data.profitability">
<div class="stat-icon" :class="data.profitability.profit_rate >= 0 ? 'green' : 'red'">
<el-icon :size="20"><DataAnalysis /></el-icon>
</div>
<div class="stat-body">
<div class="stat-value" :class="{ profit: data.profitability.profit_rate >= 0, loss: data.profitability.profit_rate < 0 }">
{{ data.profitability.profit_rate }}%
</div>
<div class="stat-label">利润率</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon" :class="(data.total_waste_rate || 0) > 20 ? 'red' : 'orange'">
<el-icon :size="20"><WarnTriangleFilled /></el-icon>
</div>
<div class="stat-body">
<div class="stat-value" :style="{color: (data.total_waste_rate || 0) > 20 ? '#FF3B30' : '#FF9500'}">
{{ data.total_waste_rate || 0 }}%
</div>
<div class="stat-label">总损耗率{{ formatSecs(data.total_waste_seconds) }}</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>
<!-- 图表行产出趋势 + 成本构成 -->
@ -82,18 +142,37 @@
</div>
</div>
<!-- 盈利分析图表 -->
<div class="chart-row" v-if="data.profitability?.profit_by_project?.length">
<div class="card chart-card full-width">
<div class="card-header">
<span class="card-title">项目盈亏分析</span>
<span class="card-count" v-if="data.profitability">
合同总额 ¥{{ formatNum(data.profitability.total_contract) }} · 总成本 ¥{{ formatNum(data.profitability.total_cost) }}
</span>
</div>
<div class="card-body"><div ref="profitChartRef" class="chart-container"></div></div>
</div>
</div>
<!-- 已结算项目 -->
<div class="card" v-if="data.settled_projects?.length">
<div class="card-header"><span class="card-title">已结算项目</span></div>
<div class="card-body">
<el-table :data="data.settled_projects" size="small">
<el-table-column prop="project_name" label="项目" />
<el-table-column label="合同金额" align="right" width="120">
<template #default="{ row }">
<span v-if="row.contract_amount">¥{{ formatNum(row.contract_amount) }}</span>
<span v-else class="text-muted"></span>
</template>
</el-table-column>
<el-table-column label="总成本" align="right" width="120">
<template #default="{ row }">¥{{ formatNum(row.total_cost) }}</template>
</el-table-column>
<el-table-column label="盈亏" align="right" width="120">
<template #default="{ row }">
<span v-if="row.profit_loss != null" class="profit" :class="{ loss: row.profit_loss < 0 }">
<span v-if="row.profit_loss != null" class="profit-text" :class="{ loss: row.profit_loss < 0 }">
{{ row.profit_loss >= 0 ? '+' : '' }}¥{{ formatNum(row.profit_loss) }}
</span>
<span v-else class="text-muted"></span>
@ -118,8 +197,9 @@ const trendChartRef = ref(null)
const costChartRef = ref(null)
const comparisonChartRef = ref(null)
const wasteChartRef = ref(null)
const profitChartRef = ref(null)
let trendChart, costChart, comparisonChart, wasteChart
let trendChart, costChart, comparisonChart, wasteChart, profitChart
function formatNum(n) { return (n || 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 }) }
function formatSecs(s) {
@ -226,11 +306,34 @@ function initWasteChart(ranking) {
})
}
function initProfitChart(profitData) {
if (!profitChartRef.value || !profitData?.length) return
profitChart = echarts.init(profitChartRef.value)
const names = profitData.map(p => p.name.length > 10 ? p.name.slice(0, 10) + '…' : p.name)
profitChart.setOption({
tooltip: { trigger: 'axis', formatter: p => `${p[0].name}<br/>盈亏 <b>${p[0].value >= 0 ? '+' : ''}¥${p[0].value.toLocaleString()}</b>` },
grid: { left: 12, right: 24, top: 16, bottom: 16, containLabel: true },
xAxis: { type: 'value', axisLabel: { fontSize: 11, color: '#8F959E', formatter: v => v >= 1000 ? (v/1000) + 'k' : v }, splitLine: { lineStyle: { color: '#F0F1F2' } } },
yAxis: { type: 'category', data: names, axisLabel: { fontSize: 12, color: '#3B3F46' }, axisLine: { show: false }, axisTick: { show: false } },
series: [{
type: 'bar', barWidth: 16,
data: profitData.map(p => ({
value: p.profit,
itemStyle: {
color: p.profit >= 0 ? '#34C759' : '#FF3B30',
borderRadius: p.profit >= 0 ? [0, 4, 4, 0] : [4, 0, 0, 4],
},
})),
}],
})
}
function handleResize() {
trendChart?.resize()
costChart?.resize()
comparisonChart?.resize()
wasteChart?.resize()
profitChart?.resize()
}
onMounted(async () => {
@ -242,6 +345,9 @@ onMounted(async () => {
initCostChart(data.value.cost_breakdown)
initComparisonChart(data.value.project_comparison)
initWasteChart(data.value.waste_ranking)
if (data.value.profitability?.profit_by_project?.length) {
initProfitChart(data.value.profitability.profit_by_project)
}
window.addEventListener('resize', handleResize)
} finally {
loading.value = false
@ -254,12 +360,14 @@ onUnmounted(() => {
costChart?.dispose()
comparisonChart?.dispose()
wasteChart?.dispose()
profitChart?.dispose()
})
</script>
<style scoped>
/* 统计网格 */
.stat-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 20px; }
@media (max-width: 1200px) { .stat-grid { grid-template-columns: repeat(2, 1fr); } }
.stat-card {
background: var(--bg-card); border: 1px solid var(--border-color);
border-radius: var(--radius-md); padding: 20px; display: flex; align-items: center; gap: 16px;
@ -269,6 +377,7 @@ onUnmounted(() => {
.stat-icon.orange { background: #FFF3E0; color: #FF9500; }
.stat-icon.green { background: #E8F8EE; color: #34C759; }
.stat-icon.purple { background: #F0E8FE; color: #9B59B6; }
.stat-icon.red { background: #FFE8E7; color: #FF3B30; }
.stat-body { flex: 1; }
.stat-value { font-size: 22px; font-weight: 700; color: var(--text-primary); line-height: 1.2; }
.stat-label { font-size: 12px; color: var(--text-secondary); margin-top: 4px; }
@ -279,6 +388,7 @@ onUnmounted(() => {
.chart-card.wide { flex: 2; }
.chart-card.narrow { flex: 1; }
.chart-card.half { flex: 1; }
.chart-card.full-width { flex: 1; }
.chart-container { width: 100%; height: 260px; }
/* 卡片 */
@ -307,7 +417,29 @@ onUnmounted(() => {
.progress-pct { font-size: 14px; font-weight: 600; color: var(--primary); }
.progress-meta { display: flex; justify-content: space-between; font-size: 12px; color: var(--text-secondary); margin-top: 6px; }
.profit { font-weight: 600; color: #34C759; }
.profit.loss { color: #FF3B30; }
.stat-value.profit { color: #34C759; }
.stat-value.loss { color: #FF3B30; }
.profit-text { font-weight: 600; color: #34C759; }
.profit-text.loss { color: #FF3B30; }
.text-muted { color: var(--text-secondary); }
/* 风险预警 */
.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>

View File

@ -23,7 +23,7 @@
<el-form-item>
<el-input v-model="form.password" placeholder="密码" type="password" size="large" show-password />
</el-form-item>
<el-button type="primary" size="large" :loading="loading" @click="handleLogin" class="login-btn">
<el-button type="primary" size="large" :loading="loading" native-type="submit" class="login-btn">
</el-button>
</el-form>

View File

@ -0,0 +1,335 @@
<template>
<div v-loading="loading">
<div class="page-header">
<div class="page-header-left">
<el-button text @click="$router.back()" class="back-btn"><el-icon><ArrowLeft /></el-icon></el-button>
<h2>{{ member.name || '成员详情' }}</h2>
<el-tag size="small">{{ member.role_name }}</el-tag>
<el-tag size="small" type="info">{{ member.phase_group }}</el-tag>
</div>
</div>
<!-- 基本信息 -->
<div class="card info-card">
<div class="info-grid">
<div class="info-item">
<span class="info-label">日成本</span>
<span class="info-value">¥{{ member.daily_cost || 0 }}</span>
</div>
<div class="info-item">
<span class="info-label">月总成本</span>
<span class="info-value">¥{{ (member.monthly_total_cost || 0).toLocaleString() }}</span>
</div>
<div class="info-item">
<span class="info-label">总提交</span>
<span class="info-value">{{ allSubmissions.length }} </span>
</div>
<div class="info-item">
<span class="info-label">总产出</span>
<span class="info-value">{{ formatSecs(totalOutputSecs) }}</span>
</div>
<div class="info-item">
<span class="info-label">活跃天数</span>
<span class="info-value">{{ activeDays }} </span>
</div>
<div class="info-item">
<span class="info-label">日均产出</span>
<span class="info-value">{{ formatSecs(avgDailySecs) }}</span>
</div>
</div>
</div>
<!-- 90天日历热力图 -->
<div class="card">
<div class="card-header"><span class="card-title"> 90 天提交热力图</span></div>
<div class="card-body heatmap-body">
<div class="calendar-heatmap" v-if="calendarData.length">
<div class="heatmap-summary"> 90 天内有 <strong>{{ activeDays }}</strong> 天有提交</div>
<div class="calendar-wrapper">
<div class="weekday-labels">
<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 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">
<span class="legend-text"></span>
<span class="calendar-day level-0"></span>
<span class="calendar-day level-1"></span>
<span class="calendar-day level-2"></span>
<span class="calendar-day level-3"></span>
<span class="calendar-day level-4"></span>
<span class="legend-text"></span>
</div>
</div>
<el-empty v-else description="暂无提交数据" :image-size="60" />
</div>
</div>
<!-- 按项目分组的提交 -->
<div class="card">
<div class="card-header"><span class="card-title">按项目统计</span></div>
<div class="card-body" v-if="projectGroups.length">
<div v-for="pg in projectGroups" :key="pg.projectId" class="project-group">
<div class="project-group-header" @click="toggleProjectExpand(pg.projectId)">
<div class="project-group-info">
<span class="project-group-name">{{ pg.projectName }}</span>
<el-tag size="small" type="info">{{ pg.submissions.length }} 次提交</el-tag>
<span class="project-group-secs">总产出 {{ formatSecs(pg.totalSecs) }}</span>
</div>
<el-icon :class="{ rotated: expandedProjectIds.has(pg.projectId) }"><ArrowRight /></el-icon>
</div>
<transition name="expand">
<div v-if="expandedProjectIds.has(pg.projectId)" class="project-group-body">
<el-table :data="pg.submissions" size="small">
<el-table-column prop="submit_date" label="日期" width="110" />
<el-table-column label="工作类型" width="80">
<template #default="{row}">
<el-tag :type="row.work_type === '测试' ? 'warning' : row.work_type === '方案' ? 'info' : ''" size="small">{{ row.work_type }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="content_type" label="内容类型" width="100" />
<el-table-column label="产出" width="90" align="right">
<template #default="{row}">{{ row.total_seconds > 0 ? formatSecs(row.total_seconds) : '—' }}</template>
</el-table-column>
<el-table-column prop="description" label="描述" show-overflow-tooltip />
</el-table>
</div>
</transition>
</div>
</div>
<div class="card-body" v-else>
<el-empty description="暂无提交记录" :image-size="60" />
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { userApi, submissionApi } from '../api'
const route = useRoute()
const loading = ref(false)
const member = ref({})
const allSubmissions = ref([])
const expandedProjectIds = ref(new Set())
const totalOutputSecs = computed(() => allSubmissions.value.reduce((s, r) => s + (r.total_seconds || 0), 0))
const activeDays = computed(() => {
const dates = new Set(allSubmissions.value.map(s => s.submit_date))
return dates.size
})
const avgDailySecs = computed(() => {
return activeDays.value > 0 ? Math.round(totalOutputSecs.value / activeDays.value) : 0
})
// 90
const calendarData = computed(() => {
const result = []
const today = new Date()
for (let i = 89; i >= 0; i--) {
const d = new Date(today)
d.setDate(d.getDate() - i)
result.push({ date: d.toISOString().slice(0, 10), secs: 0, count: 0 })
}
//
const dateMap = {}
result.forEach((d, idx) => { dateMap[d.date] = idx })
allSubmissions.value.forEach(s => {
const idx = dateMap[s.submit_date]
if (idx !== undefined) {
result[idx].secs += s.total_seconds || 0
result[idx].count += 1
}
})
return result
})
const calendarWeeks = computed(() => {
const weeks = []
const data = calendarData.value
let week = []
const firstDay = new Date(data[0]?.date).getDay()
for (let i = 0; i < firstDay; i++) week.push({ date: '', secs: 0, count: 0, empty: true })
data.forEach(d => {
week.push(d)
if (week.length === 7) { weeks.push(week); week = [] }
})
if (week.length) weeks.push(week)
return weeks
})
//
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) {
if (day.empty) return 'empty'
if (day.count === 0) return 'level-0'
if (day.secs < 30) return 'level-1'
if (day.secs < 120) return 'level-2'
if (day.secs < 300) return 'level-3'
return 'level-4'
}
function getDayTitle(day) {
if (day.empty) return ''
if (day.count === 0) return `${day.date}: 无提交`
return `${day.date}: ${day.count}次提交, 产出${formatSecs(day.secs)}`
}
//
const projectGroups = computed(() => {
const groups = {}
allSubmissions.value.forEach(s => {
if (!groups[s.project_id]) {
groups[s.project_id] = {
projectId: s.project_id,
projectName: s.project_name,
submissions: [],
totalSecs: 0,
}
}
groups[s.project_id].submissions.push(s)
groups[s.project_id].totalSecs += s.total_seconds || 0
})
return Object.values(groups).sort((a, b) => b.totalSecs - a.totalSecs)
})
function 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) {
if (!s) return '0秒'
const abs = Math.abs(s)
const m = Math.floor(abs / 60)
const sec = Math.round(abs % 60)
const sign = s < 0 ? '-' : ''
return m > 0 ? `${sign}${m}${sec > 0 ? sec + '秒' : ''}` : `${sign}${sec}`
}
async function load() {
loading.value = true
try {
const id = route.params.id
member.value = await userApi.get(id)
allSubmissions.value = await submissionApi.list({ user_id: id })
} finally { loading.value = false }
}
onMounted(load)
</script>
<style scoped>
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.page-header-left { display: flex; align-items: center; gap: 8px; }
.page-header-left h2 { font-size: 18px; font-weight: 600; }
.back-btn { font-size: 16px !important; padding: 4px !important; }
.info-card { margin-bottom: 16px; }
.info-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0; padding: 16px 20px; }
.info-item { display: flex; flex-direction: column; padding: 8px 0; border-bottom: 1px solid #f0f1f2; }
.info-item:nth-last-child(-n+3) { border-bottom: none; }
.info-label { font-size: 12px; color: var(--text-secondary); margin-bottom: 4px; }
.info-value { font-size: 14px; color: var(--text-primary); font-weight: 500; }
.card {
background: var(--bg-card); border: 1px solid var(--border-color);
border-radius: var(--radius-md); margin-bottom: 16px;
}
.card-header {
padding: 16px 20px; border-bottom: 1px solid var(--border-light);
display: flex; justify-content: space-between; align-items: center;
}
.card-title { font-size: 14px; font-weight: 600; }
.card-body { padding: 20px; }
/* 日历热力图 */
.heatmap-body { min-height: 160px; }
.heatmap-summary {
font-size: 13px; color: var(--text-secondary); margin-bottom: 12px;
}
.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-1 { background: #9be9a8; }
.calendar-day.level-2 { background: #40c463; }
.calendar-day.level-3 { background: #30a14e; }
.calendar-day.level-4 { background: #216e39; }
.calendar-day:hover:not(.empty) { outline: 2px solid #30a14e; outline-offset: 1px; }
.calendar-legend {
display: flex; align-items: center; gap: 4px; margin-top: 12px;
justify-content: flex-end; font-size: 12px;
}
.legend-text { color: var(--text-secondary); margin: 0 6px; }
/* 项目分组 */
.project-group { border-bottom: 1px solid #f0f1f2; }
.project-group:last-child { border-bottom: none; }
.project-group-header {
display: flex; justify-content: space-between; align-items: center;
padding: 12px 0; cursor: pointer;
}
.project-group-header:hover { background: #FAFBFC; margin: 0 -20px; padding-left: 20px; padding-right: 20px; }
.project-group-info { display: flex; align-items: center; gap: 8px; }
.project-group-name { font-size: 14px; font-weight: 500; color: var(--text-primary); }
.project-group-secs { font-size: 13px; color: var(--text-secondary); }
.project-group-header .el-icon { transition: transform 0.2s; color: var(--text-secondary); }
.project-group-header .el-icon.rotated { transform: rotate(90deg); }
.project-group-body { padding-bottom: 12px; }
.expand-enter-active, .expand-leave-active { transition: all 0.2s; overflow: hidden; }
.expand-enter-from, .expand-leave-to { opacity: 0; max-height: 0; }
</style>

View File

@ -5,14 +5,47 @@
<el-button text @click="$router.push('/projects')" class="back-btn"><el-icon><ArrowLeft /></el-icon></el-button>
<h2>{{ project.name }}</h2>
<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>
<el-space>
<el-button v-if="authStore.isOwner() && project.status === '制作中'" type="danger" plain @click="handleComplete">确认完成</el-button>
<el-button v-if="authStore.isOwner() && project.status === '已完成'" type="primary" @click="$router.push(`/settlement/${project.id}`)">查看结算</el-button>
<el-button v-if="authStore.hasPermission('project: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: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('project:edit') && project.status === '制作中'" type="warning" plain @click="handleAbandon">标记废弃</el-button>
</el-space>
</div>
<!-- 项目信息 -->
<div class="card info-card">
<div class="info-grid">
<div class="info-item">
<span class="info-label">合同金额</span>
<span class="info-value price">{{ project.contract_amount ? '¥' + project.contract_amount.toLocaleString() : '未设置' }}</span>
</div>
<div class="info-item">
<span class="info-label">负责人</span>
<span class="info-value">{{ project.leader_name || '未指定' }}</span>
</div>
<div class="info-item">
<span class="info-label">当前阶段</span>
<span class="info-value">{{ project.current_phase || '—' }}</span>
</div>
<div class="info-item">
<span class="info-label">创建时间</span>
<span class="info-value">{{ project.created_at ? project.created_at.slice(0,10) : '—' }}</span>
</div>
<div class="info-item">
<span class="info-label">预计完成</span>
<span class="info-value">{{ project.estimated_completion_date || '未设置' }}</span>
</div>
<div class="info-item">
<span class="info-label">目标时长</span>
<span class="info-value">{{ project.episode_count }} × {{ project.episode_duration_minutes }} = {{ formatSecs(project.target_total_seconds) }}</span>
</div>
</div>
</div>
<!-- 概览卡片 -->
<div class="stat-grid">
<div class="stat-card">
@ -24,8 +57,8 @@
<div class="stat-value">{{ formatSecs(project.total_submitted_seconds) }}</div>
</div>
<div class="stat-card">
<div class="stat-label">完成进度</div>
<div class="stat-value" :style="{color: project.progress_percent > 100 ? '#FF9500' : '#3370FF'}">{{ project.progress_percent }}%</div>
<div class="stat-label">当前阶段</div>
<div class="stat-value" :style="{color: stageColor}">{{ project.current_stage || '—' }}</div>
</div>
<div class="stat-card">
<div class="stat-label">损耗率</div>
@ -33,21 +66,145 @@
</div>
</div>
<!-- 进度 -->
<!-- 里程碑进度 -->
<div class="card">
<div class="card-header"><span class="card-title">项目进度</span></div>
<div class="card-header">
<span class="card-title">项目进度</span>
<span class="card-count" v-if="remainingDays !== null">
{{ remainingDays > 0 ? `剩余 ${remainingDays}` : remainingDays === 0 ? '今天截止' : `已超期 ${Math.abs(remainingDays)}` }}
</span>
</div>
<div class="card-body">
<el-progress :percentage="Math.min(project.progress_percent, 100)" :stroke-width="8" :show-text="false"
:color="progressColor" style="margin-bottom:12px" />
<div class="meta-row">
<span>目标{{ project.episode_count }} × {{ project.episode_duration_minutes }} = {{ formatSecs(project.target_total_seconds) }}</span>
<span v-if="project.estimated_completion_date">预估完成{{ project.estimated_completion_date }}</span>
<!-- 三阶段分段进度条 -->
<div class="milestone-pipeline" v-if="project.phase_summary">
<div class="segmented-bar">
<div class="seg-group" style="flex:2">
<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>
<!-- 时间轴 -->
<div class="timeline-section" v-if="project.created_at && project.estimated_completion_date">
<div class="progress-label-row">
<span>时间进度</span>
<span :style="{color: timePercent > 100 ? '#FF3B30' : '#8F959E', fontWeight: 600}">{{ timePercent }}%</span>
</div>
<div class="timeline-bar-wrapper">
<div class="timeline-bar">
<div class="timeline-elapsed" :style="{width: Math.min(timePercent, 100) + '%'}"></div>
<div class="timeline-today-marker" :style="{left: Math.min(timePercent, 100) + '%'}" v-if="timePercent <= 100">
<span class="timeline-today-label">今天</span>
</div>
</div>
<div class="timeline-labels">
<span>{{ project.created_at ? project.created_at.slice(0,10) : '' }}</span>
<span>{{ project.estimated_completion_date }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 团队效率 -->
<div v-if="authStore.isLeaderOrAbove() && efficiency.length" class="card">
<div v-if="authStore.hasPermission('efficiency:view') && efficiency.length" class="card">
<div class="card-header"><span class="card-title">团队效率</span></div>
<div class="card-body">
<el-table :data="efficiency" size="small">
@ -70,6 +227,144 @@
</div>
</div>
<!-- 提交概览热力图 -->
<div class="card">
<div class="card-header">
<span class="card-title">提交概览</span>
<div class="heatmap-controls">
<el-radio-group v-model="heatmapRange" size="small">
<el-radio-button label="30">最近30天</el-radio-button>
<el-radio-button label="all">项目全周期</el-radio-button>
</el-radio-group>
</div>
</div>
<div class="card-body heatmap-body" v-if="heatmapUsers.length">
<div class="heatmap-scroll">
<table class="heatmap-table">
<thead>
<tr>
<th class="heatmap-name-col">成员</th>
<th v-for="d in heatmapDates" :key="d" class="heatmap-date-col" :class="{ today: d === todayStr }">
{{ d.slice(5) }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="user in heatmapUsers" :key="user.id">
<td class="heatmap-name-col">
<a class="member-link" @click="openMemberDrawer(user)">{{ user.name }}</a>
</td>
<td v-for="d in heatmapDates" :key="d" class="heatmap-cell"
:class="getCellClass(user.id, d)"
@mouseenter="showCellTooltip($event, user.id, d)"
@mouseleave="hideCellTooltip">
<span v-if="getCellData(user.id, d)">{{ getCellData(user.id, d).totalSecs }}s</span>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 图例 -->
<div class="heatmap-legend">
<span class="legend-item"><span class="legend-dot type-制作"></span>制作</span>
<span class="legend-item"><span class="legend-dot type-测试"></span>测试</span>
<span class="legend-item"><span class="legend-dot type-方案"></span>方案</span>
</div>
</div>
<div class="card-body" v-else>
<el-empty description="暂无提交记录" :image-size="60" />
</div>
</div>
<!-- 悬浮提示 -->
<div class="cell-tooltip" v-if="tooltip.visible" :style="{ top: tooltip.y + 'px', left: tooltip.x + 'px' }">
<div v-for="(s, i) in tooltip.items" :key="i" class="tooltip-row">
<el-tag :type="s.work_type === '测试' ? 'warning' : s.work_type === '方案' ? 'info' : ''" size="small">{{ s.work_type }}</el-tag>
<span class="tooltip-type">{{ s.content_type }}</span>
<span class="tooltip-secs" v-if="s.total_seconds > 0">{{ formatSecs(s.total_seconds) }}</span>
<span class="tooltip-desc" v-if="s.description">{{ s.description }}</span>
</div>
</div>
<!-- 成员提交抽屉 -->
<el-drawer v-model="drawerVisible" :title="drawerUser?.name + ' 的提交记录'" size="480px">
<div v-if="drawerUser" class="drawer-content">
<div class="drawer-summary">
<span>总提交 <strong>{{ drawerSubmissions.length }}</strong> </span>
<span>总产出 <strong>{{ formatSecs(drawerTotalSecs) }}</strong></span>
<router-link :to="`/users/${drawerUser.id}/detail`" class="drawer-link">查看完整详情 </router-link>
</div>
<div v-for="(group, date) in drawerGrouped" :key="date" class="drawer-date-group">
<div class="drawer-date-header">{{ date }}</div>
<div v-for="s in group" :key="s.id" class="drawer-sub-item">
<div class="drawer-sub-top">
<el-tag :type="s.work_type === '测试' ? 'warning' : s.work_type === '方案' ? 'info' : ''" size="small">{{ s.work_type }}</el-tag>
<span>{{ s.content_type }}</span>
<span v-if="s.total_seconds > 0" class="drawer-sub-secs">{{ formatSecs(s.total_seconds) }}</span>
</div>
<div v-if="s.description" class="drawer-sub-desc">{{ s.description }}</div>
</div>
</div>
</div>
</el-drawer>
<!-- 编辑项目对话框 -->
<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-header">
@ -98,11 +393,12 @@
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ref, computed, reactive, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { projectApi, submissionApi } from '../api'
import { projectApi, submissionApi, userApi } from '../api'
import { useAuthStore } from '../stores/auth'
import { ElMessage, ElMessageBox } from 'element-plus'
import * as echarts from 'echarts'
const route = useRoute()
const router = useRouter()
@ -111,10 +407,242 @@ const loading = ref(false)
const project = ref({})
const submissions = ref([])
const efficiency = ref([])
const users = ref([])
const showEdit = ref(false)
const editing = ref(false)
const projectTypes = ['客户正式项目', '客户测试项目', '内部原创项目', '内部测试项目']
const typeTagMap = { '客户正式项目': 'success', '客户测试项目': 'warning', '内部原创项目': '', '内部测试项目': 'info' }
const progressColor = computed(() => project.value.progress_percent > 100 ? '#FF9500' : '#3370FF')
//
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 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 remainingDays = computed(() => {
if (!project.value.estimated_completion_date) return null
const end = new Date(project.value.estimated_completion_date)
const now = new Date()
return Math.ceil((end - now) / (1000 * 60 * 60 * 24))
})
const timePercent = computed(() => {
const p = project.value
if (!p.created_at || !p.estimated_completion_date) return 0
const start = new Date(p.created_at)
const end = new Date(p.estimated_completion_date)
const now = new Date()
const total = end - start
if (total <= 0) return 100
return Math.round((now - start) / total * 100)
})
//
const heatmapRange = ref('30')
const heatmapDates = computed(() => {
const subs = submissions.value
if (!subs.length) return []
if (heatmapRange.value === 'all') {
//
const dates = subs.map(s => s.submit_date).sort()
const start = new Date(dates[0])
const end = new Date(todayStr)
const result = []
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
result.push(d.toISOString().slice(0, 10))
}
return result
} else {
// 30
const result = []
const today = new Date()
for (let i = 29; i >= 0; i--) {
const d = new Date(today)
d.setDate(d.getDate() - i)
result.push(d.toISOString().slice(0, 10))
}
return result
}
})
const heatmapUsers = computed(() => {
const subs = submissions.value
const userMap = new Map()
subs.forEach(s => {
if (!userMap.has(s.user_id)) {
userMap.set(s.user_id, { id: s.user_id, name: s.user_name })
}
})
return Array.from(userMap.values())
})
// userId + date
const heatmapIndex = computed(() => {
const idx = {}
submissions.value.forEach(s => {
const key = `${s.user_id}_${s.submit_date}`
if (!idx[key]) idx[key] = { items: [], totalSecs: 0, mainType: '' }
idx[key].items.push(s)
idx[key].totalSecs += s.total_seconds || 0
})
//
Object.values(idx).forEach(cell => {
const typeMap = {}
cell.items.forEach(s => {
typeMap[s.work_type] = (typeMap[s.work_type] || 0) + (s.total_seconds || 1)
})
cell.mainType = Object.entries(typeMap).sort((a, b) => b[1] - a[1])[0]?.[0] || '制作'
cell.totalSecs = Math.round(cell.totalSecs)
})
return idx
})
function getCellData(userId, date) {
return heatmapIndex.value[`${userId}_${date}`] || null
}
function getCellClass(userId, date) {
const cell = getCellData(userId, date)
if (!cell) return ''
return `has-data type-${cell.mainType}`
}
//
const tooltip = reactive({ visible: false, x: 0, y: 0, items: [] })
function showCellTooltip(e, userId, date) {
const cell = getCellData(userId, date)
if (!cell) return
const rect = e.target.getBoundingClientRect()
tooltip.x = rect.left
tooltip.y = rect.bottom + 4
tooltip.items = cell.items
tooltip.visible = true
}
function hideCellTooltip() {
tooltip.visible = false
}
//
const drawerVisible = ref(false)
const drawerUser = ref(null)
const drawerSubmissions = computed(() => {
if (!drawerUser.value) return []
return submissions.value.filter(s => s.user_id === drawerUser.value.id)
})
const drawerTotalSecs = computed(() => {
return drawerSubmissions.value.reduce((sum, s) => sum + (s.total_seconds || 0), 0)
})
const drawerGrouped = computed(() => {
const groups = {}
drawerSubmissions.value.forEach(s => {
if (!groups[s.submit_date]) groups[s.submit_date] = []
groups[s.submit_date].push(s)
})
//
const sorted = {}
Object.keys(groups).sort((a, b) => b.localeCompare(a)).forEach(k => { sorted[k] = groups[k] })
return sorted
})
function openMemberDrawer(user) {
drawerUser.value = user
drawerVisible.value = true
}
//
function formatSecs(s) {
if (!s) return '0秒'
const abs = Math.abs(s)
@ -124,28 +652,95 @@ function formatSecs(s) {
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() {
loading.value = true
try {
const id = route.params.id
project.value = await projectApi.get(id)
submissions.value = await submissionApi.list({ project_id: id })
if (authStore.isLeaderOrAbove()) {
if (authStore.hasPermission('efficiency:view')) {
try { efficiency.value = await projectApi.efficiency(id) } catch {}
}
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 }
}
async function handleDelete() {
try {
await ElMessageBox.confirm(
'删除后项目及其所有提交记录、成本数据将被永久清除,此操作不可撤销。',
'确认删除项目',
{ type: 'error', confirmButtonText: '确认删除', cancelButtonText: '取消' }
)
await projectApi.delete(route.params.id)
ElMessage.success('项目已删除')
router.push('/projects')
} catch {}
}
async function handleComplete() {
try {
await ElMessageBox.confirm('确认将此项目标记为完成并进行结算?此操作不可撤销。', '确认完成', { type: 'warning' })
await ElMessageBox.confirm('确认将此项目标记为完成并进行结算?', '确认完成', { type: 'warning' })
await projectApi.complete(route.params.id)
ElMessage.success('项目已完成')
load()
} 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)
onUnmounted(() => {
window.removeEventListener('resize', handleProgressResize)
progressChart?.dispose()
})
</script>
<style scoped>
@ -154,6 +749,21 @@ onMounted(load)
.page-header-left h2 { font-size: 18px; font-weight: 600; }
.back-btn { font-size: 16px !important; padding: 4px !important; }
/* 项目信息卡片 */
.info-card { margin-bottom: 16px; }
.info-grid {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 0;
padding: 16px 20px;
}
.info-item {
display: flex; flex-direction: column; padding: 8px 0;
border-bottom: 1px solid var(--border-light, #f0f1f2);
}
.info-item:nth-last-child(-n+3) { border-bottom: none; }
.info-label { font-size: 12px; color: var(--text-secondary); margin-bottom: 4px; }
.info-value { font-size: 14px; color: var(--text-primary); font-weight: 500; }
.info-value.price { color: #FF9500; font-weight: 700; font-size: 16px; }
.stat-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 16px; }
.stat-card {
background: var(--bg-card); border: 1px solid var(--border-color);
@ -173,10 +783,175 @@ onMounted(load)
.card-title { font-size: 14px; font-weight: 600; }
.card-count { font-size: 12px; color: var(--text-secondary); }
.card-body { padding: 20px; }
.meta-row { display: flex; justify-content: space-between; font-size: 13px; color: var(--text-secondary); }
.rate-badge {
font-size: 12px; font-weight: 600; color: var(--text-secondary);
background: var(--bg-hover); padding: 2px 8px; border-radius: 4px; display: inline-block;
}
.rate-badge.danger { background: #FFE8E7; color: #FF3B30; }
.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-label-row {
display: flex; justify-content: space-between; align-items: center;
font-size: 13px; color: var(--text-secondary); margin-bottom: 8px;
}
.timeline-section { margin-top: 4px; }
.timeline-bar-wrapper { position: relative; margin-top: 24px; }
.timeline-bar {
height: 8px; background: #E5E6EB; border-radius: 4px; position: relative; overflow: visible;
}
.timeline-elapsed {
height: 100%; background: #3370FF; border-radius: 4px 0 0 4px;
transition: width 0.3s;
}
.timeline-today-marker {
position: absolute; top: -4px; transform: translateX(-50%);
width: 16px; height: 16px; background: #3370FF; border: 2px solid #fff;
border-radius: 50%; box-shadow: 0 1px 4px rgba(0,0,0,0.15);
}
.timeline-today-label {
position: absolute; top: -20px; left: 50%; transform: translateX(-50%);
font-size: 11px; color: #3370FF; font-weight: 600; white-space: nowrap;
}
.timeline-labels {
display: flex; justify-content: space-between; font-size: 12px; color: var(--text-secondary); margin-top: 8px;
}
/* 热力图 */
.heatmap-controls { display: flex; gap: 8px; }
.heatmap-body { padding: 16px 20px !important; }
.heatmap-scroll { overflow-x: auto; margin-bottom: 12px; }
.heatmap-table {
border-collapse: collapse; width: max-content; min-width: 100%;
}
.heatmap-table th, .heatmap-table td {
border: 1px solid var(--border-light, #f0f1f2); padding: 0; text-align: center;
font-size: 11px; height: 32px; min-width: 44px;
}
.heatmap-table thead th {
background: #F7F8FA; color: var(--text-secondary); font-weight: 500;
position: sticky; top: 0; z-index: 1;
}
.heatmap-table thead th.today { background: #E8F0FE; color: #3370FF; font-weight: 600; }
.heatmap-name-col {
position: sticky; left: 0; z-index: 2; background: #fff;
min-width: 80px; max-width: 80px; text-align: left; padding: 0 8px !important;
font-weight: 500; font-size: 12px; color: var(--text-primary);
}
.heatmap-table thead .heatmap-name-col { background: #F7F8FA; }
.heatmap-cell { cursor: default; transition: background 0.15s; }
.heatmap-cell:hover { filter: brightness(0.92); }
.heatmap-cell.has-data { font-weight: 600; color: #fff; }
.heatmap-cell.type-制作 { background: #3370FF; }
.heatmap-cell.type-测试 { background: #FF9500; }
.heatmap-cell.type-方案 { background: #8F959E; }
.member-link {
color: #3370FF; cursor: pointer; text-decoration: none;
}
.member-link:hover { text-decoration: underline; }
.heatmap-legend { display: flex; gap: 16px; font-size: 12px; color: var(--text-secondary); }
.legend-item { display: flex; align-items: center; gap: 4px; }
.legend-dot { width: 10px; height: 10px; border-radius: 2px; display: inline-block; }
.legend-dot.type-制作 { background: #3370FF; }
.legend-dot.type-测试 { background: #FF9500; }
.legend-dot.type-方案 { background: #8F959E; }
/* 悬浮提示 */
.cell-tooltip {
position: fixed; z-index: 9999;
background: #fff; border: 1px solid var(--border-color); border-radius: 8px;
box-shadow: 0 4px 16px rgba(0,0,0,0.12); padding: 10px 14px;
max-width: 320px; pointer-events: none;
}
.tooltip-row { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; flex-wrap: wrap; }
.tooltip-row:last-child { margin-bottom: 0; }
.tooltip-type { font-size: 12px; color: var(--text-secondary); }
.tooltip-secs { font-size: 12px; font-weight: 600; color: var(--text-primary); }
.tooltip-desc { font-size: 11px; color: #8F959E; width: 100%; margin-top: 2px; }
/* 成员抽屉 */
.drawer-content { padding: 0 4px; }
.drawer-summary {
display: flex; gap: 16px; align-items: center; flex-wrap: wrap;
padding: 12px 16px; background: #F7F8FA; border-radius: 8px;
font-size: 13px; color: var(--text-secondary); margin-bottom: 16px;
}
.drawer-summary strong { color: var(--text-primary); }
.drawer-link { color: #3370FF; font-size: 13px; margin-left: auto; }
.drawer-date-group { margin-bottom: 16px; }
.drawer-date-header {
font-size: 13px; font-weight: 600; color: var(--text-primary);
padding-bottom: 8px; border-bottom: 1px solid var(--border-light, #f0f1f2);
margin-bottom: 8px;
}
.drawer-sub-item {
padding: 6px 0; border-bottom: 1px solid var(--border-light, #f0f1f2);
}
.drawer-sub-item:last-child { border-bottom: none; }
.drawer-sub-top { display: flex; align-items: center; gap: 8px; font-size: 13px; }
.drawer-sub-secs { font-weight: 600; color: var(--text-primary); margin-left: auto; }
.drawer-sub-desc { font-size: 12px; color: #8F959E; margin-top: 4px; }
</style>

View File

@ -2,7 +2,7 @@
<div>
<div class="page-header">
<div></div>
<el-button v-if="authStore.isSupervisor()" type="primary" @click="showCreate = true">
<el-button v-if="authStore.hasPermission('project:create')" type="primary" @click="showCreate = true">
<el-icon><Plus /></el-icon> 新建项目
</el-button>
</div>
@ -12,6 +12,7 @@
<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-select>
<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" />
@ -34,7 +35,7 @@
</el-table-column>
<el-table-column label="状态" width="90">
<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>
</el-table-column>
<el-table-column prop="leader_name" label="负责人" width="90" />
@ -43,10 +44,8 @@
</el-table-column>
<el-table-column label="进度" width="180">
<template #default="{ row }">
<div class="cell-progress">
<el-progress :percentage="Math.min(row.progress_percent, 100)" :stroke-width="6" :show-text="false"
:color="row.progress_percent > 100 ? '#FF9500' : '#3370FF'" />
<span class="progress-text">{{ row.progress_percent }}%</span>
<div class="cell-stage">
<span class="stage-tag" :class="'stage-' + (row.current_stage || '')">{{ stageLabel(row) }}</span>
</div>
</template>
</el-table-column>
@ -94,6 +93,36 @@
<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-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>
<template #footer>
<el-button @click="showCreate = false">取消</el-button>
@ -124,6 +153,42 @@ const form = reactive({
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() {
loading.value = true
try { projects.value = await projectApi.list(filter) } finally { loading.value = false }
@ -133,16 +198,21 @@ async function handleCreate() {
if (!form.name) { ElMessage.warning('请输入项目名称'); return }
creating.value = true
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('项目已创建')
showCreate.value = false
// reset milestone template
msTemplate.splice(0, msTemplate.length, ...defaultMsTemplate.map(m => ({...m})))
load()
} finally { creating.value = false }
}
onMounted(async () => {
load()
if (authStore.isLeaderOrAbove()) {
if (authStore.hasPermission('user:view')) {
try { users.value = await userApi.list() } catch {}
}
})
@ -157,9 +227,15 @@ onMounted(async () => {
}
.card-body { padding: 4px 0; }
.cell-bold { font-weight: 500; color: var(--text-primary); }
.cell-progress { display: flex; align-items: center; gap: 8px; }
.cell-progress .el-progress { flex: 1; }
.progress-text { font-size: 12px; font-weight: 600; color: var(--text-secondary); min-width: 36px; text-align: right; }
.cell-stage { display: flex; align-items: center; }
.stage-tag {
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 {
font-size: 12px; font-weight: 600; color: var(--text-secondary);
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; }
.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; }
/* 里程碑模板 */
.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>

View File

@ -0,0 +1,196 @@
<template>
<div>
<div class="page-header">
<h2>角色管理</h2>
<el-button type="primary" @click="openCreate"><el-icon><Plus /></el-icon> 新建角色</el-button>
</div>
<el-table :data="roles" v-loading="loading" stripe>
<el-table-column prop="name" label="角色名称" width="140">
<template #default="{row}">
<span class="role-name">{{ row.name }}</span>
<el-tag v-if="row.is_system" size="small" type="info" style="margin-left:6px">内置</el-tag>
</template>
</el-table-column>
<el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip />
<el-table-column label="权限数" width="90" align="center">
<template #default="{row}">
<span class="perm-count">{{ row.permissions.length }}</span>
</template>
</el-table-column>
<el-table-column label="用户数" width="90" align="center">
<template #default="{row}">{{ row.user_count }}</template>
</el-table-column>
<el-table-column label="操作" width="150">
<template #default="{row}">
<el-button text size="small" @click="openEdit(row)">编辑权限</el-button>
<el-button v-if="!row.is_system" text type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 新建/编辑角色弹窗 -->
<el-dialog v-model="showDialog" :title="editingId ? '编辑角色' : '新建角色'" width="640px" destroy-on-close>
<el-form :model="form" label-width="80px">
<el-form-item label="角色名称">
<el-input v-model="form.name" :disabled="editingSystem" placeholder="如:制片人、实习生等" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" placeholder="(选填)角色职责说明" />
</el-form-item>
</el-form>
<div class="perm-panel">
<div class="perm-panel-header">
<span class="perm-panel-title">权限配置</span>
<el-button text size="small" @click="toggleAll">{{ isAllSelected ? '取消全选' : '全选' }}</el-button>
</div>
<div v-for="group in permGroups" :key="group.group" class="perm-group">
<div class="perm-group-header">
<el-checkbox
:model-value="isGroupAllSelected(group)"
:indeterminate="isGroupIndeterminate(group)"
@change="toggleGroup(group, $event)"
>{{ group.group }}</el-checkbox>
</div>
<div class="perm-items">
<el-checkbox
v-for="p in group.permissions"
:key="p.key"
:model-value="form.permissions.includes(p.key)"
@change="togglePerm(p.key, $event)"
>{{ p.label }}</el-checkbox>
</div>
</div>
</div>
<template #footer>
<el-button @click="showDialog = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { roleApi } from '../api'
import { ElMessage, ElMessageBox } from 'element-plus'
const loading = ref(false)
const saving = ref(false)
const showDialog = ref(false)
const editingId = ref(null)
const editingSystem = ref(false)
const roles = ref([])
const permGroups = ref([])
const form = ref({ name: '', description: '', permissions: [] })
const allPermKeys = computed(() => permGroups.value.flatMap(g => g.permissions.map(p => p.key)))
const isAllSelected = computed(() => allPermKeys.value.length > 0 && allPermKeys.value.every(k => form.value.permissions.includes(k)))
function isGroupAllSelected(group) {
return group.permissions.every(p => form.value.permissions.includes(p.key))
}
function isGroupIndeterminate(group) {
const selected = group.permissions.filter(p => form.value.permissions.includes(p.key)).length
return selected > 0 && selected < group.permissions.length
}
function toggleGroup(group, checked) {
const keys = group.permissions.map(p => p.key)
if (checked) {
form.value.permissions = [...new Set([...form.value.permissions, ...keys])]
} else {
form.value.permissions = form.value.permissions.filter(k => !keys.includes(k))
}
}
function togglePerm(key, checked) {
if (checked) {
form.value.permissions.push(key)
} else {
form.value.permissions = form.value.permissions.filter(k => k !== key)
}
}
function toggleAll() {
if (isAllSelected.value) {
form.value.permissions = []
} else {
form.value.permissions = [...allPermKeys.value]
}
}
function openCreate() {
editingId.value = null
editingSystem.value = false
form.value = { name: '', description: '', permissions: [] }
showDialog.value = true
}
function openEdit(role) {
editingId.value = role.id
editingSystem.value = role.is_system
form.value = {
name: role.name,
description: role.description || '',
permissions: [...role.permissions],
}
showDialog.value = true
}
async function handleSave() {
if (!form.value.name.trim()) { ElMessage.warning('请输入角色名称'); return }
saving.value = true
try {
if (editingId.value) {
await roleApi.update(editingId.value, form.value)
ElMessage.success('角色已更新')
} else {
await roleApi.create(form.value)
ElMessage.success('角色已创建')
}
showDialog.value = false
load()
} finally { saving.value = false }
}
async function handleDelete(role) {
try {
await ElMessageBox.confirm(`确认删除角色「${role.name}」?`, '删除角色', { type: 'warning' })
await roleApi.delete(role.id)
ElMessage.success('角色已删除')
load()
} catch {}
}
async function load() {
loading.value = true
try { roles.value = await roleApi.list() } finally { loading.value = false }
}
onMounted(async () => {
load()
try { permGroups.value = await roleApi.permissions() } catch {}
})
</script>
<style scoped>
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.page-header h2 { font-size: 18px; font-weight: 600; }
.role-name { font-weight: 600; color: var(--text-primary); }
.perm-count { font-weight: 600; color: var(--primary); }
/* 权限勾选面板 */
.perm-panel { margin-top: 8px; }
.perm-panel-header {
display: flex; justify-content: space-between; align-items: center;
padding: 8px 0; border-bottom: 1px solid var(--border-light); margin-bottom: 8px;
}
.perm-panel-title { font-size: 14px; font-weight: 600; color: var(--text-primary); }
.perm-group { margin-bottom: 12px; }
.perm-group-header {
padding: 6px 12px; background: #F7F8FA; border-radius: 6px;
margin-bottom: 6px; font-weight: 500;
}
.perm-items { padding: 4px 12px 4px 28px; display: flex; flex-wrap: wrap; gap: 8px 20px; }
</style>

View File

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

View File

@ -5,25 +5,40 @@
<el-button type="primary" @click="showCreate = true"><el-icon><Plus /></el-icon> 新增用户</el-button>
</div>
<el-table :data="users" v-loading="loading" stripe>
<el-table-column prop="name" label="姓名" width="100" />
<el-table-column prop="username" label="用户名" width="120" />
<el-table-column prop="phase_group" label="阶段组" width="80" />
<el-table-column label="角色" width="90">
<el-table :data="sortedUsers" v-loading="loading" stripe>
<el-table-column label="姓名" width="100">
<template #default="{row}">
<el-tag :type="roleMap[row.role]" size="small">{{ row.role }}</el-tag>
<router-link :to="`/users/${row.id}/detail`" class="user-link">{{ row.name }}</router-link>
</template>
</el-table-column>
<el-table-column label="月薪" width="110" align="right">
<el-table-column prop="username" label="用户名" width="120" />
<el-table-column prop="phase_group" label="阶段" width="70" />
<el-table-column label="角色" width="80">
<template #default="{row}">
<el-tag size="small">{{ row.role_name }}</el-tag>
</template>
</el-table-column>
<el-table-column label="月薪" width="100" align="right">
<template #default="{row}">¥{{ row.monthly_salary.toLocaleString() }}</template>
</el-table-column>
<el-table-column label="日成本" width="100" align="right">
<el-table-column label="奖金" width="90" align="right">
<template #default="{row}">{{ row.bonus > 0 ? '¥' + row.bonus.toLocaleString() : '—' }}</template>
</el-table-column>
<el-table-column label="五险一金" width="100" align="right">
<template #default="{row}">{{ row.social_insurance > 0 ? '¥' + row.social_insurance.toLocaleString() : '—' }}</template>
</el-table-column>
<el-table-column label="月总成本" width="110" align="right">
<template #default="{row}">
<span style="font-weight:600;color:var(--text-primary)">¥{{ row.monthly_total_cost.toLocaleString() }}</span>
</template>
</el-table-column>
<el-table-column label="日成本" width="90" align="right">
<template #default="{row}">¥{{ row.daily_cost.toLocaleString() }}</template>
</el-table-column>
<el-table-column label="状态" width="80">
<el-table-column label="状态" width="70">
<template #default="{row}"><el-tag :type="row.is_active ? 'success' : 'danger'" size="small">{{ row.is_active ? '启用' : '停用' }}</el-tag></template>
</el-table-column>
<el-table-column label="操作" width="100">
<el-table-column label="操作" width="80">
<template #default="{row}">
<el-button text size="small" @click="editUser(row)">编辑</el-button>
</template>
@ -31,8 +46,8 @@
</el-table>
<!-- 新增/编辑对话框 -->
<el-dialog v-model="showCreate" :title="editingId ? '编辑用户' : '新增用户'" width="480px" destroy-on-close>
<el-form :model="form" label-width="80px">
<el-dialog v-model="showCreate" :title="editingId ? '编辑用户' : '新增用户'" width="520px" destroy-on-close>
<el-form :model="form" label-width="90px">
<el-form-item label="姓名"><el-input v-model="form.name" /></el-form-item>
<el-form-item v-if="!editingId" label="用户名"><el-input v-model="form.username" /></el-form-item>
<el-form-item v-if="!editingId" label="密码"><el-input v-model="form.password" type="password" /></el-form-item>
@ -44,17 +59,25 @@
</el-select>
</el-form-item>
<el-form-item label="角色">
<el-select v-model="form.role" placeholder="系统权限角色" style="width:100%">
<el-option label="成员 — 仅提交内容" value="成员" />
<el-option label="组长 — 管理本组提交" value="组长" />
<el-option label="主管 — 管理项目和人员" value="主管" />
<el-option label="Owner — 全部权限" value="Owner" />
<el-select v-model="form.role_id" placeholder="系统权限角色" style="width:100%">
<el-option v-for="r in roles" :key="r.id" :label="r.name" :value="r.id" />
</el-select>
</el-form-item>
<el-divider content-position="left">成本信息</el-divider>
<el-form-item label="月薪">
<el-input-number v-model="form.monthly_salary" :min="0" :step="1000" :controls="false" placeholder="月薪(元),日成本=月薪÷22" style="width:100%" />
<el-input-number v-model="form.monthly_salary" :min="0" :step="1000" :controls="false" placeholder="基本月薪(元)" style="width:100%" />
</el-form-item>
<el-form-item v-if="editingId" label="状态">
<el-form-item label="奖金">
<el-input-number v-model="form.bonus" :min="0" :step="500" :controls="false" placeholder="月度奖金无则填0" style="width:100%" />
</el-form-item>
<el-form-item label="五险一金">
<el-input-number v-model="form.social_insurance" :min="0" :step="500" :controls="false" placeholder="公司承担的五险一金(元/月)" style="width:100%" />
</el-form-item>
<div class="cost-summary" v-if="form.monthly_salary || form.bonus || form.social_insurance">
月总成本 = ¥{{ (form.monthly_salary || 0).toLocaleString() }} + ¥{{ (form.bonus || 0).toLocaleString() }} + ¥{{ (form.social_insurance || 0).toLocaleString() }} = <strong>¥{{ ((form.monthly_salary || 0) + (form.bonus || 0) + (form.social_insurance || 0)).toLocaleString() }}</strong>
&nbsp;&nbsp; 日成本 <strong>¥{{ (((form.monthly_salary || 0) + (form.bonus || 0) + (form.social_insurance || 0)) / 22).toFixed(0) }}</strong>
</div>
<el-form-item v-if="editingId" label="状态" style="margin-top:16px">
<el-switch v-model="form.is_active" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="停用" />
</el-form-item>
</el-form>
@ -67,28 +90,50 @@
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { userApi } from '../api'
import { ref, reactive, computed, onMounted } from 'vue'
import { userApi, roleApi } from '../api'
import { ElMessage } from 'element-plus'
const loading = ref(false)
const showCreate = ref(false)
const editingId = ref(null)
const users = ref([])
const roleMap = { 'Owner': 'danger', '主管': 'warning', '组长': '', '成员': 'info' }
const form = reactive({ username: '', password: '', name: '', phase_group: '制作', role: '成员', monthly_salary: 0, is_active: 1 })
const roles = ref([])
const 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({
username: '', password: '', name: '', phase_group: '制作', role_id: null,
monthly_salary: 0, bonus: 0, social_insurance: 0, is_active: 1,
})
async function load() { loading.value = true; try { users.value = await userApi.list() } finally { loading.value = false } }
function editUser(u) {
editingId.value = u.id
Object.assign(form, { name: u.name, phase_group: u.phase_group, role: u.role, monthly_salary: u.monthly_salary, is_active: u.is_active })
Object.assign(form, {
name: u.name, phase_group: u.phase_group, role_id: u.role_id,
monthly_salary: u.monthly_salary, bonus: u.bonus || 0,
social_insurance: u.social_insurance || 0, is_active: u.is_active,
})
showCreate.value = true
}
async function handleSave() {
if (editingId.value) {
await userApi.update(editingId.value, { name: form.name, phase_group: form.phase_group, role: form.role, monthly_salary: form.monthly_salary, is_active: form.is_active })
await userApi.update(editingId.value, {
name: form.name, phase_group: form.phase_group, role_id: form.role_id,
monthly_salary: form.monthly_salary, bonus: form.bonus,
social_insurance: form.social_insurance, is_active: form.is_active,
})
} else {
await userApi.create(form)
}
@ -98,10 +143,21 @@ async function handleSave() {
load()
}
onMounted(load)
onMounted(async () => {
load()
try { roles.value = await roleApi.list() } catch {}
})
</script>
<style scoped>
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.page-header h2 { font-size: 18px; font-weight: 600; }
.cost-summary {
background: #F7F8FA; border-radius: 6px; padding: 10px 16px;
font-size: 13px; color: var(--text-secondary); margin: -4px 0 8px;
line-height: 1.6;
}
.cost-summary strong { color: var(--text-primary); }
.user-link { color: #3370FF; text-decoration: none; font-weight: 500; }
.user-link:hover { text-decoration: underline; }
</style>

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 后面再配?

23
人员清单.md Normal file
View File

@ -0,0 +1,23 @@
序号 姓名 职级 职位 工资 社保
1 胡皓南 主管 总导演 15929.52 1904.11
2 邓庆瑞 主管 AI导演 12429.52 1904.11
3 邱绍辉 主管 制片 9429.52 1,904.11
4 陈保丹 组长 动画制作 6729.52 2,159.11
5 代晓倩 组员 动画制作 4000 1,904.11
6 谭如平 组员 动画制作 3000 1,904.11
7 马若情 组长 AI导演 4000 1,904.11
8 郑奕晴 组员 动画制作 3000 1,904.11
9 韦春利 组长 AI导演 4000 1,904.11
10 黄雪雯 组员 动画制作 3000
11 潘梓彦 组长 剪辑 5000 1,904.11
12 刘诗琪 组员 动画制作 3000 1,904.11
13 戴玮 组员 动画制作 3000 1,904.11
14 黄溶莹 组员 编剧 3000 11.02
15 贾浩正 组员 剪辑 3000 11.02
16 王炎森 组员 剪辑 3000 11.02
17 黄秋霞 组员 动画制作 3000 11.02
18 李晶 组员 动画制作 3000 11.02
19 叶美莲 组员 动画制作 3000 11.02
20 陈煊颖 组员 动画制作 3000 11.02
21 土豆 Owner CTO 10000 1,904.11
22 青椒 Owner CEO 10000 1,904.11

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`(超级管理员)
---
*本文档用于项目交接、复盘与后续迭代参考。*