Revamp AI steward executive briefing workflows

This commit is contained in:
Yuuuu 2026-03-30 17:27:28 +08:00
parent 1b2b64d575
commit a01c451195
20 changed files with 3697 additions and 652 deletions

View File

@ -389,12 +389,15 @@ frontend/
- 更新:`frontend/src/pages/AIStewardPage.tsx`(降级为轻量入口,转发到 `pages/ai-steward/AIStewardPage`)
- 新增:`frontend/src/pages/ai-steward/types.ts`、`frontend/src/pages/ai-steward/constants.ts`(AI Steward 类型与常量集中管理)
- 新增:`frontend/src/pages/ai-steward/components/FeatureOverviewCards.tsx`(AI Steward 首页功能卡片组件)
- 新增:`frontend/src/pages/ai-steward/components/BriefingPrintDocument.tsx`、`BriefingSectionCard.tsx`、`BriefingDetailDrawer.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/app/services/tenant_briefing_service.py`、`backend/tests/test_daily_briefing_v2_api.py`(租户级简报统一内核与 `daily-briefing-v2` viewer scope 过滤回归)
- 新增:`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 首页升级为老板晨报中心,支持晨报快照、实时刷新、调度配置与异常优先看板)
- 更新:`backend/app/services/executive_briefing_service.py`(读取 `snapshot` 时若命中旧结构快照,会自动按当前代码重建新版 payload,避免简报中心默认打开仍停留在无详情/无精准跳转的旧结构)
- 更新:`frontend/src/api/aiSteward.ts`(新增 cognitive conflicts / blockers / `daily-briefing-v2` / executive briefing API 封装,AI Steward 页面不再直接 `fetch`)
- 更新:`frontend/src/pages/ai-steward/AIStewardPage.tsx`、`components/FeatureOverviewCards.tsx`、`sections/OverviewTab.tsx`、`sections/BriefingTab.tsx`(晨报中心与每日简报共享 `snapshot/live` 模式;前端只保留一个“简报中心”入口,支持固定高度卡片、项目分组预览、右侧详情抽屉、精准跳转与完整详细导出)
- 更新:`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)
@ -406,6 +409,7 @@ frontend/
- 新增:`frontend/src/lib/api.ts`、`apiError.ts`、`display.ts`、`datetime.ts`、`inbox.ts`、`inboxTextMap.ts`、`formatInboxTime.ts`、`feishuEventMode.ts`、`sessionGuard.ts`、`rbac.ts`、`title.ts`、`utils.ts`、`zhCN.ts`(前端兼容工具与消息台/AI Steward 展示格式化层)
- 更新:`frontend/src/pages/ProjectBoardPage.tsx`(降级为轻量入口,转发到 `pages/project-board/ProjectBoardPage`)
- 新增:`frontend/src/pages/project-board/helpers.ts`(Project Board 信号筛选状态与 PMO 工作项文本归一 helper)
- 更新:`frontend/src/pages/project-board/ProjectBoardPage.tsx`(支持从简报中心 URL 参数恢复 `stage` 与 `signal_*` 筛选条件)
- 新增:`frontend/src/pages/project-board/components/CreateCharterDialog.tsx`、`ProjectSettingsDialog.tsx`(Project Board 大体量弹窗组件拆分)
- 新增:`frontend/src/pages/project-board/ProjectBoardPage.tsx`(Project Board 页面编排层)
- 更新:`frontend/src/pages/ProjectsPage.tsx`(降级为轻量入口,转发到 `pages/projects/ProjectsPage`)
@ -503,7 +507,11 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
- 禁止在前端组件内直接调用 `fetch`,必须通过 `frontend/src/api/` 封装层
- 前端状态管理:服务端数据用 React Query,本地 UI 状态用 `useState`
- 老板晨报链路只允许 `BOSS/ADMIN` 访问;`GET /ai-steward/executive-briefing` 的 `snapshot/live` 语义必须分离,`live` 禁止覆盖 `PortfolioSnapshot(scope_type='executive_brief')`
- `daily-briefing-v2` 必须复用同一份租户级简报内核:先算 tenant payload,再按 `viewer_scope=tenant_full|personal` 过滤;禁止老板晨报与每日简报各自维护独立统计口径
- 简报 item 的卡片摘要、抽屉详情、导出报告与跳转目标必须复用同一份结构化字段(`id/detail_lines/meta/target.params`);禁止页面、导出、跳转各自拼接不同文案导致事实不一致
- 简报中心只允许展示具备真实业务定位的对象:项目信号必须绑定真实 `project_id`,信号源必须有真实来源名/标识,人员负载必须绑定真实 `user_id`;禁止把未归属信号用“待处理事项”等兜底标题伪装成老板待办
- 老板晨报调度配置统一落在 `Integration(type='executive_briefing')`,禁止新增独立晨报配置表;管理群自动推送必须复用现有 `feishu_reply_outbox`
- 晨报/日报导出首版只允许前端“当前所见”打印导出(浏览器 `print` / PDF),禁止新增后端二进制 PDF 生成服务
- 飞书回执发送仅允许 `main.py` 的 drain_loop 作为消费者,禁止在业务路径即时派发 outbox 发送任务
- 所有写接口必须落审计日志(`operation_logs`):至少包含 `trace_id/interface_path/caller`
- `created_at_beijing` 为计算属性返回,禁止新增数据库物理列
@ -548,6 +556,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
- 飞书捕获模拟接口:`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` 可访问)
- 每日简报 v2 接口:参考 `backend/app/api/ai_steward_api/routes_brief_blockers.py`(`GET /ai-steward/daily-briefing-v2?mode=snapshot|live`,普通成员返回个人版,`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;否则会误伤真实业务请求
@ -593,6 +602,10 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
- 项目创建返回的 `signal_sources` 仅作为 AI Steward 创建结果区的内联展示,现阶段不新增独立页面路由;底层持久化统一复用现有 `SignalSourceRegistry`
- 老板晨报首版的 `external_metrics` 只返回占位区块,不接 CRM / BI / 收入系统,也不允许伪造经营指标
- 老板晨报调度默认值为 `08:30 + workdays + Asia/Shanghai`;若 `delivery_target_ref` 未配置,调度器仍生成快照,但只记录告警、不静默伪装成已推送
- `daily-briefing-v2` 与老板晨报当前只做同会话内 `snapshot/live` 同步,不引入 WebSocket 推送;导出仅复用浏览器打印能力
- 若当天 `executive_brief` 快照仍是旧结构(缺少 `id/detail_lines/meta` 等字段),读取 `snapshot` 时会自动按当前事实重建新版 payload;因此默认快照优先保证可交互与可导出,而不是强行返回半旧版结构
- 未匹配项目的孤立 signal 不再进入简报中心卡片;这类数据仅保留在信号工作台中处理,避免老板视图出现无项目、无负责人、无真实定位的伪待办
- 简报中心首页卡片固定高度并允许内部滚动;完整明细统一放在右侧详情抽屉与打印导出中,移动端退化为全屏 Dialog,不新增第二套详情页
- 飞书长连接 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 因缺失事件密钥而拒收消息

View File

@ -1,7 +1,12 @@
from __future__ import annotations
from fastapi import Query
from fastapi.responses import JSONResponse
from .shared import *
from .schemas import BlockerResolveRequest, CodeCommentScanRequest, DailyBriefingResponse
from ...services.auth.service import parse_role_values
from ...services.tenant_briefing_service import TenantBriefingService
@router.get("/daily-briefing")
async def get_daily_briefing(
@ -34,6 +39,26 @@ async def get_daily_briefing(
detail=f"生成简报失败: {str(e)}"
)
@router.get("/daily-briefing-v2")
async def get_daily_briefing_v2(
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),
):
try:
payload = await TenantBriefingService(db, tenant_id=tenant_id).build_viewer_payload(
viewer_id=int(current_user.id),
viewer_roles=parse_role_values(getattr(current_user, "role", "")),
mode=mode,
)
except ValueError as exc:
return JSONResponse(status_code=400, content=error_response(400, str(exc)))
except Exception as exc:
return JSONResponse(status_code=500, content=error_response(500, f"生成日报失败: {exc}"))
return success_response(payload)
@router.get("/daily-briefing/team/{project_id}")
async def get_team_briefing(
project_id: int,
@ -248,4 +273,3 @@ async def resolve_blocker(
status_code=500,
detail=f"解决失败: {str(e)}"
)

View File

@ -112,10 +112,20 @@ class ExecutiveBriefingMetric(BaseModel):
class ExecutiveBriefingItem(BaseModel):
id: str
title: str
detail: str
detail_lines: List[str] = Field(default_factory=list)
meta: List[Dict[str, str]] = Field(default_factory=list)
tone: str
kind: Optional[str] = None
target: Optional[Dict[str, Any]] = None
project_id: Optional[int] = None
project_name: Optional[str] = None
user_id: Optional[int] = None
owner_name: Optional[str] = None
related_user_ids: List[int] = Field(default_factory=list)
occurred_at: Optional[str] = None
class ExecutiveBriefingSection(BaseModel):
@ -144,3 +154,29 @@ class ExecutiveBriefingPayload(BaseModel):
hero: ExecutiveBriefingHero
sections: List[ExecutiveBriefingSection] = Field(default_factory=list)
delivery: Dict[str, Any] = Field(default_factory=dict)
class DailyBriefingV2PersonalSummary(BaseModel):
headline: str
action_count: int = 0
project_count: int = 0
class DailyBriefingV2PersonalWorkload(BaseModel):
user_id: int
user_name: str
assigned_hours: float = 0
max_weekly_hours: float = 0
workload_ratio: int = 0
tone: str = "info"
class DailyBriefingV2Payload(BaseModel):
brief_date: str
mode: Literal["snapshot", "live"]
viewer_scope: Literal["tenant_full", "personal"]
hero: ExecutiveBriefingHero
sections: List[ExecutiveBriefingSection] = Field(default_factory=list)
personal_summary: Optional[DailyBriefingV2PersonalSummary] = None
personal_workload: Optional[DailyBriefingV2PersonalWorkload] = None
export_title: str

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,300 @@
"""Viewer-scoped tenant briefing service."""
from __future__ import annotations
from typing import Any, Iterable
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..models import Project, User, UserProject, UserWorkload
from .auth.service import parse_role_values
from .executive_briefing_service import ExecutiveBriefingService, _deepcopy_dict
MANAGER_ROLES = {"BOSS", "ADMIN"}
class TenantBriefingService(ExecutiveBriefingService):
"""Builds tenant payloads and filters them for the active viewer."""
def __init__(self, db: AsyncSession, *, tenant_id: int) -> None:
super().__init__(db, tenant_id=tenant_id)
async def get_tenant_payload(self, *, mode: str) -> dict[str, Any]:
payload = await super().get_briefing(mode=mode)
normalized = _deepcopy_dict(payload)
normalized.pop("delivery", None)
return normalized
async def build_viewer_payload(
self,
*,
viewer_id: int,
viewer_roles: Iterable[str],
mode: str,
) -> dict[str, Any]:
tenant_payload = await self.get_tenant_payload(mode=mode)
roles = self._normalize_roles(viewer_roles)
if roles.intersection(MANAGER_ROLES):
return self._build_manager_payload(tenant_payload)
project_ids = await self._get_viewer_project_ids(viewer_id)
workload = await self._get_viewer_workload(viewer_id)
return self._build_personal_payload(
tenant_payload=tenant_payload,
viewer_id=int(viewer_id),
project_ids=project_ids,
workload=workload,
)
@staticmethod
def _normalize_roles(values: Iterable[str]) -> set[str]:
normalized: set[str] = set()
for value in values:
normalized.update(parse_role_values(str(value or "")))
return normalized
def _build_manager_payload(self, tenant_payload: dict[str, Any]) -> dict[str, Any]:
payload = _deepcopy_dict(tenant_payload)
hero = payload.setdefault("hero", {})
hero["title"] = "全租户每日简报"
payload["viewer_scope"] = "tenant_full"
payload["personal_summary"] = {
"headline": f"今日全租户共有 {hero.get('decision_count', 0)} 项需关注事项。",
"action_count": int(hero.get("decision_count", 0) or 0),
"project_count": len(
{
int(item["project_id"])
for section in payload.get("sections", [])
if isinstance(section, dict)
for item in section.get("items", [])
if isinstance(item, dict) and item.get("project_id") is not None
}
),
}
payload["personal_workload"] = None
payload["export_title"] = "全租户每日简报"
return payload
def _build_personal_payload(
self,
*,
tenant_payload: dict[str, Any],
viewer_id: int,
project_ids: set[int],
workload: dict[str, Any] | None,
) -> dict[str, Any]:
payload = _deepcopy_dict(tenant_payload)
sections = payload.get("sections") if isinstance(payload.get("sections"), list) else []
filtered_sections = []
related_project_ids: set[int] = set()
total_items = 0
for section in sections:
if not isinstance(section, dict):
continue
key = str(section.get("key") or "").strip()
if key == "external_metrics":
filtered_sections.append(section)
continue
filtered_items = []
for item in section.get("items", []) if isinstance(section.get("items"), list) else []:
if not isinstance(item, dict):
continue
if self._item_matches_viewer(item=item, viewer_id=viewer_id, project_ids=project_ids):
filtered_items.append(item)
project_id = self._to_int(item.get("project_id"))
if project_id is not None:
related_project_ids.add(project_id)
total_items += len(filtered_items)
filtered_sections.append(
{
**section,
"summary": self._build_personal_section_summary(section_key=key, count=len(filtered_items)),
"metrics": self._build_personal_section_metrics(
section_key=key,
items=filtered_items,
workload=workload,
),
"items": filtered_items,
}
)
hero = payload.setdefault("hero", {})
decision_count = len(self._find_section_items(filtered_sections, "decisions"))
anomaly_count = total_items
hero["title"] = "我的每日简报"
hero["decision_count"] = decision_count
hero["anomaly_count"] = anomaly_count
hero["key_alerts"] = [
{"label": "我的待关注", "value": decision_count, "tone": "critical" if decision_count else "positive"},
{
"label": "我的负载",
"value": f"{int((workload or {}).get('workload_ratio') or 0)}%",
"tone": str((workload or {}).get("tone") or "info"),
},
{"label": "相关项目", "value": len(related_project_ids), "tone": "info"},
]
payload["viewer_scope"] = "personal"
payload["sections"] = filtered_sections
payload["personal_summary"] = {
"headline": f"今天有 {decision_count} 项需要你优先关注,涉及 {len(related_project_ids)} 个相关项目。",
"action_count": decision_count,
"project_count": len(related_project_ids),
}
payload["personal_workload"] = workload or {
"user_id": viewer_id,
"user_name": f"成员 #{viewer_id}",
"assigned_hours": 0,
"max_weekly_hours": 0,
"workload_ratio": 0,
"tone": "info",
}
payload["export_title"] = "我的每日简报"
return payload
@staticmethod
def _find_section_items(sections: list[dict[str, Any]], key: str) -> list[dict[str, Any]]:
for section in sections:
if str(section.get("key") or "").strip() == key:
return [item for item in section.get("items", []) if isinstance(item, dict)]
return []
def _build_personal_section_summary(self, *, section_key: str, count: int) -> str:
if section_key == "decisions":
return f"今天有 {count} 项与你直接相关的关注事项。" if count else "今天暂无需要你立即处理的拍板事项。"
if section_key == "input_health":
return f"最近 24 小时有 {count} 条与你相关的输入或来源变更。" if count else "最近 24 小时没有新的相关输入变更。"
if section_key == "delivery_risk":
return f"当前有 {count} 条与你相关的交付风险或任务提醒。" if count else "当前没有与你直接相关的交付风险。"
if section_key == "team_capacity":
return f"当前有 {count} 条与你协作或负载相关的提醒。" if count else "你的协作与负载当前比较平稳。"
if section_key == "ai_governance":
return f"当前有 {count} 条与你相关的治理提醒。" if count else "当前没有与你直接相关的治理提醒。"
return f"当前有 {count} 条与你相关的更新。" if count else "当前暂无与你相关的更新。"
def _build_personal_section_metrics(
self,
*,
section_key: str,
items: list[dict[str, Any]],
workload: dict[str, Any] | None,
) -> list[dict[str, Any]]:
related_project_count = len({self._to_int(item.get("project_id")) for item in items if self._to_int(item.get("project_id")) is not None})
related_people_count = len({self._to_int(item.get("user_id")) for item in items if self._to_int(item.get("user_id")) is not None})
workload_ratio = int((workload or {}).get("workload_ratio") or 0)
workload_tone = str((workload or {}).get("tone") or "info")
if section_key == "team_capacity":
return [
{"label": "我的负载", "value": f"{workload_ratio}%", "tone": workload_tone},
{"label": "协作提醒", "value": len(items), "tone": "warning" if items else "positive"},
{"label": "相关同事", "value": related_people_count, "tone": "info"},
]
return [
{"label": "相关事项", "value": len(items), "tone": "warning" if items else "positive"},
{"label": "相关项目", "value": related_project_count, "tone": "info"},
{"label": "我的负载", "value": f"{workload_ratio}%", "tone": workload_tone},
]
def _item_matches_viewer(self, *, item: dict[str, Any], viewer_id: int, project_ids: set[int]) -> bool:
user_id = self._to_int(item.get("user_id"))
if user_id is not None and user_id == viewer_id:
return True
related_user_ids = item.get("related_user_ids")
if isinstance(related_user_ids, list):
for value in related_user_ids:
if self._to_int(value) == viewer_id:
return True
project_id = self._to_int(item.get("project_id"))
if project_id is not None and project_id in project_ids:
return True
return False
async def _get_viewer_project_ids(self, viewer_id: int) -> set[int]:
member_rows = (
await self.db.execute(
select(UserProject.project_id).where(
and_(
UserProject.tenant_id == self.tenant_id,
UserProject.user_id == int(viewer_id),
)
)
)
).scalars().all()
owner_rows = (
await self.db.execute(
select(Project.id).where(
and_(
Project.tenant_id == self.tenant_id,
Project.owner_id == int(viewer_id),
)
)
)
).scalars().all()
return {
int(value)
for value in [*member_rows, *owner_rows]
if self._to_int(value) is not None
}
async def _get_viewer_workload(self, viewer_id: int) -> dict[str, Any]:
row = (
await self.db.execute(
select(
User.id,
User.name,
UserWorkload.current_assigned_hours,
UserWorkload.max_available_hours_per_week,
UserWorkload.workload_ratio,
)
.join(UserWorkload, UserWorkload.user_id == User.id, isouter=True)
.where(
and_(
User.tenant_id == self.tenant_id,
User.id == int(viewer_id),
)
)
)
).one_or_none()
if row is None:
return {
"user_id": int(viewer_id),
"user_name": f"成员 #{viewer_id}",
"assigned_hours": 0,
"max_weekly_hours": 0,
"workload_ratio": 0,
"tone": "info",
}
user_id, user_name, assigned_hours, max_weekly_hours, workload_ratio = row
assigned = float(assigned_hours or 0)
max_hours = float(max_weekly_hours or 0)
ratio_raw = self._display_workload_ratio(
assigned_hours=assigned_hours,
max_hours=max_weekly_hours,
stored_ratio=workload_ratio,
)
ratio_percent = round(ratio_raw * 100)
tone = "critical" if ratio_percent >= 100 else "warning" if ratio_percent >= 80 else "positive"
return {
"user_id": int(user_id),
"user_name": str(user_name or f"成员 #{viewer_id}"),
"assigned_hours": round(assigned, 1),
"max_weekly_hours": round(max_hours, 1),
"workload_ratio": int(ratio_percent),
"tone": tone,
}
@staticmethod
def _to_int(value: Any) -> int | None:
try:
if value is None or value == "":
return None
return int(value)
except (TypeError, ValueError):
return None

View File

@ -7,6 +7,7 @@ from datetime import datetime, timedelta
import pytest
import pytest_asyncio
from sqlalchemy import JSON
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///:memory:"
@ -21,9 +22,20 @@ from app.models.truth_node import TruthNode
from app.models.user import User
def _use_sqlite_json_types() -> None:
User.__table__.c.assigned_domains.type = JSON()
TruthNode.__table__.c.content.type = JSON()
Task.__table__.c.ai_recommended_assignees.type = JSON()
Task.__table__.c.ai_breakdown_payload.type = JSON()
Task.__table__.c.manual_overrides.type = JSON()
Task.__table__.c.required_skill_tag_ids.type = JSON()
CognitiveConflict.__table__.c.involved_user_ids.type = JSON()
@pytest_asyncio.fixture
async def briefing_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(

View File

@ -0,0 +1,316 @@
from __future__ import annotations
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_brief_blockers
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.project import Project
from app.models.task import Task
from app.models.user import User
from app.models.user_project import UserProject
from app.models.user_workload import UserWorkload
from app.services.tenant_briefing_service import TenantBriefingService
def _use_sqlite_json_types() -> None:
User.__table__.c.assigned_domains.type = JSON()
Task.__table__.c.ai_recommended_assignees.type = JSON()
Task.__table__.c.ai_breakdown_payload.type = JSON()
Task.__table__.c.manual_overrides.type = JSON()
Task.__table__.c.required_skill_tag_ids.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=[
User.__table__,
Project.__table__,
UserProject.__table__,
UserWorkload.__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", user_id: int = 9001) -> FastAPI:
app = FastAPI()
app.include_router(routes_brief_blockers.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=user_id,
tenant_id=1,
name="测试用户",
username="tester",
role=role,
)
return app
@pytest.mark.asyncio
async def test_daily_briefing_v2_is_enveloped_for_any_authenticated_user(db_session, monkeypatch) -> None:
async def fake_build_viewer_payload(self, *, viewer_id: int, viewer_roles: list[str], mode: str): # noqa: ANN001
_ = self, viewer_id, viewer_roles
return {
"brief_date": "2026-03-30",
"mode": mode,
"viewer_scope": "personal",
"hero": {
"title": "我的每日简报",
"decision_count": 1,
"anomaly_count": 2,
"key_alerts": [],
"snapshot_state": "ready",
},
"sections": [],
"personal_summary": {
"headline": "你今天需要关注 1 项事项",
"action_count": 1,
},
"personal_workload": {
"user_id": 7,
"user_name": "开发甲",
"workload_ratio": 88,
},
"export_title": "我的每日简报",
}
monkeypatch.setattr(TenantBriefingService, "build_viewer_payload", fake_build_viewer_payload)
app = _build_app(db_session=db_session, role="BE", user_id=7)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/ai-steward/daily-briefing-v2", params={"mode": "live"})
assert response.status_code == 200
payload = response.json()
assert payload["code"] == 0
assert payload["message"] == "success"
assert payload["data"]["viewer_scope"] == "personal"
assert payload["data"]["mode"] == "live"
assert payload["data"]["export_title"] == "我的每日简报"
@pytest.mark.asyncio
async def test_tenant_briefing_service_returns_full_scope_for_manager(db_session, monkeypatch) -> None:
service = TenantBriefingService(db_session, tenant_id=1)
async def fake_get_tenant_payload(self, *, mode: str): # noqa: ANN001
_ = self
return {
"brief_date": "2026-03-30",
"mode": mode,
"hero": {
"title": "老板晨报中心",
"decision_count": 2,
"anomaly_count": 3,
"key_alerts": [],
"snapshot_state": "ready",
},
"sections": [
{
"key": "decisions",
"title": "今日需拍板",
"summary": "有 2 项事项需要处理。",
"metrics": [],
"items": [
{
"title": "项目 A 风险",
"detail": "需要关注",
"tone": "warning",
"project_id": 11,
"target": {"type": "project_board", "ref": "11"},
},
{
"title": "成员过载",
"detail": "需要协调",
"tone": "critical",
"user_id": 22,
"target": {"type": "workload_detail", "ref": "22"},
},
],
"drilldown_tab": "signals",
}
],
}
monkeypatch.setattr(TenantBriefingService, "get_tenant_payload", fake_get_tenant_payload)
payload = await service.build_viewer_payload(viewer_id=1, viewer_roles=["BOSS"], mode="snapshot")
assert payload["viewer_scope"] == "tenant_full"
assert payload["hero"]["decision_count"] == 2
assert len(payload["sections"][0]["items"]) == 2
assert payload["sections"][0]["items"][0]["target"]["type"] == "project_board"
assert "全租户" in payload["export_title"]
@pytest.mark.asyncio
async def test_tenant_briefing_service_filters_personal_scope_and_load(db_session, monkeypatch) -> None:
viewer = User(
tenant_id=1,
username="be_member",
email="be@example.com",
password_hash="hash",
name="后端成员",
role="BE",
status="active",
is_active=True,
)
db_session.add(viewer)
await db_session.flush()
db_session.add(UserProject(tenant_id=1, user_id=viewer.id, project_id=101))
db_session.add(
UserWorkload(
tenant_id=1,
user_id=viewer.id,
current_assigned_hours=28,
max_available_hours_per_week=40,
workload_ratio=70,
)
)
await db_session.commit()
service = TenantBriefingService(db_session, tenant_id=1)
async def fake_get_tenant_payload(self, *, mode: str): # noqa: ANN001
_ = self
return {
"brief_date": "2026-03-30",
"mode": mode,
"hero": {
"title": "租户级简报",
"decision_count": 3,
"anomaly_count": 5,
"key_alerts": [],
"snapshot_state": "ready",
},
"sections": [
{
"key": "decisions",
"title": "今日需关注",
"summary": "租户汇总",
"metrics": [],
"items": [
{
"title": "项目 101 风险",
"detail": "与你项目相关",
"tone": "warning",
"project_id": 101,
"target": {"type": "project_board", "ref": "101"},
},
{
"title": "项目 202 风险",
"detail": "与他人项目相关",
"tone": "warning",
"project_id": 202,
"target": {"type": "project_board", "ref": "202"},
},
],
"drilldown_tab": "signals",
},
{
"key": "team_capacity",
"title": "负载与协作",
"summary": "租户汇总",
"metrics": [],
"items": [
{
"title": "后端成员",
"detail": "70% 负载",
"tone": "info",
"user_id": viewer.id,
"target": {"type": "workload_detail", "ref": str(viewer.id)},
},
{
"title": "其他成员",
"detail": "130% 负载",
"tone": "critical",
"user_id": 999,
"target": {"type": "workload_detail", "ref": "999"},
},
],
"drilldown_tab": "conflicts",
},
],
}
monkeypatch.setattr(TenantBriefingService, "get_tenant_payload", fake_get_tenant_payload)
payload = await service.build_viewer_payload(viewer_id=int(viewer.id), viewer_roles=["BE"], mode="live")
assert payload["viewer_scope"] == "personal"
assert payload["hero"]["decision_count"] == 1
assert payload["sections"][0]["items"] == [
{
"title": "项目 101 风险",
"detail": "与你项目相关",
"tone": "warning",
"project_id": 101,
"target": {"type": "project_board", "ref": "101"},
}
]
assert payload["personal_workload"]["user_id"] == int(viewer.id)
assert payload["personal_workload"]["workload_ratio"] == 70
assert "我的每日简报" == payload["export_title"]
@pytest.mark.asyncio
async def test_tenant_briefing_service_personal_workload_ratio_matches_weekly_hours(db_session) -> None:
viewer = User(
tenant_id=1,
username="be_member",
email="be@example.com",
password_hash="hash",
name="后端成员",
role="BE",
status="active",
is_active=True,
)
db_session.add(viewer)
await db_session.flush()
db_session.add(
UserWorkload(
tenant_id=1,
user_id=viewer.id,
current_assigned_hours=95.6,
max_available_hours_per_week=60,
workload_ratio=797,
)
)
await db_session.commit()
service = TenantBriefingService(db_session, tenant_id=1)
workload = await service._get_viewer_workload(int(viewer.id))
assert workload["user_name"] == "后端成员"
assert workload["assigned_hours"] == 95.6
assert workload["max_weekly_hours"] == 60.0
assert workload["workload_ratio"] == 159
assert workload["tone"] == "critical"

View File

@ -15,12 +15,40 @@ 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.cognitive_conflict import CognitiveConflict
from app.models.feedback_center import FeedbackCenterRecord
from app.models.integration import Integration
from app.models.notification import Notification
from app.models.portfolio_snapshot import PortfolioSnapshot
from app.models.project import Project
from app.models.project_signal_link import ProjectSignalLink
from app.models.signal import Signal
from app.models.signal_source_registry import SignalSourceRegistry
from app.models.task import Task
from app.models.truth_node import TruthNode
from app.models.user import User
from app.models.user_workload import UserWorkload
from app.services.executive_briefing_service import ExecutiveBriefingService
def _use_sqlite_json_types() -> None:
Integration.__table__.c.config.type = JSON()
User.__table__.c.assigned_domains.type = JSON()
TruthNode.__table__.c.content.type = JSON()
Task.__table__.c.ai_recommended_assignees.type = JSON()
Task.__table__.c.ai_breakdown_payload.type = JSON()
Task.__table__.c.manual_overrides.type = JSON()
Task.__table__.c.required_skill_tag_ids.type = JSON()
SignalSourceRegistry.__table__.c.config.type = JSON()
SignalSourceRegistry.__table__.c.raw_context.type = JSON()
Notification.__table__.c.payload.type = JSON()
FeedbackCenterRecord.__table__.c.before_value.type = JSON()
FeedbackCenterRecord.__table__.c.after_value.type = JSON()
FeedbackCenterRecord.__table__.c.ai_snapshot.type = JSON()
FeedbackCenterRecord.__table__.c.reason_candidates.type = JSON()
CognitiveConflict.__table__.c.involved_user_ids.type = JSON()
PortfolioSnapshot.__table__.c.summary.type = JSON()
PortfolioSnapshot.__table__.c.payload.type = JSON()
@pytest_asyncio.fixture
@ -33,6 +61,18 @@ async def db_session():
sync_conn,
tables=[
Integration.__table__,
User.__table__,
Project.__table__,
Signal.__table__,
TruthNode.__table__,
Task.__table__,
UserWorkload.__table__,
SignalSourceRegistry.__table__,
Notification.__table__,
FeedbackCenterRecord.__table__,
CognitiveConflict.__table__,
PortfolioSnapshot.__table__,
ProjectSignalLink.__table__,
],
)
)
@ -205,7 +245,7 @@ async def test_executive_briefing_service_formats_management_summary_text(db_ses
{
"brief_date": "2026-03-30",
"hero": {
"title": "老板晨报",
"title": "简报中心",
"decision_count": 2,
"anomaly_count": 4,
"baseline_generated_at": datetime(2026, 3, 30, 8, 30, 0).isoformat(),
@ -223,6 +263,363 @@ async def test_executive_briefing_service_formats_management_summary_text(db_ses
}
)
assert "老板晨报" in text
assert "简报中心" in text
assert "今日需拍板" in text
assert "P0 信号无人接" in text
@pytest.mark.asyncio
async def test_executive_briefing_service_builds_detailed_signal_items_and_target_params(db_session) -> None:
owner = User(
tenant_id=1,
username="boss",
email="boss@example.com",
password_hash="hash",
name="李雷",
role="BOSS",
status="active",
is_active=True,
)
db_session.add(owner)
await db_session.flush()
project = Project(
tenant_id=1,
name="cashflow-forecast",
title="现金流平台",
description="现金流预测与预警",
owner_id=owner.id,
status="active",
)
db_session.add(project)
await db_session.flush()
signal = Signal(
tenant_id=1,
source="feishu",
title="现金流预测功能需求",
summary="需要尽快评估接入方案",
priority="high",
processed=False,
matched_project_id=project.id,
status="pending",
planner_status="pending",
planner_assignee_user_id=owner.id,
created_at=datetime(2026, 3, 30, 7, 50, 0),
)
db_session.add(signal)
await db_session.commit()
service = ExecutiveBriefingService(db_session, tenant_id=1)
payload = await service.build_brief_payload(
mode="live",
baseline_generated_at=None,
live_generated_at=datetime(2026, 3, 30, 8, 30, 0),
)
assert payload["hero"]["title"] == "简报中心"
decisions = next(section for section in payload["sections"] if section["key"] == "decisions")
signal_item = next(item for item in decisions["items"] if item["kind"] == "signal")
assert signal_item["id"] == f"signal:{signal.id}"
assert signal_item["project_name"] == "现金流平台"
assert signal_item["owner_name"] == "李雷"
assert signal_item["occurred_at"] == "2026-03-30T07:50:00"
assert "优先级 高优" in signal_item["detail_lines"]
assert "状态 待处理" in signal_item["detail_lines"]
assert any(meta["label"] == "优先级" and meta["value"] == "高优" for meta in signal_item["meta"])
assert any(meta["label"] == "负责人" and meta["value"] == "李雷" for meta in signal_item["meta"])
assert signal_item["target"]["type"] == "project_board"
assert signal_item["target"]["params"]["stage"] == "signal"
assert signal_item["target"]["params"]["signal_keyword"] == "现金流预测功能需求"
assert signal_item["target"]["params"]["signal_status"] == "pending"
@pytest.mark.asyncio
async def test_executive_briefing_excludes_unassigned_signals_and_aligns_metrics(db_session) -> None:
owner = User(
tenant_id=1,
username="boss",
email="boss@example.com",
password_hash="hash",
name="李雷",
role="BOSS",
status="active",
is_active=True,
)
db_session.add(owner)
await db_session.flush()
project = Project(
tenant_id=1,
name="cashflow-forecast",
title="现金流平台",
description="现金流预测与预警",
owner_id=owner.id,
status="active",
)
db_session.add(project)
await db_session.flush()
db_session.add_all(
[
Signal(
tenant_id=1,
source="feishu",
title="现金流预测功能需求",
summary="需要尽快评估接入方案",
priority="high",
processed=False,
matched_project_id=project.id,
status="pending",
planner_status="pending",
planner_assignee_user_id=owner.id,
created_at=datetime(2026, 3, 30, 7, 50, 0),
),
Signal(
tenant_id=1,
source="feishu",
title="孤立高优信号",
summary="还没有归属项目",
priority="high",
processed=False,
matched_project_id=None,
status="pending",
planner_status="pending",
planner_assignee_user_id=None,
created_at=datetime(2026, 3, 30, 7, 45, 0),
),
Signal(
tenant_id=1,
source="feishu",
title=None,
summary=None,
priority="high",
processed=False,
matched_project_id=None,
status="pending",
planner_status="pending",
planner_assignee_user_id=None,
created_at=datetime(2026, 3, 30, 7, 40, 0),
),
]
)
await db_session.commit()
service = ExecutiveBriefingService(db_session, tenant_id=1)
payload = await service.build_brief_payload(
mode="live",
baseline_generated_at=None,
live_generated_at=datetime(2026, 3, 30, 8, 30, 0),
)
decisions = next(section for section in payload["sections"] if section["key"] == "decisions")
input_health = next(section for section in payload["sections"] if section["key"] == "input_health")
decision_signal_items = [item for item in decisions["items"] if item["kind"] == "signal"]
input_signal_items = [item for item in input_health["items"] if item["kind"] == "signal"]
assert [item["title"] for item in decision_signal_items] == ["现金流预测功能需求"]
assert [item["title"] for item in input_signal_items] == ["现金流预测功能需求"]
assert all(item["project_id"] == project.id for item in decision_signal_items)
assert all(item["project_id"] == project.id for item in input_signal_items)
assert all(item["title"] != "待处理事项" for item in decision_signal_items + input_signal_items)
high_priority_metric = next(metric for metric in decisions["metrics"] if metric["label"] == "高优信号")
recent_signal_metric = next(metric for metric in input_health["metrics"] if metric["label"] == "24h 新信号")
pending_signal_metric = next(metric for metric in input_health["metrics"] if metric["label"] == "待处理信号")
assert high_priority_metric["value"] == 1
assert recent_signal_metric["value"] == 1
assert pending_signal_metric["value"] == 1
@pytest.mark.asyncio
async def test_executive_briefing_snapshot_rebuilds_outdated_payload_for_detail_ui(db_session, monkeypatch) -> None:
fixed_now = datetime(2026, 3, 30, 9, 0, 0)
monkeypatch.setattr("app.services.executive_briefing_service.now_cn_naive", lambda: fixed_now)
owner = User(
tenant_id=1,
username="boss",
email="boss@example.com",
password_hash="hash",
name="李雷",
role="BOSS",
status="active",
is_active=True,
)
db_session.add(owner)
await db_session.flush()
project = Project(
tenant_id=1,
name="cashflow-forecast",
title="现金流平台",
description="现金流预测与预警",
owner_id=owner.id,
status="active",
)
db_session.add(project)
await db_session.flush()
signal = Signal(
tenant_id=1,
source="feishu",
title="现金流预测功能需求",
summary="需要尽快评估接入方案",
priority="high",
processed=False,
matched_project_id=project.id,
status="pending",
planner_status="pending",
planner_assignee_user_id=owner.id,
created_at=datetime(2026, 3, 30, 7, 50, 0),
)
db_session.add(signal)
await db_session.flush()
outdated_snapshot = PortfolioSnapshot(
tenant_id=1,
scope_type="executive_brief",
scope_ref="tenant:1",
summary={},
payload={
"brief_date": "2026-03-30",
"mode": "snapshot",
"hero": {
"title": "老板晨报中心",
"decision_count": 1,
"anomaly_count": 1,
"baseline_generated_at": datetime(2026, 3, 30, 8, 30, 0).isoformat(),
},
"sections": [
{
"key": "decisions",
"title": "今日需拍板",
"summary": "旧快照",
"metrics": [],
"items": [
{
"kind": "signal",
"tone": "warning",
"title": "📈 现金流预测功能需求",
"detail": "优先级 高优|状态 待处理|03-27 12:06",
"target": {"ref": "signals", "type": "ai_steward_tab", "label": "信号捕获"},
"project_id": project.id,
}
],
}
],
},
created_at=datetime(2026, 3, 30, 8, 30, 0),
)
db_session.add(outdated_snapshot)
await db_session.commit()
service = ExecutiveBriefingService(db_session, tenant_id=1)
payload = await service.get_briefing(mode="snapshot")
assert payload["hero"]["title"] == "简报中心"
assert payload["mode"] == "snapshot"
assert payload["hero"]["baseline_generated_at"] == datetime(2026, 3, 30, 8, 30, 0).isoformat()
decisions = next(section for section in payload["sections"] if section["key"] == "decisions")
signal_item = next(item for item in decisions["items"] if item["kind"] == "signal")
assert signal_item["id"] == f"signal:{signal.id}"
assert signal_item["target"]["type"] == "project_board"
assert signal_item["target"]["params"]["stage"] == "signal"
assert signal_item["detail_lines"]
assert signal_item["meta"]
@pytest.mark.asyncio
async def test_executive_briefing_signal_source_target_does_not_apply_mismatched_keyword_filter(db_session) -> None:
owner = User(
tenant_id=1,
username="boss",
email="boss@example.com",
password_hash="hash",
name="李雷",
role="BOSS",
status="active",
is_active=True,
)
db_session.add(owner)
await db_session.flush()
project = Project(
tenant_id=1,
name="cashflow-forecast",
title="现金流平台",
description="现金流预测与预警",
owner_id=owner.id,
status="active",
)
db_session.add(project)
await db_session.flush()
source = SignalSourceRegistry(
tenant_id=1,
project_id=project.id,
created_by_user_id=owner.id,
source_kind="scheduled",
source_name="每日现金流分析",
source_identifier="daily_cashflow_analysis",
onboarding_status="pending",
created_at=datetime(2026, 3, 30, 8, 10, 0),
)
db_session.add(source)
await db_session.commit()
service = ExecutiveBriefingService(db_session, tenant_id=1)
payload = await service.build_brief_payload(
mode="live",
baseline_generated_at=None,
live_generated_at=datetime(2026, 3, 30, 8, 30, 0),
)
input_health = next(section for section in payload["sections"] if section["key"] == "input_health")
source_item = next(item for item in input_health["items"] if item["kind"] == "signal_source")
assert source_item["target"]["type"] == "project_board"
assert source_item["target"]["params"] == {"stage": "signal"}
@pytest.mark.asyncio
async def test_executive_briefing_capacity_ratio_matches_displayed_hours(db_session) -> None:
user = User(
tenant_id=1,
username="owner",
email="owner@example.com",
password_hash="hash",
name="杨宇",
role="ADMIN",
status="active",
is_active=True,
)
db_session.add(user)
await db_session.flush()
db_session.add(
UserWorkload(
tenant_id=1,
user_id=user.id,
current_assigned_hours=95.6,
max_available_hours_per_week=60,
workload_ratio=797,
)
)
await db_session.commit()
service = ExecutiveBriefingService(db_session, tenant_id=1)
payload = await service.build_brief_payload(
mode="live",
baseline_generated_at=None,
live_generated_at=datetime(2026, 3, 30, 8, 30, 0),
)
capacity = next(section for section in payload["sections"] if section["key"] == "team_capacity")
overload_item = next(item for item in capacity["items"] if item["kind"] == "capacity")
assert overload_item["title"] == "杨宇"
assert overload_item["detail_lines"][0] == "159% 负载"
assert any(meta["label"] == "负载" and meta["value"] == "159%" for meta in overload_item["meta"])

View File

@ -120,11 +120,33 @@ export interface ExecutiveBriefingMetric {
tone: string;
}
export interface BriefingTarget {
type: "project_board" | "workload_detail" | "user_profile" | "ai_steward_tab";
ref: string;
label?: string | null;
params?: Record<string, string | number | boolean>;
}
export interface BriefingMetaEntry {
label: string;
value: string;
}
export interface ExecutiveBriefingItem {
id: string;
title: string;
detail: string;
detail_lines: string[];
meta: BriefingMetaEntry[];
tone: string;
kind?: string | null;
target?: BriefingTarget | null;
project_id?: number | null;
project_name?: string | null;
user_id?: number | null;
owner_name?: string | null;
related_user_ids?: number[];
occurred_at?: string | null;
}
export interface ExecutiveBriefingSection {
@ -166,6 +188,32 @@ export interface ExecutiveBriefingPayload {
delivery: ExecutiveBriefingConfig;
}
export interface DailyBriefingV2PersonalSummary {
headline: string;
action_count: number;
project_count: number;
}
export interface DailyBriefingV2PersonalWorkload {
user_id: number;
user_name: string;
assigned_hours: number;
max_weekly_hours: number;
workload_ratio: number;
tone: string;
}
export interface DailyBriefingV2Payload {
brief_date: string;
mode: "snapshot" | "live";
viewer_scope: "tenant_full" | "personal";
hero: ExecutiveBriefingHero;
sections: ExecutiveBriefingSection[];
personal_summary?: DailyBriefingV2PersonalSummary | null;
personal_workload?: DailyBriefingV2PersonalWorkload | null;
export_title: string;
}
export type LLMProvider = "deepseek" | "glm" | "openai" | "ollama";
export interface LLMProviderPreset {
@ -249,6 +297,10 @@ export const aiStewardApi = {
async getDailyBriefing(): Promise<DailyBriefing> {
return await apiRequest<DailyBriefing>("/ai-steward/daily-briefing");
},
async getDailyBriefingV2(mode: "snapshot" | "live"): Promise<DailyBriefingV2Payload> {
const raw = await apiRequest<ApiEnvelope<DailyBriefingV2Payload>>(`/ai-steward/daily-briefing-v2?mode=${mode}`);
return unwrap(raw);
},
async startHnMonitor(intervalMinutes: number = 30): Promise<Record<string, unknown>> {
return await apiRequest<Record<string, unknown>>(
`/ai-steward/signal/start-hn-monitor?interval_minutes=${intervalMinutes}`,

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from "react";
import { useMutation } from "@tanstack/react-query";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useNavigate, useSearchParams } from "react-router-dom";
import { Sparkles } from "lucide-react";
import { getAuthToken } from "@/lib/api";
@ -21,9 +21,10 @@ import {
ENGINE_PROVIDER_FALLBACK_PRESETS,
} from "./constants";
import type {
BriefingTarget,
Blocker,
CognitiveConflict,
DailyBriefing,
DailyBriefingV2Payload,
ExecutiveBriefingConfig,
ExecutiveBriefingPayload,
FeedbackCenterSourceGroup,
@ -37,8 +38,8 @@ import type {
AIStewardTab,
} from "./types";
import FeatureOverviewCards from "./components/FeatureOverviewCards";
import { printBriefingReport, type PrintableBriefingPayload } from "./components/BriefingPrintDocument";
import BlockersTab from "./sections/BlockersTab";
import BriefingTab from "./sections/BriefingTab";
import ConflictsTab from "./sections/ConflictsTab";
import CreateProjectTab from "./sections/CreateProjectTab";
import EngineTab from "./sections/EngineTab";
@ -71,6 +72,9 @@ const DEFAULT_EXECUTIVE_BRIEFING_CONFIG: ExecutiveBriefingConfig = {
};
function normalizeTab(value: string | null): AIStewardTab {
if (value === "briefing") {
return "overview";
}
if (value && AIS_STEWARD_TABS.includes(value as AIStewardTab)) {
return value as AIStewardTab;
}
@ -95,6 +99,15 @@ function normalizeTrendSourceGroup(value: string | null): FeedbackCenterSourceGr
return "all";
}
function serializeTargetParams(params?: BriefingTarget["params"]): string {
const query = new URLSearchParams();
for (const [key, value] of Object.entries(params || {})) {
if (value === null || value === undefined || value === "") continue;
query.set(key, String(value));
}
return query.toString();
}
export default function AIStewardPage() {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
@ -120,12 +133,7 @@ export default function AIStewardPage() {
);
const [conflicts, setConflicts] = useState<CognitiveConflict[]>([]);
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 [briefingMode, setBriefingMode] = useState<"snapshot" | "live">("snapshot");
const [executiveBriefingConfigDraft, setExecutiveBriefingConfigDraft] = useState<ExecutiveBriefingConfig>(
DEFAULT_EXECUTIVE_BRIEFING_CONFIG
);
@ -165,6 +173,31 @@ export default function AIStewardPage() {
},
});
const executiveBriefingQuery = useQuery<ExecutiveBriefingPayload>({
queryKey: ["ai-steward", "executive-briefing", briefingMode],
queryFn: () => aiStewardApi.getExecutiveBriefing(briefingMode),
enabled: canManageExecutiveBriefing,
staleTime: 30_000,
});
const dailyBriefingQuery = useQuery<DailyBriefingV2Payload>({
queryKey: ["ai-steward", "daily-briefing-v2", briefingMode],
queryFn: () => aiStewardApi.getDailyBriefingV2(briefingMode),
enabled: Boolean(user) && (activeTab === "overview" || activeTab === "briefing" || canManageExecutiveBriefing),
staleTime: 30_000,
});
const executiveBriefing = executiveBriefingQuery.data ?? null;
const executiveBriefingLoading = executiveBriefingQuery.isLoading || executiveBriefingQuery.isFetching;
const executiveBriefingError = executiveBriefingQuery.error
? getApiErrorMessage(executiveBriefingQuery.error, "获取简报失败")
: null;
const dailyBriefing = dailyBriefingQuery.data ?? null;
const dailyBriefingLoading = dailyBriefingQuery.isLoading || dailyBriefingQuery.isFetching;
const dailyBriefingError = dailyBriefingQuery.error
? getApiErrorMessage(dailyBriefingQuery.error, "获取每日简报失败")
: null;
// ==================== API Calls ====================
const loadConflicts = async () => {
@ -191,35 +224,17 @@ export default function AIStewardPage() {
}
};
const loadBriefing = async () => {
setFeatureLoading("briefing");
setBriefingError(null);
try {
const data = await aiStewardApi.getDailyBriefing();
setBriefing(data);
} catch (err) {
console.error("Failed to load briefing:", err);
setBriefing(null);
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 switchBriefingMode = (mode: "snapshot" | "live") => {
if (mode === briefingMode) {
if (canManageExecutiveBriefing) {
void executiveBriefingQuery.refetch();
}
if (activeTab === "briefing" || canManageExecutiveBriefing) {
void dailyBriefingQuery.refetch();
}
return;
}
setBriefingMode(mode);
};
const loadExecutiveBriefingConfig = async () => {
@ -244,8 +259,11 @@ export default function AIStewardPage() {
const data = await aiStewardApi.updateExecutiveBriefingConfig(executiveBriefingConfigDraft);
setExecutiveBriefingConfigDraft(data);
setExecutiveBriefingSaveMessage({ type: "success", text: "晨报调度配置已保存" });
if (activeTab === "overview") {
await loadExecutiveBriefing("snapshot");
if (canManageExecutiveBriefing) {
await executiveBriefingQuery.refetch();
}
if (dailyBriefingQuery.isFetched) {
await dailyBriefingQuery.refetch();
}
} catch (err) {
console.error("Failed to save executive briefing config:", err);
@ -541,6 +559,42 @@ export default function AIStewardPage() {
syncUrlParams(nextTab, feedbackSection);
};
const openBriefingTarget = (target?: BriefingTarget | null, fallbackTab?: AIStewardTab) => {
if (!target?.type || !target.ref) {
if (fallbackTab) {
handleTabChange(fallbackTab);
}
return;
}
const serializedParams = serializeTargetParams(target.params);
if (target.type === "project_board") {
navigate(`/projects/${target.ref}/board${serializedParams ? `?${serializedParams}` : ""}`);
return;
}
if (target.type === "workload_detail") {
navigate(`/workloads/${target.ref}${serializedParams ? `?${serializedParams}` : ""}`);
return;
}
if (target.type === "user_profile") {
navigate(`/users/${target.ref}/profile${serializedParams ? `?${serializedParams}` : ""}`);
return;
}
const nextTab = normalizeTab(target.ref);
setActiveTab(nextTab);
const next = new URLSearchParams(searchParams);
next.set("tab", nextTab);
next.set("section", String(target.params?.section || feedbackSection));
next.set("window", String(target.params?.window || trendWindowDays));
next.set("scope", String(target.params?.scope || trendSourceGroup));
for (const [key, value] of Object.entries(target.params || {})) {
if (value === null || value === undefined || value === "") continue;
next.set(key, String(value));
}
setSearchParams(next, { replace: true });
};
const handleFeedbackSectionChange = (nextSection: FeedbackCenterSection) => {
setFeedbackSection(nextSection);
syncUrlParams(activeTab, nextSection);
@ -556,6 +610,31 @@ export default function AIStewardPage() {
syncUrlParams(activeTab, feedbackSection, trendWindowDays, nextSourceGroup);
};
const buildExportPayload = (): PrintableBriefingPayload | null => {
if (canManageExecutiveBriefing && executiveBriefing) {
return {
brief_date: executiveBriefing.brief_date,
mode: executiveBriefing.mode,
hero: executiveBriefing.hero,
sections: executiveBriefing.sections,
export_title: executiveBriefing.hero.title || "简报中心",
viewer_scope: "tenant_full",
};
}
if (dailyBriefing) {
return dailyBriefing;
}
return null;
};
const exportCurrentReport = () => {
const payload = buildExportPayload();
if (!payload) {
return;
}
printBriefingReport(payload, user?.name || user?.username || undefined);
};
// ==================== Initial Load ====================
useEffect(() => {
@ -580,12 +659,10 @@ export default function AIStewardPage() {
useEffect(() => {
if (activeTab === "overview" && canManageExecutiveBriefing) {
void loadExecutiveBriefing("snapshot");
void loadExecutiveBriefingConfig();
}
if (activeTab === "conflicts") loadConflicts();
if (activeTab === "blockers") loadBlockers();
if (activeTab === "briefing") loadBriefing();
if (activeTab === "engine") {
loadEngineProviderPresets();
loadEngineConfigs();
@ -602,13 +679,13 @@ export default function AIStewardPage() {
<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">
{canManageExecutiveBriefing ? "老板晨报中心" : "AI 管家"}
简报中心
</h1>
</div>
<p className="text-slate-600 dark:text-slate-400 text-lg">
{canManageExecutiveBriefing
? "晨报快照、实时刷新与管理群推送共用同一套经营简报内核"
: "主动式 AI 管家 - 7x24 小时自动守护项目"}
? "晨报快照、实时刷新与管理群推送共用同一套租户级简报内核"
: "与你相关的任务、风险、负载和对齐事项都集中在一个入口里"}
</p>
</div>
@ -624,13 +701,12 @@ export default function AIStewardPage() {
<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">
{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>
<TabsTrigger value="blockers" className="shrink-0 whitespace-nowrap">阻塞检测</TabsTrigger>
<TabsTrigger value="briefing" className="shrink-0 whitespace-nowrap">每日简报</TabsTrigger>
<TabsTrigger value="qa" className="shrink-0 whitespace-nowrap">问答记录</TabsTrigger>
{canViewFeedbackCenter && (
<TabsTrigger value="feedback-center" className="shrink-0 whitespace-nowrap">反馈中心</TabsTrigger>
@ -640,20 +716,25 @@ export default function AIStewardPage() {
<OverviewTab
canManageExecutiveBriefing={canManageExecutiveBriefing}
dailyBriefing={dailyBriefing}
dailyBriefingError={dailyBriefingError}
dailyBriefingLoading={dailyBriefingLoading}
username={user?.username}
executiveBriefing={executiveBriefing}
executiveBriefingError={executiveBriefingError}
executiveBriefingMode={executiveBriefingMode}
executiveBriefingMode={briefingMode}
executiveBriefingLoading={executiveBriefingLoading}
executiveBriefingConfigDraft={executiveBriefingConfigDraft}
executiveBriefingConfigLoading={executiveBriefingConfigLoading}
executiveBriefingConfigError={executiveBriefingConfigError}
executiveBriefingSaveLoading={executiveBriefingConfigSaving}
executiveBriefingSaveMessage={executiveBriefingSaveMessage}
onLoadSnapshot={() => void loadExecutiveBriefing("snapshot")}
onRefreshLive={() => void loadExecutiveBriefing("live")}
onLoadSnapshot={() => switchBriefingMode("snapshot")}
onRefreshLive={() => switchBriefingMode("live")}
onExportReport={exportCurrentReport}
onConfigChange={updateExecutiveBriefingDraft}
onSaveConfig={() => void saveExecutiveBriefingConfig()}
onSelectTab={(tab) => handleTabChange(tab)}
onOpenTarget={openBriefingTarget}
/>
<CreateProjectTab
@ -688,14 +769,6 @@ export default function AIStewardPage() {
onDetectTaskBlockers={() => void detectTaskBlockers()}
/>
<BriefingTab
featureLoading={featureLoading}
briefing={briefing}
briefingError={briefingError}
username={user?.username}
onRefreshBriefing={() => void loadBriefing()}
/>
{/* QA Records Tab */}
<TabsContent value="qa" className="mt-6">
<QAHistoryPanel />

View File

@ -0,0 +1,368 @@
import { useEffect, useMemo, useState } from "react";
import { ChevronDown, ChevronRight } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type { AIStewardTab, BriefingTarget, ExecutiveBriefingItem, ExecutiveBriefingSection } from "@/pages/ai-steward/types";
import { groupBriefingItemsByProject } from "./BriefingSectionCard";
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 toneLabel(tone: string): string {
switch (tone) {
case "critical":
return "紧急";
case "warning":
return "关注";
case "positive":
return "平稳";
case "info":
return "提示";
default:
return "状态";
}
}
function buildActionLabel(target?: BriefingTarget | null, fallbackTab?: AIStewardTab): string {
switch (target?.type) {
case "project_board":
return "进入项目";
case "workload_detail":
return "查看负载";
case "user_profile":
return "查看成员";
case "ai_steward_tab":
return "打开工作台";
default:
return fallbackTab ? "打开工作台" : "查看详情";
}
}
function itemKindLabel(kind?: string | null): string {
switch (kind) {
case "signal":
return "项目信号";
case "signal_source":
return "信号源";
case "capacity":
return "人员负载";
case "task":
return "项目任务";
case "notification":
return "项目风险";
case "conflict":
return "协作冲突";
default:
return "事项";
}
}
interface BriefingDetailDrawerProps {
open: boolean;
section: ExecutiveBriefingSection | null;
focusedProjectKey?: string | null;
onOpenChange: (open: boolean) => void;
onOpenTarget: (target?: BriefingTarget | null, fallbackTab?: AIStewardTab) => void;
}
function getItemRuntimeKey(item: ExecutiveBriefingItem, groupKey: string, index: number): string {
return item.id || `${groupKey}:${item.kind || "item"}:${item.title}:${index}`;
}
function getItemDetailLines(item: ExecutiveBriefingItem): string[] {
if (Array.isArray(item.detail_lines) && item.detail_lines.length) {
return item.detail_lines;
}
const detail = String(item.detail || "").trim();
return detail ? [detail] : [];
}
function getItemMeta(item: ExecutiveBriefingItem): Array<{ label: string; value: string }> {
if (Array.isArray(item.meta) && item.meta.length) {
return item.meta;
}
const fallback: Array<{ label: string; value: string }> = [];
if (item.project_name) {
fallback.push({ label: "项目", value: item.project_name });
}
if (item.owner_name) {
fallback.push({ label: "负责人", value: item.owner_name });
}
if (item.occurred_at) {
fallback.push({ label: "时间", value: item.occurred_at });
}
return fallback;
}
export default function BriefingDetailDrawer({
open,
section,
focusedProjectKey,
onOpenChange,
onOpenTarget,
}: BriefingDetailDrawerProps) {
const groups = useMemo(() => groupBriefingItemsByProject(section?.items ?? []), [section?.items]);
const [expandedGroupKeys, setExpandedGroupKeys] = useState<Set<string>>(new Set());
const [expandedItemIds, setExpandedItemIds] = useState<Set<string>>(new Set());
useEffect(() => {
if (!open) return;
setExpandedGroupKeys(
new Set(
focusedProjectKey
? [focusedProjectKey]
: groups.map((group) => group.key)
)
);
setExpandedItemIds(new Set());
}, [focusedProjectKey, groups, open, section?.key]);
const allGroupsExpanded = groups.length > 0 && groups.every((group) => expandedGroupKeys.has(group.key));
const allItems = section?.items ?? [];
const runtimeItemKeys = groups.flatMap((group) =>
group.items.map((item, index) => getItemRuntimeKey(item, group.key, index))
);
const allItemsExpanded = runtimeItemKeys.length > 0 && runtimeItemKeys.every((itemKey) => expandedItemIds.has(itemKey));
const fallbackTab = section?.drilldown_tab as AIStewardTab | undefined;
const toggleGroup = (groupKey: string) => {
setExpandedGroupKeys((current) => {
const next = new Set(current);
if (next.has(groupKey)) {
next.delete(groupKey);
} else {
next.add(groupKey);
}
return next;
});
};
const toggleItem = (itemId: string) => {
setExpandedItemIds((current) => {
const next = new Set(current);
if (next.has(itemId)) {
next.delete(itemId);
} else {
next.add(itemId);
}
return next;
});
};
const toggleAllGroups = () => {
setExpandedGroupKeys(allGroupsExpanded ? new Set() : new Set(groups.map((group) => group.key)));
};
const toggleAllItems = () => {
setExpandedItemIds(allItemsExpanded ? new Set() : new Set(runtimeItemKeys));
};
const openAction = (target?: BriefingTarget | null, nextFallbackTab?: AIStewardTab) => {
onOpenChange(false);
onOpenTarget(target, nextFallbackTab);
};
const renderActionButtons = (item: ExecutiveBriefingItem, runtimeKey: string) => {
const actions: Array<{ key: string; label: string; target?: BriefingTarget | null; fallbackTab?: AIStewardTab; variant?: "default" | "outline" }> = [];
if (item.target || fallbackTab) {
actions.push({
key: "primary",
label: buildActionLabel(item.target, fallbackTab),
target: item.target,
fallbackTab,
variant: "default",
});
}
if (item.project_id && item.target?.type !== "project_board") {
actions.push({
key: "project",
label: "进入项目",
target: { type: "project_board", ref: String(item.project_id) },
variant: "outline",
});
}
if (item.user_id && item.target?.type !== "workload_detail") {
actions.push({
key: "workload",
label: "查看负载",
target: { type: "workload_detail", ref: String(item.user_id) },
variant: "outline",
});
}
return (
<div className="mt-4 flex flex-wrap gap-2">
{actions.map((action) => (
<Button
key={`${runtimeKey}-${action.key}`}
size="sm"
variant={action.variant}
onClick={() => openAction(action.target, action.fallbackTab)}
>
{action.label}
</Button>
))}
</div>
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="z-[100] gap-0 overflow-hidden p-0 sm:left-auto sm:right-0 sm:top-0 sm:bottom-0 sm:h-screen sm:max-h-screen sm:w-[min(760px,100vw)] sm:max-w-none sm:translate-x-0 sm:translate-y-0 sm:rounded-none"
>
<div className="flex h-full max-h-[90vh] flex-col bg-slate-50 dark:bg-slate-950">
<DialogHeader className="border-b border-slate-200 bg-white px-6 py-5 dark:border-slate-800 dark:bg-slate-950">
<DialogTitle className="text-2xl text-slate-950 dark:text-slate-50">
{section?.title || "简报详情"}
</DialogTitle>
<DialogDescription className="text-sm leading-6 text-slate-500 dark:text-slate-400">
{section?.summary || "在这里查看当前区块的完整项目分组与条目详情。"}
</DialogDescription>
<div className="mt-4 flex flex-wrap items-center gap-2">
<Badge className="bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900">
共 {allItems.length} 条
</Badge>
<Button variant="outline" size="sm" onClick={toggleAllGroups} disabled={!groups.length}>
{allGroupsExpanded ? "全部收起项目" : "全部展开项目"}
</Button>
<Button variant="outline" size="sm" onClick={toggleAllItems} disabled={!allItems.length}>
{allItemsExpanded ? "全部收起条目" : "全部展开条目"}
</Button>
</div>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-6 py-6">
<div className="space-y-4">
{groups.map((group) => {
const expanded = expandedGroupKeys.has(group.key);
return (
<div
key={group.key}
className="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900/80"
>
<button
type="button"
className="flex w-full items-start justify-between gap-3 text-left"
onClick={() => toggleGroup(group.key)}
>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="text-lg font-semibold text-slate-950 dark:text-slate-50">{group.title}</span>
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600 dark:bg-slate-800 dark:text-slate-300">
{group.categoryLabel}
</span>
<Badge className={toneBadgeClass(group.tone)}>{toneLabel(group.tone)}</Badge>
<span className="text-sm text-slate-500">{group.items.length} 条</span>
</div>
<p className="mt-2 text-sm leading-6 text-slate-500 dark:text-slate-400">
展开后查看这个项目下的完整事项、详细字段与操作按钮。
</p>
</div>
{expanded ? (
<ChevronDown className="mt-1 h-5 w-5 shrink-0 text-slate-500" />
) : (
<ChevronRight className="mt-1 h-5 w-5 shrink-0 text-slate-500" />
)}
</button>
{expanded && (
<div className="mt-5 space-y-3">
{group.items.map((item, index) => {
const runtimeKey = getItemRuntimeKey(item, group.key, index);
const expandedItem = expandedItemIds.has(runtimeKey);
const itemMeta = getItemMeta(item);
const detailLines = getItemDetailLines(item);
return (
<div
key={runtimeKey}
className="rounded-2xl border border-slate-200/80 bg-slate-50/90 p-4 dark:border-slate-800 dark:bg-slate-950/60"
>
<button
type="button"
className="flex w-full items-start justify-between gap-3 text-left"
onClick={() => toggleItem(runtimeKey)}
>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium text-slate-950 dark:text-slate-50">{item.title}</span>
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[11px] text-slate-500 dark:bg-slate-800 dark:text-slate-300">
{itemKindLabel(item.kind)}
</span>
<Badge className={toneBadgeClass(item.tone)}>{toneLabel(item.tone)}</Badge>
</div>
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-300">{item.detail}</p>
</div>
{expandedItem ? (
<ChevronDown className="mt-1 h-4 w-4 shrink-0 text-slate-500" />
) : (
<ChevronRight className="mt-1 h-4 w-4 shrink-0 text-slate-500" />
)}
</button>
{expandedItem && (
<div className="mt-4 border-t border-slate-200 pt-4 dark:border-slate-800">
<div className="grid gap-3 md:grid-cols-2">
{itemMeta.map((meta) => (
<div
key={`${runtimeKey}-${meta.label}`}
className="rounded-xl border border-slate-200/80 bg-white px-3 py-3 dark:border-slate-800 dark:bg-slate-900"
>
<p className="text-xs text-slate-500">{meta.label}</p>
<p className="mt-1 text-sm font-medium text-slate-900 dark:text-slate-100">{meta.value}</p>
</div>
))}
</div>
<div className="mt-4 space-y-2">
{detailLines.map((line, lineIndex) => (
<div
key={`${runtimeKey}-line-${lineIndex}`}
className="rounded-xl border border-dashed border-slate-200/80 bg-white/80 px-3 py-2 text-sm text-slate-600 dark:border-slate-800 dark:bg-slate-900/80 dark:text-slate-300"
>
{line}
</div>
))}
</div>
{renderActionButtons(item, runtimeKey)}
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
})}
{!groups.length && (
<div className="rounded-2xl border border-dashed border-slate-300 bg-white px-4 py-10 text-center text-sm text-slate-500 dark:border-slate-700 dark:bg-slate-900/60 dark:text-slate-400">
当前区块没有可展开的详细内容。
</div>
)}
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,571 @@
import type {
DailyBriefingV2Payload,
ExecutiveBriefingHero,
ExecutiveBriefingSection,
} from "@/pages/ai-steward/types";
import { groupBriefingItemsByProject } from "./BriefingSectionCard";
export interface PrintableBriefingPayload {
brief_date: string;
mode: "snapshot" | "live";
hero: ExecutiveBriefingHero;
sections: ExecutiveBriefingSection[];
export_title: string;
viewer_scope?: DailyBriefingV2Payload["viewer_scope"];
personal_summary?: DailyBriefingV2Payload["personal_summary"];
personal_workload?: DailyBriefingV2Payload["personal_workload"];
}
function escapeHtml(value: string | number | null | undefined): string {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function toneLabel(tone: string): string {
switch (tone) {
case "critical":
return "紧急";
case "warning":
return "关注";
case "positive":
return "正常";
case "info":
return "提示";
default:
return "状态";
}
}
function itemKindLabel(kind?: string | null): string {
switch (kind) {
case "signal":
return "项目信号";
case "signal_source":
return "信号源";
case "capacity":
return "人员负载";
case "task":
return "项目任务";
case "notification":
return "项目风险";
case "conflict":
return "协作冲突";
default:
return "事项";
}
}
function modeLabel(mode: PrintableBriefingPayload["mode"]): string {
return mode === "live" ? "实时版" : "晨报快照";
}
function formatTimestamp(value?: string | null): string {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return date.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
function renderMetrics(metrics: ExecutiveBriefingSection["metrics"]): string {
if (!metrics.length) {
return '<p class="muted">当前没有额外指标。</p>';
}
return `
<div class="metrics">
${metrics
.map(
(metric) => `
<div class="metric-card">
<div class="metric-label">${escapeHtml(metric.label)}</div>
<div class="metric-value">${escapeHtml(metric.value)}</div>
</div>
`
)
.join("")}
</div>
`;
}
function renderTargetLabel(sectionTitle: string, target?: { label?: string | null; type?: string } | null): string {
const label = String(target?.label || "").trim();
if (label) return label;
switch (target?.type) {
case "project_board":
return "进入项目";
case "workload_detail":
return "查看负载";
case "user_profile":
return "查看成员";
case "ai_steward_tab":
return `打开${sectionTitle}`;
default:
return "查看详情";
}
}
function renderItemMeta(item: ExecutiveBriefingSection["items"][number]): Array<{ label: string; value: string }> {
if (Array.isArray(item.meta) && item.meta.length) {
return item.meta;
}
const fallback: Array<{ label: string; value: string }> = [];
if (item.project_name) {
fallback.push({ label: "项目", value: item.project_name });
}
if (item.owner_name) {
fallback.push({ label: "负责人", value: item.owner_name });
}
if (item.occurred_at) {
fallback.push({ label: "时间", value: item.occurred_at });
}
return fallback;
}
function renderItemDetailLines(item: ExecutiveBriefingSection["items"][number]): string[] {
if (Array.isArray(item.detail_lines) && item.detail_lines.length) {
return item.detail_lines;
}
const detail = String(item.detail || "").trim();
return detail ? [detail] : [];
}
function renderItems(section: ExecutiveBriefingSection): string {
const groups = groupBriefingItemsByProject(section.items);
if (!section.items.length) {
return '<div class="empty-card">当前区块暂无需要补充到报告的异常或跟进项。</div>';
}
return `
<div class="project-group-list">
${groups
.map(
(group) => `
<section class="project-group">
<div class="project-head">
<div>
<div class="project-title">${escapeHtml(group.title)}</div>
<div class="project-kind">${escapeHtml(group.categoryLabel)}</div>
</div>
<div class="project-count">${escapeHtml(group.items.length)} 条</div>
</div>
<div class="item-list">
${group.items
.map((item) => {
const meta = renderItemMeta(item);
const detailLines = renderItemDetailLines(item);
return `
<div class="item-card">
<div class="item-head">
<div>
<span class="item-title">${escapeHtml(item.title)}</span>
<span class="item-kind">${escapeHtml(itemKindLabel(item.kind))}</span>
</div>
<span class="tone tone-${escapeHtml(item.tone)}">${toneLabel(item.tone)}</span>
</div>
<div class="item-detail">${escapeHtml(item.detail)}</div>
<div class="meta-grid">
${meta
.map(
(meta) => `
<div class="meta-chip">
<div class="meta-label">${escapeHtml(meta.label)}</div>
<div class="meta-value">${escapeHtml(meta.value)}</div>
</div>
`
)
.join("")}
</div>
<div class="detail-lines">
${detailLines
.map((line) => `<div class="detail-line">${escapeHtml(line)}</div>`)
.join("")}
</div>
<div class="action-tag">处理动作:${escapeHtml(renderTargetLabel(section.title, item.target))}</div>
</div>
`
})
.join("")}
</div>
</section>
`
)
.join("")}
</div>
`;
}
export function buildBriefingPrintHtml(payload: PrintableBriefingPayload, viewerName?: string): string {
const hero = payload.hero;
const summaryBlock = payload.personal_summary
? `
<section class="summary-banner">
<h2>今日结论</h2>
<p>${escapeHtml(payload.personal_summary.headline)}</p>
</section>
`
: "";
const workloadBlock = payload.personal_workload
? `
<section class="workload-card">
<div class="workload-head">个人负载</div>
<div class="workload-name">${escapeHtml(payload.personal_workload.user_name)}</div>
<div class="workload-ratio">${escapeHtml(payload.personal_workload.workload_ratio)}%</div>
<div class="workload-detail">
已分配 ${escapeHtml(payload.personal_workload.assigned_hours)} 小时 / 周上限
${escapeHtml(payload.personal_workload.max_weekly_hours)} 小时
</div>
</section>
`
: "";
const hasWorkloadBlock = Boolean(payload.personal_workload);
return `
<html>
<head>
<meta charset="utf-8" />
<title>${escapeHtml(payload.export_title)}</title>
<style>
:root {
color-scheme: light;
font-family: "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: #f7f4ee;
color: #1f2937;
}
.page {
width: 960px;
margin: 0 auto;
padding: 40px 32px 56px;
}
.hero {
padding: 28px;
border-radius: 24px;
background: linear-gradient(135deg, #fff8ef 0%, #ffe6bf 100%);
border: 1px solid #f3d39b;
}
.eyebrow {
display: inline-block;
padding: 6px 12px;
border-radius: 999px;
background: #111827;
color: #fff;
font-size: 12px;
}
h1 {
margin: 16px 0 8px;
font-size: 30px;
line-height: 1.25;
}
.hero-subtitle {
margin: 0;
color: #475569;
font-size: 14px;
line-height: 1.7;
}
.hero-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
margin-top: 24px;
}
.hero-card,
.metric-card,
.workload-card {
border-radius: 18px;
background: rgba(255, 255, 255, 0.82);
border: 1px solid #eadac0;
padding: 18px;
}
.hero-label,
.metric-label,
.workload-head {
font-size: 12px;
color: #64748b;
}
.hero-value,
.metric-value,
.workload-ratio {
margin-top: 8px;
font-size: 28px;
font-weight: 700;
color: #0f172a;
}
.meta {
display: flex;
gap: 18px;
flex-wrap: wrap;
margin-top: 18px;
font-size: 13px;
color: #475569;
}
.summary-banner {
margin-top: 24px;
padding: 22px 24px;
border-radius: 20px;
background: #ffffff;
border: 1px solid #e2e8f0;
}
.summary-banner h2,
.section-card h3 {
margin: 0 0 10px;
font-size: 20px;
}
.summary-banner p,
.section-summary,
.item-detail,
.muted,
.workload-detail {
margin: 0;
font-size: 14px;
line-height: 1.7;
color: #475569;
}
.layout {
display: grid;
grid-template-columns: minmax(0, 1.6fr) minmax(260px, 0.8fr);
gap: 18px;
margin-top: 18px;
}
.section-list {
display: grid;
gap: 16px;
}
.section-card {
padding: 22px 24px;
border-radius: 20px;
background: #fff;
border: 1px solid #e2e8f0;
}
.metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-top: 14px;
}
.item-list {
display: grid;
gap: 12px;
margin-top: 16px;
}
.project-group-list {
display: grid;
gap: 14px;
margin-top: 16px;
}
.project-group {
border-radius: 18px;
background: #fff8ef;
border: 1px solid #f0dfc1;
padding: 14px;
}
.project-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.project-title {
font-size: 16px;
font-weight: 700;
color: #0f172a;
}
.project-kind {
margin-top: 4px;
font-size: 12px;
color: #64748b;
}
.project-count {
font-size: 12px;
color: #64748b;
}
.item-card,
.empty-card {
border-radius: 16px;
background: #f8fafc;
border: 1px solid #e2e8f0;
padding: 14px 16px;
}
.item-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.item-title,
.workload-name {
font-size: 15px;
font-weight: 600;
color: #0f172a;
}
.item-kind {
display: inline-block;
margin-left: 8px;
padding: 2px 8px;
border-radius: 999px;
background: #f1f5f9;
color: #475569;
font-size: 11px;
font-weight: 500;
}
.meta-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-top: 12px;
}
.meta-chip {
border-radius: 12px;
border: 1px solid #e2e8f0;
background: #ffffff;
padding: 10px 12px;
}
.meta-label {
font-size: 11px;
color: #64748b;
}
.meta-value {
margin-top: 4px;
font-size: 13px;
font-weight: 600;
color: #0f172a;
}
.detail-lines {
display: grid;
gap: 8px;
margin-top: 12px;
}
.detail-line {
border-radius: 10px;
border: 1px dashed #d8e0ea;
background: #ffffff;
padding: 10px 12px;
font-size: 13px;
line-height: 1.6;
color: #475569;
}
.action-tag {
margin-top: 12px;
font-size: 13px;
font-weight: 600;
color: #334155;
}
.tone {
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
}
.tone-critical {
background: #ffe4e6;
color: #be123c;
}
.tone-warning {
background: #fef3c7;
color: #b45309;
}
.tone-positive {
background: #dcfce7;
color: #15803d;
}
.tone-info {
background: #e0f2fe;
color: #0369a1;
}
@media print {
body {
background: #fff;
}
.page {
width: auto;
padding: 0;
}
.layout {
display: block;
}
.section-list + .section-list {
margin-top: 18px;
}
}
</style>
</head>
<body>
<main class="page">
<section class="hero">
<span class="eyebrow">${escapeHtml(modeLabel(payload.mode))}</span>
<h1>${escapeHtml(payload.export_title)}</h1>
<p class="hero-subtitle">
${escapeHtml(hero.title)}${viewerName ? ` · 查看人:${escapeHtml(viewerName)}` : ""}
</p>
<div class="meta">
<span>查看日期:${escapeHtml(payload.brief_date)}</span>
<span>晨报快照:${escapeHtml(formatTimestamp(hero.baseline_generated_at))}</span>
<span>实时刷新:${escapeHtml(formatTimestamp(hero.live_generated_at))}</span>
<span>异常总数:${escapeHtml(hero.anomaly_count)}</span>
</div>
<div class="hero-grid">
<div class="hero-card">
<div class="hero-label">需关注事项</div>
<div class="hero-value">${escapeHtml(hero.decision_count)}</div>
</div>
<div class="hero-card">
<div class="hero-label">异常总数</div>
<div class="hero-value">${escapeHtml(hero.anomaly_count)}</div>
</div>
<div class="hero-card">
<div class="hero-label">查看范围</div>
<div class="hero-value">${escapeHtml(payload.viewer_scope === "personal" ? "个人" : "全租户")}</div>
</div>
</div>
</section>
${summaryBlock}
<section class="layout">
<div class="section-list">
${payload.sections
.map(
(section) => `
<article class="section-card">
<h3>${escapeHtml(section.title)}</h3>
<p class="section-summary">${escapeHtml(section.summary)}</p>
${renderMetrics(section.metrics)}
${renderItems(section)}
</article>
`
)
.join("")}
</div>
${hasWorkloadBlock ? `<aside class="section-list">${workloadBlock}</aside>` : ""}
</section>
</main>
</body>
</html>
`;
}
export function printBriefingReport(payload: PrintableBriefingPayload, viewerName?: string): void {
const win = window.open("", "_blank");
if (!win) {
return;
}
win.document.write(buildBriefingPrintHtml(payload, viewerName));
win.document.close();
win.focus();
win.print();
}

View File

@ -0,0 +1,314 @@
import { useEffect, useMemo, useState } from "react";
import { ChevronDown, ChevronRight, PanelRightOpen } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import type { ExecutiveBriefingItem, ExecutiveBriefingSection } from "@/pages/ai-steward/types";
export interface BriefingProjectGroup {
key: string;
title: string;
categoryLabel: string;
previewText: string;
projectId: number | null;
tone: string;
items: ExecutiveBriefingItem[];
}
function toneRank(tone: string): number {
switch (tone) {
case "critical":
return 4;
case "warning":
return 3;
case "info":
return 2;
case "positive":
return 1;
default:
return 0;
}
}
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 toneLabel(tone: string): string {
switch (tone) {
case "critical":
return "紧急";
case "warning":
return "关注";
case "positive":
return "平稳";
case "info":
return "提示";
default:
return "状态";
}
}
function formatMetricValue(value: string | number): string {
return typeof value === "number" ? String(value) : value;
}
function getMetaValue(item: ExecutiveBriefingItem, label: string): string | null {
const entry = Array.isArray(item.meta) ? item.meta.find((meta) => meta.label === label) : null;
const value = String(entry?.value || "").trim();
return value || null;
}
function itemKindLabel(kind?: string | null): string {
switch (kind) {
case "signal":
return "项目信号";
case "signal_source":
return "信号源";
case "capacity":
return "人员负载";
case "task":
return "项目任务";
case "notification":
return "项目风险";
case "conflict":
return "协作冲突";
default:
return "事项";
}
}
export function groupBriefingItemsByProject(items: ExecutiveBriefingItem[]): BriefingProjectGroup[] {
const groups = new Map<string, BriefingProjectGroup>();
for (const item of items) {
const projectId = typeof item.project_id === "number" ? item.project_id : null;
const projectName = String(item.project_name || "").trim();
const ownerName = String(item.owner_name || "").trim();
const sourceIdentifier = getMetaValue(item, "标识");
const workloadValue = getMetaValue(item, "负载");
const fallbackTitle = String(item.title || "").trim() || "其他事项";
let key = "";
let title = "";
let categoryLabel = "";
let previewText = String(item.detail || "").trim() || "展开后查看详细内容";
if (projectId !== null && projectName) {
key = `project:${projectId}`;
title = projectName;
categoryLabel = "项目";
} else if (item.kind === "signal_source") {
key = `signal-source:${item.id}`;
title = sourceIdentifier ? `${fallbackTitle}` : fallbackTitle;
categoryLabel = "信号源";
previewText = sourceIdentifier ? `来源标识 ${sourceIdentifier}` : previewText;
} else if (item.kind === "capacity" || item.user_id || ownerName) {
key = `user:${item.user_id ?? item.id}`;
title = ownerName || fallbackTitle;
categoryLabel = "成员";
previewText = workloadValue ? `${workloadValue}|${String(item.detail || "").trim()}` : previewText;
} else {
key = `item:${item.id}`;
title = fallbackTitle;
categoryLabel = itemKindLabel(item.kind);
}
const existing = groups.get(key);
if (existing) {
existing.items.push(item);
if (toneRank(item.tone) > toneRank(existing.tone)) {
existing.tone = item.tone;
}
continue;
}
groups.set(key, {
key,
title,
categoryLabel,
previewText,
projectId,
tone: item.tone,
items: [item],
});
}
return Array.from(groups.values()).sort((left, right) => {
const toneDelta = toneRank(right.tone) - toneRank(left.tone);
if (toneDelta !== 0) return toneDelta;
return right.items.length - left.items.length;
});
}
interface BriefingSectionCardProps {
section: ExecutiveBriefingSection;
className?: string;
emptyMessage: string;
onOpenDrawer: (section: ExecutiveBriefingSection, projectKey?: string) => void;
}
export default function BriefingSectionCard({
section,
className,
emptyMessage,
onOpenDrawer,
}: BriefingSectionCardProps) {
const groups = useMemo(() => groupBriefingItemsByProject(section.items), [section.items]);
const [expandedKeys, setExpandedKeys] = useState<Set<string>>(new Set());
useEffect(() => {
setExpandedKeys(new Set(groups.length ? [groups[0].key] : []));
}, [groups, section.key]);
const allExpanded = groups.length > 0 && groups.every((group) => expandedKeys.has(group.key));
const toggleGroup = (groupKey: string) => {
setExpandedKeys((current) => {
const next = new Set(current);
if (next.has(groupKey)) {
next.delete(groupKey);
} else {
next.add(groupKey);
}
return next;
});
};
const toggleAll = () => {
setExpandedKeys(allExpanded ? new Set() : new Set(groups.map((group) => group.key)));
};
return (
<Card className={cn("shadow-sm", className)}>
<CardContent className="flex min-h-[560px] max-h-[560px] flex-col p-6">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<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>
<Button
variant="outline"
size="sm"
onClick={() => onOpenDrawer(section)}
disabled={!section.items.length}
>
查看全部
</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-sm 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 flex items-center justify-between gap-3">
<p className="text-sm text-slate-500">
共 {section.items.length} 条,按项目、信号源、成员分组展示。卡片固定高度,更多内容可滚动查看。
</p>
<Button variant="ghost" size="sm" onClick={toggleAll} disabled={!groups.length}>
{allExpanded ? "全部收起预览" : "全部展开预览"}
</Button>
</div>
<div className="mt-4 flex-1 overflow-y-auto pr-2">
{groups.length ? (
<div className="space-y-3">
{groups.map((group) => {
const expanded = expandedKeys.has(group.key);
return (
<div
key={group.key}
className="rounded-2xl border border-white/70 bg-white/85 p-4 dark:border-slate-800 dark:bg-slate-950/50"
>
<div className="flex items-start justify-between gap-3">
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-3 text-left"
onClick={() => toggleGroup(group.key)}
>
{expanded ? (
<ChevronDown className="mt-0.5 h-4 w-4 shrink-0 text-slate-500" />
) : (
<ChevronRight className="mt-0.5 h-4 w-4 shrink-0 text-slate-500" />
)}
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium text-slate-950 dark:text-slate-50">{group.title}</span>
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600 dark:bg-slate-800 dark:text-slate-300">
{group.categoryLabel}
</span>
<Badge className={toneBadgeClass(group.tone)}>{toneLabel(group.tone)}</Badge>
<span className="text-xs text-slate-500">{group.items.length} 条</span>
</div>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
{group.previewText || group.items[0]?.detail || "展开后查看详细内容"}
</p>
</div>
</button>
<Button variant="ghost" size="sm" onClick={() => onOpenDrawer(section, group.key)}>
<PanelRightOpen className="h-4 w-4" />
查看详情
</Button>
</div>
{expanded && (
<div className="mt-4 space-y-2">
{group.items.map((item, index) => (
<button
key={`${group.key}-${item.id}-${index}`}
type="button"
className="w-full rounded-xl border border-slate-200/70 bg-slate-50/90 px-3 py-3 text-left transition hover:border-slate-300 hover:bg-white dark:border-slate-800 dark:bg-slate-900/70 dark:hover:border-slate-700"
onClick={() => onOpenDrawer(section, group.key)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="font-medium text-slate-950 dark:text-slate-50">{item.title}</p>
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[11px] text-slate-500 dark:bg-slate-800 dark:text-slate-300">
{itemKindLabel(item.kind)}
</span>
</div>
<p className="mt-1 line-clamp-2 text-sm leading-6 text-slate-600 dark:text-slate-300">
{item.detail}
</p>
</div>
<span className="shrink-0 text-xs text-slate-400">展开查看</span>
</div>
</button>
))}
</div>
)}
</div>
);
})}
</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">
{emptyMessage}
</div>
)}
</div>
</CardContent>
</Card>
);
}

View File

@ -53,6 +53,7 @@ export default function FeatureOverviewCards({
const Icon = meta.icon;
const primaryMetric = section.metrics[0];
const value = primaryMetric ? String(primaryMetric.value) : "-";
const metricLabel = primaryMetric?.label || "关注数";
const targetTab = (section.drilldown_tab as AIStewardTab | undefined) || meta.fallbackTab;
return (
<Card
@ -68,8 +69,14 @@ export default function FeatureOverviewCards({
<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>
<div className="flex items-end justify-between gap-3">
<div>
<p className="text-xs text-slate-500">{metricLabel}</p>
<p className="text-3xl font-semibold tracking-tight text-slate-950 dark:text-slate-50">{value}</p>
</div>
<span className="text-xs text-slate-500">点击查看</span>
</div>
<p className="min-h-[40px] text-sm leading-6 text-slate-600 dark:text-slate-300">{section.summary}</p>
</CardContent>
</Card>
);
@ -119,16 +126,16 @@ export default function FeatureOverviewCards({
</CardContent>
</Card>
<Card className="border-green-200 dark:border-green-800 hover:shadow-lg transition-shadow cursor-pointer" onClick={() => onSelectTab("briefing")}>
<Card className="border-green-200 dark:border-green-800 hover:shadow-lg transition-shadow cursor-pointer" onClick={() => onSelectTab("overview")}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<Bell className="w-8 h-8 text-green-500" />
<Badge variant="secondary">智能简报</Badge>
<Badge variant="secondary">统一入口</Badge>
</div>
<CardTitle className="text-lg">进度同步</CardTitle>
<CardTitle className="text-lg">简报中心</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-slate-600 dark:text-slate-400">个性化每日简报,只显示相关变更,替代日报</p>
<p className="text-sm text-slate-600 dark:text-slate-400">晨报与日报已合并到一个入口,点击后直接看与你相关的变化</p>
</CardContent>
</Card>
</div>

View File

@ -1,257 +1,294 @@
import {
AlertCircle,
AlertTriangle,
Bell,
CheckSquare,
FileText,
ListTodo,
Loader2,
Sparkles,
TrendingUp,
Users,
} from "lucide-react";
import { toZhStatus } from "@/lib/display";
import { useState } from "react";
import { Bell, Download, FileText, Loader2, RefreshCcw, Sparkles } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent } from "@/components/ui/card";
import { TabsContent } from "@/components/ui/tabs";
import type { DailyBriefing } from "../types";
import type {
AIStewardTab,
BriefingTarget,
DailyBriefingV2Payload,
ExecutiveBriefingSection,
} from "../types";
import BriefingDetailDrawer from "../components/BriefingDetailDrawer";
import BriefingSectionCard from "../components/BriefingSectionCard";
interface BriefingTabProps {
featureLoading: string | null;
briefing: DailyBriefing | null;
tabValue?: string;
briefing: DailyBriefingV2Payload | null;
briefingError?: string | null;
briefingLoading: boolean;
briefingMode: "snapshot" | "live";
username?: string;
onRefreshBriefing: () => void;
onLoadSnapshot: () => void;
onRefreshLive: () => void;
onExportReport: () => void;
onOpenTarget: (target?: BriefingTarget | null, fallbackTab?: AIStewardTab) => void;
}
export default function BriefingTab({ featureLoading, briefing, briefingError, username, onRefreshBriefing }: BriefingTabProps) {
const hasContent =
!!briefing &&
Boolean(
(briefing.your_tasks && briefing.your_tasks.length > 0) ||
(briefing.action_items && briefing.action_items.length > 0) ||
(briefing.risks && briefing.risks.length > 0) ||
(briefing.conflicts && briefing.conflicts.length > 0) ||
(briefing.signals && briefing.signals.length > 0) ||
(briefing.team_updates && briefing.team_updates.length > 0)
);
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";
}
}
export default function BriefingTab({
tabValue = "briefing",
briefing,
briefingError,
briefingLoading,
briefingMode,
username,
onLoadSnapshot,
onRefreshLive,
onExportReport,
onOpenTarget,
}: BriefingTabProps) {
const [drawerSection, setDrawerSection] = useState<ExecutiveBriefingSection | null>(null);
const [drawerProjectKey, setDrawerProjectKey] = useState<string | null>(null);
const sections = briefing?.sections ?? [];
const totalItems = sections.reduce((count, section) => count + section.items.length, 0);
const hero = briefing?.hero;
const personalWorkload = briefing?.personal_workload;
const personalSummary = briefing?.personal_summary;
const openDrawer = (section: ExecutiveBriefingSection, projectKey?: string) => {
setDrawerSection(section);
setDrawerProjectKey(projectKey || null);
};
return (
<TabsContent value="briefing" className="mt-6">
<TabsContent value={tabValue} className="mt-6">
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-xl font-semibold flex items-center gap-2">
<Bell className="w-5 h-5 text-green-500" />
每日进度简报
</h3>
<p className="text-sm text-slate-600 dark:text-slate-400">{username} 的个性化简报 - 只显示相关内容</p>
</div>
<Button onClick={onRefreshBriefing} disabled={featureLoading === "briefing"}>
{featureLoading === "briefing" ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
刷新中…
</>
) : (
<>
<TrendingUp className="w-4 h-4 mr-2" />
刷新简报
</>
)}
</Button>
</div>
<Card className="overflow-hidden border-slate-200 bg-gradient-to-br from-white via-slate-50 to-stone-100 shadow-sm dark:border-slate-800 dark:from-slate-950 dark:via-slate-900 dark:to-stone-950">
<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">
{briefingMode === "live" ? "实时版" : "晨报快照"}
</Badge>
{briefing?.viewer_scope === "tenant_full" ? (
<Badge className="bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-200">全租户视角</Badge>
) : (
<Badge className="bg-sky-100 text-sky-700 dark:bg-sky-950 dark:text-sky-200">个人视角</Badge>
)}
</div>
{featureLoading === "briefing" ? (
<div className="flex justify-center py-8">
<Loader2 className="w-8 h-8 animate-spin text-violet-500" />
</div>
) : briefingError ? (
<Card className="border-rose-200 bg-rose-50/80">
<CardContent className="pt-6 text-center">
<Bell className="w-12 h-12 text-rose-400 mx-auto mb-3" />
<h4 className="font-semibold text-rose-700">简报加载失败</h4>
<p className="mt-2 text-sm text-rose-600">{briefingError}</p>
<p className="mt-2 text-sm text-rose-500">你可以稍后重试,或者先去看问答记录 / 反馈中心。</p>
</CardContent>
</Card>
) : !briefing ? (
<Card>
<CardContent className="pt-6 text-center">
<Bell className="w-12 h-12 text-slate-400 mx-auto mb-3" />
<h4 className="font-semibold text-slate-700 dark:text-slate-300">点击"刷新简报"加载今日内容</h4>
<p className="text-sm text-slate-600 dark:text-slate-400 mt-2">AI 会根据您的角色筛选相关信息</p>
</CardContent>
</Card>
) : (
<div className="space-y-4">
<Card className="bg-gradient-to-r from-violet-50 to-purple-50 dark:from-violet-900/20 dark:to-purple-900/20 border-violet-200 dark:border-violet-800">
<CardContent className="pt-6">
<div className="flex items-start gap-3">
<Sparkles className="w-6 h-6 text-violet-500 mt-0.5" />
<div>
<h4 className="font-semibold mb-2">AI 摘要</h4>
<p className="text-sm text-slate-700 dark:text-slate-300">{briefing.summary}</p>
<div>
<h3 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-slate-900 dark:text-slate-100" />
{briefing?.export_title || "每日简报"}
</h3>
<p className="mt-2 max-w-3xl text-sm leading-6 text-slate-600 dark:text-slate-300">
{personalSummary?.headline ||
`${username || "当前成员"} 的日报视图。与晨报中心共享同一套快照与实时口径,先看异常,再看需要你处理的内容。`}
</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-sm 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-sm text-slate-500">相关更新</p>
<p className="mt-2 text-3xl font-semibold text-slate-950 dark:text-slate-50">{totalItems}</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-sm text-slate-500">查看日期</p>
<p className="mt-2 text-3xl font-semibold text-slate-950 dark:text-slate-50">{briefing?.brief_date || "-"}</p>
</div>
</div>
</CardContent>
</Card>
</div>
{!hasContent && (
<Card className="border-dashed border-slate-200 bg-slate-50/80">
<CardContent className="pt-6 text-center">
<Bell className="w-10 h-10 text-slate-400 mx-auto mb-3" />
<h4 className="font-semibold text-slate-700 dark:text-slate-300">最近 24 小时没有新的相关更新</h4>
<p className="mt-2 text-sm text-slate-600 dark:text-slate-400">
这张简报只展示近期变化;如果项目这一天没有新增任务、风险、冲突或信号,页面就会显得很空。
</p>
</CardContent>
</Card>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<ListTodo className="w-4 h-4 text-blue-500" />
你的任务 ({briefing.your_tasks?.length || 0})
</CardTitle>
</CardHeader>
<CardContent>
{briefing.your_tasks && briefing.your_tasks.length > 0 ? (
<div className="space-y-2">
{briefing.your_tasks.map((task) => (
<div key={task.id} className="flex items-center justify-between p-2 bg-slate-50 dark:bg-slate-800 rounded">
<span className="text-sm">{task.title}</span>
<Badge variant="outline" className="text-xs">{toZhStatus(task.status)}</Badge>
</div>
))}
<div className="min-w-0 space-y-3 xl:w-[390px]">
<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>
{personalWorkload && (
<div className="rounded-2xl border border-slate-200/80 bg-white/80 p-4 dark:border-slate-800 dark:bg-slate-900/70">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm text-slate-500">个人负载</p>
<p className="mt-2 text-xl font-semibold text-slate-950 dark:text-slate-50">{personalWorkload.user_name}</p>
<p className="mt-1 text-sm text-slate-600 dark:text-slate-300">
已分配 {personalWorkload.assigned_hours} 小时 / 周上限 {personalWorkload.max_weekly_hours} 小时
</p>
</div>
<Badge className={toneBadgeClass(personalWorkload.tone)}>{personalWorkload.workload_ratio}%</Badge>
</div>
) : (
<p className="text-sm text-slate-500">暂无任务</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<CheckSquare className="w-4 h-4 text-orange-500" />
行动项 ({briefing.action_items?.length || 0})
</CardTitle>
</CardHeader>
<CardContent>
{briefing.action_items && briefing.action_items.length > 0 ? (
<div className="space-y-2">
{briefing.action_items.map((item, index) => (
<div key={index} className="flex items-center gap-2 p-2 bg-orange-50 dark:bg-orange-900/20 rounded">
<div className="w-2 h-2 rounded-full bg-orange-500" />
<span className="text-sm">{item.content}</span>
</div>
))}
</div>
) : (
<p className="text-sm text-slate-500">暂无行动项</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-red-500" />
风险 ({briefing.risks?.length || 0})
</CardTitle>
</CardHeader>
<CardContent>
{briefing.risks && briefing.risks.length > 0 ? (
<div className="space-y-2">
{briefing.risks.map((risk, index) => (
<div key={index} className="flex items-center gap-2 p-2 bg-red-50 dark:bg-red-900/20 rounded">
<AlertTriangle className="w-3 h-3 text-red-500" />
<span className="text-sm">{risk.task}</span>
</div>
))}
</div>
) : (
<p className="text-sm text-slate-500">暂无风险</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-orange-500" />
需要对齐 ({briefing.conflicts?.length || 0})
</CardTitle>
</CardHeader>
<CardContent>
{briefing.conflicts && briefing.conflicts.length > 0 ? (
<div className="space-y-2">
{briefing.conflicts.map((conflict) => (
<div key={conflict.id} className="p-2 bg-orange-50 dark:bg-orange-900/20 rounded">
<div className="flex items-center gap-1 mb-1">
<Badge variant="outline" className="text-xs">{conflict.type}</Badge>
</div>
<p className="text-xs">{conflict.description.substring(0, 60)}...</p>
</div>
))}
</div>
) : (
<p className="text-sm text-slate-500">暂无冲突</p>
)}
</CardContent>
</Card>
</div>
)}
<div className="flex flex-wrap gap-3">
<Button variant="outline" onClick={onLoadSnapshot} disabled={briefingLoading}>
查看晨报快照
</Button>
<Button onClick={onRefreshLive} disabled={briefingLoading}>
<RefreshCcw className="mr-2 h-4 w-4" />
实时刷新
</Button>
<Button variant="outline" onClick={onExportReport} disabled={!briefing}>
<Download className="mr-2 h-4 w-4" />
导出报告
</Button>
</div>
</div>
</div>
{briefing.signals && briefing.signals.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<FileText className="w-4 h-4 text-purple-500" />
高影响信号 ({briefing.signals.length})
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{briefing.signals.map((signal) => (
<div key={signal.id} className="p-3 bg-purple-50 dark:bg-purple-900/20 rounded border border-purple-200 dark:border-purple-800">
<div className="flex items-center justify-between mb-1">
<Badge variant="outline" className="text-xs">{signal.source}</Badge>
<span className="text-xs text-purple-600 dark:text-purple-400">影响分: {signal.impact_score.toFixed(1)}</span>
</div>
<p className="text-sm">{signal.content}</p>
</div>
))}
</div>
</CardContent>
</Card>
{briefingLoading && (
<div className="mt-4 flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400">
<Loader2 className="h-4 w-4 animate-spin" />
正在更新日报内容...
</div>
)}
{briefingError && (
<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">
{briefingError}
</p>
)}
</CardContent>
</Card>
{briefing.team_updates && briefing.team_updates.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Users className="w-4 h-4 text-slate-500" />
团队更新 ({briefing.team_updates.length})
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{briefing.team_updates.map((update, index) => (
<div key={index} className="flex items-center justify-between p-2 bg-slate-50 dark:bg-slate-800 rounded">
<span className="text-sm">{update.task}</span>
<Badge variant="outline" className="text-xs">{toZhStatus(update.status)}</Badge>
</div>
))}
{!briefingLoading && !briefingError && !briefing && (
<Card className="border-dashed border-slate-200 bg-slate-50/80 dark:border-slate-800 dark:bg-slate-950/40">
<CardContent className="py-12 text-center">
<Bell className="mx-auto mb-3 h-12 w-12 text-slate-400" />
<h4 className="text-lg font-semibold text-slate-700 dark:text-slate-200">点击“实时刷新”生成今天的日报</h4>
<p className="mt-2 text-sm text-slate-600 dark:text-slate-400">系统会按照你当前角色自动筛选应关注的项目、风险、负载和治理信息。</p>
</CardContent>
</Card>
)}
{briefing && !briefingError && (
<div className="grid grid-cols-1 gap-5 xl:grid-cols-[minmax(0,1.7fr)_360px]">
<div className="space-y-5">
{sections.map((section) => (
<BriefingSectionCard
key={section.key}
section={section}
className={SECTION_STYLE[section.key] || SECTION_STYLE.external_metrics}
emptyMessage="当前区块暂无新的日报事项。"
onOpenDrawer={openDrawer}
/>
))}
</div>
<div className="space-y-5">
<Card className="border-slate-200 shadow-sm dark:border-slate-800">
<CardContent className="p-6">
<div className="flex items-center gap-3">
<FileText className="h-5 w-5 text-slate-700 dark:text-slate-300" />
<div>
<h4 className="font-semibold text-slate-950 dark:text-slate-50">日报摘要</h4>
<p className="text-sm text-slate-600 dark:text-slate-400">导出会基于当前视角生成完整详细版,不只导出首页摘要。</p>
</div>
</div>
<div className="mt-4 space-y-3 rounded-2xl bg-slate-50 p-4 dark:bg-slate-900/60">
<div className="flex items-center justify-between gap-3">
<span className="text-sm text-slate-500">模式</span>
<span className="font-medium text-slate-900 dark:text-slate-100">
{briefing.mode === "live" ? "实时版" : "晨报快照"}
</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-sm text-slate-500">需关注事项</span>
<span className="font-medium text-slate-900 dark:text-slate-100">{hero?.decision_count ?? 0}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-sm text-slate-500">相关项目</span>
<span className="font-medium text-slate-900 dark:text-slate-100">{personalSummary?.project_count ?? "-"}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-sm text-slate-500">动作项</span>
<span className="font-medium text-slate-900 dark:text-slate-100">{personalSummary?.action_count ?? hero?.decision_count ?? 0}</span>
</div>
</div>
</CardContent>
</Card>
)}
{(hero?.key_alerts?.length ?? 0) > 0 && (
<Card className="border-slate-200 shadow-sm dark:border-slate-800">
<CardContent className="p-6">
<h4 className="font-semibold text-slate-950 dark:text-slate-50">关键提醒</h4>
<div className="mt-4 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>
</CardContent>
</Card>
)}
{personalSummary && (
<Card className="border-slate-200 shadow-sm dark:border-slate-800">
<CardContent className="p-6">
<div className="flex items-center gap-3">
<Sparkles className="h-5 w-5 text-slate-700 dark:text-slate-300" />
<h4 className="font-semibold text-slate-950 dark:text-slate-50">AI 提炼结论</h4>
</div>
<p className="mt-4 text-sm leading-7 text-slate-600 dark:text-slate-300">{personalSummary.headline}</p>
</CardContent>
</Card>
)}
</div>
</div>
)}
<BriefingDetailDrawer
open={Boolean(drawerSection)}
section={drawerSection}
focusedProjectKey={drawerProjectKey}
onOpenChange={(open) => {
if (!open) {
setDrawerSection(null);
setDrawerProjectKey(null);
}
}}
onOpenTarget={onOpenTarget}
/>
</div>
</TabsContent>
);

View File

@ -1,15 +1,5 @@
import {
Activity,
AlertTriangle,
Bell,
CheckCircle2,
Clock,
MessageSquare,
Radar,
RefreshCcw,
Shield,
Sparkles,
} from "lucide-react";
import { useState } from "react";
import { Download, RefreshCcw, Sparkles } from "lucide-react";
import { TabsContent } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@ -18,13 +8,23 @@ import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import type {
AIStewardTab,
BriefingTarget,
DailyBriefingV2Payload,
EngineMessage,
ExecutiveBriefingConfig,
ExecutiveBriefingPayload,
ExecutiveBriefingSection,
} from "@/pages/ai-steward/types";
import BriefingDetailDrawer from "../components/BriefingDetailDrawer";
import BriefingSectionCard from "../components/BriefingSectionCard";
import BriefingTab from "./BriefingTab";
interface OverviewTabProps {
canManageExecutiveBriefing: boolean;
dailyBriefing: DailyBriefingV2Payload | null;
dailyBriefingError: string | null;
dailyBriefingLoading: boolean;
username?: string;
executiveBriefing: ExecutiveBriefingPayload | null;
executiveBriefingError: string | null;
executiveBriefingMode: "snapshot" | "live";
@ -36,9 +36,10 @@ interface OverviewTabProps {
executiveBriefingSaveMessage: EngineMessage | null;
onLoadSnapshot: () => void;
onRefreshLive: () => void;
onExportReport: () => void;
onConfigChange: (patch: Partial<ExecutiveBriefingConfig>) => void;
onSaveConfig: () => void;
onSelectTab: (tab: AIStewardTab) => void;
onOpenTarget: (target?: BriefingTarget | null, fallbackTab?: AIStewardTab) => void;
}
const SECTION_STYLE: Record<string, string> = {
@ -81,129 +82,12 @@ function toneBadgeClass(tone: string): string {
}
}
function LegacyOverview() {
return (
<TabsContent value="overview" className="mt-6">
<div className="space-y-6">
<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="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>
<p className="text-sm text-slate-600 dark:text-slate-400">无需手动录入,AI 自动捕获所有需求来源</p>
</div>
</div>
<ul className="space-y-2 text-sm">
<li className="flex items-center gap-2">
<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="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="h-4 w-4" />
浏览器标签监控 - 需安装扩展
</li>
</ul>
</div>
<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>
<p className="text-sm text-slate-600 dark:text-slate-400">提前发现隐性分歧,避免返工</p>
</div>
</div>
<ul className="space-y-2 text-sm">
<li className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-green-500" />
自动检测定义不一致(如"留存" vs "DAU")
</li>
<li className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-green-500" />
任务与宪章一致性检查
</li>
<li className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-green-500" />
AI 生成对齐方案
</li>
</ul>
</div>
<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>
<p className="text-sm text-slate-600 dark:text-slate-400">代码注释扫描,自动推荐替代方案</p>
</div>
</div>
<ul className="space-y-2 text-sm">
<li className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-green-500" />
识别阻塞模式("等待X接口"、"达不到效果")
</li>
<li className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-green-500" />
AI 分析严重程度并生成解决方案
</li>
<li className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-green-500" />
自动创建追踪任务
</li>
</ul>
</div>
<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>
<p className="text-sm text-slate-600 dark:text-slate-400">个性化简报,彻底取消日报周报</p>
</div>
</div>
<ul className="space-y-2 text-sm">
<li className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-green-500" />
按角色智能过滤信息
</li>
<li className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-green-500" />
只显示相关任务变更和风险
</li>
<li className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-green-500" />
推送行动项和对齐提醒
</li>
</ul>
</div>
</div>
</div>
</TabsContent>
);
}
export default function OverviewTab({
canManageExecutiveBriefing,
dailyBriefing,
dailyBriefingError,
dailyBriefingLoading,
username,
executiveBriefing,
executiveBriefingError,
executiveBriefingMode,
@ -215,16 +99,37 @@ export default function OverviewTab({
executiveBriefingSaveMessage,
onLoadSnapshot,
onRefreshLive,
onExportReport,
onConfigChange,
onSaveConfig,
onSelectTab,
onOpenTarget,
}: OverviewTabProps) {
const [drawerSection, setDrawerSection] = useState<ExecutiveBriefingSection | null>(null);
const [drawerProjectKey, setDrawerProjectKey] = useState<string | null>(null);
if (!canManageExecutiveBriefing) {
return <LegacyOverview />;
return (
<BriefingTab
tabValue="overview"
briefing={dailyBriefing}
briefingError={dailyBriefingError}
briefingLoading={dailyBriefingLoading}
briefingMode={executiveBriefingMode}
username={username}
onLoadSnapshot={onLoadSnapshot}
onRefreshLive={onRefreshLive}
onExportReport={onExportReport}
onOpenTarget={onOpenTarget}
/>
);
}
const sections = executiveBriefing?.sections ?? [];
const hero = executiveBriefing?.hero;
const openDrawer = (section: ExecutiveBriefingSection, projectKey?: string) => {
setDrawerSection(section);
setDrawerProjectKey(projectKey || null);
};
return (
<TabsContent value="overview" className="mt-6">
@ -235,7 +140,7 @@ export default function OverviewTab({
<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" ? "实时模式" : "晨报快照"}
{executiveBriefingMode === "live" ? "实时版" : "晨报快照"}
</Badge>
{hero?.snapshot_state === "missing" && (
<Badge className="bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-200">
@ -246,27 +151,23 @@ export default function OverviewTab({
<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>
<p className="text-sm 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>
<p className="text-sm 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="text-sm text-slate-500">查看日期</p>
<p className="mt-2 text-3xl font-semibold text-slate-950 dark:text-slate-50">
{executiveBriefing?.brief_date || "-"}
</p>
@ -274,19 +175,15 @@ export default function OverviewTab({
</div>
</div>
<div className="min-w-0 space-y-3 xl:w-[360px]">
<div className="min-w-0 space-y-3 xl:w-[380px]">
<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>
<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>
<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">
@ -310,9 +207,18 @@ export default function OverviewTab({
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" />
<RefreshCcw className="mr-2 h-4 w-4" />
实时刷新
</Button>
<Button
variant="outline"
onClick={onExportReport}
disabled={!executiveBriefing}
className="border-slate-300 bg-white/80 dark:border-slate-700 dark:bg-slate-900/60"
>
<Download className="mr-2 h-4 w-4" />
导出报告
</Button>
</div>
</div>
</div>
@ -329,48 +235,13 @@ export default function OverviewTab({
<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>
<BriefingSectionCard
key={section.key}
section={section}
className={SECTION_STYLE[section.key] || SECTION_STYLE.external_metrics}
emptyMessage="当前区块暂无新的异常或风险提示。"
onOpenDrawer={openDrawer}
/>
))}
</div>
@ -380,7 +251,7 @@ export default function OverviewTab({
<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 快照、定时调度和管理群自动推送都会沿用同一套口径。
这份配置是租户级的。修改后,Web 快照、定时调度和管理群自动推送都会沿用同一套晨报口径。
</p>
</div>
<div className="flex items-center gap-3">
@ -467,6 +338,19 @@ export default function OverviewTab({
</div>
</CardContent>
</Card>
<BriefingDetailDrawer
open={Boolean(drawerSection)}
section={drawerSection}
focusedProjectKey={drawerProjectKey}
onOpenChange={(open) => {
if (!open) {
setDrawerSection(null);
setDrawerProjectKey(null);
}
}}
onOpenTarget={onOpenTarget}
/>
</div>
</TabsContent>
);

View File

@ -1,8 +1,13 @@
import type { LLMEngine, LLMEngineListResponse } from "@/api/aiSteward";
export type {
BriefingMetaEntry,
BriefingTarget,
Blocker,
CognitiveConflict,
DailyBriefing,
DailyBriefingV2Payload,
DailyBriefingV2PersonalSummary,
DailyBriefingV2PersonalWorkload,
ExecutiveBriefingConfig,
ExecutiveBriefingHero,
ExecutiveBriefingItem,

View File

@ -1,5 +1,5 @@
import { useDeferredValue, useEffect, useMemo, useState } from "react";
import { useParams, Link, useNavigate } from "react-router-dom";
import { useParams, Link, useNavigate, useSearchParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, ArrowLeft, ChevronDown, ChevronRight, Plus } from "lucide-react";
import { aiPmoApi } from "@/api/aiPmo";
@ -28,10 +28,47 @@ import {
toReadableWorkItemTitle,
} from "./helpers";
function normalizeBoardStage(value: string | null): StageKey {
switch (value) {
case "charter":
case "task":
case "execution":
case "release":
case "review":
return value;
default:
return "signal";
}
}
function buildSignalFiltersFromSearch(searchParams: URLSearchParams): SignalFilterState {
return {
status: searchParams.get("signal_status") || DEFAULT_SIGNAL_FILTERS.status,
keyword: searchParams.get("signal_keyword") || "",
source: searchParams.get("signal_source") || "",
priority: searchParams.get("signal_priority") || "",
created_from: searchParams.get("signal_created_from") || "",
created_to: searchParams.get("signal_created_to") || "",
};
}
function sameSignalFilters(left: SignalFilterState, right: SignalFilterState): boolean {
return (
left.status === right.status
&& left.keyword === right.keyword
&& left.source === right.source
&& left.priority === right.priority
&& left.created_from === right.created_from
&& left.created_to === right.created_to
);
}
export default function ProjectBoardPage() {
const SIGNAL_PAGE_SIZE = 10;
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const boardSearchKey = searchParams.toString();
const queryClient = useQueryClient();
const { toast } = useToast();
const [isMobileViewport, setIsMobileViewport] = useState(() =>
@ -207,6 +244,14 @@ export default function ProjectBoardPage() {
setSignalFilters(DEFAULT_SIGNAL_FILTERS);
}, [id]);
useEffect(() => {
const nextStage = normalizeBoardStage(searchParams.get("stage"));
setActiveStage((current) => (current === nextStage ? current : nextStage));
const nextFilters = buildSignalFiltersFromSearch(searchParams);
setSignalFilters((current) => (sameSignalFilters(current, nextFilters) ? current : nextFilters));
setSignalPage(1);
}, [boardSearchKey, id]);
const refreshAll = async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["sstc-board-overview", id] }),

File diff suppressed because one or more lines are too long

BIN
工单数据_20260327.xlsx Normal file

Binary file not shown.