Revamp AI steward executive briefing workflows
This commit is contained in:
parent
1b2b64d575
commit
a01c451195
17
CLAUDE.md
17
CLAUDE.md
|
|
@ -389,12 +389,15 @@ frontend/
|
||||||
- 更新:`frontend/src/pages/AIStewardPage.tsx`(降级为轻量入口,转发到 `pages/ai-steward/AIStewardPage`)
|
- 更新:`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/types.ts`、`frontend/src/pages/ai-steward/constants.ts`(AI Steward 类型与常量集中管理)
|
||||||
- 新增:`frontend/src/pages/ai-steward/components/FeatureOverviewCards.tsx`(AI Steward 首页功能卡片组件)
|
- 新增:`frontend/src/pages/ai-steward/components/FeatureOverviewCards.tsx`(AI Steward 首页功能卡片组件)
|
||||||
|
- 新增:`frontend/src/pages/ai-steward/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 拆分)
|
- 新增:`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/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/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 推送)
|
- 更新:`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`)
|
- 更新:`backend/app/services/executive_briefing_service.py`(读取 `snapshot` 时若命中旧结构快照,会自动按当前代码重建新版 payload,避免简报中心默认打开仍停留在无详情/无精准跳转的旧结构)
|
||||||
- 更新:`frontend/src/pages/ai-steward/AIStewardPage.tsx`、`components/FeatureOverviewCards.tsx`、`sections/OverviewTab.tsx`(BOSS/ADMIN 首页升级为老板晨报中心,支持晨报快照、实时刷新、调度配置与异常优先看板)
|
- 更新:`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/sections/CreateProjectTab.tsx`(零配置创建结果区内联展示 `signal_sources` 分组,复用现有 AI Steward 页面,不新增独立路由)
|
||||||
- 新增:`frontend/src/pages/ai-steward/components/feedback-center/FeedbackList.tsx`、`FeedbackDetail.tsx`、`ReasonPicker.tsx`、`MetricsPanel.tsx`、`TrendPanel.tsx`(反馈中心三栏工作台、指标页与趋势页组件;趋势页支持 `7/30 天` 与 `all/task/qa/signal` 局部筛选,并提供洞察卡、原因筛选与一键钻取)
|
- 新增:`frontend/src/pages/ai-steward/components/feedback-center/FeedbackList.tsx`、`FeedbackDetail.tsx`、`ReasonPicker.tsx`、`MetricsPanel.tsx`、`TrendPanel.tsx`(反馈中心三栏工作台、指标页与趋势页组件;趋势页支持 `7/30 天` 与 `all/task/qa/signal` 局部筛选,并提供洞察卡、原因筛选与一键钻取)
|
||||||
- 新增:`frontend/src/pages/ai-steward/components/feedback-center/TrendPanel.tsx` 的趋势决策版布局(Scope / KPI / Insights / Drilldown / Evidence)
|
- 新增:`frontend/src/pages/ai-steward/components/feedback-center/TrendPanel.tsx` 的趋势决策版布局(Scope / KPI / Insights / Drilldown / Evidence)
|
||||||
|
|
@ -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/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/ProjectBoardPage.tsx`(降级为轻量入口,转发到 `pages/project-board/ProjectBoardPage`)
|
||||||
- 新增:`frontend/src/pages/project-board/helpers.ts`(Project Board 信号筛选状态与 PMO 工作项文本归一 helper)
|
- 新增:`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/components/CreateCharterDialog.tsx`、`ProjectSettingsDialog.tsx`(Project Board 大体量弹窗组件拆分)
|
||||||
- 新增:`frontend/src/pages/project-board/ProjectBoardPage.tsx`(Project Board 页面编排层)
|
- 新增:`frontend/src/pages/project-board/ProjectBoardPage.tsx`(Project Board 页面编排层)
|
||||||
- 更新:`frontend/src/pages/ProjectsPage.tsx`(降级为轻量入口,转发到 `pages/projects/ProjectsPage`)
|
- 更新:`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/` 封装层
|
- 禁止在前端组件内直接调用 `fetch`,必须通过 `frontend/src/api/` 封装层
|
||||||
- 前端状态管理:服务端数据用 React Query,本地 UI 状态用 `useState`
|
- 前端状态管理:服务端数据用 React Query,本地 UI 状态用 `useState`
|
||||||
- 老板晨报链路只允许 `BOSS/ADMIN` 访问;`GET /ai-steward/executive-briefing` 的 `snapshot/live` 语义必须分离,`live` 禁止覆盖 `PortfolioSnapshot(scope_type='executive_brief')`
|
- 老板晨报链路只允许 `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`
|
- 老板晨报调度配置统一落在 `Integration(type='executive_briefing')`,禁止新增独立晨报配置表;管理群自动推送必须复用现有 `feishu_reply_outbox`
|
||||||
|
- 晨报/日报导出首版只允许前端“当前所见”打印导出(浏览器 `print` / PDF),禁止新增后端二进制 PDF 生成服务
|
||||||
- 飞书回执发送仅允许 `main.py` 的 drain_loop 作为消费者,禁止在业务路径即时派发 outbox 发送任务
|
- 飞书回执发送仅允许 `main.py` 的 drain_loop 作为消费者,禁止在业务路径即时派发 outbox 发送任务
|
||||||
- 所有写接口必须落审计日志(`operation_logs`):至少包含 `trace_id/interface_path/caller`
|
- 所有写接口必须落审计日志(`operation_logs`):至少包含 `trace_id/interface_path/caller`
|
||||||
- `created_at_beijing` 为计算属性返回,禁止新增数据库物理列
|
- `created_at_beijing` 为计算属性返回,禁止新增数据库物理列
|
||||||
|
|
@ -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` 便于链路自检
|
- 飞书捕获模拟接口:`POST /ai-steward/signal/capture-feishu` 在 `direct_reply_required=true` 时会同步写入 `feishu_reply_outbox`,并返回 `reply_outbox_enqueued/reply_outbox_id` 便于链路自检
|
||||||
- 配置读取类接口:参考 `backend/app/api/llm_providers.py`(`GET /ai-steward/llm/providers`)
|
- 配置读取类接口:参考 `backend/app/api/llm_providers.py`(`GET /ai-steward/llm/providers`)
|
||||||
- 老板晨报接口:参考 `backend/app/api/ai_steward_api/routes_executive_briefing.py`(`GET /ai-steward/executive-briefing?mode=snapshot|live`、`GET/PUT /ai-steward/executive-briefing/config`,仅 `BOSS/ADMIN` 可访问)
|
- 老板晨报接口:参考 `backend/app/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`)
|
- 模型配置唯一来源:参考 `backend/app/services/model/model_config.py`(统一读取 `backend/.openclaw/pmflow-routing-rules.json` 内的 `provider_defaults/tier_defaults/rules`)
|
||||||
- 模型配置热更新后,LLM provider circuit 会在下一次调用时自动清空旧熔断状态,避免旧配置失败污染新配置的连通性判断
|
- 模型配置热更新后,LLM provider circuit 会在下一次调用时自动清空旧熔断状态,避免旧配置失败污染新配置的连通性判断
|
||||||
- 启动期模型探针只做连通性观测,不应把探针失败写回 provider circuit;否则会误伤真实业务请求
|
- 启动期模型探针只做连通性观测,不应把探针失败写回 provider circuit;否则会误伤真实业务请求
|
||||||
|
|
@ -593,6 +602,10 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||||
- 项目创建返回的 `signal_sources` 仅作为 AI Steward 创建结果区的内联展示,现阶段不新增独立页面路由;底层持久化统一复用现有 `SignalSourceRegistry`
|
- 项目创建返回的 `signal_sources` 仅作为 AI Steward 创建结果区的内联展示,现阶段不新增独立页面路由;底层持久化统一复用现有 `SignalSourceRegistry`
|
||||||
- 老板晨报首版的 `external_metrics` 只返回占位区块,不接 CRM / BI / 收入系统,也不允许伪造经营指标
|
- 老板晨报首版的 `external_metrics` 只返回占位区块,不接 CRM / BI / 收入系统,也不允许伪造经营指标
|
||||||
- 老板晨报调度默认值为 `08:30 + workdays + Asia/Shanghai`;若 `delivery_target_ref` 未配置,调度器仍生成快照,但只记录告警、不静默伪装成已推送
|
- 老板晨报调度默认值为 `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` 覆盖
|
- 飞书长连接 websocket 在本地/CI 环境默认使用 `certifi` CA bundle 作为信任链;如需自定义 CA,可通过 `FEISHU_LONG_CONNECTION_SSL_CERT_FILE` 或 `SSL_CERT_FILE` 覆盖
|
||||||
- 飞书长连接状态分为两层:`last_received_at/last_received_message_id` 表示 SDK 回调已收到事件,`last_event_at` 表示 worker 完成处理;排查“没吃进去”时必须优先看 `last_received_*`
|
- 飞书长连接状态分为两层:`last_received_at/last_received_message_id` 表示 SDK 回调已收到事件,`last_event_at` 表示 worker 完成处理;排查“没吃进去”时必须优先看 `last_received_*`
|
||||||
- 飞书长连接会话应复用 `Integration.config` 中已有的 `encrypt_key/verification_token`;配置保存时若未显式修改这两个字段,必须保留现有值,避免 SDK 因缺失事件密钥而拒收消息
|
- 飞书长连接会话应复用 `Integration.config` 中已有的 `encrypt_key/verification_token`;配置保存时若未显式修改这两个字段,必须保留现有值,避免 SDK 因缺失事件密钥而拒收消息
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,12 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import Query
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from .shared import *
|
from .shared import *
|
||||||
from .schemas import BlockerResolveRequest, CodeCommentScanRequest, DailyBriefingResponse
|
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")
|
@router.get("/daily-briefing")
|
||||||
async def get_daily_briefing(
|
async def get_daily_briefing(
|
||||||
|
|
@ -34,6 +39,26 @@ async def get_daily_briefing(
|
||||||
detail=f"生成简报失败: {str(e)}"
|
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}")
|
@router.get("/daily-briefing/team/{project_id}")
|
||||||
async def get_team_briefing(
|
async def get_team_briefing(
|
||||||
project_id: int,
|
project_id: int,
|
||||||
|
|
@ -248,4 +273,3 @@ async def resolve_blocker(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
detail=f"解决失败: {str(e)}"
|
detail=f"解决失败: {str(e)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -112,10 +112,20 @@ class ExecutiveBriefingMetric(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class ExecutiveBriefingItem(BaseModel):
|
class ExecutiveBriefingItem(BaseModel):
|
||||||
|
id: str
|
||||||
title: str
|
title: str
|
||||||
detail: str
|
detail: str
|
||||||
|
detail_lines: List[str] = Field(default_factory=list)
|
||||||
|
meta: List[Dict[str, str]] = Field(default_factory=list)
|
||||||
tone: str
|
tone: str
|
||||||
kind: Optional[str] = None
|
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):
|
class ExecutiveBriefingSection(BaseModel):
|
||||||
|
|
@ -144,3 +154,29 @@ class ExecutiveBriefingPayload(BaseModel):
|
||||||
hero: ExecutiveBriefingHero
|
hero: ExecutiveBriefingHero
|
||||||
sections: List[ExecutiveBriefingSection] = Field(default_factory=list)
|
sections: List[ExecutiveBriefingSection] = Field(default_factory=list)
|
||||||
delivery: Dict[str, Any] = Field(default_factory=dict)
|
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
|
|
@ -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
|
||||||
|
|
@ -7,6 +7,7 @@ from datetime import datetime, timedelta
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
|
from sqlalchemy import JSON
|
||||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///:memory:"
|
os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///:memory:"
|
||||||
|
|
@ -21,9 +22,20 @@ from app.models.truth_node import TruthNode
|
||||||
from app.models.user import User
|
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
|
@pytest_asyncio.fixture
|
||||||
async def briefing_session():
|
async def briefing_session():
|
||||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||||
|
_use_sqlite_json_types()
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(
|
await conn.run_sync(
|
||||||
lambda sync_conn: Base.metadata.create_all(
|
lambda sync_conn: Base.metadata.create_all(
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -15,12 +15,40 @@ from app.core.database import get_db
|
||||||
from app.dependencies.auth import get_current_user
|
from app.dependencies.auth import get_current_user
|
||||||
from app.dependencies.tenant import get_tenant_id
|
from app.dependencies.tenant import get_tenant_id
|
||||||
from app.models.base import Base
|
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.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
|
from app.services.executive_briefing_service import ExecutiveBriefingService
|
||||||
|
|
||||||
|
|
||||||
def _use_sqlite_json_types() -> None:
|
def _use_sqlite_json_types() -> None:
|
||||||
Integration.__table__.c.config.type = JSON()
|
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
|
@pytest_asyncio.fixture
|
||||||
|
|
@ -33,6 +61,18 @@ async def db_session():
|
||||||
sync_conn,
|
sync_conn,
|
||||||
tables=[
|
tables=[
|
||||||
Integration.__table__,
|
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",
|
"brief_date": "2026-03-30",
|
||||||
"hero": {
|
"hero": {
|
||||||
"title": "老板晨报",
|
"title": "简报中心",
|
||||||
"decision_count": 2,
|
"decision_count": 2,
|
||||||
"anomaly_count": 4,
|
"anomaly_count": 4,
|
||||||
"baseline_generated_at": datetime(2026, 3, 30, 8, 30, 0).isoformat(),
|
"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 "今日需拍板" in text
|
||||||
assert "P0 信号无人接" 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"])
|
||||||
|
|
|
||||||
|
|
@ -120,11 +120,33 @@ export interface ExecutiveBriefingMetric {
|
||||||
tone: string;
|
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 {
|
export interface ExecutiveBriefingItem {
|
||||||
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
detail: string;
|
detail: string;
|
||||||
|
detail_lines: string[];
|
||||||
|
meta: BriefingMetaEntry[];
|
||||||
tone: string;
|
tone: string;
|
||||||
kind?: string | null;
|
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 {
|
export interface ExecutiveBriefingSection {
|
||||||
|
|
@ -166,6 +188,32 @@ export interface ExecutiveBriefingPayload {
|
||||||
delivery: ExecutiveBriefingConfig;
|
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 type LLMProvider = "deepseek" | "glm" | "openai" | "ollama";
|
||||||
|
|
||||||
export interface LLMProviderPreset {
|
export interface LLMProviderPreset {
|
||||||
|
|
@ -249,6 +297,10 @@ export const aiStewardApi = {
|
||||||
async getDailyBriefing(): Promise<DailyBriefing> {
|
async getDailyBriefing(): Promise<DailyBriefing> {
|
||||||
return await apiRequest<DailyBriefing>("/ai-steward/daily-briefing");
|
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>> {
|
async startHnMonitor(intervalMinutes: number = 30): Promise<Record<string, unknown>> {
|
||||||
return await apiRequest<Record<string, unknown>>(
|
return await apiRequest<Record<string, unknown>>(
|
||||||
`/ai-steward/signal/start-hn-monitor?interval_minutes=${intervalMinutes}`,
|
`/ai-steward/signal/start-hn-monitor?interval_minutes=${intervalMinutes}`,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useEffect } from "react";
|
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 { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { Sparkles } from "lucide-react";
|
import { Sparkles } from "lucide-react";
|
||||||
import { getAuthToken } from "@/lib/api";
|
import { getAuthToken } from "@/lib/api";
|
||||||
|
|
@ -21,9 +21,10 @@ import {
|
||||||
ENGINE_PROVIDER_FALLBACK_PRESETS,
|
ENGINE_PROVIDER_FALLBACK_PRESETS,
|
||||||
} from "./constants";
|
} from "./constants";
|
||||||
import type {
|
import type {
|
||||||
|
BriefingTarget,
|
||||||
Blocker,
|
Blocker,
|
||||||
CognitiveConflict,
|
CognitiveConflict,
|
||||||
DailyBriefing,
|
DailyBriefingV2Payload,
|
||||||
ExecutiveBriefingConfig,
|
ExecutiveBriefingConfig,
|
||||||
ExecutiveBriefingPayload,
|
ExecutiveBriefingPayload,
|
||||||
FeedbackCenterSourceGroup,
|
FeedbackCenterSourceGroup,
|
||||||
|
|
@ -37,8 +38,8 @@ import type {
|
||||||
AIStewardTab,
|
AIStewardTab,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import FeatureOverviewCards from "./components/FeatureOverviewCards";
|
import FeatureOverviewCards from "./components/FeatureOverviewCards";
|
||||||
|
import { printBriefingReport, type PrintableBriefingPayload } from "./components/BriefingPrintDocument";
|
||||||
import BlockersTab from "./sections/BlockersTab";
|
import BlockersTab from "./sections/BlockersTab";
|
||||||
import BriefingTab from "./sections/BriefingTab";
|
|
||||||
import ConflictsTab from "./sections/ConflictsTab";
|
import ConflictsTab from "./sections/ConflictsTab";
|
||||||
import CreateProjectTab from "./sections/CreateProjectTab";
|
import CreateProjectTab from "./sections/CreateProjectTab";
|
||||||
import EngineTab from "./sections/EngineTab";
|
import EngineTab from "./sections/EngineTab";
|
||||||
|
|
@ -71,6 +72,9 @@ const DEFAULT_EXECUTIVE_BRIEFING_CONFIG: ExecutiveBriefingConfig = {
|
||||||
};
|
};
|
||||||
|
|
||||||
function normalizeTab(value: string | null): AIStewardTab {
|
function normalizeTab(value: string | null): AIStewardTab {
|
||||||
|
if (value === "briefing") {
|
||||||
|
return "overview";
|
||||||
|
}
|
||||||
if (value && AIS_STEWARD_TABS.includes(value as AIStewardTab)) {
|
if (value && AIS_STEWARD_TABS.includes(value as AIStewardTab)) {
|
||||||
return value as AIStewardTab;
|
return value as AIStewardTab;
|
||||||
}
|
}
|
||||||
|
|
@ -95,6 +99,15 @@ function normalizeTrendSourceGroup(value: string | null): FeedbackCenterSourceGr
|
||||||
return "all";
|
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() {
|
export default function AIStewardPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
@ -120,12 +133,7 @@ export default function AIStewardPage() {
|
||||||
);
|
);
|
||||||
const [conflicts, setConflicts] = useState<CognitiveConflict[]>([]);
|
const [conflicts, setConflicts] = useState<CognitiveConflict[]>([]);
|
||||||
const [blockers, setBlockers] = useState<Blocker[]>([]);
|
const [blockers, setBlockers] = useState<Blocker[]>([]);
|
||||||
const [briefing, setBriefing] = useState<DailyBriefing | null>(null);
|
const [briefingMode, setBriefingMode] = useState<"snapshot" | "live">("snapshot");
|
||||||
const [briefingError, setBriefingError] = useState<string | null>(null);
|
|
||||||
const [executiveBriefing, setExecutiveBriefing] = useState<ExecutiveBriefingPayload | null>(null);
|
|
||||||
const [executiveBriefingMode, setExecutiveBriefingMode] = useState<"snapshot" | "live">("snapshot");
|
|
||||||
const [executiveBriefingError, setExecutiveBriefingError] = useState<string | null>(null);
|
|
||||||
const [executiveBriefingLoading, setExecutiveBriefingLoading] = useState(false);
|
|
||||||
const [executiveBriefingConfigDraft, setExecutiveBriefingConfigDraft] = useState<ExecutiveBriefingConfig>(
|
const [executiveBriefingConfigDraft, setExecutiveBriefingConfigDraft] = useState<ExecutiveBriefingConfig>(
|
||||||
DEFAULT_EXECUTIVE_BRIEFING_CONFIG
|
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 ====================
|
// ==================== API Calls ====================
|
||||||
|
|
||||||
const loadConflicts = async () => {
|
const loadConflicts = async () => {
|
||||||
|
|
@ -191,35 +224,17 @@ export default function AIStewardPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadBriefing = async () => {
|
const switchBriefingMode = (mode: "snapshot" | "live") => {
|
||||||
setFeatureLoading("briefing");
|
if (mode === briefingMode) {
|
||||||
setBriefingError(null);
|
if (canManageExecutiveBriefing) {
|
||||||
try {
|
void executiveBriefingQuery.refetch();
|
||||||
const data = await aiStewardApi.getDailyBriefing();
|
|
||||||
setBriefing(data);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to load briefing:", err);
|
|
||||||
setBriefing(null);
|
|
||||||
setBriefingError(getApiErrorMessage(err, "获取简报失败"));
|
|
||||||
} finally {
|
|
||||||
setFeatureLoading(null);
|
|
||||||
}
|
}
|
||||||
};
|
if (activeTab === "briefing" || canManageExecutiveBriefing) {
|
||||||
|
void dailyBriefingQuery.refetch();
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBriefingMode(mode);
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadExecutiveBriefingConfig = async () => {
|
const loadExecutiveBriefingConfig = async () => {
|
||||||
|
|
@ -244,8 +259,11 @@ export default function AIStewardPage() {
|
||||||
const data = await aiStewardApi.updateExecutiveBriefingConfig(executiveBriefingConfigDraft);
|
const data = await aiStewardApi.updateExecutiveBriefingConfig(executiveBriefingConfigDraft);
|
||||||
setExecutiveBriefingConfigDraft(data);
|
setExecutiveBriefingConfigDraft(data);
|
||||||
setExecutiveBriefingSaveMessage({ type: "success", text: "晨报调度配置已保存" });
|
setExecutiveBriefingSaveMessage({ type: "success", text: "晨报调度配置已保存" });
|
||||||
if (activeTab === "overview") {
|
if (canManageExecutiveBriefing) {
|
||||||
await loadExecutiveBriefing("snapshot");
|
await executiveBriefingQuery.refetch();
|
||||||
|
}
|
||||||
|
if (dailyBriefingQuery.isFetched) {
|
||||||
|
await dailyBriefingQuery.refetch();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to save executive briefing config:", err);
|
console.error("Failed to save executive briefing config:", err);
|
||||||
|
|
@ -541,6 +559,42 @@ export default function AIStewardPage() {
|
||||||
syncUrlParams(nextTab, feedbackSection);
|
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) => {
|
const handleFeedbackSectionChange = (nextSection: FeedbackCenterSection) => {
|
||||||
setFeedbackSection(nextSection);
|
setFeedbackSection(nextSection);
|
||||||
syncUrlParams(activeTab, nextSection);
|
syncUrlParams(activeTab, nextSection);
|
||||||
|
|
@ -556,6 +610,31 @@ export default function AIStewardPage() {
|
||||||
syncUrlParams(activeTab, feedbackSection, trendWindowDays, nextSourceGroup);
|
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 ====================
|
// ==================== Initial Load ====================
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -580,12 +659,10 @@ export default function AIStewardPage() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeTab === "overview" && canManageExecutiveBriefing) {
|
if (activeTab === "overview" && canManageExecutiveBriefing) {
|
||||||
void loadExecutiveBriefing("snapshot");
|
|
||||||
void loadExecutiveBriefingConfig();
|
void loadExecutiveBriefingConfig();
|
||||||
}
|
}
|
||||||
if (activeTab === "conflicts") loadConflicts();
|
if (activeTab === "conflicts") loadConflicts();
|
||||||
if (activeTab === "blockers") loadBlockers();
|
if (activeTab === "blockers") loadBlockers();
|
||||||
if (activeTab === "briefing") loadBriefing();
|
|
||||||
if (activeTab === "engine") {
|
if (activeTab === "engine") {
|
||||||
loadEngineProviderPresets();
|
loadEngineProviderPresets();
|
||||||
loadEngineConfigs();
|
loadEngineConfigs();
|
||||||
|
|
@ -602,13 +679,13 @@ export default function AIStewardPage() {
|
||||||
<div className="mb-4 flex items-center justify-center gap-3">
|
<div className="mb-4 flex items-center justify-center gap-3">
|
||||||
<Sparkles className={`h-10 w-10 ${canManageExecutiveBriefing ? "text-amber-500" : "text-violet-500"}`} />
|
<Sparkles className={`h-10 w-10 ${canManageExecutiveBriefing ? "text-amber-500" : "text-violet-500"}`} />
|
||||||
<h1 className="text-4xl font-bold text-slate-900 dark:text-slate-100">
|
<h1 className="text-4xl font-bold text-slate-900 dark:text-slate-100">
|
||||||
{canManageExecutiveBriefing ? "老板晨报中心" : "AI 管家"}
|
简报中心
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-slate-600 dark:text-slate-400 text-lg">
|
<p className="text-slate-600 dark:text-slate-400 text-lg">
|
||||||
{canManageExecutiveBriefing
|
{canManageExecutiveBriefing
|
||||||
? "晨报快照、实时刷新与管理群推送共用同一套经营简报内核"
|
? "晨报快照、实时刷新与管理群推送共用同一套租户级简报内核"
|
||||||
: "主动式 AI 管家 - 7x24 小时自动守护项目"}
|
: "与你相关的任务、风险、负载和对齐事项都集中在一个入口里"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -624,13 +701,12 @@ export default function AIStewardPage() {
|
||||||
<Tabs value={activeTab} onValueChange={handleTabChange}>
|
<Tabs value={activeTab} onValueChange={handleTabChange}>
|
||||||
<TabsList className="flex w-full min-w-0 flex-nowrap gap-1 overflow-x-auto rounded-lg p-1 [-webkit-overflow-scrolling:touch]">
|
<TabsList className="flex w-full min-w-0 flex-nowrap gap-1 overflow-x-auto rounded-lg p-1 [-webkit-overflow-scrolling:touch]">
|
||||||
<TabsTrigger value="overview" className="shrink-0 whitespace-nowrap">
|
<TabsTrigger value="overview" className="shrink-0 whitespace-nowrap">
|
||||||
{canManageExecutiveBriefing ? "晨报中心" : "总览"}
|
简报中心
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="create" className="shrink-0 whitespace-nowrap">零配置创建</TabsTrigger>
|
<TabsTrigger value="create" className="shrink-0 whitespace-nowrap">零配置创建</TabsTrigger>
|
||||||
<TabsTrigger value="signals" className="shrink-0 whitespace-nowrap">信号捕获</TabsTrigger>
|
<TabsTrigger value="signals" className="shrink-0 whitespace-nowrap">信号捕获</TabsTrigger>
|
||||||
<TabsTrigger value="conflicts" className="shrink-0 whitespace-nowrap">认知冲突</TabsTrigger>
|
<TabsTrigger value="conflicts" className="shrink-0 whitespace-nowrap">认知冲突</TabsTrigger>
|
||||||
<TabsTrigger value="blockers" 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>
|
<TabsTrigger value="qa" className="shrink-0 whitespace-nowrap">问答记录</TabsTrigger>
|
||||||
{canViewFeedbackCenter && (
|
{canViewFeedbackCenter && (
|
||||||
<TabsTrigger value="feedback-center" className="shrink-0 whitespace-nowrap">反馈中心</TabsTrigger>
|
<TabsTrigger value="feedback-center" className="shrink-0 whitespace-nowrap">反馈中心</TabsTrigger>
|
||||||
|
|
@ -640,20 +716,25 @@ export default function AIStewardPage() {
|
||||||
|
|
||||||
<OverviewTab
|
<OverviewTab
|
||||||
canManageExecutiveBriefing={canManageExecutiveBriefing}
|
canManageExecutiveBriefing={canManageExecutiveBriefing}
|
||||||
|
dailyBriefing={dailyBriefing}
|
||||||
|
dailyBriefingError={dailyBriefingError}
|
||||||
|
dailyBriefingLoading={dailyBriefingLoading}
|
||||||
|
username={user?.username}
|
||||||
executiveBriefing={executiveBriefing}
|
executiveBriefing={executiveBriefing}
|
||||||
executiveBriefingError={executiveBriefingError}
|
executiveBriefingError={executiveBriefingError}
|
||||||
executiveBriefingMode={executiveBriefingMode}
|
executiveBriefingMode={briefingMode}
|
||||||
executiveBriefingLoading={executiveBriefingLoading}
|
executiveBriefingLoading={executiveBriefingLoading}
|
||||||
executiveBriefingConfigDraft={executiveBriefingConfigDraft}
|
executiveBriefingConfigDraft={executiveBriefingConfigDraft}
|
||||||
executiveBriefingConfigLoading={executiveBriefingConfigLoading}
|
executiveBriefingConfigLoading={executiveBriefingConfigLoading}
|
||||||
executiveBriefingConfigError={executiveBriefingConfigError}
|
executiveBriefingConfigError={executiveBriefingConfigError}
|
||||||
executiveBriefingSaveLoading={executiveBriefingConfigSaving}
|
executiveBriefingSaveLoading={executiveBriefingConfigSaving}
|
||||||
executiveBriefingSaveMessage={executiveBriefingSaveMessage}
|
executiveBriefingSaveMessage={executiveBriefingSaveMessage}
|
||||||
onLoadSnapshot={() => void loadExecutiveBriefing("snapshot")}
|
onLoadSnapshot={() => switchBriefingMode("snapshot")}
|
||||||
onRefreshLive={() => void loadExecutiveBriefing("live")}
|
onRefreshLive={() => switchBriefingMode("live")}
|
||||||
|
onExportReport={exportCurrentReport}
|
||||||
onConfigChange={updateExecutiveBriefingDraft}
|
onConfigChange={updateExecutiveBriefingDraft}
|
||||||
onSaveConfig={() => void saveExecutiveBriefingConfig()}
|
onSaveConfig={() => void saveExecutiveBriefingConfig()}
|
||||||
onSelectTab={(tab) => handleTabChange(tab)}
|
onOpenTarget={openBriefingTarget}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CreateProjectTab
|
<CreateProjectTab
|
||||||
|
|
@ -688,14 +769,6 @@ export default function AIStewardPage() {
|
||||||
onDetectTaskBlockers={() => void detectTaskBlockers()}
|
onDetectTaskBlockers={() => void detectTaskBlockers()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<BriefingTab
|
|
||||||
featureLoading={featureLoading}
|
|
||||||
briefing={briefing}
|
|
||||||
briefingError={briefingError}
|
|
||||||
username={user?.username}
|
|
||||||
onRefreshBriefing={() => void loadBriefing()}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* QA Records Tab */}
|
{/* QA Records Tab */}
|
||||||
<TabsContent value="qa" className="mt-6">
|
<TabsContent value="qa" className="mt-6">
|
||||||
<QAHistoryPanel />
|
<QAHistoryPanel />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -53,6 +53,7 @@ export default function FeatureOverviewCards({
|
||||||
const Icon = meta.icon;
|
const Icon = meta.icon;
|
||||||
const primaryMetric = section.metrics[0];
|
const primaryMetric = section.metrics[0];
|
||||||
const value = primaryMetric ? String(primaryMetric.value) : "-";
|
const value = primaryMetric ? String(primaryMetric.value) : "-";
|
||||||
|
const metricLabel = primaryMetric?.label || "关注数";
|
||||||
const targetTab = (section.drilldown_tab as AIStewardTab | undefined) || meta.fallbackTab;
|
const targetTab = (section.drilldown_tab as AIStewardTab | undefined) || meta.fallbackTab;
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
|
|
@ -68,8 +69,14 @@ export default function FeatureOverviewCards({
|
||||||
<CardTitle className="text-lg">{section.title}</CardTitle>
|
<CardTitle className="text-lg">{section.title}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2">
|
<CardContent className="space-y-2">
|
||||||
|
<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>
|
<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>
|
||||||
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
@ -119,16 +126,16 @@ export default function FeatureOverviewCards({
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Bell className="w-8 h-8 text-green-500" />
|
<Bell className="w-8 h-8 text-green-500" />
|
||||||
<Badge variant="secondary">智能简报</Badge>
|
<Badge variant="secondary">统一入口</Badge>
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-lg">进度同步</CardTitle>
|
<CardTitle className="text-lg">简报中心</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,257 +1,294 @@
|
||||||
import {
|
import { useState } from "react";
|
||||||
AlertCircle,
|
import { Bell, Download, FileText, Loader2, RefreshCcw, Sparkles } from "lucide-react";
|
||||||
AlertTriangle,
|
|
||||||
Bell,
|
|
||||||
CheckSquare,
|
|
||||||
FileText,
|
|
||||||
ListTodo,
|
|
||||||
Loader2,
|
|
||||||
Sparkles,
|
|
||||||
TrendingUp,
|
|
||||||
Users,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { toZhStatus } from "@/lib/display";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
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 { 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 {
|
interface BriefingTabProps {
|
||||||
featureLoading: string | null;
|
tabValue?: string;
|
||||||
briefing: DailyBriefing | null;
|
briefing: DailyBriefingV2Payload | null;
|
||||||
briefingError?: string | null;
|
briefingError?: string | null;
|
||||||
|
briefingLoading: boolean;
|
||||||
|
briefingMode: "snapshot" | "live";
|
||||||
username?: string;
|
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 SECTION_STYLE: Record<string, string> = {
|
||||||
const hasContent =
|
decisions: "border-amber-200 bg-amber-50/70 dark:border-amber-900 dark:bg-amber-950/30",
|
||||||
!!briefing &&
|
input_health: "border-sky-200 bg-sky-50/70 dark:border-sky-900 dark:bg-sky-950/30",
|
||||||
Boolean(
|
delivery_risk: "border-rose-200 bg-rose-50/70 dark:border-rose-900 dark:bg-rose-950/30",
|
||||||
(briefing.your_tasks && briefing.your_tasks.length > 0) ||
|
team_capacity: "border-emerald-200 bg-emerald-50/70 dark:border-emerald-900 dark:bg-emerald-950/30",
|
||||||
(briefing.action_items && briefing.action_items.length > 0) ||
|
ai_governance: "border-indigo-200 bg-indigo-50/70 dark:border-indigo-900 dark:bg-indigo-950/30",
|
||||||
(briefing.risks && briefing.risks.length > 0) ||
|
external_metrics: "border-slate-200 bg-slate-50/70 dark:border-slate-800 dark:bg-slate-950/30",
|
||||||
(briefing.conflicts && briefing.conflicts.length > 0) ||
|
};
|
||||||
(briefing.signals && briefing.signals.length > 0) ||
|
|
||||||
(briefing.team_updates && briefing.team_updates.length > 0)
|
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 (
|
return (
|
||||||
<TabsContent value="briefing" className="mt-6">
|
<TabsContent value={tabValue} className="mt-6">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<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">
|
||||||
<div>
|
<CardContent className="p-6">
|
||||||
<h3 className="text-xl font-semibold flex items-center gap-2">
|
<div className="flex flex-col gap-6 xl:flex-row xl:items-end xl:justify-between">
|
||||||
<Bell className="w-5 h-5 text-green-500" />
|
<div className="space-y-4">
|
||||||
每日进度简报
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
</h3>
|
<Badge className="bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900">
|
||||||
<p className="text-sm text-slate-600 dark:text-slate-400">{username} 的个性化简报 - 只显示相关内容</p>
|
{briefingMode === "live" ? "实时版" : "晨报快照"}
|
||||||
</div>
|
</Badge>
|
||||||
<Button onClick={onRefreshBriefing} disabled={featureLoading === "briefing"}>
|
{briefing?.viewer_scope === "tenant_full" ? (
|
||||||
{featureLoading === "briefing" ? (
|
<Badge className="bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-200">全租户视角</Badge>
|
||||||
<>
|
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
||||||
刷新中…
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<Badge className="bg-sky-100 text-sky-700 dark:bg-sky-950 dark:text-sky-200">个人视角</Badge>
|
||||||
<TrendingUp className="w-4 h-4 mr-2" />
|
|
||||||
刷新简报
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
</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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{featureLoading === "briefing" ? (
|
{briefingLoading && (
|
||||||
<div className="flex justify-center py-8">
|
<div className="mt-4 flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400">
|
||||||
<Loader2 className="w-8 h-8 animate-spin text-violet-500" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
正在更新日报内容...
|
||||||
</div>
|
</div>
|
||||||
) : briefingError ? (
|
)}
|
||||||
<Card className="border-rose-200 bg-rose-50/80">
|
{briefingError && (
|
||||||
<CardContent className="pt-6 text-center">
|
<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">
|
||||||
<Bell className="w-12 h-12 text-rose-400 mx-auto mb-3" />
|
{briefingError}
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{!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>
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{!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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
{briefing && !briefingError && (
|
||||||
<Card>
|
<div className="grid grid-cols-1 gap-5 xl:grid-cols-[minmax(0,1.7fr)_360px]">
|
||||||
<CardHeader>
|
<div className="space-y-5">
|
||||||
<CardTitle className="text-base flex items-center gap-2">
|
{sections.map((section) => (
|
||||||
<ListTodo className="w-4 h-4 text-blue-500" />
|
<BriefingSectionCard
|
||||||
你的任务 ({briefing.your_tasks?.length || 0})
|
key={section.key}
|
||||||
</CardTitle>
|
section={section}
|
||||||
</CardHeader>
|
className={SECTION_STYLE[section.key] || SECTION_STYLE.external_metrics}
|
||||||
<CardContent>
|
emptyMessage="当前区块暂无新的日报事项。"
|
||||||
{briefing.your_tasks && briefing.your_tasks.length > 0 ? (
|
onOpenDrawer={openDrawer}
|
||||||
<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>
|
</div>
|
||||||
) : (
|
|
||||||
<p className="text-sm text-slate-500">暂无任务</p>
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
{(hero?.key_alerts?.length ?? 0) > 0 && (
|
||||||
<CardHeader>
|
<Card className="border-slate-200 shadow-sm dark:border-slate-800">
|
||||||
<CardTitle className="text-base flex items-center gap-2">
|
<CardContent className="p-6">
|
||||||
<CheckSquare className="w-4 h-4 text-orange-500" />
|
<h4 className="font-semibold text-slate-950 dark:text-slate-50">关键提醒</h4>
|
||||||
行动项 ({briefing.action_items?.length || 0})
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
</CardTitle>
|
{(hero?.key_alerts ?? []).map((item) => (
|
||||||
</CardHeader>
|
<Badge key={`${item.label}-${item.value}`} className={toneBadgeClass(item.tone)}>
|
||||||
<CardContent>
|
{item.label} {formatMetricValue(item.value)}
|
||||||
{briefing.action_items && briefing.action_items.length > 0 ? (
|
</Badge>
|
||||||
<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>
|
|
||||||
|
|
||||||
{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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{briefing.team_updates && briefing.team_updates.length > 0 && (
|
{personalSummary && (
|
||||||
<Card>
|
<Card className="border-slate-200 shadow-sm dark:border-slate-800">
|
||||||
<CardHeader>
|
<CardContent className="p-6">
|
||||||
<CardTitle className="text-base flex items-center gap-2">
|
<div className="flex items-center gap-3">
|
||||||
<Users className="w-4 h-4 text-slate-500" />
|
<Sparkles className="h-5 w-5 text-slate-700 dark:text-slate-300" />
|
||||||
团队更新 ({briefing.team_updates.length})
|
<h4 className="font-semibold text-slate-950 dark:text-slate-50">AI 提炼结论</h4>
|
||||||
</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>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="mt-4 text-sm leading-7 text-slate-600 dark:text-slate-300">{personalSummary.headline}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<BriefingDetailDrawer
|
||||||
|
open={Boolean(drawerSection)}
|
||||||
|
section={drawerSection}
|
||||||
|
focusedProjectKey={drawerProjectKey}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setDrawerSection(null);
|
||||||
|
setDrawerProjectKey(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onOpenTarget={onOpenTarget}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,5 @@
|
||||||
import {
|
import { useState } from "react";
|
||||||
Activity,
|
import { Download, RefreshCcw, Sparkles } from "lucide-react";
|
||||||
AlertTriangle,
|
|
||||||
Bell,
|
|
||||||
CheckCircle2,
|
|
||||||
Clock,
|
|
||||||
MessageSquare,
|
|
||||||
Radar,
|
|
||||||
RefreshCcw,
|
|
||||||
Shield,
|
|
||||||
Sparkles,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { TabsContent } from "@/components/ui/tabs";
|
import { TabsContent } from "@/components/ui/tabs";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -18,13 +8,23 @@ import { Input } from "@/components/ui/input";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import type {
|
import type {
|
||||||
AIStewardTab,
|
AIStewardTab,
|
||||||
|
BriefingTarget,
|
||||||
|
DailyBriefingV2Payload,
|
||||||
EngineMessage,
|
EngineMessage,
|
||||||
ExecutiveBriefingConfig,
|
ExecutiveBriefingConfig,
|
||||||
ExecutiveBriefingPayload,
|
ExecutiveBriefingPayload,
|
||||||
|
ExecutiveBriefingSection,
|
||||||
} from "@/pages/ai-steward/types";
|
} from "@/pages/ai-steward/types";
|
||||||
|
import BriefingDetailDrawer from "../components/BriefingDetailDrawer";
|
||||||
|
import BriefingSectionCard from "../components/BriefingSectionCard";
|
||||||
|
import BriefingTab from "./BriefingTab";
|
||||||
|
|
||||||
interface OverviewTabProps {
|
interface OverviewTabProps {
|
||||||
canManageExecutiveBriefing: boolean;
|
canManageExecutiveBriefing: boolean;
|
||||||
|
dailyBriefing: DailyBriefingV2Payload | null;
|
||||||
|
dailyBriefingError: string | null;
|
||||||
|
dailyBriefingLoading: boolean;
|
||||||
|
username?: string;
|
||||||
executiveBriefing: ExecutiveBriefingPayload | null;
|
executiveBriefing: ExecutiveBriefingPayload | null;
|
||||||
executiveBriefingError: string | null;
|
executiveBriefingError: string | null;
|
||||||
executiveBriefingMode: "snapshot" | "live";
|
executiveBriefingMode: "snapshot" | "live";
|
||||||
|
|
@ -36,9 +36,10 @@ interface OverviewTabProps {
|
||||||
executiveBriefingSaveMessage: EngineMessage | null;
|
executiveBriefingSaveMessage: EngineMessage | null;
|
||||||
onLoadSnapshot: () => void;
|
onLoadSnapshot: () => void;
|
||||||
onRefreshLive: () => void;
|
onRefreshLive: () => void;
|
||||||
|
onExportReport: () => void;
|
||||||
onConfigChange: (patch: Partial<ExecutiveBriefingConfig>) => void;
|
onConfigChange: (patch: Partial<ExecutiveBriefingConfig>) => void;
|
||||||
onSaveConfig: () => void;
|
onSaveConfig: () => void;
|
||||||
onSelectTab: (tab: AIStewardTab) => void;
|
onOpenTarget: (target?: BriefingTarget | null, fallbackTab?: AIStewardTab) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SECTION_STYLE: Record<string, string> = {
|
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({
|
export default function OverviewTab({
|
||||||
canManageExecutiveBriefing,
|
canManageExecutiveBriefing,
|
||||||
|
dailyBriefing,
|
||||||
|
dailyBriefingError,
|
||||||
|
dailyBriefingLoading,
|
||||||
|
username,
|
||||||
executiveBriefing,
|
executiveBriefing,
|
||||||
executiveBriefingError,
|
executiveBriefingError,
|
||||||
executiveBriefingMode,
|
executiveBriefingMode,
|
||||||
|
|
@ -215,16 +99,37 @@ export default function OverviewTab({
|
||||||
executiveBriefingSaveMessage,
|
executiveBriefingSaveMessage,
|
||||||
onLoadSnapshot,
|
onLoadSnapshot,
|
||||||
onRefreshLive,
|
onRefreshLive,
|
||||||
|
onExportReport,
|
||||||
onConfigChange,
|
onConfigChange,
|
||||||
onSaveConfig,
|
onSaveConfig,
|
||||||
onSelectTab,
|
onOpenTarget,
|
||||||
}: OverviewTabProps) {
|
}: OverviewTabProps) {
|
||||||
|
const [drawerSection, setDrawerSection] = useState<ExecutiveBriefingSection | null>(null);
|
||||||
|
const [drawerProjectKey, setDrawerProjectKey] = useState<string | null>(null);
|
||||||
|
|
||||||
if (!canManageExecutiveBriefing) {
|
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 sections = executiveBriefing?.sections ?? [];
|
||||||
const hero = executiveBriefing?.hero;
|
const hero = executiveBriefing?.hero;
|
||||||
|
const openDrawer = (section: ExecutiveBriefingSection, projectKey?: string) => {
|
||||||
|
setDrawerSection(section);
|
||||||
|
setDrawerProjectKey(projectKey || null);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TabsContent value="overview" className="mt-6">
|
<TabsContent value="overview" className="mt-6">
|
||||||
|
|
@ -235,7 +140,7 @@ export default function OverviewTab({
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<Badge className="bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900">
|
<Badge className="bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900">
|
||||||
{executiveBriefingMode === "live" ? "实时模式" : "晨报快照"}
|
{executiveBriefingMode === "live" ? "实时版" : "晨报快照"}
|
||||||
</Badge>
|
</Badge>
|
||||||
{hero?.snapshot_state === "missing" && (
|
{hero?.snapshot_state === "missing" && (
|
||||||
<Badge className="bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-200">
|
<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>
|
<div>
|
||||||
<h2 className="flex items-center gap-3 text-3xl font-semibold tracking-tight text-slate-950 dark:text-slate-50">
|
<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" />
|
<Sparkles className="h-7 w-7 text-amber-600 dark:text-amber-300" />
|
||||||
老板晨报中心
|
简报中心
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-slate-600 dark:text-slate-300">
|
<p className="mt-2 max-w-3xl text-sm leading-6 text-slate-600 dark:text-slate-300">
|
||||||
用晨报快照做日常基线,用实时刷新做临时复盘。先看异常,再看输入、交付、团队和 AI 治理。
|
先看异常,再按项目展开预览,右侧抽屉里看完整详情并决定下一步去项目、成员还是工作台。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
<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">
|
<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">
|
<p className="mt-2 text-3xl font-semibold text-slate-950 dark:text-slate-50">{hero?.decision_count ?? 0}</p>
|
||||||
{hero?.decision_count ?? 0}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<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="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">
|
<p className="mt-2 text-3xl font-semibold text-slate-950 dark:text-slate-50">{hero?.anomaly_count ?? 0}</p>
|
||||||
{hero?.anomaly_count ?? 0}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<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="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">
|
<p className="mt-2 text-3xl font-semibold text-slate-950 dark:text-slate-50">
|
||||||
{executiveBriefing?.brief_date || "-"}
|
{executiveBriefing?.brief_date || "-"}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -274,19 +175,15 @@ export default function OverviewTab({
|
||||||
</div>
|
</div>
|
||||||
</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="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">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<span className="text-slate-500">晨报快照</span>
|
<span className="text-slate-500">晨报快照</span>
|
||||||
<span className="font-medium text-slate-900 dark:text-slate-100">
|
<span className="font-medium text-slate-900 dark:text-slate-100">{formatTimestamp(hero?.baseline_generated_at)}</span>
|
||||||
{formatTimestamp(hero?.baseline_generated_at)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex items-center justify-between gap-3">
|
<div className="mt-2 flex items-center justify-between gap-3">
|
||||||
<span className="text-slate-500">实时刷新</span>
|
<span className="text-slate-500">实时刷新</span>
|
||||||
<span className="font-medium text-slate-900 dark:text-slate-100">
|
<span className="font-medium text-slate-900 dark:text-slate-100">{formatTimestamp(hero?.live_generated_at)}</span>
|
||||||
{formatTimestamp(hero?.live_generated_at)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
|
|
@ -310,9 +207,18 @@ export default function OverviewTab({
|
||||||
disabled={executiveBriefingLoading}
|
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"
|
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>
|
||||||
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -329,48 +235,13 @@ export default function OverviewTab({
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-5 xl:grid-cols-2">
|
<div className="grid grid-cols-1 gap-5 xl:grid-cols-2">
|
||||||
{sections.map((section) => (
|
{sections.map((section) => (
|
||||||
<Card key={section.key} className={`${SECTION_STYLE[section.key] || SECTION_STYLE.external_metrics} shadow-sm`}>
|
<BriefingSectionCard
|
||||||
<CardContent className="p-6">
|
key={section.key}
|
||||||
<div className="flex items-start justify-between gap-4">
|
section={section}
|
||||||
<div>
|
className={SECTION_STYLE[section.key] || SECTION_STYLE.external_metrics}
|
||||||
<h3 className="text-xl font-semibold text-slate-950 dark:text-slate-50">{section.title}</h3>
|
emptyMessage="当前区块暂无新的异常或风险提示。"
|
||||||
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-300">{section.summary}</p>
|
onOpenDrawer={openDrawer}
|
||||||
</div>
|
/>
|
||||||
{section.drilldown_tab && section.drilldown_tab !== "overview" && (
|
|
||||||
<Button variant="outline" size="sm" onClick={() => onSelectTab(section.drilldown_tab as AIStewardTab)}>
|
|
||||||
查看明细
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-3">
|
|
||||||
{section.metrics.map((metric) => (
|
|
||||||
<div key={`${section.key}-${metric.label}`} className="rounded-xl border border-white/60 bg-white/70 p-3 dark:border-slate-800 dark:bg-slate-950/50">
|
|
||||||
<p className="text-xs uppercase tracking-[0.14em] text-slate-500">{metric.label}</p>
|
|
||||||
<p className="mt-2 text-2xl font-semibold text-slate-950 dark:text-slate-50">
|
|
||||||
{formatMetricValue(metric.value)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 space-y-3">
|
|
||||||
{section.items.length ? (
|
|
||||||
section.items.slice(0, 3).map((item, index) => (
|
|
||||||
<div key={`${section.key}-${item.title}-${index}`} className="rounded-xl border border-white/70 bg-white/80 px-4 py-3 dark:border-slate-800 dark:bg-slate-950/50">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<span className="font-medium text-slate-950 dark:text-slate-50">{item.title}</span>
|
|
||||||
<Badge className={toneBadgeClass(item.tone)}>{item.tone}</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 text-sm text-slate-600 dark:text-slate-300">{item.detail}</p>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className="rounded-xl border border-dashed border-slate-300 bg-white/70 px-4 py-6 text-center text-sm text-slate-500 dark:border-slate-700 dark:bg-slate-950/40 dark:text-slate-400">
|
|
||||||
当前区块暂无新增异常。
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -380,7 +251,7 @@ export default function OverviewTab({
|
||||||
<div className="max-w-xl">
|
<div className="max-w-xl">
|
||||||
<h3 className="text-xl font-semibold text-slate-950 dark:text-slate-50">晨报调度配置</h3>
|
<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">
|
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-300">
|
||||||
这份配置是租户级的。修改后,Web 快照、定时调度和管理群自动推送都会沿用同一套口径。
|
这份配置是租户级的。修改后,Web 快照、定时调度和管理群自动推送都会沿用同一套晨报口径。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|
@ -467,6 +338,19 @@ export default function OverviewTab({
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<BriefingDetailDrawer
|
||||||
|
open={Boolean(drawerSection)}
|
||||||
|
section={drawerSection}
|
||||||
|
focusedProjectKey={drawerProjectKey}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setDrawerSection(null);
|
||||||
|
setDrawerProjectKey(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onOpenTarget={onOpenTarget}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,13 @@
|
||||||
import type { LLMEngine, LLMEngineListResponse } from "@/api/aiSteward";
|
import type { LLMEngine, LLMEngineListResponse } from "@/api/aiSteward";
|
||||||
export type {
|
export type {
|
||||||
|
BriefingMetaEntry,
|
||||||
|
BriefingTarget,
|
||||||
Blocker,
|
Blocker,
|
||||||
CognitiveConflict,
|
CognitiveConflict,
|
||||||
DailyBriefing,
|
DailyBriefing,
|
||||||
|
DailyBriefingV2Payload,
|
||||||
|
DailyBriefingV2PersonalSummary,
|
||||||
|
DailyBriefingV2PersonalWorkload,
|
||||||
ExecutiveBriefingConfig,
|
ExecutiveBriefingConfig,
|
||||||
ExecutiveBriefingHero,
|
ExecutiveBriefingHero,
|
||||||
ExecutiveBriefingItem,
|
ExecutiveBriefingItem,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useDeferredValue, useEffect, useMemo, useState } from "react";
|
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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { AlertTriangle, ArrowLeft, ChevronDown, ChevronRight, Plus } from "lucide-react";
|
import { AlertTriangle, ArrowLeft, ChevronDown, ChevronRight, Plus } from "lucide-react";
|
||||||
import { aiPmoApi } from "@/api/aiPmo";
|
import { aiPmoApi } from "@/api/aiPmo";
|
||||||
|
|
@ -28,10 +28,47 @@ import {
|
||||||
toReadableWorkItemTitle,
|
toReadableWorkItemTitle,
|
||||||
} from "./helpers";
|
} 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() {
|
export default function ProjectBoardPage() {
|
||||||
const SIGNAL_PAGE_SIZE = 10;
|
const SIGNAL_PAGE_SIZE = 10;
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const boardSearchKey = searchParams.toString();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [isMobileViewport, setIsMobileViewport] = useState(() =>
|
const [isMobileViewport, setIsMobileViewport] = useState(() =>
|
||||||
|
|
@ -207,6 +244,14 @@ export default function ProjectBoardPage() {
|
||||||
setSignalFilters(DEFAULT_SIGNAL_FILTERS);
|
setSignalFilters(DEFAULT_SIGNAL_FILTERS);
|
||||||
}, [id]);
|
}, [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 () => {
|
const refreshAll = async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: ["sstc-board-overview", id] }),
|
queryClient.invalidateQueries({ queryKey: ["sstc-board-overview", id] }),
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
Binary file not shown.
Loading…
Reference in New Issue