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/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 拆分)
|
||||
- 新增:`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/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)
|
||||
|
|
@ -497,6 +502,8 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
|||
- 禁止在 API 层写业务逻辑,业务逻辑归属 `services/`
|
||||
- 禁止在前端组件内直接调用 `fetch`,必须通过 `frontend/src/api/` 封装层
|
||||
- 前端状态管理:服务端数据用 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 发送任务
|
||||
- 所有写接口必须落审计日志(`operation_logs`):至少包含 `trace_id/interface_path/caller`
|
||||
- `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`,仅管理员可修改本地知识库问答开关)
|
||||
- 飞书捕获模拟接口:`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/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`)
|
||||
- 模型配置热更新后,LLM provider circuit 会在下一次调用时自动清空旧熔断状态,避免旧配置失败污染新配置的连通性判断
|
||||
- 启动期模型探针只做连通性观测,不应把探针失败写回 provider circuit;否则会误伤真实业务请求
|
||||
|
|
@ -583,6 +591,8 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
|||
## 11. 已知 BOUNDARY(不要幻构行为)
|
||||
- 飞书回调字段可能缺失文件名/大小,仅保证 `attachment_id/key/type` 可用
|
||||
- 项目创建返回的 `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` 覆盖
|
||||
- 飞书长连接状态分为两层:`last_received_at/last_received_message_id` 表示 SDK 回调已收到事件,`last_event_at` 表示 worker 完成处理;排查“没吃进去”时必须优先看 `last_received_*`
|
||||
- 飞书长连接会话应复用 `Integration.config` 中已有的 `encrypt_key/verification_token`;配置保存时若未显式修改这两个字段,必须保留现有值,避免 SDK 因缺失事件密钥而拒收消息
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from . import (
|
|||
routes_brief_blockers,
|
||||
routes_capture,
|
||||
routes_conflicts,
|
||||
routes_executive_briefing,
|
||||
routes_feedback_center,
|
||||
routes_reply_iteration,
|
||||
routes_rag_sources,
|
||||
|
|
@ -19,6 +20,7 @@ for _module in [
|
|||
routes_rag_sources,
|
||||
routes_capture,
|
||||
routes_conflicts,
|
||||
routes_executive_briefing,
|
||||
routes_feedback_center,
|
||||
routes_reply_iteration,
|
||||
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_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],
|
||||
}
|
||||
|
||||
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
|
||||
def _build_focus_actions(*, pending_manual_count: int, blocked_task_count: int) -> list[str]:
|
||||
actions: list[str] = []
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ class CadenceSnapshotsMixin:
|
|||
payload = await self._build_weekly_review(project_id=project_id)
|
||||
elif normalized_type == "risk_review":
|
||||
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:
|
||||
payload = await self._build_milestone_check(project_id=project_id)
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ class AIPMOCadenceService(
|
|||
"weekly_review",
|
||||
"risk_review",
|
||||
"milestone_check",
|
||||
"executive_brief",
|
||||
}
|
||||
|
||||
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 ..integrations.feishu_outbox import FeishuOutboxService
|
||||
from ..executive_briefing_service import ExecutiveBriefingService
|
||||
from .shared import (
|
||||
AIPMOCadenceService,
|
||||
PortfolioSnapshot,
|
||||
|
|
@ -73,6 +75,7 @@ class AIPMOCadenceScheduler:
|
|||
emitted = 0
|
||||
for tenant_id in effective_tenants:
|
||||
service = AIPMOCadenceService(db, tenant_id=int(tenant_id))
|
||||
executive_service = ExecutiveBriefingService(db, tenant_id=int(tenant_id))
|
||||
project_ids = (
|
||||
await db.execute(
|
||||
select(Project.id).where(
|
||||
|
|
@ -84,6 +87,46 @@ class AIPMOCadenceScheduler:
|
|||
)
|
||||
).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(
|
||||
db=db,
|
||||
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)
|
||||
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
|
||||
async def _snapshot_due(*, db, tenant_id: int, scope_type: str, scope_ref: str, freshness: timedelta) -> bool:
|
||||
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[];
|
||||
}
|
||||
|
||||
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 interface LLMProviderPreset {
|
||||
|
|
@ -145,6 +225,53 @@ export interface LLMEngineConnectionTestResponse {
|
|||
}
|
||||
|
||||
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> {
|
||||
const raw = await apiRequest<ApiEnvelope<LLMProviderPresetResponse>>("/ai-steward/llm/providers");
|
||||
return unwrap(raw);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useState, useEffect } from "react";
|
|||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { apiBaseURL, getAuthToken } from "@/lib/api";
|
||||
import { getAuthToken } from "@/lib/api";
|
||||
import { getApiErrorMessage } from "@/lib/apiError";
|
||||
import {
|
||||
Card, CardContent,
|
||||
|
|
@ -24,6 +24,8 @@ import type {
|
|||
Blocker,
|
||||
CognitiveConflict,
|
||||
DailyBriefing,
|
||||
ExecutiveBriefingConfig,
|
||||
ExecutiveBriefingPayload,
|
||||
FeedbackCenterSourceGroup,
|
||||
FeedbackCenterSection,
|
||||
FeedbackCenterTrendWindowDays,
|
||||
|
|
@ -57,6 +59,16 @@ const AIS_STEWARD_TABS: AIStewardTab[] = [
|
|||
];
|
||||
|
||||
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 {
|
||||
if (value && AIS_STEWARD_TABS.includes(value as AIStewardTab)) {
|
||||
|
|
@ -88,6 +100,7 @@ export default function AIStewardPage() {
|
|||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const canViewFeedbackCenter = parseUserRoles(user?.role).some((role) => role === "ADMIN" || role === "BOSS");
|
||||
const canManageExecutiveBriefing = canViewFeedbackCenter;
|
||||
|
||||
// Project creation state
|
||||
const [input, setInput] = useState("");
|
||||
|
|
@ -109,6 +122,17 @@ export default function AIStewardPage() {
|
|||
const [blockers, setBlockers] = useState<Blocker[]>([]);
|
||||
const [briefing, setBriefing] = useState<DailyBriefing | 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 [engineData, setEngineData] = useState<EngineDataState>({
|
||||
active_engine_id: null,
|
||||
|
|
@ -146,14 +170,8 @@ export default function AIStewardPage() {
|
|||
const loadConflicts = async () => {
|
||||
setFeatureLoading("conflicts");
|
||||
try {
|
||||
const token = getAuthToken();
|
||||
const response = await fetch(`${apiBaseURL}/ai-steward/cognitive-conflicts`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const data = await aiStewardApi.listCognitiveConflicts();
|
||||
setConflicts(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load conflicts:", err);
|
||||
} finally {
|
||||
|
|
@ -164,14 +182,8 @@ export default function AIStewardPage() {
|
|||
const loadBlockers = async () => {
|
||||
setFeatureLoading("blockers");
|
||||
try {
|
||||
const token = getAuthToken();
|
||||
const response = await fetch(`${apiBaseURL}/ai-steward/blockers/active`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const data = await aiStewardApi.listActiveBlockers();
|
||||
setBlockers(data.blockers || []);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load blockers:", err);
|
||||
} finally {
|
||||
|
|
@ -183,45 +195,74 @@ export default function AIStewardPage() {
|
|||
setFeatureLoading("briefing");
|
||||
setBriefingError(null);
|
||||
try {
|
||||
const token = getAuthToken();
|
||||
const response = await fetch(`${apiBaseURL}/ai-steward/daily-briefing`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const data = await aiStewardApi.getDailyBriefing();
|
||||
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) {
|
||||
console.error("Failed to load briefing:", err);
|
||||
setBriefing(null);
|
||||
setBriefingError(err instanceof Error ? err.message : "获取简报失败");
|
||||
setBriefingError(getApiErrorMessage(err, "获取简报失败"));
|
||||
} finally {
|
||||
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) => {
|
||||
setFeatureLoading("detect-conflicts");
|
||||
try {
|
||||
const token = getAuthToken();
|
||||
const response = await fetch(`${apiBaseURL}/ai-steward/conflicts/detect`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ project_id: projectId, detect_type: "all" }),
|
||||
});
|
||||
if (response.ok) {
|
||||
await aiStewardApi.detectConflicts(projectId);
|
||||
await loadConflicts();
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to detect conflicts:", err);
|
||||
} finally {
|
||||
|
|
@ -233,15 +274,9 @@ export default function AIStewardPage() {
|
|||
const detectTaskBlockers = async () => {
|
||||
setFeatureLoading("detect-blockers");
|
||||
try {
|
||||
const token = getAuthToken();
|
||||
const response = await fetch(`${apiBaseURL}/ai-steward/blockers/detect-tasks`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (response.ok) {
|
||||
await aiStewardApi.detectTaskBlockers();
|
||||
await loadBlockers();
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to detect blockers:", err);
|
||||
} finally {
|
||||
|
|
@ -253,14 +288,8 @@ export default function AIStewardPage() {
|
|||
const startHNMonitor = async () => {
|
||||
setFeatureLoading("hn-monitor");
|
||||
try {
|
||||
const token = getAuthToken();
|
||||
const response = await fetch(`${apiBaseURL}/ai-steward/signal/start-hn-monitor?interval_minutes=30`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (response.ok) {
|
||||
await aiStewardApi.startHnMonitor(30);
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to start HN monitor:", err);
|
||||
} finally {
|
||||
|
|
@ -460,6 +489,13 @@ export default function AIStewardPage() {
|
|||
setEngineMessage(null);
|
||||
};
|
||||
|
||||
const updateExecutiveBriefingDraft = (patch: Partial<ExecutiveBriefingConfig>) => {
|
||||
setExecutiveBriefingConfigDraft((current) => ({
|
||||
...current,
|
||||
...patch,
|
||||
}));
|
||||
};
|
||||
|
||||
// ==================== Project Creation ====================
|
||||
|
||||
const handleProjectSubmit = async () => {
|
||||
|
|
@ -543,6 +579,10 @@ export default function AIStewardPage() {
|
|||
}, [searchParams, activeTab, feedbackSection, trendWindowDays, trendSourceGroup, canViewFeedbackCenter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === "overview" && canManageExecutiveBriefing) {
|
||||
void loadExecutiveBriefing("snapshot");
|
||||
void loadExecutiveBriefingConfig();
|
||||
}
|
||||
if (activeTab === "conflicts") loadConflicts();
|
||||
if (activeTab === "blockers") loadBlockers();
|
||||
if (activeTab === "briefing") loadBriefing();
|
||||
|
|
@ -550,7 +590,7 @@ export default function AIStewardPage() {
|
|||
loadEngineProviderPresets();
|
||||
loadEngineConfigs();
|
||||
}
|
||||
}, [activeTab]);
|
||||
}, [activeTab, canManageExecutiveBriefing]);
|
||||
|
||||
// ==================== 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="max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex items-center justify-center gap-3 mb-4">
|
||||
<Sparkles className="w-10 h-10 text-violet-500" />
|
||||
<div className="mb-8 text-center">
|
||||
<div className="mb-4 flex items-center justify-center gap-3">
|
||||
<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">
|
||||
AI 管家
|
||||
{canManageExecutiveBriefing ? "老板晨报中心" : "AI 管家"}
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-slate-600 dark:text-slate-400 text-lg">
|
||||
主动式 AI 管家 - 7x24 小时自动守护项目
|
||||
{canManageExecutiveBriefing
|
||||
? "晨报快照、实时刷新与管理群推送共用同一套经营简报内核"
|
||||
: "主动式 AI 管家 - 7x24 小时自动守护项目"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FeatureOverviewCards onSelectTab={(tab) => handleTabChange(tab)} />
|
||||
<FeatureOverviewCards
|
||||
onSelectTab={(tab) => handleTabChange(tab)}
|
||||
canManageExecutiveBriefing={canManageExecutiveBriefing}
|
||||
executiveBriefing={executiveBriefing}
|
||||
/>
|
||||
|
||||
{/* Main Content */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<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]">
|
||||
<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="signals" 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>
|
||||
</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
|
||||
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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import type { AIStewardTab, ExecutiveBriefingPayload } from "@/pages/ai-steward/types";
|
||||
|
||||
interface FeatureOverviewCardsProps {
|
||||
onSelectTab: (tab: "signals" | "conflicts" | "blockers" | "briefing") => void;
|
||||
onSelectTab: (tab: AIStewardTab) => void;
|
||||
canManageExecutiveBriefing?: boolean;
|
||||
executiveBriefing?: ExecutiveBriefingPayload | null;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FeatureOverviewCards({ onSelectTab }: FeatureOverviewCardsProps) {
|
||||
return (
|
||||
<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")}>
|
||||
|
|
|
|||
|
|
@ -6,24 +6,95 @@ import {
|
|||
Clock,
|
||||
MessageSquare,
|
||||
Radar,
|
||||
RefreshCcw,
|
||||
Shield,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
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 (
|
||||
<TabsContent value="overview" className="mt-6">
|
||||
<div className="space-y-6">
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-2xl font-bold mb-2">AI 管家正在运行</h2>
|
||||
<div className="mb-8 text-center">
|
||||
<h2 className="mb-2 text-2xl font-bold">AI 管家正在运行</h2>
|
||||
<p className="text-slate-600 dark:text-slate-400">4 大主动服务守护你的项目,无需手动触发</p>
|
||||
</div>
|
||||
|
||||
<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="flex items-center gap-3 mb-4">
|
||||
<div className="w-12 h-12 rounded-full bg-blue-500 flex items-center justify-center">
|
||||
<Radar className="w-6 h-6 text-white" />
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-6 dark:border-blue-800 dark:bg-blue-900/20">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-blue-500">
|
||||
<Radar className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">1. 主动抓取信号</h3>
|
||||
|
|
@ -32,26 +103,26 @@ export default function OverviewTab() {
|
|||
</div>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
||||
<MessageSquare className="w-4 h-4 text-blue-500" />
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<MessageSquare className="h-4 w-4 text-blue-500" />
|
||||
飞书群聊监控 - 配置完成,正在运行
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
||||
<Activity className="w-4 h-4 text-purple-500" />
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<Activity className="h-4 w-4 text-purple-500" />
|
||||
技术资讯定时抓取 - 可启用
|
||||
</li>
|
||||
<li className="flex items-center gap-2 opacity-50">
|
||||
<Clock className="w-4 h-4" />
|
||||
<Clock className="h-4 w-4" />
|
||||
浏览器标签监控 - 需安装扩展
|
||||
</li>
|
||||
</ul>
|
||||
</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="flex items-center gap-3 mb-4">
|
||||
<div className="w-12 h-12 rounded-full bg-orange-500 flex items-center justify-center">
|
||||
<AlertTriangle className="w-6 h-6 text-white" />
|
||||
<div className="rounded-lg border border-orange-200 bg-orange-50 p-6 dark:border-orange-800 dark:bg-orange-900/20">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-orange-500">
|
||||
<AlertTriangle className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">2. 主动消解认知差</h3>
|
||||
|
|
@ -60,24 +131,24 @@ export default function OverviewTab() {
|
|||
</div>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<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")
|
||||
</li>
|
||||
<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 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 生成对齐方案
|
||||
</li>
|
||||
</ul>
|
||||
</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="flex items-center gap-3 mb-4">
|
||||
<div className="w-12 h-12 rounded-full bg-red-500 flex items-center justify-center">
|
||||
<Shield className="w-6 h-6 text-white" />
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-6 dark:border-red-800 dark:bg-red-900/20">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-red-500">
|
||||
<Shield className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">3. 主动解决阻塞</h3>
|
||||
|
|
@ -86,24 +157,24 @@ export default function OverviewTab() {
|
|||
</div>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<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接口"、"达不到效果")
|
||||
</li>
|
||||
<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 分析严重程度并生成解决方案
|
||||
</li>
|
||||
<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>
|
||||
</ul>
|
||||
</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="flex items-center gap-3 mb-4">
|
||||
<div className="w-12 h-12 rounded-full bg-green-500 flex items-center justify-center">
|
||||
<Bell className="w-6 h-6 text-white" />
|
||||
<div className="rounded-lg border border-green-200 bg-green-50 p-6 dark:border-green-800 dark:bg-green-900/20">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-green-500">
|
||||
<Bell className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">4. 主动同步进度</h3>
|
||||
|
|
@ -112,15 +183,15 @@ export default function OverviewTab() {
|
|||
</div>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<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 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 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>
|
||||
</ul>
|
||||
|
|
@ -130,3 +201,273 @@ export default function OverviewTab() {
|
|||
</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";
|
||||
export type {
|
||||
Blocker,
|
||||
CognitiveConflict,
|
||||
DailyBriefing,
|
||||
ExecutiveBriefingConfig,
|
||||
ExecutiveBriefingHero,
|
||||
ExecutiveBriefingItem,
|
||||
ExecutiveBriefingMetric,
|
||||
ExecutiveBriefingPayload,
|
||||
ExecutiveBriefingSection,
|
||||
ProjectFromTextRequest,
|
||||
ProjectFromTextRequirementItem,
|
||||
ProjectFromTextResponse,
|
||||
|
|
@ -19,34 +28,6 @@ export type AIStewardTab =
|
|||
| "feedback-center"
|
||||
| "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 {
|
||||
model: string;
|
||||
base_url: string;
|
||||
|
|
|
|||
Loading…
Reference in New Issue