feat: add executive briefing center
This commit is contained in:
parent
d2672702bc
commit
1b2b64d575
10
CLAUDE.md
10
CLAUDE.md
|
|
@ -390,6 +390,11 @@ frontend/
|
||||||
- 新增:`frontend/src/pages/ai-steward/types.ts`、`frontend/src/pages/ai-steward/constants.ts`(AI Steward 类型与常量集中管理)
|
- 新增:`frontend/src/pages/ai-steward/types.ts`、`frontend/src/pages/ai-steward/constants.ts`(AI Steward 类型与常量集中管理)
|
||||||
- 新增:`frontend/src/pages/ai-steward/components/FeatureOverviewCards.tsx`(AI Steward 首页功能卡片组件)
|
- 新增:`frontend/src/pages/ai-steward/components/FeatureOverviewCards.tsx`(AI Steward 首页功能卡片组件)
|
||||||
- 新增:`frontend/src/pages/ai-steward/sections/OverviewTab.tsx`、`CreateProjectTab.tsx`、`SignalsTab.tsx`、`ConflictsTab.tsx`、`BlockersTab.tsx`、`BriefingTab.tsx`、`EngineTab.tsx`、`FeedbackCenterTab.tsx`(AI Steward 分阶段 UI 拆分)
|
- 新增:`frontend/src/pages/ai-steward/sections/OverviewTab.tsx`、`CreateProjectTab.tsx`、`SignalsTab.tsx`、`ConflictsTab.tsx`、`BlockersTab.tsx`、`BriefingTab.tsx`、`EngineTab.tsx`、`FeedbackCenterTab.tsx`(AI Steward 分阶段 UI 拆分)
|
||||||
|
- 新增:`backend/app/services/executive_briefing_service.py`、`backend/app/api/ai_steward_api/routes_executive_briefing.py`(老板晨报聚合服务与 manager-only 接口,配置持久化复用 `Integration(type='executive_briefing')`,快照复用 `PortfolioSnapshot(scope_type='executive_brief')`)
|
||||||
|
- 新增:`backend/tests/test_executive_briefing_api.py`、`backend/tests/test_ai_pmo_cadence_scheduler.py`(老板晨报接口包络、配置落库、管理群文案与调度时间窗回归)
|
||||||
|
- 更新:`backend/app/services/ai_pmo/ai_pmo_cadence/service.py`、`mixin_builders.py`、`mixin_snapshots.py`、`backend/app/services/scheduler_center_core/ai_pmo_cadence.py`(新增 `executive_brief` cadence type,支持租户级晨报快照生成与管理群 outbox 推送)
|
||||||
|
- 更新:`frontend/src/api/aiSteward.ts`(新增 cognitive conflicts / blockers / daily briefing / executive briefing API 封装,AI Steward 页面不再直接 `fetch`)
|
||||||
|
- 更新:`frontend/src/pages/ai-steward/AIStewardPage.tsx`、`components/FeatureOverviewCards.tsx`、`sections/OverviewTab.tsx`(BOSS/ADMIN 首页升级为老板晨报中心,支持晨报快照、实时刷新、调度配置与异常优先看板)
|
||||||
- 更新:`frontend/src/pages/ai-steward/sections/CreateProjectTab.tsx`(零配置创建结果区内联展示 `signal_sources` 分组,复用现有 AI Steward 页面,不新增独立路由)
|
- 更新:`frontend/src/pages/ai-steward/sections/CreateProjectTab.tsx`(零配置创建结果区内联展示 `signal_sources` 分组,复用现有 AI Steward 页面,不新增独立路由)
|
||||||
- 新增:`frontend/src/pages/ai-steward/components/feedback-center/FeedbackList.tsx`、`FeedbackDetail.tsx`、`ReasonPicker.tsx`、`MetricsPanel.tsx`、`TrendPanel.tsx`(反馈中心三栏工作台、指标页与趋势页组件;趋势页支持 `7/30 天` 与 `all/task/qa/signal` 局部筛选,并提供洞察卡、原因筛选与一键钻取)
|
- 新增:`frontend/src/pages/ai-steward/components/feedback-center/FeedbackList.tsx`、`FeedbackDetail.tsx`、`ReasonPicker.tsx`、`MetricsPanel.tsx`、`TrendPanel.tsx`(反馈中心三栏工作台、指标页与趋势页组件;趋势页支持 `7/30 天` 与 `all/task/qa/signal` 局部筛选,并提供洞察卡、原因筛选与一键钻取)
|
||||||
- 新增:`frontend/src/pages/ai-steward/components/feedback-center/TrendPanel.tsx` 的趋势决策版布局(Scope / KPI / Insights / Drilldown / Evidence)
|
- 新增:`frontend/src/pages/ai-steward/components/feedback-center/TrendPanel.tsx` 的趋势决策版布局(Scope / KPI / Insights / Drilldown / Evidence)
|
||||||
|
|
@ -497,6 +502,8 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||||
- 禁止在 API 层写业务逻辑,业务逻辑归属 `services/`
|
- 禁止在 API 层写业务逻辑,业务逻辑归属 `services/`
|
||||||
- 禁止在前端组件内直接调用 `fetch`,必须通过 `frontend/src/api/` 封装层
|
- 禁止在前端组件内直接调用 `fetch`,必须通过 `frontend/src/api/` 封装层
|
||||||
- 前端状态管理:服务端数据用 React Query,本地 UI 状态用 `useState`
|
- 前端状态管理:服务端数据用 React Query,本地 UI 状态用 `useState`
|
||||||
|
- 老板晨报链路只允许 `BOSS/ADMIN` 访问;`GET /ai-steward/executive-briefing` 的 `snapshot/live` 语义必须分离,`live` 禁止覆盖 `PortfolioSnapshot(scope_type='executive_brief')`
|
||||||
|
- 老板晨报调度配置统一落在 `Integration(type='executive_briefing')`,禁止新增独立晨报配置表;管理群自动推送必须复用现有 `feishu_reply_outbox`
|
||||||
- 飞书回执发送仅允许 `main.py` 的 drain_loop 作为消费者,禁止在业务路径即时派发 outbox 发送任务
|
- 飞书回执发送仅允许 `main.py` 的 drain_loop 作为消费者,禁止在业务路径即时派发 outbox 发送任务
|
||||||
- 所有写接口必须落审计日志(`operation_logs`):至少包含 `trace_id/interface_path/caller`
|
- 所有写接口必须落审计日志(`operation_logs`):至少包含 `trace_id/interface_path/caller`
|
||||||
- `created_at_beijing` 为计算属性返回,禁止新增数据库物理列
|
- `created_at_beijing` 为计算属性返回,禁止新增数据库物理列
|
||||||
|
|
@ -540,6 +547,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||||
- LLM 引擎设置接口:参考 `backend/app/api/llm_engines.py`(`PUT /ai-steward/llm-engines/settings`,仅管理员可修改本地知识库问答开关)
|
- LLM 引擎设置接口:参考 `backend/app/api/llm_engines.py`(`PUT /ai-steward/llm-engines/settings`,仅管理员可修改本地知识库问答开关)
|
||||||
- 飞书捕获模拟接口:`POST /ai-steward/signal/capture-feishu` 在 `direct_reply_required=true` 时会同步写入 `feishu_reply_outbox`,并返回 `reply_outbox_enqueued/reply_outbox_id` 便于链路自检
|
- 飞书捕获模拟接口:`POST /ai-steward/signal/capture-feishu` 在 `direct_reply_required=true` 时会同步写入 `feishu_reply_outbox`,并返回 `reply_outbox_enqueued/reply_outbox_id` 便于链路自检
|
||||||
- 配置读取类接口:参考 `backend/app/api/llm_providers.py`(`GET /ai-steward/llm/providers`)
|
- 配置读取类接口:参考 `backend/app/api/llm_providers.py`(`GET /ai-steward/llm/providers`)
|
||||||
|
- 老板晨报接口:参考 `backend/app/api/ai_steward_api/routes_executive_briefing.py`(`GET /ai-steward/executive-briefing?mode=snapshot|live`、`GET/PUT /ai-steward/executive-briefing/config`,仅 `BOSS/ADMIN` 可访问)
|
||||||
- 模型配置唯一来源:参考 `backend/app/services/model/model_config.py`(统一读取 `backend/.openclaw/pmflow-routing-rules.json` 内的 `provider_defaults/tier_defaults/rules`)
|
- 模型配置唯一来源:参考 `backend/app/services/model/model_config.py`(统一读取 `backend/.openclaw/pmflow-routing-rules.json` 内的 `provider_defaults/tier_defaults/rules`)
|
||||||
- 模型配置热更新后,LLM provider circuit 会在下一次调用时自动清空旧熔断状态,避免旧配置失败污染新配置的连通性判断
|
- 模型配置热更新后,LLM provider circuit 会在下一次调用时自动清空旧熔断状态,避免旧配置失败污染新配置的连通性判断
|
||||||
- 启动期模型探针只做连通性观测,不应把探针失败写回 provider circuit;否则会误伤真实业务请求
|
- 启动期模型探针只做连通性观测,不应把探针失败写回 provider circuit;否则会误伤真实业务请求
|
||||||
|
|
@ -583,6 +591,8 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||||
## 11. 已知 BOUNDARY(不要幻构行为)
|
## 11. 已知 BOUNDARY(不要幻构行为)
|
||||||
- 飞书回调字段可能缺失文件名/大小,仅保证 `attachment_id/key/type` 可用
|
- 飞书回调字段可能缺失文件名/大小,仅保证 `attachment_id/key/type` 可用
|
||||||
- 项目创建返回的 `signal_sources` 仅作为 AI Steward 创建结果区的内联展示,现阶段不新增独立页面路由;底层持久化统一复用现有 `SignalSourceRegistry`
|
- 项目创建返回的 `signal_sources` 仅作为 AI Steward 创建结果区的内联展示,现阶段不新增独立页面路由;底层持久化统一复用现有 `SignalSourceRegistry`
|
||||||
|
- 老板晨报首版的 `external_metrics` 只返回占位区块,不接 CRM / BI / 收入系统,也不允许伪造经营指标
|
||||||
|
- 老板晨报调度默认值为 `08:30 + workdays + Asia/Shanghai`;若 `delivery_target_ref` 未配置,调度器仍生成快照,但只记录告警、不静默伪装成已推送
|
||||||
- 飞书长连接 websocket 在本地/CI 环境默认使用 `certifi` CA bundle 作为信任链;如需自定义 CA,可通过 `FEISHU_LONG_CONNECTION_SSL_CERT_FILE` 或 `SSL_CERT_FILE` 覆盖
|
- 飞书长连接 websocket 在本地/CI 环境默认使用 `certifi` CA bundle 作为信任链;如需自定义 CA,可通过 `FEISHU_LONG_CONNECTION_SSL_CERT_FILE` 或 `SSL_CERT_FILE` 覆盖
|
||||||
- 飞书长连接状态分为两层:`last_received_at/last_received_message_id` 表示 SDK 回调已收到事件,`last_event_at` 表示 worker 完成处理;排查“没吃进去”时必须优先看 `last_received_*`
|
- 飞书长连接状态分为两层:`last_received_at/last_received_message_id` 表示 SDK 回调已收到事件,`last_event_at` 表示 worker 完成处理;排查“没吃进去”时必须优先看 `last_received_*`
|
||||||
- 飞书长连接会话应复用 `Integration.config` 中已有的 `encrypt_key/verification_token`;配置保存时若未显式修改这两个字段,必须保留现有值,避免 SDK 因缺失事件密钥而拒收消息
|
- 飞书长连接会话应复用 `Integration.config` 中已有的 `encrypt_key/verification_token`;配置保存时若未显式修改这两个字段,必须保留现有值,避免 SDK 因缺失事件密钥而拒收消息
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ from . import (
|
||||||
routes_brief_blockers,
|
routes_brief_blockers,
|
||||||
routes_capture,
|
routes_capture,
|
||||||
routes_conflicts,
|
routes_conflicts,
|
||||||
|
routes_executive_briefing,
|
||||||
routes_feedback_center,
|
routes_feedback_center,
|
||||||
routes_reply_iteration,
|
routes_reply_iteration,
|
||||||
routes_rag_sources,
|
routes_rag_sources,
|
||||||
|
|
@ -19,6 +20,7 @@ for _module in [
|
||||||
routes_rag_sources,
|
routes_rag_sources,
|
||||||
routes_capture,
|
routes_capture,
|
||||||
routes_conflicts,
|
routes_conflicts,
|
||||||
|
routes_executive_briefing,
|
||||||
routes_feedback_center,
|
routes_feedback_center,
|
||||||
routes_reply_iteration,
|
routes_reply_iteration,
|
||||||
routes_brief_blockers,
|
routes_brief_blockers,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import Depends, Query
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from ...services.auth.service import parse_role_values
|
||||||
|
from ...services.executive_briefing_service import ExecutiveBriefingService
|
||||||
|
from .schemas import ExecutiveBriefingConfigRequest
|
||||||
|
from .shared import AsyncSession, ErrorCode, User, error_response, get_current_user, get_db, get_tenant_id, router, success_response
|
||||||
|
|
||||||
|
|
||||||
|
def _manager_roles(user: User) -> set[str]:
|
||||||
|
return set(parse_role_values(getattr(user, "role", "")))
|
||||||
|
|
||||||
|
|
||||||
|
def _forbidden_response() -> JSONResponse:
|
||||||
|
return JSONResponse(status_code=403, content=error_response(ErrorCode.FORBIDDEN, "Manager permission required"))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/executive-briefing")
|
||||||
|
async def get_executive_briefing(
|
||||||
|
mode: str = Query(default="snapshot", pattern="^(snapshot|live)$"),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
if not _manager_roles(current_user).intersection({"BOSS", "ADMIN"}):
|
||||||
|
return _forbidden_response()
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = await ExecutiveBriefingService(db, tenant_id=tenant_id).get_briefing(mode=mode)
|
||||||
|
except ValueError as exc:
|
||||||
|
return JSONResponse(status_code=400, content=error_response(400, str(exc)))
|
||||||
|
return success_response(payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/executive-briefing/config")
|
||||||
|
async def get_executive_briefing_config(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
if not _manager_roles(current_user).intersection({"BOSS", "ADMIN"}):
|
||||||
|
return _forbidden_response()
|
||||||
|
payload = await ExecutiveBriefingService(db, tenant_id=tenant_id).get_config()
|
||||||
|
return success_response(payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/executive-briefing/config")
|
||||||
|
async def update_executive_briefing_config(
|
||||||
|
request: ExecutiveBriefingConfigRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
if not _manager_roles(current_user).intersection({"BOSS", "ADMIN"}):
|
||||||
|
return _forbidden_response()
|
||||||
|
|
||||||
|
service = ExecutiveBriefingService(db, tenant_id=tenant_id)
|
||||||
|
try:
|
||||||
|
payload = await service.update_config(payload=request.model_dump(), operator_user_id=int(current_user.id))
|
||||||
|
await db.commit()
|
||||||
|
except ValueError as exc:
|
||||||
|
await db.rollback()
|
||||||
|
return JSONResponse(status_code=422, content=error_response(ErrorCode.VALIDATION_ERROR, str(exc)))
|
||||||
|
return success_response(payload)
|
||||||
|
|
@ -94,3 +94,53 @@ class WorkflowTriggerRequest(BaseModel):
|
||||||
"""工作流触发请求"""
|
"""工作流触发请求"""
|
||||||
event_type: str
|
event_type: str
|
||||||
event_data: Dict[str, Any]
|
event_data: Dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutiveBriefingConfigRequest(BaseModel):
|
||||||
|
enabled: bool = True
|
||||||
|
send_time: str = Field(default="08:30", pattern=r"^\d{2}:\d{2}$")
|
||||||
|
send_cycle: Literal["workdays", "daily"] = "workdays"
|
||||||
|
delivery_target_type: Literal["management_group"] = "management_group"
|
||||||
|
delivery_target_ref: Optional[str] = Field(default=None, max_length=128)
|
||||||
|
timezone: str = Field(default="Asia/Shanghai", min_length=3, max_length=64)
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutiveBriefingMetric(BaseModel):
|
||||||
|
label: str
|
||||||
|
value: Any
|
||||||
|
tone: str
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutiveBriefingItem(BaseModel):
|
||||||
|
title: str
|
||||||
|
detail: str
|
||||||
|
tone: str
|
||||||
|
kind: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutiveBriefingSection(BaseModel):
|
||||||
|
key: str
|
||||||
|
title: str
|
||||||
|
summary: str
|
||||||
|
metrics: List[ExecutiveBriefingMetric] = Field(default_factory=list)
|
||||||
|
items: List[ExecutiveBriefingItem] = Field(default_factory=list)
|
||||||
|
drilldown_tab: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutiveBriefingHero(BaseModel):
|
||||||
|
title: str
|
||||||
|
decision_count: int
|
||||||
|
anomaly_count: int
|
||||||
|
baseline_generated_at: Optional[str] = None
|
||||||
|
live_generated_at: Optional[str] = None
|
||||||
|
key_alerts: List[ExecutiveBriefingMetric] = Field(default_factory=list)
|
||||||
|
snapshot_state: str = "missing"
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutiveBriefingPayload(BaseModel):
|
||||||
|
brief_date: str
|
||||||
|
mode: Literal["snapshot", "live"]
|
||||||
|
summary: Dict[str, Any]
|
||||||
|
hero: ExecutiveBriefingHero
|
||||||
|
sections: List[ExecutiveBriefingSection] = Field(default_factory=list)
|
||||||
|
delivery: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,17 @@ class CadenceBuildersMixin:
|
||||||
"overdue_tasks": overdue_tasks[:20],
|
"overdue_tasks": overdue_tasks[:20],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async def _build_executive_brief(self, *, project_id: Optional[int]) -> dict[str, Any]:
|
||||||
|
_ = project_id
|
||||||
|
from ...executive_briefing_service import ExecutiveBriefingService
|
||||||
|
|
||||||
|
generated_at = utc_now_naive()
|
||||||
|
return await ExecutiveBriefingService(self.db, tenant_id=self.tenant_id).build_brief_payload(
|
||||||
|
mode="snapshot",
|
||||||
|
baseline_generated_at=generated_at,
|
||||||
|
live_generated_at=None,
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _build_focus_actions(*, pending_manual_count: int, blocked_task_count: int) -> list[str]:
|
def _build_focus_actions(*, pending_manual_count: int, blocked_task_count: int) -> list[str]:
|
||||||
actions: list[str] = []
|
actions: list[str] = []
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@ class CadenceSnapshotsMixin:
|
||||||
payload = await self._build_weekly_review(project_id=project_id)
|
payload = await self._build_weekly_review(project_id=project_id)
|
||||||
elif normalized_type == "risk_review":
|
elif normalized_type == "risk_review":
|
||||||
payload = await self._build_risk_review(project_id=project_id)
|
payload = await self._build_risk_review(project_id=project_id)
|
||||||
|
elif normalized_type == "executive_brief":
|
||||||
|
payload = await self._build_executive_brief(project_id=project_id)
|
||||||
else:
|
else:
|
||||||
payload = await self._build_milestone_check(project_id=project_id)
|
payload = await self._build_milestone_check(project_id=project_id)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ class AIPMOCadenceService(
|
||||||
"weekly_review",
|
"weekly_review",
|
||||||
"risk_review",
|
"risk_review",
|
||||||
"milestone_check",
|
"milestone_check",
|
||||||
|
"executive_brief",
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, db: AsyncSession, tenant_id: int) -> None:
|
def __init__(self, db: AsyncSession, tenant_id: int) -> None:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,927 @@
|
||||||
|
"""Tenant-level executive briefing service."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Any, Optional
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from sqlalchemy import and_, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ..models import (
|
||||||
|
CognitiveConflict,
|
||||||
|
FeedbackCenterRecord,
|
||||||
|
Integration,
|
||||||
|
Notification,
|
||||||
|
PortfolioSnapshot,
|
||||||
|
Signal,
|
||||||
|
SignalSourceRegistry,
|
||||||
|
Task,
|
||||||
|
User,
|
||||||
|
UserWorkload,
|
||||||
|
)
|
||||||
|
from ..utils.timezone import CN_TZ, now_cn_naive
|
||||||
|
|
||||||
|
INTEGRATION_TYPE = "executive_briefing"
|
||||||
|
DEFAULT_TIMEZONE = "Asia/Shanghai"
|
||||||
|
DEFAULT_SEND_TIME = "08:30"
|
||||||
|
DEFAULT_SEND_CYCLE = "workdays"
|
||||||
|
DEFAULT_DELIVERY_TARGET_TYPE = "management_group"
|
||||||
|
VALID_SEND_CYCLES = {"workdays", "daily"}
|
||||||
|
VALID_DELIVERY_TARGET_TYPES = {DEFAULT_DELIVERY_TARGET_TYPE}
|
||||||
|
HIGH_SIGNAL_PRIORITIES = {"p0", "p1", "high", "critical", "紧急", "高"}
|
||||||
|
PENDING_SIGNAL_STATUSES = {"pending", "todo", "待处理", "待确认"}
|
||||||
|
BLOCKED_TASK_STATUSES = {"blocked", "blocking", "阻塞", "已阻塞"}
|
||||||
|
DONE_TASK_STATUSES = {"done", "completed", "closed", "已完成", "已关闭"}
|
||||||
|
ACTIVE_CONFLICT_DONE_STATUSES = {"resolved", "closed", "done", "已解决", "已关闭"}
|
||||||
|
REWORK_TASK_STATUSES = {"rework", "reopened", "返工", "重新打开"}
|
||||||
|
REVIEW_ACCEPTED_STATUSES = {"accepted", "approved", "done"}
|
||||||
|
REVIEW_REJECTED_STATUSES = {"rejected"}
|
||||||
|
|
||||||
|
|
||||||
|
def _deepcopy_dict(value: Any) -> dict[str, Any]:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
try:
|
||||||
|
return json.loads(json.dumps(value, ensure_ascii=False))
|
||||||
|
except Exception:
|
||||||
|
return dict(value)
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
parsed = json.loads(value)
|
||||||
|
if isinstance(parsed, dict):
|
||||||
|
return parsed
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_text(value: Any, *, fallback: str = "-") -> str:
|
||||||
|
text = str(value or "").strip()
|
||||||
|
return text or fallback
|
||||||
|
|
||||||
|
|
||||||
|
def _iso_or_none(value: Optional[datetime]) -> Optional[str]:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return value.isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutiveBriefingService:
|
||||||
|
"""Builds tenant-wide executive briefing payloads and config."""
|
||||||
|
|
||||||
|
def __init__(self, db: AsyncSession, *, tenant_id: int) -> None:
|
||||||
|
self.db = db
|
||||||
|
self.tenant_id = int(tenant_id)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def default_config(cls) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"enabled": True,
|
||||||
|
"send_time": DEFAULT_SEND_TIME,
|
||||||
|
"send_cycle": DEFAULT_SEND_CYCLE,
|
||||||
|
"delivery_target_type": DEFAULT_DELIVERY_TARGET_TYPE,
|
||||||
|
"delivery_target_ref": None,
|
||||||
|
"timezone": DEFAULT_TIMEZONE,
|
||||||
|
"updated_at": None,
|
||||||
|
"updated_by": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_config(self) -> dict[str, Any]:
|
||||||
|
row = await self._get_config_row()
|
||||||
|
if row is None:
|
||||||
|
return self.default_config()
|
||||||
|
normalized = self._normalize_config(row.config)
|
||||||
|
if normalized != _deepcopy_dict(row.config):
|
||||||
|
row.config = normalized
|
||||||
|
row.is_active = True
|
||||||
|
await self.db.flush()
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
async def update_config(self, *, payload: dict[str, Any], operator_user_id: int) -> dict[str, Any]:
|
||||||
|
normalized = self._normalize_config(payload)
|
||||||
|
normalized["updated_by"] = int(operator_user_id)
|
||||||
|
normalized["updated_at"] = now_cn_naive().isoformat()
|
||||||
|
|
||||||
|
row = await self._get_config_row()
|
||||||
|
if row is None:
|
||||||
|
row = Integration(
|
||||||
|
tenant_id=self.tenant_id,
|
||||||
|
type=INTEGRATION_TYPE,
|
||||||
|
config=normalized,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
self.db.add(row)
|
||||||
|
else:
|
||||||
|
row.config = normalized
|
||||||
|
row.is_active = True
|
||||||
|
await self.db.flush()
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
async def get_briefing(self, *, mode: str) -> dict[str, Any]:
|
||||||
|
normalized_mode = str(mode or "").strip().lower() or "snapshot"
|
||||||
|
if normalized_mode not in {"snapshot", "live"}:
|
||||||
|
raise ValueError("invalid mode")
|
||||||
|
|
||||||
|
if normalized_mode == "snapshot":
|
||||||
|
snapshot = await self._get_latest_today_snapshot()
|
||||||
|
if snapshot is not None:
|
||||||
|
payload = _deepcopy_dict(snapshot.payload)
|
||||||
|
hero = payload.setdefault("hero", {})
|
||||||
|
payload["mode"] = "snapshot"
|
||||||
|
hero["baseline_generated_at"] = hero.get("baseline_generated_at") or _iso_or_none(snapshot.created_at)
|
||||||
|
payload["brief_date"] = payload.get("brief_date") or (
|
||||||
|
snapshot.created_at.date().isoformat() if snapshot.created_at else now_cn_naive().date().isoformat()
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
return await self.build_brief_payload(mode="snapshot", baseline_generated_at=None, live_generated_at=None)
|
||||||
|
|
||||||
|
live_generated_at = now_cn_naive()
|
||||||
|
latest_snapshot = await self._get_latest_snapshot()
|
||||||
|
baseline_generated_at = latest_snapshot.created_at if latest_snapshot is not None else None
|
||||||
|
return await self.build_brief_payload(
|
||||||
|
mode="live",
|
||||||
|
baseline_generated_at=baseline_generated_at,
|
||||||
|
live_generated_at=live_generated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def build_brief_payload(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
mode: str,
|
||||||
|
baseline_generated_at: Optional[datetime],
|
||||||
|
live_generated_at: Optional[datetime],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
generated_at = live_generated_at or baseline_generated_at or now_cn_naive()
|
||||||
|
since = generated_at - timedelta(hours=24)
|
||||||
|
|
||||||
|
decision_signals = await self._list_priority_pending_signals(limit=3)
|
||||||
|
pending_sources = await self._list_pending_signal_sources(limit=3)
|
||||||
|
overloaded_members = await self._list_workload_members(kind="overloaded", limit=3)
|
||||||
|
active_conflicts = await self._list_active_conflicts(limit=3)
|
||||||
|
risk_notifications = await self._list_risk_notifications(since=since, limit=3)
|
||||||
|
blocked_tasks = await self._list_blocked_tasks(limit=3)
|
||||||
|
overdue_tasks = await self._list_overdue_tasks(now=generated_at, limit=3)
|
||||||
|
recent_signals = await self._list_recent_signals(since=since, limit=3)
|
||||||
|
idle_members = await self._list_workload_members(kind="idle", limit=3)
|
||||||
|
|
||||||
|
pending_signal_count = await self._count_pending_signals()
|
||||||
|
high_priority_signal_count = await self._count_high_priority_pending_signals()
|
||||||
|
recent_signal_count = await self._count_recent_signals(since=since)
|
||||||
|
pending_source_count = await self._count_pending_signal_sources()
|
||||||
|
blocked_task_count = await self._count_blocked_tasks()
|
||||||
|
overdue_task_count = await self._count_overdue_tasks(now=generated_at)
|
||||||
|
risk_notification_count = await self._count_risk_notifications(since=since)
|
||||||
|
overloaded_member_count = await self._count_workload_members(kind="overloaded")
|
||||||
|
idle_member_count = await self._count_workload_members(kind="idle")
|
||||||
|
active_conflict_count = await self._count_active_conflicts()
|
||||||
|
feedback_stats = await self._get_feedback_stats()
|
||||||
|
task_stats = await self._get_task_governance_stats()
|
||||||
|
|
||||||
|
decision_items = [
|
||||||
|
*[
|
||||||
|
{
|
||||||
|
"title": _safe_text(item.get("title"), fallback="高优先级信号待跟进"),
|
||||||
|
"detail": _safe_text(item.get("detail")),
|
||||||
|
"tone": "warning",
|
||||||
|
"kind": "signal",
|
||||||
|
}
|
||||||
|
for item in decision_signals[:2]
|
||||||
|
],
|
||||||
|
*[
|
||||||
|
{
|
||||||
|
"title": _safe_text(item.get("title"), fallback="信号源待确认"),
|
||||||
|
"detail": _safe_text(item.get("detail")),
|
||||||
|
"tone": "warning",
|
||||||
|
"kind": "signal_source",
|
||||||
|
}
|
||||||
|
for item in pending_sources[:1]
|
||||||
|
],
|
||||||
|
*[
|
||||||
|
{
|
||||||
|
"title": _safe_text(item.get("title"), fallback="成员持续过载"),
|
||||||
|
"detail": _safe_text(item.get("detail")),
|
||||||
|
"tone": "critical",
|
||||||
|
"kind": "capacity",
|
||||||
|
}
|
||||||
|
for item in overloaded_members[:1]
|
||||||
|
],
|
||||||
|
*[
|
||||||
|
{
|
||||||
|
"title": _safe_text(item.get("title"), fallback="关键冲突待解"),
|
||||||
|
"detail": _safe_text(item.get("detail")),
|
||||||
|
"tone": "critical",
|
||||||
|
"kind": "conflict",
|
||||||
|
}
|
||||||
|
for item in active_conflicts[:1]
|
||||||
|
],
|
||||||
|
][:3]
|
||||||
|
|
||||||
|
decisions_section = {
|
||||||
|
"key": "decisions",
|
||||||
|
"title": "今日需拍板",
|
||||||
|
"summary": (
|
||||||
|
f"有 {len(decision_items)} 项事项需要管理层拍板或立即关注。"
|
||||||
|
if decision_items
|
||||||
|
else "当前没有需要老板拍板的事项。"
|
||||||
|
),
|
||||||
|
"metrics": [
|
||||||
|
{"label": "需拍板", "value": len(decision_items), "tone": "critical" if decision_items else "positive"},
|
||||||
|
{"label": "高优信号", "value": high_priority_signal_count, "tone": "warning"},
|
||||||
|
{"label": "活跃冲突", "value": active_conflict_count, "tone": "warning"},
|
||||||
|
],
|
||||||
|
"items": decision_items,
|
||||||
|
"drilldown_tab": "signals",
|
||||||
|
}
|
||||||
|
|
||||||
|
input_items = [*recent_signals, *pending_sources][:3]
|
||||||
|
input_section = {
|
||||||
|
"key": "input_health",
|
||||||
|
"title": "业务机会与输入健康",
|
||||||
|
"summary": (
|
||||||
|
f"最近 24 小时新增 {recent_signal_count} 条信号,待处理 {pending_signal_count} 条。"
|
||||||
|
if recent_signal_count or pending_signal_count or pending_source_count
|
||||||
|
else "最近 24 小时没有新增输入,当前输入面为空。"
|
||||||
|
),
|
||||||
|
"metrics": [
|
||||||
|
{"label": "24h 新信号", "value": recent_signal_count, "tone": "info"},
|
||||||
|
{"label": "待处理信号", "value": pending_signal_count, "tone": "warning"},
|
||||||
|
{"label": "待接入来源", "value": pending_source_count, "tone": "warning"},
|
||||||
|
],
|
||||||
|
"items": input_items,
|
||||||
|
"drilldown_tab": "signals",
|
||||||
|
}
|
||||||
|
|
||||||
|
delivery_items = [*blocked_tasks, *overdue_tasks, *risk_notifications][:3]
|
||||||
|
delivery_section = {
|
||||||
|
"key": "delivery_risk",
|
||||||
|
"title": "交付兑现与项目风险",
|
||||||
|
"summary": (
|
||||||
|
f"当前有 {blocked_task_count} 个阻塞任务、{overdue_task_count} 个逾期任务。"
|
||||||
|
if blocked_task_count or overdue_task_count or risk_notification_count
|
||||||
|
else "当前交付节奏平稳,未发现显著的阻塞或逾期。"
|
||||||
|
),
|
||||||
|
"metrics": [
|
||||||
|
{"label": "阻塞任务", "value": blocked_task_count, "tone": "critical" if blocked_task_count else "positive"},
|
||||||
|
{"label": "逾期任务", "value": overdue_task_count, "tone": "warning" if overdue_task_count else "positive"},
|
||||||
|
{"label": "风险通知", "value": risk_notification_count, "tone": "warning"},
|
||||||
|
],
|
||||||
|
"items": delivery_items,
|
||||||
|
"drilldown_tab": "blockers",
|
||||||
|
}
|
||||||
|
|
||||||
|
capacity_items = [*overloaded_members, *idle_members, *active_conflicts][:3]
|
||||||
|
capacity_section = {
|
||||||
|
"key": "team_capacity",
|
||||||
|
"title": "团队产能与协同失衡",
|
||||||
|
"summary": (
|
||||||
|
f"当前过载成员 {overloaded_member_count} 人,闲置成员 {idle_member_count} 人。"
|
||||||
|
if overloaded_member_count or idle_member_count or active_conflict_count
|
||||||
|
else "团队产能分布基本均衡。"
|
||||||
|
),
|
||||||
|
"metrics": [
|
||||||
|
{"label": "过载成员", "value": overloaded_member_count, "tone": "critical" if overloaded_member_count else "positive"},
|
||||||
|
{"label": "闲置成员", "value": idle_member_count, "tone": "info"},
|
||||||
|
{"label": "协同冲突", "value": active_conflict_count, "tone": "warning"},
|
||||||
|
],
|
||||||
|
"items": capacity_items,
|
||||||
|
"drilldown_tab": "conflicts",
|
||||||
|
}
|
||||||
|
|
||||||
|
governance_items = self._build_governance_items(feedback_stats=feedback_stats, task_stats=task_stats)
|
||||||
|
governance_section = {
|
||||||
|
"key": "ai_governance",
|
||||||
|
"title": "AI 治理与系统可信度",
|
||||||
|
"summary": (
|
||||||
|
f"反馈待审 {feedback_stats['pending_count']} 条,人工覆盖率 {task_stats['manual_override_rate']}。"
|
||||||
|
if feedback_stats["pending_count"] or feedback_stats["reviewed_total"] or task_stats["manual_override_count"]
|
||||||
|
else "当前没有待处理的 AI 治理积压。"
|
||||||
|
),
|
||||||
|
"metrics": [
|
||||||
|
{"label": "待审反馈", "value": feedback_stats["pending_count"], "tone": "warning"},
|
||||||
|
{"label": "人工覆盖率", "value": task_stats["manual_override_rate"], "tone": "info"},
|
||||||
|
{"label": "返工率", "value": task_stats["rework_rate"], "tone": "warning"},
|
||||||
|
],
|
||||||
|
"items": governance_items,
|
||||||
|
"drilldown_tab": "feedback-center",
|
||||||
|
}
|
||||||
|
|
||||||
|
external_section = {
|
||||||
|
"key": "external_metrics",
|
||||||
|
"title": "外部经营指标",
|
||||||
|
"summary": "首版仅保留占位,待接入 CRM / BI / 收入指标后展示。",
|
||||||
|
"metrics": [
|
||||||
|
{"label": "接入状态", "value": "待接入", "tone": "muted"},
|
||||||
|
],
|
||||||
|
"items": [],
|
||||||
|
"drilldown_tab": "overview",
|
||||||
|
}
|
||||||
|
|
||||||
|
sections = [
|
||||||
|
decisions_section,
|
||||||
|
input_section,
|
||||||
|
delivery_section,
|
||||||
|
capacity_section,
|
||||||
|
governance_section,
|
||||||
|
external_section,
|
||||||
|
]
|
||||||
|
|
||||||
|
decision_count = len(decision_items)
|
||||||
|
anomaly_count = (
|
||||||
|
high_priority_signal_count
|
||||||
|
+ blocked_task_count
|
||||||
|
+ overdue_task_count
|
||||||
|
+ overloaded_member_count
|
||||||
|
+ active_conflict_count
|
||||||
|
+ feedback_stats["pending_count"]
|
||||||
|
)
|
||||||
|
|
||||||
|
key_alerts = [
|
||||||
|
{"label": "需拍板", "value": decision_count, "tone": "critical"},
|
||||||
|
{"label": "阻塞任务", "value": blocked_task_count, "tone": "critical"},
|
||||||
|
{"label": "过载成员", "value": overloaded_member_count, "tone": "warning"},
|
||||||
|
{"label": "待审反馈", "value": feedback_stats["pending_count"], "tone": "warning"},
|
||||||
|
]
|
||||||
|
key_alerts = [item for item in key_alerts if self._alert_has_value(item.get("value"))][:4]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"brief_date": generated_at.date().isoformat(),
|
||||||
|
"mode": mode,
|
||||||
|
"summary": {
|
||||||
|
"cadence_type": "executive_brief",
|
||||||
|
"decision_count": decision_count,
|
||||||
|
"anomaly_count": anomaly_count,
|
||||||
|
"high_priority_signal_count": high_priority_signal_count,
|
||||||
|
"blocked_task_count": blocked_task_count,
|
||||||
|
"overloaded_member_count": overloaded_member_count,
|
||||||
|
"pending_feedback_count": feedback_stats["pending_count"],
|
||||||
|
},
|
||||||
|
"hero": {
|
||||||
|
"title": "老板晨报中心",
|
||||||
|
"decision_count": decision_count,
|
||||||
|
"anomaly_count": anomaly_count,
|
||||||
|
"baseline_generated_at": _iso_or_none(baseline_generated_at),
|
||||||
|
"live_generated_at": _iso_or_none(live_generated_at),
|
||||||
|
"key_alerts": key_alerts,
|
||||||
|
"snapshot_state": "ready" if baseline_generated_at else "missing",
|
||||||
|
},
|
||||||
|
"sections": sections,
|
||||||
|
"delivery": await self.get_config(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def render_management_group_message(self, payload: dict[str, Any]) -> str:
|
||||||
|
hero = payload.get("hero") if isinstance(payload.get("hero"), dict) else {}
|
||||||
|
sections = payload.get("sections") if isinstance(payload.get("sections"), list) else []
|
||||||
|
lines = [
|
||||||
|
f"【{_safe_text(hero.get('title'), fallback='老板晨报')}】 {payload.get('brief_date') or '-'}",
|
||||||
|
(
|
||||||
|
f"需拍板 {hero.get('decision_count', 0)} 项"
|
||||||
|
f"|异常 {hero.get('anomaly_count', 0)} 项"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
generated_at = hero.get("baseline_generated_at") or hero.get("live_generated_at")
|
||||||
|
if generated_at:
|
||||||
|
lines.append(f"生成时间:{generated_at}")
|
||||||
|
for section in sections[:4]:
|
||||||
|
if not isinstance(section, dict):
|
||||||
|
continue
|
||||||
|
lines.append(f"\n{_safe_text(section.get('title'))}")
|
||||||
|
summary = str(section.get("summary") or "").strip()
|
||||||
|
if summary:
|
||||||
|
lines.append(summary)
|
||||||
|
items = section.get("items") if isinstance(section.get("items"), list) else []
|
||||||
|
for item in items[:3]:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
lines.append(f"- {_safe_text(item.get('title'))}:{_safe_text(item.get('detail'))}")
|
||||||
|
return "\n".join(lines).strip()
|
||||||
|
|
||||||
|
async def _get_config_row(self) -> Optional[Integration]:
|
||||||
|
return (
|
||||||
|
await self.db.execute(
|
||||||
|
select(Integration).where(
|
||||||
|
and_(
|
||||||
|
Integration.tenant_id == self.tenant_id,
|
||||||
|
Integration.type == INTEGRATION_TYPE,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
async def _get_latest_snapshot(self) -> Optional[PortfolioSnapshot]:
|
||||||
|
return (
|
||||||
|
await self.db.execute(
|
||||||
|
select(PortfolioSnapshot)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
PortfolioSnapshot.tenant_id == self.tenant_id,
|
||||||
|
PortfolioSnapshot.scope_type == "executive_brief",
|
||||||
|
PortfolioSnapshot.scope_ref == f"tenant:{self.tenant_id}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(PortfolioSnapshot.id.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
async def _get_latest_today_snapshot(self) -> Optional[PortfolioSnapshot]:
|
||||||
|
row = await self._get_latest_snapshot()
|
||||||
|
if row is None or row.created_at is None:
|
||||||
|
return None
|
||||||
|
if row.created_at.date() != now_cn_naive().date():
|
||||||
|
return None
|
||||||
|
return row
|
||||||
|
|
||||||
|
def _normalize_config(self, raw: Any) -> dict[str, Any]:
|
||||||
|
config = self.default_config()
|
||||||
|
incoming = _deepcopy_dict(raw)
|
||||||
|
|
||||||
|
config["enabled"] = bool(incoming.get("enabled", config["enabled"]))
|
||||||
|
|
||||||
|
send_time = str(incoming.get("send_time") or config["send_time"]).strip()
|
||||||
|
if not self._is_valid_send_time(send_time):
|
||||||
|
raise ValueError("send_time must be HH:MM")
|
||||||
|
config["send_time"] = send_time
|
||||||
|
|
||||||
|
send_cycle = str(incoming.get("send_cycle") or config["send_cycle"]).strip().lower()
|
||||||
|
if send_cycle not in VALID_SEND_CYCLES:
|
||||||
|
raise ValueError("send_cycle must be workdays or daily")
|
||||||
|
config["send_cycle"] = send_cycle
|
||||||
|
|
||||||
|
delivery_target_type = str(
|
||||||
|
incoming.get("delivery_target_type") or config["delivery_target_type"]
|
||||||
|
).strip().lower()
|
||||||
|
if delivery_target_type not in VALID_DELIVERY_TARGET_TYPES:
|
||||||
|
raise ValueError("delivery_target_type must be management_group")
|
||||||
|
config["delivery_target_type"] = delivery_target_type
|
||||||
|
|
||||||
|
delivery_target_ref = str(incoming.get("delivery_target_ref") or "").strip() or None
|
||||||
|
config["delivery_target_ref"] = delivery_target_ref
|
||||||
|
|
||||||
|
timezone_value = str(incoming.get("timezone") or config["timezone"]).strip() or DEFAULT_TIMEZONE
|
||||||
|
config["timezone"] = self._normalize_timezone(timezone_value)
|
||||||
|
config["updated_at"] = str(incoming.get("updated_at") or "").strip() or None
|
||||||
|
updated_by = incoming.get("updated_by")
|
||||||
|
try:
|
||||||
|
config["updated_by"] = int(updated_by) if updated_by is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
config["updated_by"] = None
|
||||||
|
return config
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_timezone(timezone_value: str) -> str:
|
||||||
|
try:
|
||||||
|
ZoneInfo(timezone_value)
|
||||||
|
return timezone_value
|
||||||
|
except Exception:
|
||||||
|
return DEFAULT_TIMEZONE
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_valid_send_time(value: str) -> bool:
|
||||||
|
if len(value) != 5 or value[2] != ":":
|
||||||
|
return False
|
||||||
|
hour_text, minute_text = value.split(":", 1)
|
||||||
|
if not hour_text.isdigit() or not minute_text.isdigit():
|
||||||
|
return False
|
||||||
|
hour = int(hour_text)
|
||||||
|
minute = int(minute_text)
|
||||||
|
return 0 <= hour <= 23 and 0 <= minute <= 59
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _alert_has_value(value: Any) -> bool:
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return value > 0
|
||||||
|
return bool(str(value or "").strip())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_percent(numerator: int, denominator: int) -> str:
|
||||||
|
if denominator <= 0:
|
||||||
|
return "0%"
|
||||||
|
return f"{round((numerator / denominator) * 100)}%"
|
||||||
|
|
||||||
|
def _display_time(self, value: datetime) -> str:
|
||||||
|
localized = value.replace(tzinfo=CN_TZ) if value.tzinfo is None else value.astimezone(CN_TZ)
|
||||||
|
return localized.strftime("%m-%d %H:%M")
|
||||||
|
|
||||||
|
async def _list_priority_pending_signals(self, *, limit: int) -> list[dict[str, Any]]:
|
||||||
|
rows = (
|
||||||
|
await self.db.execute(
|
||||||
|
select(Signal)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
Signal.tenant_id == self.tenant_id,
|
||||||
|
Signal.priority.is_not(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(Signal.created_at.desc(), Signal.id.desc())
|
||||||
|
.limit(max(limit * 4, limit))
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
|
||||||
|
items: list[dict[str, Any]] = []
|
||||||
|
for row in rows:
|
||||||
|
priority = str(row.priority or "").strip().lower()
|
||||||
|
status = str(row.status or row.planner_status or "").strip().lower()
|
||||||
|
pending = (not bool(row.processed)) or status in PENDING_SIGNAL_STATUSES or str(row.planner_status or "").strip().lower() == "pending"
|
||||||
|
if priority not in HIGH_SIGNAL_PRIORITIES or not pending:
|
||||||
|
continue
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"title": _safe_text(row.title or row.summary, fallback="高优先级信号待跟进"),
|
||||||
|
"detail": f"{_safe_text(row.priority)}|{_safe_text(row.status or row.planner_status)}|{self._display_time(row.created_at)}",
|
||||||
|
"tone": "warning",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if len(items) >= limit:
|
||||||
|
break
|
||||||
|
return items
|
||||||
|
|
||||||
|
async def _list_pending_signal_sources(self, *, limit: int) -> list[dict[str, Any]]:
|
||||||
|
rows = (
|
||||||
|
await self.db.execute(
|
||||||
|
select(SignalSourceRegistry)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
SignalSourceRegistry.tenant_id == self.tenant_id,
|
||||||
|
SignalSourceRegistry.onboarding_status == "pending",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(SignalSourceRegistry.created_at.desc(), SignalSourceRegistry.id.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"title": _safe_text(row.source_name, fallback="信号源待确认"),
|
||||||
|
"detail": f"{_safe_text(row.source_kind)}|{_safe_text(row.source_identifier)}",
|
||||||
|
"tone": "warning",
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
async def _list_recent_signals(self, *, since: datetime, limit: int) -> list[dict[str, Any]]:
|
||||||
|
rows = (
|
||||||
|
await self.db.execute(
|
||||||
|
select(Signal)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
Signal.tenant_id == self.tenant_id,
|
||||||
|
Signal.created_at >= since,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(Signal.created_at.desc(), Signal.id.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"title": _safe_text(row.title or row.summary, fallback="新增业务信号"),
|
||||||
|
"detail": f"{_safe_text(row.priority)}|{_safe_text(row.status)}|{self._display_time(row.created_at)}",
|
||||||
|
"tone": "info",
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
async def _list_blocked_tasks(self, *, limit: int) -> list[dict[str, Any]]:
|
||||||
|
rows = (
|
||||||
|
await self.db.execute(
|
||||||
|
select(Task)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
Task.tenant_id == self.tenant_id,
|
||||||
|
Task.status.in_(tuple(BLOCKED_TASK_STATUSES)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(Task.updated_at.desc(), Task.id.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"title": _safe_text(row.title, fallback="阻塞任务"),
|
||||||
|
"detail": f"{_safe_text(row.priority)}|状态 {_safe_text(row.status)}",
|
||||||
|
"tone": "critical",
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
async def _list_overdue_tasks(self, *, now: datetime, limit: int) -> list[dict[str, Any]]:
|
||||||
|
rows = (
|
||||||
|
await self.db.execute(
|
||||||
|
select(Task)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
Task.tenant_id == self.tenant_id,
|
||||||
|
Task.due_date.is_not(None),
|
||||||
|
Task.due_date < now,
|
||||||
|
Task.status.not_in(tuple(DONE_TASK_STATUSES)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(Task.due_date.asc(), Task.id.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"title": _safe_text(row.title, fallback="逾期任务"),
|
||||||
|
"detail": f"截止 {row.due_date.date().isoformat() if row.due_date else '-'}|{_safe_text(row.status)}",
|
||||||
|
"tone": "warning",
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
async def _list_risk_notifications(self, *, since: datetime, limit: int) -> list[dict[str, Any]]:
|
||||||
|
rows = (
|
||||||
|
await self.db.execute(
|
||||||
|
select(Notification)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
Notification.tenant_id == self.tenant_id,
|
||||||
|
Notification.created_at >= since,
|
||||||
|
Notification.is_read.is_(False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(Notification.created_at.desc(), Notification.id.desc())
|
||||||
|
.limit(limit * 3)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
items: list[dict[str, Any]] = []
|
||||||
|
for row in rows:
|
||||||
|
title = f"{row.title} {row.content or ''}".lower()
|
||||||
|
if not any(keyword in title for keyword in ("risk", "风险", "blocked", "阻塞", "overdue", "逾期")):
|
||||||
|
continue
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"title": _safe_text(row.title, fallback="风险通知"),
|
||||||
|
"detail": _safe_text(row.content or row.blocking_reason or row.priority),
|
||||||
|
"tone": "warning",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if len(items) >= limit:
|
||||||
|
break
|
||||||
|
return items
|
||||||
|
|
||||||
|
async def _list_active_conflicts(self, *, limit: int) -> list[dict[str, Any]]:
|
||||||
|
rows = (
|
||||||
|
await self.db.execute(
|
||||||
|
select(CognitiveConflict)
|
||||||
|
.where(CognitiveConflict.tenant_id == self.tenant_id)
|
||||||
|
.order_by(CognitiveConflict.created_at.desc(), CognitiveConflict.id.desc())
|
||||||
|
.limit(limit * 3)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
items: list[dict[str, Any]] = []
|
||||||
|
for row in rows:
|
||||||
|
status = str(row.status or "").strip().lower()
|
||||||
|
if status in ACTIVE_CONFLICT_DONE_STATUSES:
|
||||||
|
continue
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"title": _safe_text(row.conflict_type, fallback="认知冲突"),
|
||||||
|
"detail": _safe_text(row.description),
|
||||||
|
"tone": "critical",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if len(items) >= limit:
|
||||||
|
break
|
||||||
|
return items
|
||||||
|
|
||||||
|
async def _list_workload_members(self, *, kind: str, limit: int) -> list[dict[str, Any]]:
|
||||||
|
rows = (
|
||||||
|
await self.db.execute(
|
||||||
|
select(User.name, UserWorkload.current_assigned_hours, UserWorkload.max_available_hours_per_week, UserWorkload.workload_ratio)
|
||||||
|
.join(User, User.id == UserWorkload.user_id)
|
||||||
|
.where(UserWorkload.tenant_id == self.tenant_id)
|
||||||
|
.order_by(UserWorkload.workload_ratio.desc(), UserWorkload.id.desc())
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
items: list[dict[str, Any]] = []
|
||||||
|
for name, assigned_hours, max_hours, ratio_value in rows:
|
||||||
|
ratio = float(ratio_value or 0)
|
||||||
|
if ratio <= 0 and max_hours:
|
||||||
|
try:
|
||||||
|
ratio = float(assigned_hours or 0) / float(max_hours or 1)
|
||||||
|
except Exception:
|
||||||
|
ratio = 0
|
||||||
|
if kind == "overloaded" and ratio < 1:
|
||||||
|
continue
|
||||||
|
if kind == "idle" and ratio > 0.3:
|
||||||
|
continue
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"title": _safe_text(name, fallback="未命名成员"),
|
||||||
|
"detail": f"{round(ratio * 100)}% 负载",
|
||||||
|
"tone": "critical" if kind == "overloaded" else "info",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if len(items) >= limit:
|
||||||
|
break
|
||||||
|
return items
|
||||||
|
|
||||||
|
async def _count_pending_signals(self) -> int:
|
||||||
|
rows = (
|
||||||
|
await self.db.execute(
|
||||||
|
select(Signal.processed, Signal.status, Signal.planner_status).where(Signal.tenant_id == self.tenant_id)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
total = 0
|
||||||
|
for processed, status, planner_status in rows:
|
||||||
|
normalized_status = str(status or planner_status or "").strip().lower()
|
||||||
|
normalized_planner_status = str(planner_status or "").strip().lower()
|
||||||
|
if (not bool(processed)) or normalized_status in PENDING_SIGNAL_STATUSES or normalized_planner_status == "pending":
|
||||||
|
total += 1
|
||||||
|
return total
|
||||||
|
|
||||||
|
async def _count_high_priority_pending_signals(self) -> int:
|
||||||
|
rows = (
|
||||||
|
await self.db.execute(
|
||||||
|
select(Signal.priority, Signal.processed, Signal.status, Signal.planner_status).where(Signal.tenant_id == self.tenant_id)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
total = 0
|
||||||
|
for priority, processed, status, planner_status in rows:
|
||||||
|
normalized_priority = str(priority or "").strip().lower()
|
||||||
|
normalized_status = str(status or planner_status or "").strip().lower()
|
||||||
|
pending = (not bool(processed)) or normalized_status in PENDING_SIGNAL_STATUSES or str(planner_status or "").strip().lower() == "pending"
|
||||||
|
if normalized_priority in HIGH_SIGNAL_PRIORITIES and pending:
|
||||||
|
total += 1
|
||||||
|
return total
|
||||||
|
|
||||||
|
async def _count_recent_signals(self, *, since: datetime) -> int:
|
||||||
|
return len(
|
||||||
|
(
|
||||||
|
await self.db.execute(
|
||||||
|
select(Signal.id).where(
|
||||||
|
and_(
|
||||||
|
Signal.tenant_id == self.tenant_id,
|
||||||
|
Signal.created_at >= since,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _count_pending_signal_sources(self) -> int:
|
||||||
|
return len(
|
||||||
|
(
|
||||||
|
await self.db.execute(
|
||||||
|
select(SignalSourceRegistry.id).where(
|
||||||
|
and_(
|
||||||
|
SignalSourceRegistry.tenant_id == self.tenant_id,
|
||||||
|
SignalSourceRegistry.onboarding_status == "pending",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _count_blocked_tasks(self) -> int:
|
||||||
|
return len(
|
||||||
|
(
|
||||||
|
await self.db.execute(
|
||||||
|
select(Task.id).where(
|
||||||
|
and_(
|
||||||
|
Task.tenant_id == self.tenant_id,
|
||||||
|
Task.status.in_(tuple(BLOCKED_TASK_STATUSES)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _count_overdue_tasks(self, *, now: datetime) -> int:
|
||||||
|
return len(
|
||||||
|
(
|
||||||
|
await self.db.execute(
|
||||||
|
select(Task.id).where(
|
||||||
|
and_(
|
||||||
|
Task.tenant_id == self.tenant_id,
|
||||||
|
Task.due_date.is_not(None),
|
||||||
|
Task.due_date < now,
|
||||||
|
Task.status.not_in(tuple(DONE_TASK_STATUSES)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _count_risk_notifications(self, *, since: datetime) -> int:
|
||||||
|
rows = (
|
||||||
|
await self.db.execute(
|
||||||
|
select(Notification.title, Notification.content)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
Notification.tenant_id == self.tenant_id,
|
||||||
|
Notification.created_at >= since,
|
||||||
|
Notification.is_read.is_(False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
total = 0
|
||||||
|
for title, content in rows:
|
||||||
|
text = f"{title or ''} {content or ''}".lower()
|
||||||
|
if any(keyword in text for keyword in ("risk", "风险", "blocked", "阻塞", "overdue", "逾期")):
|
||||||
|
total += 1
|
||||||
|
return total
|
||||||
|
|
||||||
|
async def _count_active_conflicts(self) -> int:
|
||||||
|
rows = (
|
||||||
|
await self.db.execute(select(CognitiveConflict.status).where(CognitiveConflict.tenant_id == self.tenant_id))
|
||||||
|
).scalars().all()
|
||||||
|
return sum(1 for status in rows if str(status or "").strip().lower() not in ACTIVE_CONFLICT_DONE_STATUSES)
|
||||||
|
|
||||||
|
async def _count_workload_members(self, *, kind: str) -> int:
|
||||||
|
rows = await self._list_workload_members(kind=kind, limit=100)
|
||||||
|
return len(rows)
|
||||||
|
|
||||||
|
async def _get_feedback_stats(self) -> dict[str, Any]:
|
||||||
|
rows = (
|
||||||
|
await self.db.execute(
|
||||||
|
select(FeedbackCenterRecord.review_status, FeedbackCenterRecord.created_at, FeedbackCenterRecord.reviewed_at).where(
|
||||||
|
FeedbackCenterRecord.tenant_id == self.tenant_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
pending_count = 0
|
||||||
|
accepted_count = 0
|
||||||
|
rejected_count = 0
|
||||||
|
latency_samples: list[float] = []
|
||||||
|
for status, created_at, reviewed_at in rows:
|
||||||
|
normalized_status = str(status or "").strip().lower()
|
||||||
|
if normalized_status == "pending":
|
||||||
|
pending_count += 1
|
||||||
|
elif normalized_status in REVIEW_ACCEPTED_STATUSES:
|
||||||
|
accepted_count += 1
|
||||||
|
elif normalized_status in REVIEW_REJECTED_STATUSES:
|
||||||
|
rejected_count += 1
|
||||||
|
if created_at and reviewed_at:
|
||||||
|
latency_samples.append(max((reviewed_at - created_at).total_seconds() / 3600, 0))
|
||||||
|
avg_latency_hours = round(sum(latency_samples) / len(latency_samples), 1) if latency_samples else 0.0
|
||||||
|
reviewed_total = accepted_count + rejected_count
|
||||||
|
return {
|
||||||
|
"pending_count": pending_count,
|
||||||
|
"accepted_count": accepted_count,
|
||||||
|
"rejected_count": rejected_count,
|
||||||
|
"reviewed_total": reviewed_total,
|
||||||
|
"avg_review_latency_hours": avg_latency_hours,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _get_task_governance_stats(self) -> dict[str, Any]:
|
||||||
|
rows = (
|
||||||
|
await self.db.execute(select(Task.manual_overrides, Task.status).where(Task.tenant_id == self.tenant_id))
|
||||||
|
).all()
|
||||||
|
total = len(rows)
|
||||||
|
manual_override_count = 0
|
||||||
|
rework_count = 0
|
||||||
|
for manual_overrides, status in rows:
|
||||||
|
if manual_overrides not in (None, {}, []):
|
||||||
|
manual_override_count += 1
|
||||||
|
if str(status or "").strip().lower() in REWORK_TASK_STATUSES:
|
||||||
|
rework_count += 1
|
||||||
|
return {
|
||||||
|
"total": total,
|
||||||
|
"manual_override_count": manual_override_count,
|
||||||
|
"manual_override_rate": self._format_percent(manual_override_count, total),
|
||||||
|
"rework_count": rework_count,
|
||||||
|
"rework_rate": self._format_percent(rework_count, total),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _build_governance_items(self, *, feedback_stats: dict[str, Any], task_stats: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
items: list[dict[str, Any]] = []
|
||||||
|
if feedback_stats["pending_count"] > 0:
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"title": "反馈中心待审积压",
|
||||||
|
"detail": f"{feedback_stats['pending_count']} 条待审,平均复核延迟 {feedback_stats['avg_review_latency_hours']}h",
|
||||||
|
"tone": "warning",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if task_stats["manual_override_count"] > 0:
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"title": "人工覆盖偏高",
|
||||||
|
"detail": f"当前人工覆盖率 {task_stats['manual_override_rate']}",
|
||||||
|
"tone": "info",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if task_stats["rework_count"] > 0:
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"title": "返工任务存在",
|
||||||
|
"detail": f"当前返工率 {task_stats['rework_rate']}",
|
||||||
|
"tone": "warning",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if not items:
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"title": "治理状态正常",
|
||||||
|
"detail": "当前没有显著的 AI 治理积压。",
|
||||||
|
"tone": "positive",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return items[:3]
|
||||||
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ..integrations.feishu_outbox import FeishuOutboxService
|
||||||
|
from ..executive_briefing_service import ExecutiveBriefingService
|
||||||
from .shared import (
|
from .shared import (
|
||||||
AIPMOCadenceService,
|
AIPMOCadenceService,
|
||||||
PortfolioSnapshot,
|
PortfolioSnapshot,
|
||||||
|
|
@ -73,6 +75,7 @@ class AIPMOCadenceScheduler:
|
||||||
emitted = 0
|
emitted = 0
|
||||||
for tenant_id in effective_tenants:
|
for tenant_id in effective_tenants:
|
||||||
service = AIPMOCadenceService(db, tenant_id=int(tenant_id))
|
service = AIPMOCadenceService(db, tenant_id=int(tenant_id))
|
||||||
|
executive_service = ExecutiveBriefingService(db, tenant_id=int(tenant_id))
|
||||||
project_ids = (
|
project_ids = (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
select(Project.id).where(
|
select(Project.id).where(
|
||||||
|
|
@ -84,6 +87,46 @@ class AIPMOCadenceScheduler:
|
||||||
)
|
)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
|
|
||||||
|
executive_config = await executive_service.get_config()
|
||||||
|
last_executive_snapshot_at = (
|
||||||
|
await db.execute(
|
||||||
|
select(PortfolioSnapshot.created_at)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
PortfolioSnapshot.tenant_id == int(tenant_id),
|
||||||
|
PortfolioSnapshot.scope_type == "executive_brief",
|
||||||
|
PortfolioSnapshot.scope_ref == f"tenant:{tenant_id}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(PortfolioSnapshot.id.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
).scalars().first()
|
||||||
|
if self._should_emit_executive_brief(
|
||||||
|
now=now,
|
||||||
|
config=executive_config,
|
||||||
|
last_snapshot_at=last_executive_snapshot_at,
|
||||||
|
):
|
||||||
|
snapshot = await service.generate_snapshot(cadence_type="executive_brief", project_id=None)
|
||||||
|
emitted += 1
|
||||||
|
delivery_target_ref = str(executive_config.get("delivery_target_ref") or "").strip()
|
||||||
|
if delivery_target_ref:
|
||||||
|
text = executive_service.render_management_group_message(snapshot.get("payload") or {})
|
||||||
|
await FeishuOutboxService.enqueue(
|
||||||
|
db,
|
||||||
|
tenant_id=int(tenant_id),
|
||||||
|
chat_id=delivery_target_ref,
|
||||||
|
text=text,
|
||||||
|
source_message_id=None,
|
||||||
|
dedupe_seed=f"executive_brief:{tenant_id}:{(snapshot.get('payload') or {}).get('brief_date') or now.date().isoformat()}",
|
||||||
|
trace_id=f"executive_brief:{tenant_id}",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"executive_brief_delivery_target_missing",
|
||||||
|
tenant_id=int(tenant_id),
|
||||||
|
)
|
||||||
|
|
||||||
if await self._snapshot_due(
|
if await self._snapshot_due(
|
||||||
db=db,
|
db=db,
|
||||||
tenant_id=int(tenant_id),
|
tenant_id=int(tenant_id),
|
||||||
|
|
@ -152,6 +195,36 @@ class AIPMOCadenceScheduler:
|
||||||
logger.info("ai_pmo_cadence_scheduler_done", tenant_count=len(effective_tenants), snapshot_count=emitted)
|
logger.info("ai_pmo_cadence_scheduler_done", tenant_count=len(effective_tenants), snapshot_count=emitted)
|
||||||
return {"tenant_count": len(effective_tenants), "snapshot_count": emitted}
|
return {"tenant_count": len(effective_tenants), "snapshot_count": emitted}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _should_emit_executive_brief(
|
||||||
|
*,
|
||||||
|
now: datetime,
|
||||||
|
config: dict,
|
||||||
|
last_snapshot_at: datetime | None,
|
||||||
|
) -> bool:
|
||||||
|
if not bool(config.get("enabled", True)):
|
||||||
|
return False
|
||||||
|
|
||||||
|
send_time = str(config.get("send_time") or "08:30").strip()
|
||||||
|
try:
|
||||||
|
hour_text, minute_text = send_time.split(":", 1)
|
||||||
|
send_hour = int(hour_text)
|
||||||
|
send_minute = int(minute_text)
|
||||||
|
except Exception:
|
||||||
|
send_hour = 8
|
||||||
|
send_minute = 30
|
||||||
|
|
||||||
|
if str(config.get("send_cycle") or "workdays").strip().lower() == "workdays" and now.weekday() >= 5:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if (now.hour, now.minute) < (send_hour, send_minute):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if last_snapshot_at is not None and last_snapshot_at.date() == now.date():
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _snapshot_due(*, db, tenant_id: int, scope_type: str, scope_ref: str, freshness: timedelta) -> bool:
|
async def _snapshot_due(*, db, tenant_id: int, scope_type: str, scope_ref: str, freshness: timedelta) -> bool:
|
||||||
row = (
|
row = (
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.services.scheduler_center_core.ai_pmo_cadence import AIPMOCadenceScheduler
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_emit_executive_brief_honors_send_time_and_cycle() -> None:
|
||||||
|
config = {
|
||||||
|
"enabled": True,
|
||||||
|
"send_time": "08:30",
|
||||||
|
"send_cycle": "workdays",
|
||||||
|
"timezone": "Asia/Shanghai",
|
||||||
|
}
|
||||||
|
|
||||||
|
assert (
|
||||||
|
AIPMOCadenceScheduler._should_emit_executive_brief(
|
||||||
|
now=datetime(2026, 3, 30, 9, 0, 0),
|
||||||
|
config=config,
|
||||||
|
last_snapshot_at=None,
|
||||||
|
)
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
AIPMOCadenceScheduler._should_emit_executive_brief(
|
||||||
|
now=datetime(2026, 3, 30, 8, 0, 0),
|
||||||
|
config=config,
|
||||||
|
last_snapshot_at=None,
|
||||||
|
)
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
AIPMOCadenceScheduler._should_emit_executive_brief(
|
||||||
|
now=datetime(2026, 4, 4, 9, 0, 0),
|
||||||
|
config=config,
|
||||||
|
last_snapshot_at=None,
|
||||||
|
)
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_emit_executive_brief_skips_when_today_snapshot_already_exists() -> None:
|
||||||
|
config = {
|
||||||
|
"enabled": True,
|
||||||
|
"send_time": "08:30",
|
||||||
|
"send_cycle": "daily",
|
||||||
|
"timezone": "Asia/Shanghai",
|
||||||
|
}
|
||||||
|
|
||||||
|
assert (
|
||||||
|
AIPMOCadenceScheduler._should_emit_executive_brief(
|
||||||
|
now=datetime(2026, 3, 30, 9, 0, 0),
|
||||||
|
config=config,
|
||||||
|
last_snapshot_at=datetime(2026, 3, 30, 8, 45, 0),
|
||||||
|
)
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
AIPMOCadenceScheduler._should_emit_executive_brief(
|
||||||
|
now=datetime(2026, 3, 30, 9, 0, 0),
|
||||||
|
config={
|
||||||
|
**config,
|
||||||
|
"enabled": False,
|
||||||
|
},
|
||||||
|
last_snapshot_at=None,
|
||||||
|
)
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,228 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
from sqlalchemy import JSON
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
from app.api.ai_steward_api import routes_executive_briefing
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.dependencies.auth import get_current_user
|
||||||
|
from app.dependencies.tenant import get_tenant_id
|
||||||
|
from app.models.base import Base
|
||||||
|
from app.models.integration import Integration
|
||||||
|
from app.services.executive_briefing_service import ExecutiveBriefingService
|
||||||
|
|
||||||
|
|
||||||
|
def _use_sqlite_json_types() -> None:
|
||||||
|
Integration.__table__.c.config.type = JSON()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def db_session():
|
||||||
|
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||||
|
_use_sqlite_json_types()
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(
|
||||||
|
lambda sync_conn: Base.metadata.create_all(
|
||||||
|
sync_conn,
|
||||||
|
tables=[
|
||||||
|
Integration.__table__,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
maker = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
async with maker() as session:
|
||||||
|
yield session
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_app(*, db_session, role: str = "ADMIN") -> FastAPI:
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(routes_executive_briefing.router)
|
||||||
|
|
||||||
|
async def _fake_get_db():
|
||||||
|
yield db_session
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = _fake_get_db
|
||||||
|
app.dependency_overrides[get_tenant_id] = lambda: 1
|
||||||
|
app.dependency_overrides[get_current_user] = lambda: SimpleNamespace(
|
||||||
|
id=9001,
|
||||||
|
tenant_id=1,
|
||||||
|
name="管理员",
|
||||||
|
username="admin",
|
||||||
|
role=role,
|
||||||
|
)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_executive_briefing_snapshot_and_live_are_enveloped(db_session, monkeypatch) -> None:
|
||||||
|
async def fake_get_briefing(self, *, mode: str): # noqa: ANN001
|
||||||
|
_ = self
|
||||||
|
return {
|
||||||
|
"brief_date": "2026-03-30",
|
||||||
|
"mode": mode,
|
||||||
|
"hero": {
|
||||||
|
"title": "老板晨报",
|
||||||
|
"decision_count": 2,
|
||||||
|
"anomaly_count": 4,
|
||||||
|
},
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"key": "decisions",
|
||||||
|
"title": "今日需拍板",
|
||||||
|
"summary": "有 2 项需要拍板",
|
||||||
|
"metrics": [{"label": "需拍板", "value": 2, "tone": "warning"}],
|
||||||
|
"items": [],
|
||||||
|
"drilldown_tab": "signals",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(ExecutiveBriefingService, "get_briefing", fake_get_briefing)
|
||||||
|
|
||||||
|
app = _build_app(db_session=db_session)
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
snapshot_response = await client.get("/ai-steward/executive-briefing", params={"mode": "snapshot"})
|
||||||
|
live_response = await client.get("/ai-steward/executive-briefing", params={"mode": "live"})
|
||||||
|
|
||||||
|
assert snapshot_response.status_code == 200
|
||||||
|
snapshot_payload = snapshot_response.json()
|
||||||
|
assert snapshot_payload["code"] == 0
|
||||||
|
assert snapshot_payload["message"] == "success"
|
||||||
|
assert snapshot_payload["data"]["mode"] == "snapshot"
|
||||||
|
assert snapshot_payload["data"]["hero"]["decision_count"] == 2
|
||||||
|
|
||||||
|
assert live_response.status_code == 200
|
||||||
|
live_payload = live_response.json()
|
||||||
|
assert live_payload["code"] == 0
|
||||||
|
assert live_payload["data"]["mode"] == "live"
|
||||||
|
assert live_payload["data"]["sections"][0]["key"] == "decisions"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_executive_briefing_config_round_trip_is_enveloped(db_session) -> None:
|
||||||
|
app = _build_app(db_session=db_session)
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
default_response = await client.get("/ai-steward/executive-briefing/config")
|
||||||
|
update_response = await client.put(
|
||||||
|
"/ai-steward/executive-briefing/config",
|
||||||
|
json={
|
||||||
|
"enabled": True,
|
||||||
|
"send_time": "09:15",
|
||||||
|
"send_cycle": "daily",
|
||||||
|
"delivery_target_type": "management_group",
|
||||||
|
"delivery_target_ref": "oc_management_group",
|
||||||
|
"timezone": "Asia/Shanghai",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
refreshed_response = await client.get("/ai-steward/executive-briefing/config")
|
||||||
|
|
||||||
|
assert default_response.status_code == 200
|
||||||
|
default_payload = default_response.json()
|
||||||
|
assert default_payload["code"] == 0
|
||||||
|
assert default_payload["data"]["send_time"] == "08:30"
|
||||||
|
assert default_payload["data"]["send_cycle"] == "workdays"
|
||||||
|
|
||||||
|
assert update_response.status_code == 200
|
||||||
|
update_payload = update_response.json()
|
||||||
|
assert update_payload["code"] == 0
|
||||||
|
assert update_payload["data"]["send_time"] == "09:15"
|
||||||
|
assert update_payload["data"]["delivery_target_ref"] == "oc_management_group"
|
||||||
|
assert update_payload["data"]["updated_by"] == 9001
|
||||||
|
|
||||||
|
refreshed_payload = refreshed_response.json()
|
||||||
|
assert refreshed_payload["code"] == 0
|
||||||
|
assert refreshed_payload["data"]["send_time"] == "09:15"
|
||||||
|
assert refreshed_payload["data"]["send_cycle"] == "daily"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_executive_briefing_config_rejects_non_manager_users_with_envelope(db_session) -> None:
|
||||||
|
app = _build_app(db_session=db_session, role="BE")
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
response = await client.put(
|
||||||
|
"/ai-steward/executive-briefing/config",
|
||||||
|
json={
|
||||||
|
"enabled": True,
|
||||||
|
"send_time": "09:15",
|
||||||
|
"send_cycle": "daily",
|
||||||
|
"delivery_target_type": "management_group",
|
||||||
|
"delivery_target_ref": "oc_management_group",
|
||||||
|
"timezone": "Asia/Shanghai",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["code"] == 403
|
||||||
|
assert payload["data"] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_executive_briefing_service_persists_config_in_integration_row(db_session) -> None:
|
||||||
|
service = ExecutiveBriefingService(db_session, tenant_id=1)
|
||||||
|
|
||||||
|
updated = await service.update_config(
|
||||||
|
payload={
|
||||||
|
"enabled": True,
|
||||||
|
"send_time": "09:45",
|
||||||
|
"send_cycle": "daily",
|
||||||
|
"delivery_target_type": "management_group",
|
||||||
|
"delivery_target_ref": "oc_management_group_2",
|
||||||
|
"timezone": "Asia/Shanghai",
|
||||||
|
},
|
||||||
|
operator_user_id=777,
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
assert updated["send_time"] == "09:45"
|
||||||
|
assert updated["updated_by"] == 777
|
||||||
|
|
||||||
|
row = (
|
||||||
|
await db_session.execute(
|
||||||
|
Integration.__table__.select().where(Integration.tenant_id == 1, Integration.type == "executive_briefing")
|
||||||
|
)
|
||||||
|
).mappings().one()
|
||||||
|
assert row["config"]["send_time"] == "09:45"
|
||||||
|
assert row["config"]["delivery_target_ref"] == "oc_management_group_2"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_executive_briefing_service_formats_management_summary_text(db_session) -> None:
|
||||||
|
service = ExecutiveBriefingService(db_session, tenant_id=1)
|
||||||
|
|
||||||
|
text = service.render_management_group_message(
|
||||||
|
{
|
||||||
|
"brief_date": "2026-03-30",
|
||||||
|
"hero": {
|
||||||
|
"title": "老板晨报",
|
||||||
|
"decision_count": 2,
|
||||||
|
"anomaly_count": 4,
|
||||||
|
"baseline_generated_at": datetime(2026, 3, 30, 8, 30, 0).isoformat(),
|
||||||
|
},
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"title": "今日需拍板",
|
||||||
|
"summary": "有 2 项需要拍板",
|
||||||
|
"items": [
|
||||||
|
{"title": "P0 信号无人接", "detail": "项目 A"},
|
||||||
|
{"title": "持续过载成员", "detail": "张三 128%"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "老板晨报" in text
|
||||||
|
assert "今日需拍板" in text
|
||||||
|
assert "P0 信号无人接" in text
|
||||||
|
|
@ -86,6 +86,86 @@ export interface SignalSourceRepairResponse {
|
||||||
items: SignalSourceRepairItem[];
|
items: SignalSourceRepairItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CognitiveConflict {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
description: string;
|
||||||
|
severity: string;
|
||||||
|
related_users: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Blocker {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
description: string;
|
||||||
|
severity: string;
|
||||||
|
detected_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailyBriefing {
|
||||||
|
date: string;
|
||||||
|
summary: string;
|
||||||
|
highlights: Array<{ type: string; priority: string; content: string }>;
|
||||||
|
your_tasks: Array<{ id: number; title: string; status: string; priority: string }>;
|
||||||
|
risks: Array<{ type: string; task: string; severity: string }>;
|
||||||
|
signals: Array<{ id: number; source: string; content: string; impact_score: number }>;
|
||||||
|
conflicts: Array<{ id: string; type: string; description: string }>;
|
||||||
|
action_items: Array<{ type: string; content: string; priority: string }>;
|
||||||
|
team_updates: Array<{ assignee_id: number; task: string; status: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutiveBriefingMetric {
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
tone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutiveBriefingItem {
|
||||||
|
title: string;
|
||||||
|
detail: string;
|
||||||
|
tone: string;
|
||||||
|
kind?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutiveBriefingSection {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
metrics: ExecutiveBriefingMetric[];
|
||||||
|
items: ExecutiveBriefingItem[];
|
||||||
|
drilldown_tab?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutiveBriefingHero {
|
||||||
|
title: string;
|
||||||
|
decision_count: number;
|
||||||
|
anomaly_count: number;
|
||||||
|
baseline_generated_at?: string | null;
|
||||||
|
live_generated_at?: string | null;
|
||||||
|
key_alerts: ExecutiveBriefingMetric[];
|
||||||
|
snapshot_state: "ready" | "missing" | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutiveBriefingConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
send_time: string;
|
||||||
|
send_cycle: "workdays" | "daily";
|
||||||
|
delivery_target_type: "management_group";
|
||||||
|
delivery_target_ref: string | null;
|
||||||
|
timezone: string;
|
||||||
|
updated_at?: string | null;
|
||||||
|
updated_by?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutiveBriefingPayload {
|
||||||
|
brief_date: string;
|
||||||
|
mode: "snapshot" | "live";
|
||||||
|
summary: Record<string, unknown>;
|
||||||
|
hero: ExecutiveBriefingHero;
|
||||||
|
sections: ExecutiveBriefingSection[];
|
||||||
|
delivery: ExecutiveBriefingConfig;
|
||||||
|
}
|
||||||
|
|
||||||
export type LLMProvider = "deepseek" | "glm" | "openai" | "ollama";
|
export type LLMProvider = "deepseek" | "glm" | "openai" | "ollama";
|
||||||
|
|
||||||
export interface LLMProviderPreset {
|
export interface LLMProviderPreset {
|
||||||
|
|
@ -145,6 +225,53 @@ export interface LLMEngineConnectionTestResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const aiStewardApi = {
|
export const aiStewardApi = {
|
||||||
|
async listCognitiveConflicts(projectId?: number): Promise<CognitiveConflict[]> {
|
||||||
|
const query = typeof projectId === "number" ? `?project_id=${projectId}` : "";
|
||||||
|
return await apiRequest<CognitiveConflict[]>(`/ai-steward/cognitive-conflicts${query}`);
|
||||||
|
},
|
||||||
|
async detectConflicts(projectId: number): Promise<{ project_id: number; detected_count: number; conflicts: CognitiveConflict[] }> {
|
||||||
|
return await apiRequest<{ project_id: number; detected_count: number; conflicts: CognitiveConflict[] }>(
|
||||||
|
"/ai-steward/conflicts/detect",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ project_id: projectId, detect_type: "all" }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
async listActiveBlockers(): Promise<{ total: number; blockers: Blocker[] }> {
|
||||||
|
return await apiRequest<{ total: number; blockers: Blocker[] }>("/ai-steward/blockers/active");
|
||||||
|
},
|
||||||
|
async detectTaskBlockers(): Promise<{ total_detected: number; blockers: Blocker[] }> {
|
||||||
|
return await apiRequest<{ total_detected: number; blockers: Blocker[] }>("/ai-steward/blockers/detect-tasks", {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async getDailyBriefing(): Promise<DailyBriefing> {
|
||||||
|
return await apiRequest<DailyBriefing>("/ai-steward/daily-briefing");
|
||||||
|
},
|
||||||
|
async startHnMonitor(intervalMinutes: number = 30): Promise<Record<string, unknown>> {
|
||||||
|
return await apiRequest<Record<string, unknown>>(
|
||||||
|
`/ai-steward/signal/start-hn-monitor?interval_minutes=${intervalMinutes}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
async getExecutiveBriefing(mode: "snapshot" | "live"): Promise<ExecutiveBriefingPayload> {
|
||||||
|
const raw = await apiRequest<ApiEnvelope<ExecutiveBriefingPayload>>(`/ai-steward/executive-briefing?mode=${mode}`);
|
||||||
|
return unwrap(raw);
|
||||||
|
},
|
||||||
|
async getExecutiveBriefingConfig(): Promise<ExecutiveBriefingConfig> {
|
||||||
|
const raw = await apiRequest<ApiEnvelope<ExecutiveBriefingConfig>>("/ai-steward/executive-briefing/config");
|
||||||
|
return unwrap(raw);
|
||||||
|
},
|
||||||
|
async updateExecutiveBriefingConfig(body: ExecutiveBriefingConfig): Promise<ExecutiveBriefingConfig> {
|
||||||
|
const raw = await apiRequest<ApiEnvelope<ExecutiveBriefingConfig>>("/ai-steward/executive-briefing/config", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return unwrap(raw);
|
||||||
|
},
|
||||||
async getLlmProviderPresets(): Promise<LLMProviderPresetResponse> {
|
async getLlmProviderPresets(): Promise<LLMProviderPresetResponse> {
|
||||||
const raw = await apiRequest<ApiEnvelope<LLMProviderPresetResponse>>("/ai-steward/llm/providers");
|
const raw = await apiRequest<ApiEnvelope<LLMProviderPresetResponse>>("/ai-steward/llm/providers");
|
||||||
return unwrap(raw);
|
return unwrap(raw);
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useState, useEffect } from "react";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { Sparkles } from "lucide-react";
|
import { Sparkles } from "lucide-react";
|
||||||
import { apiBaseURL, getAuthToken } from "@/lib/api";
|
import { getAuthToken } from "@/lib/api";
|
||||||
import { getApiErrorMessage } from "@/lib/apiError";
|
import { getApiErrorMessage } from "@/lib/apiError";
|
||||||
import {
|
import {
|
||||||
Card, CardContent,
|
Card, CardContent,
|
||||||
|
|
@ -24,6 +24,8 @@ import type {
|
||||||
Blocker,
|
Blocker,
|
||||||
CognitiveConflict,
|
CognitiveConflict,
|
||||||
DailyBriefing,
|
DailyBriefing,
|
||||||
|
ExecutiveBriefingConfig,
|
||||||
|
ExecutiveBriefingPayload,
|
||||||
FeedbackCenterSourceGroup,
|
FeedbackCenterSourceGroup,
|
||||||
FeedbackCenterSection,
|
FeedbackCenterSection,
|
||||||
FeedbackCenterTrendWindowDays,
|
FeedbackCenterTrendWindowDays,
|
||||||
|
|
@ -57,6 +59,16 @@ const AIS_STEWARD_TABS: AIStewardTab[] = [
|
||||||
];
|
];
|
||||||
|
|
||||||
const FEEDBACK_SECTIONS: FeedbackCenterSection[] = ["overview", "metrics", "trend", "task", "qa", "signal"];
|
const FEEDBACK_SECTIONS: FeedbackCenterSection[] = ["overview", "metrics", "trend", "task", "qa", "signal"];
|
||||||
|
const DEFAULT_EXECUTIVE_BRIEFING_CONFIG: ExecutiveBriefingConfig = {
|
||||||
|
enabled: true,
|
||||||
|
send_time: "08:30",
|
||||||
|
send_cycle: "workdays",
|
||||||
|
delivery_target_type: "management_group",
|
||||||
|
delivery_target_ref: null,
|
||||||
|
timezone: "Asia/Shanghai",
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
};
|
||||||
|
|
||||||
function normalizeTab(value: string | null): AIStewardTab {
|
function normalizeTab(value: string | null): AIStewardTab {
|
||||||
if (value && AIS_STEWARD_TABS.includes(value as AIStewardTab)) {
|
if (value && AIS_STEWARD_TABS.includes(value as AIStewardTab)) {
|
||||||
|
|
@ -88,6 +100,7 @@ export default function AIStewardPage() {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const canViewFeedbackCenter = parseUserRoles(user?.role).some((role) => role === "ADMIN" || role === "BOSS");
|
const canViewFeedbackCenter = parseUserRoles(user?.role).some((role) => role === "ADMIN" || role === "BOSS");
|
||||||
|
const canManageExecutiveBriefing = canViewFeedbackCenter;
|
||||||
|
|
||||||
// Project creation state
|
// Project creation state
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
|
|
@ -109,6 +122,17 @@ export default function AIStewardPage() {
|
||||||
const [blockers, setBlockers] = useState<Blocker[]>([]);
|
const [blockers, setBlockers] = useState<Blocker[]>([]);
|
||||||
const [briefing, setBriefing] = useState<DailyBriefing | null>(null);
|
const [briefing, setBriefing] = useState<DailyBriefing | null>(null);
|
||||||
const [briefingError, setBriefingError] = useState<string | null>(null);
|
const [briefingError, setBriefingError] = useState<string | null>(null);
|
||||||
|
const [executiveBriefing, setExecutiveBriefing] = useState<ExecutiveBriefingPayload | null>(null);
|
||||||
|
const [executiveBriefingMode, setExecutiveBriefingMode] = useState<"snapshot" | "live">("snapshot");
|
||||||
|
const [executiveBriefingError, setExecutiveBriefingError] = useState<string | null>(null);
|
||||||
|
const [executiveBriefingLoading, setExecutiveBriefingLoading] = useState(false);
|
||||||
|
const [executiveBriefingConfigDraft, setExecutiveBriefingConfigDraft] = useState<ExecutiveBriefingConfig>(
|
||||||
|
DEFAULT_EXECUTIVE_BRIEFING_CONFIG
|
||||||
|
);
|
||||||
|
const [executiveBriefingConfigError, setExecutiveBriefingConfigError] = useState<string | null>(null);
|
||||||
|
const [executiveBriefingSaveMessage, setExecutiveBriefingSaveMessage] = useState<EngineMessage | null>(null);
|
||||||
|
const [executiveBriefingConfigLoading, setExecutiveBriefingConfigLoading] = useState(false);
|
||||||
|
const [executiveBriefingConfigSaving, setExecutiveBriefingConfigSaving] = useState(false);
|
||||||
const [featureLoading, setFeatureLoading] = useState<string | null>(null);
|
const [featureLoading, setFeatureLoading] = useState<string | null>(null);
|
||||||
const [engineData, setEngineData] = useState<EngineDataState>({
|
const [engineData, setEngineData] = useState<EngineDataState>({
|
||||||
active_engine_id: null,
|
active_engine_id: null,
|
||||||
|
|
@ -146,14 +170,8 @@ export default function AIStewardPage() {
|
||||||
const loadConflicts = async () => {
|
const loadConflicts = async () => {
|
||||||
setFeatureLoading("conflicts");
|
setFeatureLoading("conflicts");
|
||||||
try {
|
try {
|
||||||
const token = getAuthToken();
|
const data = await aiStewardApi.listCognitiveConflicts();
|
||||||
const response = await fetch(`${apiBaseURL}/ai-steward/cognitive-conflicts`, {
|
setConflicts(data);
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
});
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
setConflicts(data);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load conflicts:", err);
|
console.error("Failed to load conflicts:", err);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -164,14 +182,8 @@ export default function AIStewardPage() {
|
||||||
const loadBlockers = async () => {
|
const loadBlockers = async () => {
|
||||||
setFeatureLoading("blockers");
|
setFeatureLoading("blockers");
|
||||||
try {
|
try {
|
||||||
const token = getAuthToken();
|
const data = await aiStewardApi.listActiveBlockers();
|
||||||
const response = await fetch(`${apiBaseURL}/ai-steward/blockers/active`, {
|
setBlockers(data.blockers || []);
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
});
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
setBlockers(data.blockers || []);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load blockers:", err);
|
console.error("Failed to load blockers:", err);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -183,45 +195,74 @@ export default function AIStewardPage() {
|
||||||
setFeatureLoading("briefing");
|
setFeatureLoading("briefing");
|
||||||
setBriefingError(null);
|
setBriefingError(null);
|
||||||
try {
|
try {
|
||||||
const token = getAuthToken();
|
const data = await aiStewardApi.getDailyBriefing();
|
||||||
const response = await fetch(`${apiBaseURL}/ai-steward/daily-briefing`, {
|
setBriefing(data);
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
});
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
setBriefing(data);
|
|
||||||
} else {
|
|
||||||
const payload = await response.json().catch(() => null);
|
|
||||||
const detail = typeof payload?.detail === "string" ? payload.detail : typeof payload?.message === "string" ? payload.message : "";
|
|
||||||
setBriefing(null);
|
|
||||||
setBriefingError(detail || `获取简报失败 (${response.status})`);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load briefing:", err);
|
console.error("Failed to load briefing:", err);
|
||||||
setBriefing(null);
|
setBriefing(null);
|
||||||
setBriefingError(err instanceof Error ? err.message : "获取简报失败");
|
setBriefingError(getApiErrorMessage(err, "获取简报失败"));
|
||||||
} finally {
|
} finally {
|
||||||
setFeatureLoading(null);
|
setFeatureLoading(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadExecutiveBriefing = async (mode: "snapshot" | "live") => {
|
||||||
|
setExecutiveBriefingLoading(true);
|
||||||
|
setExecutiveBriefingError(null);
|
||||||
|
setExecutiveBriefingMode(mode);
|
||||||
|
try {
|
||||||
|
const data = await aiStewardApi.getExecutiveBriefing(mode);
|
||||||
|
setExecutiveBriefing(data);
|
||||||
|
setExecutiveBriefingMode(data.mode);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load executive briefing:", err);
|
||||||
|
setExecutiveBriefingError(getApiErrorMessage(err, "获取老板晨报失败"));
|
||||||
|
} finally {
|
||||||
|
setExecutiveBriefingLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadExecutiveBriefingConfig = async () => {
|
||||||
|
setExecutiveBriefingConfigLoading(true);
|
||||||
|
setExecutiveBriefingConfigError(null);
|
||||||
|
try {
|
||||||
|
const data = await aiStewardApi.getExecutiveBriefingConfig();
|
||||||
|
setExecutiveBriefingConfigDraft(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load executive briefing config:", err);
|
||||||
|
setExecutiveBriefingConfigError(getApiErrorMessage(err, "加载晨报配置失败"));
|
||||||
|
} finally {
|
||||||
|
setExecutiveBriefingConfigLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveExecutiveBriefingConfig = async () => {
|
||||||
|
setExecutiveBriefingConfigSaving(true);
|
||||||
|
setExecutiveBriefingConfigError(null);
|
||||||
|
setExecutiveBriefingSaveMessage(null);
|
||||||
|
try {
|
||||||
|
const data = await aiStewardApi.updateExecutiveBriefingConfig(executiveBriefingConfigDraft);
|
||||||
|
setExecutiveBriefingConfigDraft(data);
|
||||||
|
setExecutiveBriefingSaveMessage({ type: "success", text: "晨报调度配置已保存" });
|
||||||
|
if (activeTab === "overview") {
|
||||||
|
await loadExecutiveBriefing("snapshot");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to save executive briefing config:", err);
|
||||||
|
const message = getApiErrorMessage(err, "保存晨报配置失败");
|
||||||
|
setExecutiveBriefingConfigError(message);
|
||||||
|
setExecutiveBriefingSaveMessage({ type: "error", text: message });
|
||||||
|
} finally {
|
||||||
|
setExecutiveBriefingConfigSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const detectConflicts = async (projectId: number = 1) => {
|
const detectConflicts = async (projectId: number = 1) => {
|
||||||
setFeatureLoading("detect-conflicts");
|
setFeatureLoading("detect-conflicts");
|
||||||
try {
|
try {
|
||||||
const token = getAuthToken();
|
await aiStewardApi.detectConflicts(projectId);
|
||||||
const response = await fetch(`${apiBaseURL}/ai-steward/conflicts/detect`, {
|
await loadConflicts();
|
||||||
method: "POST",
|
return true;
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ project_id: projectId, detect_type: "all" }),
|
|
||||||
});
|
|
||||||
if (response.ok) {
|
|
||||||
await loadConflicts();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to detect conflicts:", err);
|
console.error("Failed to detect conflicts:", err);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -233,15 +274,9 @@ export default function AIStewardPage() {
|
||||||
const detectTaskBlockers = async () => {
|
const detectTaskBlockers = async () => {
|
||||||
setFeatureLoading("detect-blockers");
|
setFeatureLoading("detect-blockers");
|
||||||
try {
|
try {
|
||||||
const token = getAuthToken();
|
await aiStewardApi.detectTaskBlockers();
|
||||||
const response = await fetch(`${apiBaseURL}/ai-steward/blockers/detect-tasks`, {
|
await loadBlockers();
|
||||||
method: "POST",
|
return true;
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
});
|
|
||||||
if (response.ok) {
|
|
||||||
await loadBlockers();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to detect blockers:", err);
|
console.error("Failed to detect blockers:", err);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -253,14 +288,8 @@ export default function AIStewardPage() {
|
||||||
const startHNMonitor = async () => {
|
const startHNMonitor = async () => {
|
||||||
setFeatureLoading("hn-monitor");
|
setFeatureLoading("hn-monitor");
|
||||||
try {
|
try {
|
||||||
const token = getAuthToken();
|
await aiStewardApi.startHnMonitor(30);
|
||||||
const response = await fetch(`${apiBaseURL}/ai-steward/signal/start-hn-monitor?interval_minutes=30`, {
|
return true;
|
||||||
method: "POST",
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
});
|
|
||||||
if (response.ok) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to start HN monitor:", err);
|
console.error("Failed to start HN monitor:", err);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -460,6 +489,13 @@ export default function AIStewardPage() {
|
||||||
setEngineMessage(null);
|
setEngineMessage(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateExecutiveBriefingDraft = (patch: Partial<ExecutiveBriefingConfig>) => {
|
||||||
|
setExecutiveBriefingConfigDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
...patch,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
// ==================== Project Creation ====================
|
// ==================== Project Creation ====================
|
||||||
|
|
||||||
const handleProjectSubmit = async () => {
|
const handleProjectSubmit = async () => {
|
||||||
|
|
@ -543,6 +579,10 @@ export default function AIStewardPage() {
|
||||||
}, [searchParams, activeTab, feedbackSection, trendWindowDays, trendSourceGroup, canViewFeedbackCenter]);
|
}, [searchParams, activeTab, feedbackSection, trendWindowDays, trendSourceGroup, canViewFeedbackCenter]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (activeTab === "overview" && canManageExecutiveBriefing) {
|
||||||
|
void loadExecutiveBriefing("snapshot");
|
||||||
|
void loadExecutiveBriefingConfig();
|
||||||
|
}
|
||||||
if (activeTab === "conflicts") loadConflicts();
|
if (activeTab === "conflicts") loadConflicts();
|
||||||
if (activeTab === "blockers") loadBlockers();
|
if (activeTab === "blockers") loadBlockers();
|
||||||
if (activeTab === "briefing") loadBriefing();
|
if (activeTab === "briefing") loadBriefing();
|
||||||
|
|
@ -550,7 +590,7 @@ export default function AIStewardPage() {
|
||||||
loadEngineProviderPresets();
|
loadEngineProviderPresets();
|
||||||
loadEngineConfigs();
|
loadEngineConfigs();
|
||||||
}
|
}
|
||||||
}, [activeTab]);
|
}, [activeTab, canManageExecutiveBriefing]);
|
||||||
|
|
||||||
// ==================== Render ====================
|
// ==================== Render ====================
|
||||||
|
|
||||||
|
|
@ -558,26 +598,34 @@ export default function AIStewardPage() {
|
||||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 p-3 dark:from-slate-950 dark:to-slate-900 sm:p-4 md:p-6">
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 p-3 dark:from-slate-950 dark:to-slate-900 sm:p-4 md:p-6">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="text-center mb-8">
|
<div className="mb-8 text-center">
|
||||||
<div className="flex items-center justify-center gap-3 mb-4">
|
<div className="mb-4 flex items-center justify-center gap-3">
|
||||||
<Sparkles className="w-10 h-10 text-violet-500" />
|
<Sparkles className={`h-10 w-10 ${canManageExecutiveBriefing ? "text-amber-500" : "text-violet-500"}`} />
|
||||||
<h1 className="text-4xl font-bold text-slate-900 dark:text-slate-100">
|
<h1 className="text-4xl font-bold text-slate-900 dark:text-slate-100">
|
||||||
AI 管家
|
{canManageExecutiveBriefing ? "老板晨报中心" : "AI 管家"}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-slate-600 dark:text-slate-400 text-lg">
|
<p className="text-slate-600 dark:text-slate-400 text-lg">
|
||||||
主动式 AI 管家 - 7x24 小时自动守护项目
|
{canManageExecutiveBriefing
|
||||||
|
? "晨报快照、实时刷新与管理群推送共用同一套经营简报内核"
|
||||||
|
: "主动式 AI 管家 - 7x24 小时自动守护项目"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FeatureOverviewCards onSelectTab={(tab) => handleTabChange(tab)} />
|
<FeatureOverviewCards
|
||||||
|
onSelectTab={(tab) => handleTabChange(tab)}
|
||||||
|
canManageExecutiveBriefing={canManageExecutiveBriefing}
|
||||||
|
executiveBriefing={executiveBriefing}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<Tabs value={activeTab} onValueChange={handleTabChange}>
|
<Tabs value={activeTab} onValueChange={handleTabChange}>
|
||||||
<TabsList className="flex w-full min-w-0 flex-nowrap gap-1 overflow-x-auto rounded-lg p-1 [-webkit-overflow-scrolling:touch]">
|
<TabsList className="flex w-full min-w-0 flex-nowrap gap-1 overflow-x-auto rounded-lg p-1 [-webkit-overflow-scrolling:touch]">
|
||||||
<TabsTrigger value="overview" className="shrink-0 whitespace-nowrap">总览</TabsTrigger>
|
<TabsTrigger value="overview" className="shrink-0 whitespace-nowrap">
|
||||||
|
{canManageExecutiveBriefing ? "晨报中心" : "总览"}
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger value="create" className="shrink-0 whitespace-nowrap">零配置创建</TabsTrigger>
|
<TabsTrigger value="create" className="shrink-0 whitespace-nowrap">零配置创建</TabsTrigger>
|
||||||
<TabsTrigger value="signals" className="shrink-0 whitespace-nowrap">信号捕获</TabsTrigger>
|
<TabsTrigger value="signals" className="shrink-0 whitespace-nowrap">信号捕获</TabsTrigger>
|
||||||
<TabsTrigger value="conflicts" className="shrink-0 whitespace-nowrap">认知冲突</TabsTrigger>
|
<TabsTrigger value="conflicts" className="shrink-0 whitespace-nowrap">认知冲突</TabsTrigger>
|
||||||
|
|
@ -590,7 +638,23 @@ export default function AIStewardPage() {
|
||||||
<TabsTrigger value="engine" className="shrink-0 whitespace-nowrap">引擎配置</TabsTrigger>
|
<TabsTrigger value="engine" className="shrink-0 whitespace-nowrap">引擎配置</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<OverviewTab />
|
<OverviewTab
|
||||||
|
canManageExecutiveBriefing={canManageExecutiveBriefing}
|
||||||
|
executiveBriefing={executiveBriefing}
|
||||||
|
executiveBriefingError={executiveBriefingError}
|
||||||
|
executiveBriefingMode={executiveBriefingMode}
|
||||||
|
executiveBriefingLoading={executiveBriefingLoading}
|
||||||
|
executiveBriefingConfigDraft={executiveBriefingConfigDraft}
|
||||||
|
executiveBriefingConfigLoading={executiveBriefingConfigLoading}
|
||||||
|
executiveBriefingConfigError={executiveBriefingConfigError}
|
||||||
|
executiveBriefingSaveLoading={executiveBriefingConfigSaving}
|
||||||
|
executiveBriefingSaveMessage={executiveBriefingSaveMessage}
|
||||||
|
onLoadSnapshot={() => void loadExecutiveBriefing("snapshot")}
|
||||||
|
onRefreshLive={() => void loadExecutiveBriefing("live")}
|
||||||
|
onConfigChange={updateExecutiveBriefingDraft}
|
||||||
|
onSaveConfig={() => void saveExecutiveBriefingConfig()}
|
||||||
|
onSelectTab={(tab) => handleTabChange(tab)}
|
||||||
|
/>
|
||||||
|
|
||||||
<CreateProjectTab
|
<CreateProjectTab
|
||||||
input={input}
|
input={input}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,83 @@
|
||||||
import { AlertTriangle, Bell, Radar, Shield } from "lucide-react";
|
import { AlertTriangle, Bell, BriefcaseBusiness, Radar, Shield, Users2 } from "lucide-react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import type { AIStewardTab, ExecutiveBriefingPayload } from "@/pages/ai-steward/types";
|
||||||
|
|
||||||
interface FeatureOverviewCardsProps {
|
interface FeatureOverviewCardsProps {
|
||||||
onSelectTab: (tab: "signals" | "conflicts" | "blockers" | "briefing") => void;
|
onSelectTab: (tab: AIStewardTab) => void;
|
||||||
|
canManageExecutiveBriefing?: boolean;
|
||||||
|
executiveBriefing?: ExecutiveBriefingPayload | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FeatureOverviewCards({ onSelectTab }: FeatureOverviewCardsProps) {
|
const EXECUTIVE_CARD_META: Record<
|
||||||
|
string,
|
||||||
|
{ badge: string; icon: typeof AlertTriangle; className: string; fallbackTab: AIStewardTab }
|
||||||
|
> = {
|
||||||
|
decisions: {
|
||||||
|
badge: "需拍板",
|
||||||
|
icon: BriefcaseBusiness,
|
||||||
|
className: "border-amber-200 bg-amber-50/80 dark:border-amber-900 dark:bg-amber-950/30",
|
||||||
|
fallbackTab: "signals",
|
||||||
|
},
|
||||||
|
input_health: {
|
||||||
|
badge: "业务输入",
|
||||||
|
icon: Radar,
|
||||||
|
className: "border-sky-200 bg-sky-50/80 dark:border-sky-900 dark:bg-sky-950/30",
|
||||||
|
fallbackTab: "signals",
|
||||||
|
},
|
||||||
|
delivery_risk: {
|
||||||
|
badge: "交付风险",
|
||||||
|
icon: Shield,
|
||||||
|
className: "border-rose-200 bg-rose-50/80 dark:border-rose-900 dark:bg-rose-950/30",
|
||||||
|
fallbackTab: "blockers",
|
||||||
|
},
|
||||||
|
team_capacity: {
|
||||||
|
badge: "团队产能",
|
||||||
|
icon: Users2,
|
||||||
|
className: "border-emerald-200 bg-emerald-50/80 dark:border-emerald-900 dark:bg-emerald-950/30",
|
||||||
|
fallbackTab: "conflicts",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FeatureOverviewCards({
|
||||||
|
onSelectTab,
|
||||||
|
canManageExecutiveBriefing = false,
|
||||||
|
executiveBriefing,
|
||||||
|
}: FeatureOverviewCardsProps) {
|
||||||
|
if (canManageExecutiveBriefing && executiveBriefing) {
|
||||||
|
const cards = executiveBriefing.sections.filter((section) => section.key in EXECUTIVE_CARD_META).slice(0, 4);
|
||||||
|
return (
|
||||||
|
<div className="mb-8 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{cards.map((section) => {
|
||||||
|
const meta = EXECUTIVE_CARD_META[section.key];
|
||||||
|
const Icon = meta.icon;
|
||||||
|
const primaryMetric = section.metrics[0];
|
||||||
|
const value = primaryMetric ? String(primaryMetric.value) : "-";
|
||||||
|
const targetTab = (section.drilldown_tab as AIStewardTab | undefined) || meta.fallbackTab;
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={section.key}
|
||||||
|
className={`${meta.className} cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-lg`}
|
||||||
|
onClick={() => onSelectTab(targetTab)}
|
||||||
|
>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Icon className="h-8 w-8 text-slate-900 dark:text-slate-100" />
|
||||||
|
<Badge variant="secondary">{meta.badge}</Badge>
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-lg">{section.title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<p className="text-3xl font-semibold tracking-tight text-slate-950 dark:text-slate-50">{value}</p>
|
||||||
|
<p className="min-h-[40px] text-sm text-slate-600 dark:text-slate-300">{section.summary}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-8 grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
<div className="mb-8 grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
<Card className="border-blue-200 dark:border-blue-800 hover:shadow-lg transition-shadow cursor-pointer" onClick={() => onSelectTab("signals")}>
|
<Card className="border-blue-200 dark:border-blue-800 hover:shadow-lg transition-shadow cursor-pointer" onClick={() => onSelectTab("signals")}>
|
||||||
|
|
|
||||||
|
|
@ -6,24 +6,95 @@ import {
|
||||||
Clock,
|
Clock,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Radar,
|
Radar,
|
||||||
|
RefreshCcw,
|
||||||
Shield,
|
Shield,
|
||||||
|
Sparkles,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { TabsContent } from "@/components/ui/tabs";
|
import { TabsContent } from "@/components/ui/tabs";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import type {
|
||||||
|
AIStewardTab,
|
||||||
|
EngineMessage,
|
||||||
|
ExecutiveBriefingConfig,
|
||||||
|
ExecutiveBriefingPayload,
|
||||||
|
} from "@/pages/ai-steward/types";
|
||||||
|
|
||||||
export default function OverviewTab() {
|
interface OverviewTabProps {
|
||||||
|
canManageExecutiveBriefing: boolean;
|
||||||
|
executiveBriefing: ExecutiveBriefingPayload | null;
|
||||||
|
executiveBriefingError: string | null;
|
||||||
|
executiveBriefingMode: "snapshot" | "live";
|
||||||
|
executiveBriefingLoading: boolean;
|
||||||
|
executiveBriefingConfigDraft: ExecutiveBriefingConfig;
|
||||||
|
executiveBriefingConfigLoading: boolean;
|
||||||
|
executiveBriefingConfigError: string | null;
|
||||||
|
executiveBriefingSaveLoading: boolean;
|
||||||
|
executiveBriefingSaveMessage: EngineMessage | null;
|
||||||
|
onLoadSnapshot: () => void;
|
||||||
|
onRefreshLive: () => void;
|
||||||
|
onConfigChange: (patch: Partial<ExecutiveBriefingConfig>) => void;
|
||||||
|
onSaveConfig: () => void;
|
||||||
|
onSelectTab: (tab: AIStewardTab) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SECTION_STYLE: Record<string, string> = {
|
||||||
|
decisions: "border-amber-200 bg-amber-50/70 dark:border-amber-900 dark:bg-amber-950/30",
|
||||||
|
input_health: "border-sky-200 bg-sky-50/70 dark:border-sky-900 dark:bg-sky-950/30",
|
||||||
|
delivery_risk: "border-rose-200 bg-rose-50/70 dark:border-rose-900 dark:bg-rose-950/30",
|
||||||
|
team_capacity: "border-emerald-200 bg-emerald-50/70 dark:border-emerald-900 dark:bg-emerald-950/30",
|
||||||
|
ai_governance: "border-indigo-200 bg-indigo-50/70 dark:border-indigo-900 dark:bg-indigo-950/30",
|
||||||
|
external_metrics: "border-slate-200 bg-slate-50/70 dark:border-slate-800 dark:bg-slate-950/30",
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatTimestamp(value?: string | null): string {
|
||||||
|
if (!value) return "-";
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
|
return date.toLocaleString("zh-CN", {
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMetricValue(value: string | number): string {
|
||||||
|
return typeof value === "number" ? String(value) : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toneBadgeClass(tone: string): string {
|
||||||
|
switch (tone) {
|
||||||
|
case "critical":
|
||||||
|
return "bg-rose-100 text-rose-700 dark:bg-rose-950 dark:text-rose-200";
|
||||||
|
case "warning":
|
||||||
|
return "bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-200";
|
||||||
|
case "positive":
|
||||||
|
return "bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-200";
|
||||||
|
case "info":
|
||||||
|
return "bg-sky-100 text-sky-700 dark:bg-sky-950 dark:text-sky-200";
|
||||||
|
default:
|
||||||
|
return "bg-slate-100 text-slate-700 dark:bg-slate-900 dark:text-slate-200";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function LegacyOverview() {
|
||||||
return (
|
return (
|
||||||
<TabsContent value="overview" className="mt-6">
|
<TabsContent value="overview" className="mt-6">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="text-center mb-8">
|
<div className="mb-8 text-center">
|
||||||
<h2 className="text-2xl font-bold mb-2">AI 管家正在运行</h2>
|
<h2 className="mb-2 text-2xl font-bold">AI 管家正在运行</h2>
|
||||||
<p className="text-slate-600 dark:text-slate-400">4 大主动服务守护你的项目,无需手动触发</p>
|
<p className="text-slate-600 dark:text-slate-400">4 大主动服务守护你的项目,无需手动触发</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
<div className="p-6 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
|
<div className="rounded-lg border border-blue-200 bg-blue-50 p-6 dark:border-blue-800 dark:bg-blue-900/20">
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="mb-4 flex items-center gap-3">
|
||||||
<div className="w-12 h-12 rounded-full bg-blue-500 flex items-center justify-center">
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-blue-500">
|
||||||
<Radar className="w-6 h-6 text-white" />
|
<Radar className="h-6 w-6 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold">1. 主动抓取信号</h3>
|
<h3 className="font-semibold">1. 主动抓取信号</h3>
|
||||||
|
|
@ -32,26 +103,26 @@ export default function OverviewTab() {
|
||||||
</div>
|
</div>
|
||||||
<ul className="space-y-2 text-sm">
|
<ul className="space-y-2 text-sm">
|
||||||
<li className="flex items-center gap-2">
|
<li className="flex items-center gap-2">
|
||||||
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||||
<MessageSquare className="w-4 h-4 text-blue-500" />
|
<MessageSquare className="h-4 w-4 text-blue-500" />
|
||||||
飞书群聊监控 - 配置完成,正在运行
|
飞书群聊监控 - 配置完成,正在运行
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-center gap-2">
|
<li className="flex items-center gap-2">
|
||||||
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||||
<Activity className="w-4 h-4 text-purple-500" />
|
<Activity className="h-4 w-4 text-purple-500" />
|
||||||
技术资讯定时抓取 - 可启用
|
技术资讯定时抓取 - 可启用
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-center gap-2 opacity-50">
|
<li className="flex items-center gap-2 opacity-50">
|
||||||
<Clock className="w-4 h-4" />
|
<Clock className="h-4 w-4" />
|
||||||
浏览器标签监控 - 需安装扩展
|
浏览器标签监控 - 需安装扩展
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6 bg-orange-50 dark:bg-orange-900/20 rounded-lg border border-orange-200 dark:border-orange-800">
|
<div className="rounded-lg border border-orange-200 bg-orange-50 p-6 dark:border-orange-800 dark:bg-orange-900/20">
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="mb-4 flex items-center gap-3">
|
||||||
<div className="w-12 h-12 rounded-full bg-orange-500 flex items-center justify-center">
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-orange-500">
|
||||||
<AlertTriangle className="w-6 h-6 text-white" />
|
<AlertTriangle className="h-6 w-6 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold">2. 主动消解认知差</h3>
|
<h3 className="font-semibold">2. 主动消解认知差</h3>
|
||||||
|
|
@ -60,24 +131,24 @@ export default function OverviewTab() {
|
||||||
</div>
|
</div>
|
||||||
<ul className="space-y-2 text-sm">
|
<ul className="space-y-2 text-sm">
|
||||||
<li className="flex items-center gap-2">
|
<li className="flex items-center gap-2">
|
||||||
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||||
自动检测定义不一致(如"留存" vs "DAU")
|
自动检测定义不一致(如"留存" vs "DAU")
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-center gap-2">
|
<li className="flex items-center gap-2">
|
||||||
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||||
任务与宪章一致性检查
|
任务与宪章一致性检查
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-center gap-2">
|
<li className="flex items-center gap-2">
|
||||||
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||||
AI 生成对齐方案
|
AI 生成对齐方案
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800">
|
<div className="rounded-lg border border-red-200 bg-red-50 p-6 dark:border-red-800 dark:bg-red-900/20">
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="mb-4 flex items-center gap-3">
|
||||||
<div className="w-12 h-12 rounded-full bg-red-500 flex items-center justify-center">
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-red-500">
|
||||||
<Shield className="w-6 h-6 text-white" />
|
<Shield className="h-6 w-6 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold">3. 主动解决阻塞</h3>
|
<h3 className="font-semibold">3. 主动解决阻塞</h3>
|
||||||
|
|
@ -86,24 +157,24 @@ export default function OverviewTab() {
|
||||||
</div>
|
</div>
|
||||||
<ul className="space-y-2 text-sm">
|
<ul className="space-y-2 text-sm">
|
||||||
<li className="flex items-center gap-2">
|
<li className="flex items-center gap-2">
|
||||||
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||||
识别阻塞模式("等待X接口"、"达不到效果")
|
识别阻塞模式("等待X接口"、"达不到效果")
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-center gap-2">
|
<li className="flex items-center gap-2">
|
||||||
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||||
AI 分析严重程度并生成解决方案
|
AI 分析严重程度并生成解决方案
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-center gap-2">
|
<li className="flex items-center gap-2">
|
||||||
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||||
自动创建追踪任务
|
自动创建追踪任务
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800">
|
<div className="rounded-lg border border-green-200 bg-green-50 p-6 dark:border-green-800 dark:bg-green-900/20">
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="mb-4 flex items-center gap-3">
|
||||||
<div className="w-12 h-12 rounded-full bg-green-500 flex items-center justify-center">
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-green-500">
|
||||||
<Bell className="w-6 h-6 text-white" />
|
<Bell className="h-6 w-6 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold">4. 主动同步进度</h3>
|
<h3 className="font-semibold">4. 主动同步进度</h3>
|
||||||
|
|
@ -112,15 +183,15 @@ export default function OverviewTab() {
|
||||||
</div>
|
</div>
|
||||||
<ul className="space-y-2 text-sm">
|
<ul className="space-y-2 text-sm">
|
||||||
<li className="flex items-center gap-2">
|
<li className="flex items-center gap-2">
|
||||||
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||||
按角色智能过滤信息
|
按角色智能过滤信息
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-center gap-2">
|
<li className="flex items-center gap-2">
|
||||||
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||||
只显示相关任务变更和风险
|
只显示相关任务变更和风险
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-center gap-2">
|
<li className="flex items-center gap-2">
|
||||||
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||||
推送行动项和对齐提醒
|
推送行动项和对齐提醒
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
@ -130,3 +201,273 @@ export default function OverviewTab() {
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function OverviewTab({
|
||||||
|
canManageExecutiveBriefing,
|
||||||
|
executiveBriefing,
|
||||||
|
executiveBriefingError,
|
||||||
|
executiveBriefingMode,
|
||||||
|
executiveBriefingLoading,
|
||||||
|
executiveBriefingConfigDraft,
|
||||||
|
executiveBriefingConfigLoading,
|
||||||
|
executiveBriefingConfigError,
|
||||||
|
executiveBriefingSaveLoading,
|
||||||
|
executiveBriefingSaveMessage,
|
||||||
|
onLoadSnapshot,
|
||||||
|
onRefreshLive,
|
||||||
|
onConfigChange,
|
||||||
|
onSaveConfig,
|
||||||
|
onSelectTab,
|
||||||
|
}: OverviewTabProps) {
|
||||||
|
if (!canManageExecutiveBriefing) {
|
||||||
|
return <LegacyOverview />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sections = executiveBriefing?.sections ?? [];
|
||||||
|
const hero = executiveBriefing?.hero;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabsContent value="overview" className="mt-6">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card className="overflow-hidden border-amber-200 bg-gradient-to-br from-stone-50 via-amber-50 to-orange-100 shadow-sm dark:border-amber-900 dark:from-slate-950 dark:via-stone-950 dark:to-amber-950/50">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex flex-col gap-6 xl:flex-row xl:items-end xl:justify-between">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Badge className="bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900">
|
||||||
|
{executiveBriefingMode === "live" ? "实时模式" : "晨报快照"}
|
||||||
|
</Badge>
|
||||||
|
{hero?.snapshot_state === "missing" && (
|
||||||
|
<Badge className="bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-200">
|
||||||
|
今日快照未生成
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="flex items-center gap-3 text-3xl font-semibold tracking-tight text-slate-950 dark:text-slate-50">
|
||||||
|
<Sparkles className="h-7 w-7 text-amber-600 dark:text-amber-300" />
|
||||||
|
老板晨报中心
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 max-w-3xl text-sm leading-6 text-slate-600 dark:text-slate-300">
|
||||||
|
用晨报快照做日常基线,用实时刷新做临时复盘。先看异常,再看输入、交付、团队和 AI 治理。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||||
|
<div className="rounded-2xl border border-slate-200/80 bg-white/80 p-4 dark:border-slate-800 dark:bg-slate-900/70">
|
||||||
|
<p className="text-xs uppercase tracking-[0.18em] text-slate-500">需拍板</p>
|
||||||
|
<p className="mt-2 text-3xl font-semibold text-slate-950 dark:text-slate-50">
|
||||||
|
{hero?.decision_count ?? 0}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-slate-200/80 bg-white/80 p-4 dark:border-slate-800 dark:bg-slate-900/70">
|
||||||
|
<p className="text-xs uppercase tracking-[0.18em] text-slate-500">异常总数</p>
|
||||||
|
<p className="mt-2 text-3xl font-semibold text-slate-950 dark:text-slate-50">
|
||||||
|
{hero?.anomaly_count ?? 0}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-slate-200/80 bg-white/80 p-4 dark:border-slate-800 dark:bg-slate-900/70">
|
||||||
|
<p className="text-xs uppercase tracking-[0.18em] text-slate-500">查看日期</p>
|
||||||
|
<p className="mt-2 text-3xl font-semibold text-slate-950 dark:text-slate-50">
|
||||||
|
{executiveBriefing?.brief_date || "-"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 space-y-3 xl:w-[360px]">
|
||||||
|
<div className="rounded-2xl border border-slate-200/80 bg-white/80 p-4 text-sm dark:border-slate-800 dark:bg-slate-900/70">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="text-slate-500">晨报快照</span>
|
||||||
|
<span className="font-medium text-slate-900 dark:text-slate-100">
|
||||||
|
{formatTimestamp(hero?.baseline_generated_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center justify-between gap-3">
|
||||||
|
<span className="text-slate-500">实时刷新</span>
|
||||||
|
<span className="font-medium text-slate-900 dark:text-slate-100">
|
||||||
|
{formatTimestamp(hero?.live_generated_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{(hero?.key_alerts ?? []).map((item) => (
|
||||||
|
<Badge key={`${item.label}-${item.value}`} className={toneBadgeClass(item.tone)}>
|
||||||
|
{item.label} {formatMetricValue(item.value)}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onLoadSnapshot}
|
||||||
|
disabled={executiveBriefingLoading}
|
||||||
|
className="border-slate-300 bg-white/80 dark:border-slate-700 dark:bg-slate-900/60"
|
||||||
|
>
|
||||||
|
查看晨报快照
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={onRefreshLive}
|
||||||
|
disabled={executiveBriefingLoading}
|
||||||
|
className="bg-slate-900 text-white hover:bg-slate-800 dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-slate-200"
|
||||||
|
>
|
||||||
|
<RefreshCcw className="h-4 w-4" />
|
||||||
|
实时刷新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{executiveBriefingLoading && (
|
||||||
|
<p className="mt-4 text-sm text-slate-500 dark:text-slate-400">正在更新晨报数据...</p>
|
||||||
|
)}
|
||||||
|
{executiveBriefingError && (
|
||||||
|
<p className="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700 dark:border-rose-900 dark:bg-rose-950/40 dark:text-rose-200">
|
||||||
|
{executiveBriefingError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-5 xl:grid-cols-2">
|
||||||
|
{sections.map((section) => (
|
||||||
|
<Card key={section.key} className={`${SECTION_STYLE[section.key] || SECTION_STYLE.external_metrics} shadow-sm`}>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-semibold text-slate-950 dark:text-slate-50">{section.title}</h3>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-300">{section.summary}</p>
|
||||||
|
</div>
|
||||||
|
{section.drilldown_tab && section.drilldown_tab !== "overview" && (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => onSelectTab(section.drilldown_tab as AIStewardTab)}>
|
||||||
|
查看明细
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||||
|
{section.metrics.map((metric) => (
|
||||||
|
<div key={`${section.key}-${metric.label}`} className="rounded-xl border border-white/60 bg-white/70 p-3 dark:border-slate-800 dark:bg-slate-950/50">
|
||||||
|
<p className="text-xs uppercase tracking-[0.14em] text-slate-500">{metric.label}</p>
|
||||||
|
<p className="mt-2 text-2xl font-semibold text-slate-950 dark:text-slate-50">
|
||||||
|
{formatMetricValue(metric.value)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{section.items.length ? (
|
||||||
|
section.items.slice(0, 3).map((item, index) => (
|
||||||
|
<div key={`${section.key}-${item.title}-${index}`} className="rounded-xl border border-white/70 bg-white/80 px-4 py-3 dark:border-slate-800 dark:bg-slate-950/50">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="font-medium text-slate-950 dark:text-slate-50">{item.title}</span>
|
||||||
|
<Badge className={toneBadgeClass(item.tone)}>{item.tone}</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-slate-600 dark:text-slate-300">{item.detail}</p>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="rounded-xl border border-dashed border-slate-300 bg-white/70 px-4 py-6 text-center text-sm text-slate-500 dark:border-slate-700 dark:bg-slate-950/40 dark:text-slate-400">
|
||||||
|
当前区块暂无新增异常。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="border-slate-200 shadow-sm dark:border-slate-800">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div className="max-w-xl">
|
||||||
|
<h3 className="text-xl font-semibold text-slate-950 dark:text-slate-50">晨报调度配置</h3>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-300">
|
||||||
|
这份配置是租户级的。修改后,Web 快照、定时调度和管理群自动推送都会沿用同一套口径。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm text-slate-500">自动发送</span>
|
||||||
|
<Switch
|
||||||
|
checked={executiveBriefingConfigDraft.enabled}
|
||||||
|
onCheckedChange={(checked) => onConfigChange({ enabled: checked })}
|
||||||
|
disabled={executiveBriefingConfigLoading || executiveBriefingSaveLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">发送时间</p>
|
||||||
|
<Input
|
||||||
|
type="time"
|
||||||
|
value={executiveBriefingConfigDraft.send_time}
|
||||||
|
onChange={(event) => onConfigChange({ send_time: event.target.value })}
|
||||||
|
disabled={executiveBriefingConfigLoading || executiveBriefingSaveLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">发送周期</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant={executiveBriefingConfigDraft.send_cycle === "workdays" ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onConfigChange({ send_cycle: "workdays" })}
|
||||||
|
disabled={executiveBriefingConfigLoading || executiveBriefingSaveLoading}
|
||||||
|
>
|
||||||
|
工作日
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={executiveBriefingConfigDraft.send_cycle === "daily" ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onConfigChange({ send_cycle: "daily" })}
|
||||||
|
disabled={executiveBriefingConfigLoading || executiveBriefingSaveLoading}
|
||||||
|
>
|
||||||
|
每天
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">管理群 Chat ID</p>
|
||||||
|
<Input
|
||||||
|
value={executiveBriefingConfigDraft.delivery_target_ref || ""}
|
||||||
|
onChange={(event) => onConfigChange({ delivery_target_ref: event.target.value || null })}
|
||||||
|
placeholder="oc_management_group"
|
||||||
|
disabled={executiveBriefingConfigLoading || executiveBriefingSaveLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">时区</p>
|
||||||
|
<Input
|
||||||
|
value={executiveBriefingConfigDraft.timezone}
|
||||||
|
onChange={(event) => onConfigChange({ timezone: event.target.value })}
|
||||||
|
placeholder="Asia/Shanghai"
|
||||||
|
disabled={executiveBriefingConfigLoading || executiveBriefingSaveLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{executiveBriefingConfigError && (
|
||||||
|
<p className="text-sm text-rose-600 dark:text-rose-300">{executiveBriefingConfigError}</p>
|
||||||
|
)}
|
||||||
|
{executiveBriefingSaveMessage && (
|
||||||
|
<p
|
||||||
|
className={`text-sm ${
|
||||||
|
executiveBriefingSaveMessage.type === "success"
|
||||||
|
? "text-emerald-600 dark:text-emerald-300"
|
||||||
|
: "text-rose-600 dark:text-rose-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{executiveBriefingSaveMessage.text}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button onClick={onSaveConfig} disabled={executiveBriefingConfigLoading || executiveBriefingSaveLoading}>
|
||||||
|
保存晨报配置
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,14 @@
|
||||||
import type { LLMEngine, LLMEngineListResponse } from "@/api/aiSteward";
|
import type { LLMEngine, LLMEngineListResponse } from "@/api/aiSteward";
|
||||||
export type {
|
export type {
|
||||||
|
Blocker,
|
||||||
|
CognitiveConflict,
|
||||||
|
DailyBriefing,
|
||||||
|
ExecutiveBriefingConfig,
|
||||||
|
ExecutiveBriefingHero,
|
||||||
|
ExecutiveBriefingItem,
|
||||||
|
ExecutiveBriefingMetric,
|
||||||
|
ExecutiveBriefingPayload,
|
||||||
|
ExecutiveBriefingSection,
|
||||||
ProjectFromTextRequest,
|
ProjectFromTextRequest,
|
||||||
ProjectFromTextRequirementItem,
|
ProjectFromTextRequirementItem,
|
||||||
ProjectFromTextResponse,
|
ProjectFromTextResponse,
|
||||||
|
|
@ -19,34 +28,6 @@ export type AIStewardTab =
|
||||||
| "feedback-center"
|
| "feedback-center"
|
||||||
| "engine";
|
| "engine";
|
||||||
|
|
||||||
export interface CognitiveConflict {
|
|
||||||
id: string;
|
|
||||||
type: string;
|
|
||||||
description: string;
|
|
||||||
severity: string;
|
|
||||||
related_users: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Blocker {
|
|
||||||
id: string;
|
|
||||||
type: string;
|
|
||||||
description: string;
|
|
||||||
severity: string;
|
|
||||||
detected_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DailyBriefing {
|
|
||||||
date: string;
|
|
||||||
summary: string;
|
|
||||||
highlights: Array<{ type: string; priority: string; content: string }>;
|
|
||||||
your_tasks: Array<{ id: number; title: string; status: string; priority: string }>;
|
|
||||||
risks: Array<{ type: string; task: string; severity: string }>;
|
|
||||||
signals: Array<{ id: number; source: string; content: string; impact_score: number }>;
|
|
||||||
conflicts: Array<{ id: string; type: string; description: string }>;
|
|
||||||
action_items: Array<{ type: string; content: string; priority: string }>;
|
|
||||||
team_updates: Array<{ assignee_id: number; task: string; status: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EngineProviderPreset {
|
export interface EngineProviderPreset {
|
||||||
model: string;
|
model: string;
|
||||||
base_url: string;
|
base_url: string;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue