From a01c451195c6c720e32806b27d025292373a0295 Mon Sep 17 00:00:00 2001 From: Yuuuu <1569752928@qq.com> Date: Mon, 30 Mar 2026 17:27:28 +0800 Subject: [PATCH] Revamp AI steward executive briefing workflows --- CLAUDE.md | 17 +- .../ai_steward_api/routes_brief_blockers.py | 26 +- backend/app/api/ai_steward_api/schemas.py | 36 + .../services/executive_briefing_service.py | 899 +++++++++++++++--- .../app/services/tenant_briefing_service.py | 300 ++++++ backend/tests/test_daily_briefing_service.py | 12 + backend/tests/test_daily_briefing_v2_api.py | 316 ++++++ backend/tests/test_executive_briefing_api.py | 401 +++++++- frontend/src/api/aiSteward.ts | 52 + .../src/pages/ai-steward/AIStewardPage.tsx | 189 ++-- .../components/BriefingDetailDrawer.tsx | 368 +++++++ .../components/BriefingPrintDocument.tsx | 571 +++++++++++ .../components/BriefingSectionCard.tsx | 314 ++++++ .../components/FeatureOverviewCards.tsx | 19 +- .../pages/ai-steward/sections/BriefingTab.tsx | 493 +++++----- .../pages/ai-steward/sections/OverviewTab.tsx | 282 ++---- frontend/src/pages/ai-steward/types.ts | 5 + .../pages/project-board/ProjectBoardPage.tsx | 47 +- frontend/tsconfig.tsbuildinfo | 2 +- 工单数据_20260327.xlsx | Bin 0 -> 48533 bytes 20 files changed, 3697 insertions(+), 652 deletions(-) create mode 100644 backend/app/services/tenant_briefing_service.py create mode 100644 backend/tests/test_daily_briefing_v2_api.py create mode 100644 frontend/src/pages/ai-steward/components/BriefingDetailDrawer.tsx create mode 100644 frontend/src/pages/ai-steward/components/BriefingPrintDocument.tsx create mode 100644 frontend/src/pages/ai-steward/components/BriefingSectionCard.tsx create mode 100644 工单数据_20260327.xlsx diff --git a/CLAUDE.md b/CLAUDE.md index 03e57705..e25a7c87 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -389,12 +389,15 @@ frontend/ - 更新:`frontend/src/pages/AIStewardPage.tsx`(降级为轻量入口,转发到 `pages/ai-steward/AIStewardPage`) - 新增:`frontend/src/pages/ai-steward/types.ts`、`frontend/src/pages/ai-steward/constants.ts`(AI Steward 类型与常量集中管理) - 新增:`frontend/src/pages/ai-steward/components/FeatureOverviewCards.tsx`(AI Steward 首页功能卡片组件) +- 新增:`frontend/src/pages/ai-steward/components/BriefingPrintDocument.tsx`、`BriefingSectionCard.tsx`、`BriefingDetailDrawer.tsx`(AI Steward 简报中心打印导出模板、固定高度项目分组卡片与右侧详情抽屉) - 新增:`frontend/src/pages/ai-steward/sections/OverviewTab.tsx`、`CreateProjectTab.tsx`、`SignalsTab.tsx`、`ConflictsTab.tsx`、`BlockersTab.tsx`、`BriefingTab.tsx`、`EngineTab.tsx`、`FeedbackCenterTab.tsx`(AI Steward 分阶段 UI 拆分) - 新增:`backend/app/services/executive_briefing_service.py`、`backend/app/api/ai_steward_api/routes_executive_briefing.py`(老板晨报聚合服务与 manager-only 接口,配置持久化复用 `Integration(type='executive_briefing')`,快照复用 `PortfolioSnapshot(scope_type='executive_brief')`) +- 新增:`backend/app/services/tenant_briefing_service.py`、`backend/tests/test_daily_briefing_v2_api.py`(租户级简报统一内核与 `daily-briefing-v2` viewer scope 过滤回归) - 新增:`backend/tests/test_executive_briefing_api.py`、`backend/tests/test_ai_pmo_cadence_scheduler.py`(老板晨报接口包络、配置落库、管理群文案与调度时间窗回归) - 更新:`backend/app/services/ai_pmo/ai_pmo_cadence/service.py`、`mixin_builders.py`、`mixin_snapshots.py`、`backend/app/services/scheduler_center_core/ai_pmo_cadence.py`(新增 `executive_brief` cadence type,支持租户级晨报快照生成与管理群 outbox 推送) -- 更新:`frontend/src/api/aiSteward.ts`(新增 cognitive conflicts / blockers / daily briefing / executive briefing API 封装,AI Steward 页面不再直接 `fetch`) -- 更新:`frontend/src/pages/ai-steward/AIStewardPage.tsx`、`components/FeatureOverviewCards.tsx`、`sections/OverviewTab.tsx`(BOSS/ADMIN 首页升级为老板晨报中心,支持晨报快照、实时刷新、调度配置与异常优先看板) +- 更新:`backend/app/services/executive_briefing_service.py`(读取 `snapshot` 时若命中旧结构快照,会自动按当前代码重建新版 payload,避免简报中心默认打开仍停留在无详情/无精准跳转的旧结构) +- 更新:`frontend/src/api/aiSteward.ts`(新增 cognitive conflicts / blockers / `daily-briefing-v2` / executive briefing API 封装,AI Steward 页面不再直接 `fetch`) +- 更新:`frontend/src/pages/ai-steward/AIStewardPage.tsx`、`components/FeatureOverviewCards.tsx`、`sections/OverviewTab.tsx`、`sections/BriefingTab.tsx`(晨报中心与每日简报共享 `snapshot/live` 模式;前端只保留一个“简报中心”入口,支持固定高度卡片、项目分组预览、右侧详情抽屉、精准跳转与完整详细导出) - 更新:`frontend/src/pages/ai-steward/sections/CreateProjectTab.tsx`(零配置创建结果区内联展示 `signal_sources` 分组,复用现有 AI Steward 页面,不新增独立路由) - 新增:`frontend/src/pages/ai-steward/components/feedback-center/FeedbackList.tsx`、`FeedbackDetail.tsx`、`ReasonPicker.tsx`、`MetricsPanel.tsx`、`TrendPanel.tsx`(反馈中心三栏工作台、指标页与趋势页组件;趋势页支持 `7/30 天` 与 `all/task/qa/signal` 局部筛选,并提供洞察卡、原因筛选与一键钻取) - 新增:`frontend/src/pages/ai-steward/components/feedback-center/TrendPanel.tsx` 的趋势决策版布局(Scope / KPI / Insights / Drilldown / Evidence) @@ -406,6 +409,7 @@ frontend/ - 新增:`frontend/src/lib/api.ts`、`apiError.ts`、`display.ts`、`datetime.ts`、`inbox.ts`、`inboxTextMap.ts`、`formatInboxTime.ts`、`feishuEventMode.ts`、`sessionGuard.ts`、`rbac.ts`、`title.ts`、`utils.ts`、`zhCN.ts`(前端兼容工具与消息台/AI Steward 展示格式化层) - 更新:`frontend/src/pages/ProjectBoardPage.tsx`(降级为轻量入口,转发到 `pages/project-board/ProjectBoardPage`) - 新增:`frontend/src/pages/project-board/helpers.ts`(Project Board 信号筛选状态与 PMO 工作项文本归一 helper) +- 更新:`frontend/src/pages/project-board/ProjectBoardPage.tsx`(支持从简报中心 URL 参数恢复 `stage` 与 `signal_*` 筛选条件) - 新增:`frontend/src/pages/project-board/components/CreateCharterDialog.tsx`、`ProjectSettingsDialog.tsx`(Project Board 大体量弹窗组件拆分) - 新增:`frontend/src/pages/project-board/ProjectBoardPage.tsx`(Project Board 页面编排层) - 更新:`frontend/src/pages/ProjectsPage.tsx`(降级为轻量入口,转发到 `pages/projects/ProjectsPage`) @@ -503,7 +507,11 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 - 禁止在前端组件内直接调用 `fetch`,必须通过 `frontend/src/api/` 封装层 - 前端状态管理:服务端数据用 React Query,本地 UI 状态用 `useState` - 老板晨报链路只允许 `BOSS/ADMIN` 访问;`GET /ai-steward/executive-briefing` 的 `snapshot/live` 语义必须分离,`live` 禁止覆盖 `PortfolioSnapshot(scope_type='executive_brief')` +- `daily-briefing-v2` 必须复用同一份租户级简报内核:先算 tenant payload,再按 `viewer_scope=tenant_full|personal` 过滤;禁止老板晨报与每日简报各自维护独立统计口径 +- 简报 item 的卡片摘要、抽屉详情、导出报告与跳转目标必须复用同一份结构化字段(`id/detail_lines/meta/target.params`);禁止页面、导出、跳转各自拼接不同文案导致事实不一致 +- 简报中心只允许展示具备真实业务定位的对象:项目信号必须绑定真实 `project_id`,信号源必须有真实来源名/标识,人员负载必须绑定真实 `user_id`;禁止把未归属信号用“待处理事项”等兜底标题伪装成老板待办 - 老板晨报调度配置统一落在 `Integration(type='executive_briefing')`,禁止新增独立晨报配置表;管理群自动推送必须复用现有 `feishu_reply_outbox` +- 晨报/日报导出首版只允许前端“当前所见”打印导出(浏览器 `print` / PDF),禁止新增后端二进制 PDF 生成服务 - 飞书回执发送仅允许 `main.py` 的 drain_loop 作为消费者,禁止在业务路径即时派发 outbox 发送任务 - 所有写接口必须落审计日志(`operation_logs`):至少包含 `trace_id/interface_path/caller` - `created_at_beijing` 为计算属性返回,禁止新增数据库物理列 @@ -548,6 +556,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 - 飞书捕获模拟接口:`POST /ai-steward/signal/capture-feishu` 在 `direct_reply_required=true` 时会同步写入 `feishu_reply_outbox`,并返回 `reply_outbox_enqueued/reply_outbox_id` 便于链路自检 - 配置读取类接口:参考 `backend/app/api/llm_providers.py`(`GET /ai-steward/llm/providers`) - 老板晨报接口:参考 `backend/app/api/ai_steward_api/routes_executive_briefing.py`(`GET /ai-steward/executive-briefing?mode=snapshot|live`、`GET/PUT /ai-steward/executive-briefing/config`,仅 `BOSS/ADMIN` 可访问) +- 每日简报 v2 接口:参考 `backend/app/api/ai_steward_api/routes_brief_blockers.py`(`GET /ai-steward/daily-briefing-v2?mode=snapshot|live`,普通成员返回个人版,`BOSS/ADMIN` 返回全租户版) - 模型配置唯一来源:参考 `backend/app/services/model/model_config.py`(统一读取 `backend/.openclaw/pmflow-routing-rules.json` 内的 `provider_defaults/tier_defaults/rules`) - 模型配置热更新后,LLM provider circuit 会在下一次调用时自动清空旧熔断状态,避免旧配置失败污染新配置的连通性判断 - 启动期模型探针只做连通性观测,不应把探针失败写回 provider circuit;否则会误伤真实业务请求 @@ -593,6 +602,10 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 - 项目创建返回的 `signal_sources` 仅作为 AI Steward 创建结果区的内联展示,现阶段不新增独立页面路由;底层持久化统一复用现有 `SignalSourceRegistry` - 老板晨报首版的 `external_metrics` 只返回占位区块,不接 CRM / BI / 收入系统,也不允许伪造经营指标 - 老板晨报调度默认值为 `08:30 + workdays + Asia/Shanghai`;若 `delivery_target_ref` 未配置,调度器仍生成快照,但只记录告警、不静默伪装成已推送 +- `daily-briefing-v2` 与老板晨报当前只做同会话内 `snapshot/live` 同步,不引入 WebSocket 推送;导出仅复用浏览器打印能力 +- 若当天 `executive_brief` 快照仍是旧结构(缺少 `id/detail_lines/meta` 等字段),读取 `snapshot` 时会自动按当前事实重建新版 payload;因此默认快照优先保证可交互与可导出,而不是强行返回半旧版结构 +- 未匹配项目的孤立 signal 不再进入简报中心卡片;这类数据仅保留在信号工作台中处理,避免老板视图出现无项目、无负责人、无真实定位的伪待办 +- 简报中心首页卡片固定高度并允许内部滚动;完整明细统一放在右侧详情抽屉与打印导出中,移动端退化为全屏 Dialog,不新增第二套详情页 - 飞书长连接 websocket 在本地/CI 环境默认使用 `certifi` CA bundle 作为信任链;如需自定义 CA,可通过 `FEISHU_LONG_CONNECTION_SSL_CERT_FILE` 或 `SSL_CERT_FILE` 覆盖 - 飞书长连接状态分为两层:`last_received_at/last_received_message_id` 表示 SDK 回调已收到事件,`last_event_at` 表示 worker 完成处理;排查“没吃进去”时必须优先看 `last_received_*` - 飞书长连接会话应复用 `Integration.config` 中已有的 `encrypt_key/verification_token`;配置保存时若未显式修改这两个字段,必须保留现有值,避免 SDK 因缺失事件密钥而拒收消息 diff --git a/backend/app/api/ai_steward_api/routes_brief_blockers.py b/backend/app/api/ai_steward_api/routes_brief_blockers.py index c41e37bb..4e2a74a7 100644 --- a/backend/app/api/ai_steward_api/routes_brief_blockers.py +++ b/backend/app/api/ai_steward_api/routes_brief_blockers.py @@ -1,7 +1,12 @@ from __future__ import annotations +from fastapi import Query +from fastapi.responses import JSONResponse + from .shared import * from .schemas import BlockerResolveRequest, CodeCommentScanRequest, DailyBriefingResponse +from ...services.auth.service import parse_role_values +from ...services.tenant_briefing_service import TenantBriefingService @router.get("/daily-briefing") async def get_daily_briefing( @@ -34,6 +39,26 @@ async def get_daily_briefing( detail=f"生成简报失败: {str(e)}" ) + +@router.get("/daily-briefing-v2") +async def get_daily_briefing_v2( + mode: str = Query(default="snapshot", pattern="^(snapshot|live)$"), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + try: + payload = await TenantBriefingService(db, tenant_id=tenant_id).build_viewer_payload( + viewer_id=int(current_user.id), + viewer_roles=parse_role_values(getattr(current_user, "role", "")), + mode=mode, + ) + except ValueError as exc: + return JSONResponse(status_code=400, content=error_response(400, str(exc))) + except Exception as exc: + return JSONResponse(status_code=500, content=error_response(500, f"生成日报失败: {exc}")) + return success_response(payload) + @router.get("/daily-briefing/team/{project_id}") async def get_team_briefing( project_id: int, @@ -248,4 +273,3 @@ async def resolve_blocker( status_code=500, detail=f"解决失败: {str(e)}" ) - diff --git a/backend/app/api/ai_steward_api/schemas.py b/backend/app/api/ai_steward_api/schemas.py index b047b03c..e8019df7 100644 --- a/backend/app/api/ai_steward_api/schemas.py +++ b/backend/app/api/ai_steward_api/schemas.py @@ -112,10 +112,20 @@ class ExecutiveBriefingMetric(BaseModel): class ExecutiveBriefingItem(BaseModel): + id: str title: str detail: str + detail_lines: List[str] = Field(default_factory=list) + meta: List[Dict[str, str]] = Field(default_factory=list) tone: str kind: Optional[str] = None + target: Optional[Dict[str, Any]] = None + project_id: Optional[int] = None + project_name: Optional[str] = None + user_id: Optional[int] = None + owner_name: Optional[str] = None + related_user_ids: List[int] = Field(default_factory=list) + occurred_at: Optional[str] = None class ExecutiveBriefingSection(BaseModel): @@ -144,3 +154,29 @@ class ExecutiveBriefingPayload(BaseModel): hero: ExecutiveBriefingHero sections: List[ExecutiveBriefingSection] = Field(default_factory=list) delivery: Dict[str, Any] = Field(default_factory=dict) + + +class DailyBriefingV2PersonalSummary(BaseModel): + headline: str + action_count: int = 0 + project_count: int = 0 + + +class DailyBriefingV2PersonalWorkload(BaseModel): + user_id: int + user_name: str + assigned_hours: float = 0 + max_weekly_hours: float = 0 + workload_ratio: int = 0 + tone: str = "info" + + +class DailyBriefingV2Payload(BaseModel): + brief_date: str + mode: Literal["snapshot", "live"] + viewer_scope: Literal["tenant_full", "personal"] + hero: ExecutiveBriefingHero + sections: List[ExecutiveBriefingSection] = Field(default_factory=list) + personal_summary: Optional[DailyBriefingV2PersonalSummary] = None + personal_workload: Optional[DailyBriefingV2PersonalWorkload] = None + export_title: str diff --git a/backend/app/services/executive_briefing_service.py b/backend/app/services/executive_briefing_service.py index 8be1f449..465b3ec1 100644 --- a/backend/app/services/executive_briefing_service.py +++ b/backend/app/services/executive_briefing_service.py @@ -16,9 +16,12 @@ from ..models import ( Integration, Notification, PortfolioSnapshot, + Project, + ProjectSignalLink, Signal, SignalSourceRegistry, Task, + TruthNode, User, UserWorkload, ) @@ -39,6 +42,39 @@ ACTIVE_CONFLICT_DONE_STATUSES = {"resolved", "closed", "done", "已解决", "已 REWORK_TASK_STATUSES = {"rework", "reopened", "返工", "重新打开"} REVIEW_ACCEPTED_STATUSES = {"accepted", "approved", "done"} REVIEW_REJECTED_STATUSES = {"rejected"} +PRIORITY_LABELS = { + "p0": "P0", + "p1": "P1", + "critical": "紧急", + "high": "高优", + "medium": "中优", + "low": "低优", + "紧急": "紧急", + "高": "高优", +} +STATUS_LABELS = { + "pending": "待处理", + "todo": "待处理", + "待处理": "待处理", + "待确认": "待确认", + "blocked": "阻塞", + "blocking": "阻塞", + "阻塞": "阻塞", + "已阻塞": "阻塞", + "scheduled": "定时任务", + "active": "进行中", + "in_progress": "进行中", + "done": "已完成", + "completed": "已完成", + "closed": "已关闭", + "resolved": "已解决", +} +SOURCE_KIND_LABELS = { + "webhook": "Webhook 来源", + "scheduled": "定时来源", + "manual": "人工录入", + "feishu": "飞书来源", +} def _deepcopy_dict(value: Any) -> dict[str, Any]: @@ -68,12 +104,49 @@ def _iso_or_none(value: Optional[datetime]) -> Optional[str]: return value.isoformat() +def _label_priority(value: Any) -> str: + raw = str(value or "").strip() + if not raw: + return "-" + return PRIORITY_LABELS.get(raw.lower(), raw) + + +def _label_status(value: Any) -> str: + raw = str(value or "").strip() + if not raw: + return "-" + return STATUS_LABELS.get(raw.lower(), raw) + + +def _label_source_kind(value: Any) -> str: + raw = str(value or "").strip() + if not raw: + return "-" + return SOURCE_KIND_LABELS.get(raw.lower(), raw) + + +def _target( + target_type: str, + ref: str, + label: Optional[str] = None, + params: Optional[dict[str, Any]] = None, +) -> dict[str, Any]: + payload: dict[str, Any] = {"type": str(target_type), "ref": str(ref)} + if label: + payload["label"] = str(label) + if isinstance(params, dict) and params: + payload["params"] = {str(key): value for key, value in params.items() if value not in (None, "", [])} + return payload + + class ExecutiveBriefingService: """Builds tenant-wide executive briefing payloads and config.""" def __init__(self, db: AsyncSession, *, tenant_id: int) -> None: self.db = db self.tenant_id = int(tenant_id) + self._project_name_cache: dict[int, str] = {} + self._user_name_cache: dict[int, str] = {} @classmethod def default_config(cls) -> dict[str, Any]: @@ -128,13 +201,26 @@ class ExecutiveBriefingService: snapshot = await self._get_latest_today_snapshot() if snapshot is not None: payload = _deepcopy_dict(snapshot.payload) - hero = payload.setdefault("hero", {}) - payload["mode"] = "snapshot" - hero["baseline_generated_at"] = hero.get("baseline_generated_at") or _iso_or_none(snapshot.created_at) - payload["brief_date"] = payload.get("brief_date") or ( + if self._snapshot_payload_is_current(payload): + hero = payload.setdefault("hero", {}) + payload["mode"] = "snapshot" + hero["baseline_generated_at"] = hero.get("baseline_generated_at") or _iso_or_none(snapshot.created_at) + payload["brief_date"] = payload.get("brief_date") or ( + snapshot.created_at.date().isoformat() if snapshot.created_at else now_cn_naive().date().isoformat() + ) + return payload + + # 兼容旧快照结构:默认页签优先返回当前可交互、可导出的新版结构, + # 避免老板打开后仍停留在无详情、无精准跳转的旧版 payload。 + rebuilt = await self.build_brief_payload( + mode="snapshot", + baseline_generated_at=snapshot.created_at, + live_generated_at=None, + ) + rebuilt["brief_date"] = rebuilt.get("brief_date") or ( snapshot.created_at.date().isoformat() if snapshot.created_at else now_cn_naive().date().isoformat() ) - return payload + return rebuilt return await self.build_brief_payload(mode="snapshot", baseline_generated_at=None, live_generated_at=None) live_generated_at = now_cn_naive() @@ -146,6 +232,35 @@ class ExecutiveBriefingService: live_generated_at=live_generated_at, ) + @staticmethod + def _snapshot_payload_is_current(payload: dict[str, Any]) -> bool: + sections = payload.get("sections") + hero = payload.get("hero") + if not isinstance(sections, list) or not isinstance(hero, dict): + return False + if str(hero.get("title") or "").strip() != "简报中心": + return False + + for section in sections: + if not isinstance(section, dict): + return False + items = section.get("items") + if not isinstance(items, list): + return False + for item in items[:3]: + if not isinstance(item, dict): + return False + if not str(item.get("id") or "").strip(): + return False + if not isinstance(item.get("detail_lines"), list): + return False + if not isinstance(item.get("meta"), list): + return False + target = item.get("target") + if target is not None and not isinstance(target, dict): + return False + return True + async def build_brief_payload( self, *, @@ -156,15 +271,17 @@ class ExecutiveBriefingService: generated_at = live_generated_at or baseline_generated_at or now_cn_naive() since = generated_at - timedelta(hours=24) - decision_signals = await self._list_priority_pending_signals(limit=3) - pending_sources = await self._list_pending_signal_sources(limit=3) - overloaded_members = await self._list_workload_members(kind="overloaded", limit=3) - active_conflicts = await self._list_active_conflicts(limit=3) - risk_notifications = await self._list_risk_notifications(since=since, limit=3) - blocked_tasks = await self._list_blocked_tasks(limit=3) - overdue_tasks = await self._list_overdue_tasks(now=generated_at, limit=3) - recent_signals = await self._list_recent_signals(since=since, limit=3) - idle_members = await self._list_workload_members(kind="idle", limit=3) + item_limit = 12 + + decision_signals = await self._list_priority_pending_signals(limit=item_limit) + pending_sources = await self._list_pending_signal_sources(limit=item_limit) + overloaded_members = await self._list_workload_members(kind="overloaded", limit=item_limit) + active_conflicts = await self._list_active_conflicts(limit=item_limit) + risk_notifications = await self._list_risk_notifications(since=since, limit=item_limit) + blocked_tasks = await self._list_blocked_tasks(limit=item_limit) + overdue_tasks = await self._list_overdue_tasks(now=generated_at, limit=item_limit) + recent_signals = await self._list_recent_signals(since=since, limit=item_limit) + idle_members = await self._list_workload_members(kind="idle", limit=item_limit) pending_signal_count = await self._count_pending_signals() high_priority_signal_count = await self._count_high_priority_pending_signals() @@ -180,43 +297,11 @@ class ExecutiveBriefingService: task_stats = await self._get_task_governance_stats() decision_items = [ - *[ - { - "title": _safe_text(item.get("title"), fallback="高优先级信号待跟进"), - "detail": _safe_text(item.get("detail")), - "tone": "warning", - "kind": "signal", - } - for item in decision_signals[:2] - ], - *[ - { - "title": _safe_text(item.get("title"), fallback="信号源待确认"), - "detail": _safe_text(item.get("detail")), - "tone": "warning", - "kind": "signal_source", - } - for item in pending_sources[:1] - ], - *[ - { - "title": _safe_text(item.get("title"), fallback="成员持续过载"), - "detail": _safe_text(item.get("detail")), - "tone": "critical", - "kind": "capacity", - } - for item in overloaded_members[:1] - ], - *[ - { - "title": _safe_text(item.get("title"), fallback="关键冲突待解"), - "detail": _safe_text(item.get("detail")), - "tone": "critical", - "kind": "conflict", - } - for item in active_conflicts[:1] - ], - ][:3] + *decision_signals, + *pending_sources[:4], + *overloaded_members[:4], + *active_conflicts[:4], + ][:item_limit] decisions_section = { "key": "decisions", @@ -235,7 +320,7 @@ class ExecutiveBriefingService: "drilldown_tab": "signals", } - input_items = [*recent_signals, *pending_sources][:3] + input_items = [*recent_signals, *pending_sources][:item_limit] input_section = { "key": "input_health", "title": "业务机会与输入健康", @@ -253,7 +338,7 @@ class ExecutiveBriefingService: "drilldown_tab": "signals", } - delivery_items = [*blocked_tasks, *overdue_tasks, *risk_notifications][:3] + delivery_items = [*blocked_tasks, *overdue_tasks, *risk_notifications][:item_limit] delivery_section = { "key": "delivery_risk", "title": "交付兑现与项目风险", @@ -271,7 +356,7 @@ class ExecutiveBriefingService: "drilldown_tab": "blockers", } - capacity_items = [*overloaded_members, *idle_members, *active_conflicts][:3] + capacity_items = [*overloaded_members, *idle_members, *active_conflicts][:item_limit] capacity_section = { "key": "team_capacity", "title": "团队产能与协同失衡", @@ -289,7 +374,7 @@ class ExecutiveBriefingService: "drilldown_tab": "conflicts", } - governance_items = self._build_governance_items(feedback_stats=feedback_stats, task_stats=task_stats) + governance_items = await self._build_governance_items(feedback_stats=feedback_stats, task_stats=task_stats) governance_section = { "key": "ai_governance", "title": "AI 治理与系统可信度", @@ -358,7 +443,7 @@ class ExecutiveBriefingService: "pending_feedback_count": feedback_stats["pending_count"], }, "hero": { - "title": "老板晨报中心", + "title": "简报中心", "decision_count": decision_count, "anomaly_count": anomaly_count, "baseline_generated_at": _iso_or_none(baseline_generated_at), @@ -504,6 +589,147 @@ class ExecutiveBriefingService: localized = value.replace(tzinfo=CN_TZ) if value.tzinfo is None else value.astimezone(CN_TZ) return localized.strftime("%m-%d %H:%M") + @staticmethod + def _detail_lines(*parts: Any) -> list[str]: + lines: list[str] = [] + for value in parts: + text = str(value or "").strip() + if text: + lines.append(text) + return lines + + @staticmethod + def _pick_display_text(*values: Any) -> Optional[str]: + for value in values: + text = str(value or "").strip() + if text: + return text + return None + + @classmethod + def _is_reportable_signal(cls, *, project_id: Optional[int], title: Optional[str]) -> bool: + return project_id is not None and bool(cls._pick_display_text(title)) + + @classmethod + def _is_reportable_task(cls, *, project_id: Optional[int], title: Optional[str]) -> bool: + return project_id is not None and bool(cls._pick_display_text(title)) + + @classmethod + def _is_reportable_signal_source(cls, *, title: Optional[str], source_identifier: Any) -> bool: + return bool(cls._pick_display_text(title, source_identifier)) + + @classmethod + def _is_reportable_notification( + cls, + *, + project_id: Optional[int], + user_id: Optional[int], + title: Optional[str], + content: Any, + ) -> bool: + return (project_id is not None or user_id is not None) and bool(cls._pick_display_text(title, content)) + + @classmethod + def _is_reportable_conflict( + cls, + *, + project_id: Optional[int], + related_user_ids: list[int], + title: Optional[str], + description: Any, + ) -> bool: + return (project_id is not None or bool(related_user_ids)) and bool(cls._pick_display_text(title, description)) + + @staticmethod + def _meta_entries(*pairs: tuple[str, Any]) -> list[dict[str, str]]: + entries: list[dict[str, str]] = [] + for label, value in pairs: + label_text = str(label or "").strip() + value_text = str(value or "").strip() + if not label_text or not value_text: + continue + entries.append({"label": label_text, "value": value_text}) + return entries + + async def _get_project_name(self, project_id: Optional[int]) -> Optional[str]: + if project_id is None: + return None + if project_id in self._project_name_cache: + return self._project_name_cache[project_id] + row = ( + await self.db.execute( + select(Project.title, Project.name).where( + and_( + Project.tenant_id == self.tenant_id, + Project.id == int(project_id), + ) + ) + ) + ).one_or_none() + if row is None: + return None + title, name = row + project_name = _safe_text(title or name, fallback=f"项目 #{project_id}") + self._project_name_cache[int(project_id)] = project_name + return project_name + + async def _get_user_name(self, user_id: Optional[int]) -> Optional[str]: + if user_id is None: + return None + if user_id in self._user_name_cache: + return self._user_name_cache[user_id] + row = ( + await self.db.execute( + select(User.name, User.username).where( + and_( + User.tenant_id == self.tenant_id, + User.id == int(user_id), + ) + ) + ) + ).one_or_none() + if row is None: + return None + name, username = row + user_name = _safe_text(name or username, fallback=f"成员 #{user_id}") + self._user_name_cache[int(user_id)] = user_name + return user_name + + async def _build_brief_item( + self, + *, + item_id: str, + title: Any, + tone: str, + kind: str, + detail_lines: list[str], + meta: list[dict[str, str]] | None = None, + detail: Optional[str] = None, + project_id: Optional[int] = None, + user_id: Optional[int] = None, + related_user_ids: Optional[list[int]] = None, + occurred_at: Optional[datetime] = None, + target: Optional[dict[str, Any]] = None, + ) -> dict[str, Any]: + cleaned_detail_lines = [line for line in detail_lines if str(line or "").strip()] + cleaned_meta = [entry for entry in (meta or []) if str(entry.get("label") or "").strip() and str(entry.get("value") or "").strip()] + return { + "id": str(item_id), + "title": _safe_text(title, fallback="待处理事项"), + "detail": detail or "|".join(cleaned_detail_lines[:3]) or "-", + "detail_lines": cleaned_detail_lines, + "meta": cleaned_meta, + "tone": str(tone or "info"), + "kind": str(kind or "briefing"), + "target": target, + "project_id": project_id, + "project_name": await self._get_project_name(project_id), + "user_id": user_id, + "owner_name": await self._get_user_name(user_id), + "related_user_ids": related_user_ids or [], + "occurred_at": _iso_or_none(occurred_at), + } + async def _list_priority_pending_signals(self, *, limit: int) -> list[dict[str, Any]]: rows = ( await self.db.execute( @@ -526,12 +752,48 @@ class ExecutiveBriefingService: pending = (not bool(row.processed)) or status in PENDING_SIGNAL_STATUSES or str(row.planner_status or "").strip().lower() == "pending" if priority not in HIGH_SIGNAL_PRIORITIES or not pending: continue + project_id = self._to_int(getattr(row, "matched_project_id", None)) + user_id = self._to_int(getattr(row, "planner_assignee_user_id", None)) + title_text = self._pick_display_text(row.title, row.summary) + if not self._is_reportable_signal(project_id=project_id, title=title_text): + continue + priority_label = _label_priority(row.priority) + status_label = _label_status(row.status or row.planner_status) + occurred_label = self._display_time(row.created_at) items.append( - { - "title": _safe_text(row.title or row.summary, fallback="高优先级信号待跟进"), - "detail": f"{_safe_text(row.priority)}|{_safe_text(row.status or row.planner_status)}|{self._display_time(row.created_at)}", - "tone": "warning", - } + await self._build_brief_item( + item_id=f"signal:{row.id}", + title=title_text, + tone="warning", + kind="signal", + detail_lines=self._detail_lines( + f"优先级 {priority_label}", + f"状态 {status_label}", + f"时间 {occurred_label}", + _safe_text(row.summary, fallback=""), + ), + meta=self._meta_entries( + ("优先级", priority_label), + ("状态", status_label), + ("负责人", await self._get_user_name(user_id)), + ("项目", await self._get_project_name(project_id)), + ("时间", occurred_label), + ), + project_id=project_id, + user_id=user_id, + occurred_at=row.created_at, + target=self._project_target( + project_id, + params={ + "stage": "signal", + "signal_keyword": title_text, + "signal_status": str(row.planner_status or row.status or "").strip().lower(), + "signal_priority": str(row.priority or "").strip().lower(), + }, + ) + or self._user_target(user_id) + or _target("ai_steward_tab", "signals", "信号捕获"), + ) ) if len(items) >= limit: break @@ -551,14 +813,45 @@ class ExecutiveBriefingService: .limit(limit) ) ).scalars().all() - return [ - { - "title": _safe_text(row.source_name, fallback="信号源待确认"), - "detail": f"{_safe_text(row.source_kind)}|{_safe_text(row.source_identifier)}", - "tone": "warning", - } - for row in rows - ] + items: list[dict[str, Any]] = [] + for row in rows: + project_id = self._to_int(row.project_id) + user_id = self._to_int(row.created_by_user_id) + title_text = self._pick_display_text(row.source_name, row.source_identifier) + if not self._is_reportable_signal_source(title=title_text, source_identifier=row.source_identifier): + continue + kind_label = _label_source_kind(row.source_kind) + items.append( + await self._build_brief_item( + item_id=f"signal_source:{row.id}", + title=title_text, + tone="warning", + kind="signal_source", + detail_lines=self._detail_lines( + kind_label, + _safe_text(row.source_identifier), + f"状态 {_label_status(row.onboarding_status)}", + ), + meta=self._meta_entries( + ("来源类型", kind_label), + ("标识", _safe_text(row.source_identifier)), + ("接入状态", _label_status(row.onboarding_status)), + ("负责人", await self._get_user_name(user_id)), + ("项目", await self._get_project_name(project_id)), + ), + project_id=project_id, + user_id=user_id, + occurred_at=row.created_at, + target=self._project_target( + project_id, + params={ + "stage": "signal", + }, + ) + or _target("ai_steward_tab", "signals", "信号捕获"), + ) + ) + return items async def _list_recent_signals(self, *, since: datetime, limit: int) -> list[dict[str, Any]]: rows = ( @@ -574,14 +867,51 @@ class ExecutiveBriefingService: .limit(limit) ) ).scalars().all() - return [ - { - "title": _safe_text(row.title or row.summary, fallback="新增业务信号"), - "detail": f"{_safe_text(row.priority)}|{_safe_text(row.status)}|{self._display_time(row.created_at)}", - "tone": "info", - } - for row in rows - ] + items: list[dict[str, Any]] = [] + for row in rows: + project_id = self._to_int(row.matched_project_id) + user_id = self._to_int(row.planner_assignee_user_id) + title_text = self._pick_display_text(row.title, row.summary) + if not self._is_reportable_signal(project_id=project_id, title=title_text): + continue + priority_label = _label_priority(row.priority) + status_label = _label_status(row.status) + occurred_label = self._display_time(row.created_at) + items.append( + await self._build_brief_item( + item_id=f"signal:{row.id}", + title=title_text, + tone="info", + kind="signal", + detail_lines=self._detail_lines( + f"优先级 {priority_label}", + f"状态 {status_label}", + f"时间 {occurred_label}", + _safe_text(row.summary, fallback=""), + ), + meta=self._meta_entries( + ("优先级", priority_label), + ("状态", status_label), + ("负责人", await self._get_user_name(user_id)), + ("项目", await self._get_project_name(project_id)), + ("时间", occurred_label), + ), + project_id=project_id, + user_id=user_id, + occurred_at=row.created_at, + target=self._project_target( + project_id, + params={ + "stage": "signal", + "signal_keyword": title_text, + "signal_status": str(row.status or "").strip().lower(), + "signal_priority": str(row.priority or "").strip().lower(), + }, + ) + or _target("ai_steward_tab", "signals", "信号捕获"), + ) + ) + return items async def _list_blocked_tasks(self, *, limit: int) -> list[dict[str, Any]]: rows = ( @@ -597,14 +927,48 @@ class ExecutiveBriefingService: .limit(limit) ) ).scalars().all() - return [ - { - "title": _safe_text(row.title, fallback="阻塞任务"), - "detail": f"{_safe_text(row.priority)}|状态 {_safe_text(row.status)}", - "tone": "critical", - } - for row in rows - ] + items: list[dict[str, Any]] = [] + for row in rows: + project_id = self._to_int(row.project_id) + user_id = self._to_int(row.assignee_id) + title_text = self._pick_display_text(row.title) + if not self._is_reportable_task(project_id=project_id, title=title_text): + continue + priority_label = _label_priority(row.priority) + status_label = _label_status(row.status) + items.append( + await self._build_brief_item( + item_id=f"task:{row.id}", + title=title_text, + tone="critical", + kind="task", + detail_lines=self._detail_lines( + f"优先级 {priority_label}", + f"状态 {status_label}", + f"负责人 {await self._get_user_name(user_id) or '-'}", + ), + meta=self._meta_entries( + ("优先级", priority_label), + ("状态", status_label), + ("负责人", await self._get_user_name(user_id)), + ("项目", await self._get_project_name(project_id)), + ), + project_id=project_id, + user_id=user_id, + occurred_at=row.updated_at, + target=self._project_target( + project_id, + params={ + "stage": "task", + "task_keyword": title_text, + "task_status": str(row.status or "").strip().lower(), + "task_priority": str(row.priority or "").strip().lower(), + }, + ) + or _target("ai_steward_tab", "blockers", "阻塞检测"), + ) + ) + return items async def _list_overdue_tasks(self, *, now: datetime, limit: int) -> list[dict[str, Any]]: rows = ( @@ -622,14 +986,50 @@ class ExecutiveBriefingService: .limit(limit) ) ).scalars().all() - return [ - { - "title": _safe_text(row.title, fallback="逾期任务"), - "detail": f"截止 {row.due_date.date().isoformat() if row.due_date else '-'}|{_safe_text(row.status)}", - "tone": "warning", - } - for row in rows - ] + items: list[dict[str, Any]] = [] + for row in rows: + project_id = self._to_int(row.project_id) + user_id = self._to_int(row.assignee_id) + title_text = self._pick_display_text(row.title) + if not self._is_reportable_task(project_id=project_id, title=title_text): + continue + due_label = row.due_date.date().isoformat() if row.due_date else "-" + status_label = _label_status(row.status) + priority_label = _label_priority(row.priority) + items.append( + await self._build_brief_item( + item_id=f"task:{row.id}", + title=title_text, + tone="warning", + kind="task", + detail_lines=self._detail_lines( + f"截止 {due_label}", + f"状态 {status_label}", + f"优先级 {priority_label}", + ), + meta=self._meta_entries( + ("截止日期", due_label), + ("状态", status_label), + ("优先级", priority_label), + ("负责人", await self._get_user_name(user_id)), + ("项目", await self._get_project_name(project_id)), + ), + project_id=project_id, + user_id=user_id, + occurred_at=row.due_date, + target=self._project_target( + project_id, + params={ + "stage": "task", + "task_keyword": title_text, + "task_status": str(row.status or "").strip().lower(), + "task_priority": str(row.priority or "").strip().lower(), + }, + ) + or _target("ai_steward_tab", "blockers", "阻塞检测"), + ) + ) + return items async def _list_risk_notifications(self, *, since: datetime, limit: int) -> list[dict[str, Any]]: rows = ( @@ -651,12 +1051,39 @@ class ExecutiveBriefingService: title = f"{row.title} {row.content or ''}".lower() if not any(keyword in title for keyword in ("risk", "风险", "blocked", "阻塞", "overdue", "逾期")): continue + notification_target = self._notification_target(row) + project_id = self._to_int(getattr(row, "entity_id", None)) if str(getattr(row, "entity_type", "")).lower() == "project" else None + user_id = self._to_int(getattr(row, "user_id", None)) + title_text = self._pick_display_text(row.title, row.content, row.blocking_reason, row.priority) + if not self._is_reportable_notification( + project_id=project_id, + user_id=user_id, + title=title_text, + content=row.content, + ): + continue items.append( - { - "title": _safe_text(row.title, fallback="风险通知"), - "detail": _safe_text(row.content or row.blocking_reason or row.priority), - "tone": "warning", - } + await self._build_brief_item( + item_id=f"notification:{row.id}", + title=title_text, + tone="warning", + kind="notification", + detail_lines=self._detail_lines( + _safe_text(row.content or row.blocking_reason or row.priority), + f"状态 {_label_status(row.status)}", + f"时间 {self._display_time(row.created_at)}", + ), + meta=self._meta_entries( + ("状态", _label_status(row.status)), + ("优先级", _label_priority(row.priority)), + ("负责人", await self._get_user_name(user_id)), + ("项目", await self._get_project_name(project_id)), + ), + project_id=project_id, + user_id=user_id, + occurred_at=row.created_at, + target=notification_target, + ) ) if len(items) >= limit: break @@ -665,23 +1092,52 @@ class ExecutiveBriefingService: async def _list_active_conflicts(self, *, limit: int) -> list[dict[str, Any]]: rows = ( await self.db.execute( - select(CognitiveConflict) + select(CognitiveConflict, TruthNode.project_id) + .join(TruthNode, TruthNode.id == CognitiveConflict.truth_node_id, isouter=True) .where(CognitiveConflict.tenant_id == self.tenant_id) .order_by(CognitiveConflict.created_at.desc(), CognitiveConflict.id.desc()) .limit(limit * 3) ) - ).scalars().all() + ).all() items: list[dict[str, Any]] = [] - for row in rows: + for row, project_id in rows: status = str(row.status or "").strip().lower() if status in ACTIVE_CONFLICT_DONE_STATUSES: continue + normalized_project_id = self._to_int(project_id) + related_user_ids = [uid for uid in [self._to_int(value) for value in (row.involved_user_ids or [])] if uid is not None] + title_text = self._pick_display_text(row.conflict_type, row.description) + if not self._is_reportable_conflict( + project_id=normalized_project_id, + related_user_ids=related_user_ids, + title=title_text, + description=row.description, + ): + continue + related_names = [await self._get_user_name(uid) for uid in related_user_ids] items.append( - { - "title": _safe_text(row.conflict_type, fallback="认知冲突"), - "detail": _safe_text(row.description), - "tone": "critical", - } + await self._build_brief_item( + item_id=f"conflict:{row.id}", + title=title_text, + tone="critical", + kind="conflict", + detail_lines=self._detail_lines( + _safe_text(row.description), + f"涉及 {len(related_user_ids)} 人", + ), + meta=self._meta_entries( + ("相关项目", await self._get_project_name(normalized_project_id)), + ("涉及成员", "、".join([name for name in related_names if name])), + ), + project_id=normalized_project_id, + related_user_ids=related_user_ids, + occurred_at=row.created_at, + target=self._project_target( + normalized_project_id, + params={"stage": "signal"}, + ) + or _target("ai_steward_tab", "conflicts", "认知冲突"), + ) ) if len(items) >= limit: break @@ -690,7 +1146,7 @@ class ExecutiveBriefingService: async def _list_workload_members(self, *, kind: str, limit: int) -> list[dict[str, Any]]: rows = ( await self.db.execute( - select(User.name, UserWorkload.current_assigned_hours, UserWorkload.max_available_hours_per_week, UserWorkload.workload_ratio) + select(User.id, User.name, UserWorkload.current_assigned_hours, UserWorkload.max_available_hours_per_week, UserWorkload.workload_ratio) .join(User, User.id == UserWorkload.user_id) .where(UserWorkload.tenant_id == self.tenant_id) .order_by(UserWorkload.workload_ratio.desc(), UserWorkload.id.desc()) @@ -698,23 +1154,37 @@ class ExecutiveBriefingService: ).all() items: list[dict[str, Any]] = [] - for name, assigned_hours, max_hours, ratio_value in rows: - ratio = float(ratio_value or 0) - if ratio <= 0 and max_hours: - try: - ratio = float(assigned_hours or 0) / float(max_hours or 1) - except Exception: - ratio = 0 + for user_id, name, assigned_hours, max_hours, ratio_value in rows: + ratio = self._display_workload_ratio( + assigned_hours=assigned_hours, + max_hours=max_hours, + stored_ratio=ratio_value, + ) if kind == "overloaded" and ratio < 1: continue if kind == "idle" and ratio > 0.3: continue + normalized_user_id = self._to_int(user_id) + ratio_percent = round(ratio * 100) items.append( - { - "title": _safe_text(name, fallback="未命名成员"), - "detail": f"{round(ratio * 100)}% 负载", - "tone": "critical" if kind == "overloaded" else "info", - } + await self._build_brief_item( + item_id=f"capacity:{kind}:{user_id}", + title=name, + tone="critical" if kind == "overloaded" else "info", + kind="capacity", + detail_lines=self._detail_lines( + f"{ratio_percent}% 负载", + f"已分配 {round(float(assigned_hours or 0), 1)} 小时", + f"周上限 {round(float(max_hours or 0), 1)} 小时", + ), + meta=self._meta_entries( + ("负载", f"{ratio_percent}%"), + ("已分配", f"{round(float(assigned_hours or 0), 1)} 小时"), + ("周上限", f"{round(float(max_hours or 0), 1)} 小时"), + ), + user_id=normalized_user_id, + target=self._user_target(normalized_user_id) or _target("ai_steward_tab", "conflicts", "团队产能"), + ) ) if len(items) >= limit: break @@ -723,13 +1193,24 @@ class ExecutiveBriefingService: async def _count_pending_signals(self) -> int: rows = ( await self.db.execute( - select(Signal.processed, Signal.status, Signal.planner_status).where(Signal.tenant_id == self.tenant_id) + select( + Signal.processed, + Signal.status, + Signal.planner_status, + Signal.matched_project_id, + Signal.title, + Signal.summary, + ).where(Signal.tenant_id == self.tenant_id) ) ).all() total = 0 - for processed, status, planner_status in rows: + for processed, status, planner_status, matched_project_id, title, summary in rows: normalized_status = str(status or planner_status or "").strip().lower() normalized_planner_status = str(planner_status or "").strip().lower() + project_id = self._to_int(matched_project_id) + title_text = self._pick_display_text(title, summary) + if not self._is_reportable_signal(project_id=project_id, title=title_text): + continue if (not bool(processed)) or normalized_status in PENDING_SIGNAL_STATUSES or normalized_planner_status == "pending": total += 1 return total @@ -737,31 +1218,48 @@ class ExecutiveBriefingService: async def _count_high_priority_pending_signals(self) -> int: rows = ( await self.db.execute( - select(Signal.priority, Signal.processed, Signal.status, Signal.planner_status).where(Signal.tenant_id == self.tenant_id) + select( + Signal.priority, + Signal.processed, + Signal.status, + Signal.planner_status, + Signal.matched_project_id, + Signal.title, + Signal.summary, + ).where(Signal.tenant_id == self.tenant_id) ) ).all() total = 0 - for priority, processed, status, planner_status in rows: + for priority, processed, status, planner_status, matched_project_id, title, summary in rows: normalized_priority = str(priority or "").strip().lower() normalized_status = str(status or planner_status or "").strip().lower() + project_id = self._to_int(matched_project_id) + title_text = self._pick_display_text(title, summary) + if not self._is_reportable_signal(project_id=project_id, title=title_text): + continue pending = (not bool(processed)) or normalized_status in PENDING_SIGNAL_STATUSES or str(planner_status or "").strip().lower() == "pending" if normalized_priority in HIGH_SIGNAL_PRIORITIES and pending: total += 1 return total async def _count_recent_signals(self, *, since: datetime) -> int: - return len( - ( - await self.db.execute( - select(Signal.id).where( - and_( - Signal.tenant_id == self.tenant_id, - Signal.created_at >= since, - ) + rows = ( + await self.db.execute( + select(Signal.matched_project_id, Signal.title, Signal.summary).where( + and_( + Signal.tenant_id == self.tenant_id, + Signal.created_at >= since, ) ) - ).scalars().all() - ) + ) + ).all() + total = 0 + for matched_project_id, title, summary in rows: + project_id = self._to_int(matched_project_id) + title_text = self._pick_display_text(title, summary) + if self._is_reportable_signal(project_id=project_id, title=title_text): + total += 1 + return total async def _count_pending_signal_sources(self) -> int: return len( @@ -889,39 +1387,132 @@ class ExecutiveBriefingService: "rework_rate": self._format_percent(rework_count, total), } - def _build_governance_items(self, *, feedback_stats: dict[str, Any], task_stats: dict[str, Any]) -> list[dict[str, Any]]: + async def _build_governance_items(self, *, feedback_stats: dict[str, Any], task_stats: dict[str, Any]) -> list[dict[str, Any]]: items: list[dict[str, Any]] = [] if feedback_stats["pending_count"] > 0: items.append( - { - "title": "反馈中心待审积压", - "detail": f"{feedback_stats['pending_count']} 条待审,平均复核延迟 {feedback_stats['avg_review_latency_hours']}h", - "tone": "warning", - } + await self._build_brief_item( + item_id="governance:pending_feedback", + title="反馈中心待审积压", + tone="warning", + kind="governance", + detail_lines=self._detail_lines( + f"{feedback_stats['pending_count']} 条待审", + f"平均复核延迟 {feedback_stats['avg_review_latency_hours']}h", + ), + meta=self._meta_entries( + ("待审反馈", feedback_stats["pending_count"]), + ("平均复核延迟", f"{feedback_stats['avg_review_latency_hours']}h"), + ), + target=_target("ai_steward_tab", "feedback-center", "反馈中心"), + ) ) if task_stats["manual_override_count"] > 0: items.append( - { - "title": "人工覆盖偏高", - "detail": f"当前人工覆盖率 {task_stats['manual_override_rate']}", - "tone": "info", - } + await self._build_brief_item( + item_id="governance:manual_override", + title="人工覆盖偏高", + tone="info", + kind="governance", + detail_lines=self._detail_lines(f"当前人工覆盖率 {task_stats['manual_override_rate']}"), + meta=self._meta_entries( + ("人工覆盖率", task_stats["manual_override_rate"]), + ("人工覆盖次数", task_stats["manual_override_count"]), + ), + target=_target("ai_steward_tab", "feedback-center", "反馈中心"), + ) ) if task_stats["rework_count"] > 0: items.append( - { - "title": "返工任务存在", - "detail": f"当前返工率 {task_stats['rework_rate']}", - "tone": "warning", - } + await self._build_brief_item( + item_id="governance:rework", + title="返工任务存在", + tone="warning", + kind="governance", + detail_lines=self._detail_lines(f"当前返工率 {task_stats['rework_rate']}"), + meta=self._meta_entries( + ("返工率", task_stats["rework_rate"]), + ("返工任务数", task_stats["rework_count"]), + ), + target=_target("ai_steward_tab", "feedback-center", "反馈中心"), + ) ) if not items: items.append( - { - "title": "治理状态正常", - "detail": "当前没有显著的 AI 治理积压。", - "tone": "positive", - } + await self._build_brief_item( + item_id="governance:healthy", + title="治理状态正常", + tone="positive", + kind="governance", + detail_lines=self._detail_lines("当前没有显著的 AI 治理积压。"), + meta=[], + target=_target("ai_steward_tab", "feedback-center", "反馈中心"), + ) ) return items[:3] + @staticmethod + def _to_int(value: Any) -> Optional[int]: + try: + if value in (None, ""): + return None + return int(value) + except (TypeError, ValueError): + return None + + @staticmethod + def _normalize_workload_ratio(value: Any) -> float: + try: + ratio = float(value or 0.0) + except (TypeError, ValueError): + return 0.0 + if ratio <= 0: + return 0.0 + return ratio / 100.0 if ratio > 1.0 else ratio + + @classmethod + def _display_workload_ratio(cls, *, assigned_hours: Any, max_hours: Any, stored_ratio: Any) -> float: + try: + assigned = max(float(assigned_hours or 0.0), 0.0) + except (TypeError, ValueError): + assigned = 0.0 + try: + weekly_limit = float(max_hours or 0.0) + except (TypeError, ValueError): + weekly_limit = 0.0 + if weekly_limit > 0: + return assigned / weekly_limit + return cls._normalize_workload_ratio(stored_ratio) + + def _project_target( + self, + project_id: Optional[int], + *, + params: Optional[dict[str, Any]] = None, + ) -> Optional[dict[str, Any]]: + if project_id is None: + return None + return _target("project_board", str(project_id), "项目看板", params=params) + + def _user_target(self, user_id: Optional[int], *, params: Optional[dict[str, Any]] = None) -> Optional[dict[str, Any]]: + if user_id is None: + return None + return _target("workload_detail", str(user_id), "成员负载", params=params) + + def _notification_target(self, row: Notification) -> dict[str, Any]: + target_url = str(getattr(row, "target_url", "") or "").strip() + if target_url.startswith("/projects/") and "/board" in target_url: + project_ref = target_url.split("/projects/", 1)[1].split("/", 1)[0] + if project_ref.isdigit(): + return _target("project_board", project_ref, "项目看板", params={"stage": "task"}) + if target_url.startswith("/workloads/"): + user_ref = target_url.split("/workloads/", 1)[1].split("/", 1)[0] + if user_ref.isdigit(): + return _target("workload_detail", user_ref, "成员负载") + entity_type = str(getattr(row, "entity_type", "") or "").strip().lower() + entity_id = self._to_int(getattr(row, "entity_id", None)) + if entity_type == "project" and entity_id is not None: + return _target("project_board", str(entity_id), "项目看板", params={"stage": "task"}) + if entity_type in {"user", "member"} and entity_id is not None: + return _target("user_profile", str(entity_id), "成员档案") + return _target("ai_steward_tab", "blockers", "阻塞检测") diff --git a/backend/app/services/tenant_briefing_service.py b/backend/app/services/tenant_briefing_service.py new file mode 100644 index 00000000..b142310c --- /dev/null +++ b/backend/app/services/tenant_briefing_service.py @@ -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 diff --git a/backend/tests/test_daily_briefing_service.py b/backend/tests/test_daily_briefing_service.py index 19c88d61..19a77a5a 100644 --- a/backend/tests/test_daily_briefing_service.py +++ b/backend/tests/test_daily_briefing_service.py @@ -7,6 +7,7 @@ from datetime import datetime, timedelta import pytest import pytest_asyncio +from sqlalchemy import JSON from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///:memory:" @@ -21,9 +22,20 @@ from app.models.truth_node import TruthNode from app.models.user import User +def _use_sqlite_json_types() -> None: + User.__table__.c.assigned_domains.type = JSON() + TruthNode.__table__.c.content.type = JSON() + Task.__table__.c.ai_recommended_assignees.type = JSON() + Task.__table__.c.ai_breakdown_payload.type = JSON() + Task.__table__.c.manual_overrides.type = JSON() + Task.__table__.c.required_skill_tag_ids.type = JSON() + CognitiveConflict.__table__.c.involved_user_ids.type = JSON() + + @pytest_asyncio.fixture async def briefing_session(): engine = create_async_engine("sqlite+aiosqlite:///:memory:") + _use_sqlite_json_types() async with engine.begin() as conn: await conn.run_sync( lambda sync_conn: Base.metadata.create_all( diff --git a/backend/tests/test_daily_briefing_v2_api.py b/backend/tests/test_daily_briefing_v2_api.py new file mode 100644 index 00000000..f7453e3b --- /dev/null +++ b/backend/tests/test_daily_briefing_v2_api.py @@ -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" diff --git a/backend/tests/test_executive_briefing_api.py b/backend/tests/test_executive_briefing_api.py index cc143f7f..335d31ee 100644 --- a/backend/tests/test_executive_briefing_api.py +++ b/backend/tests/test_executive_briefing_api.py @@ -15,12 +15,40 @@ from app.core.database import get_db from app.dependencies.auth import get_current_user from app.dependencies.tenant import get_tenant_id from app.models.base import Base +from app.models.cognitive_conflict import CognitiveConflict +from app.models.feedback_center import FeedbackCenterRecord from app.models.integration import Integration +from app.models.notification import Notification +from app.models.portfolio_snapshot import PortfolioSnapshot +from app.models.project import Project +from app.models.project_signal_link import ProjectSignalLink +from app.models.signal import Signal +from app.models.signal_source_registry import SignalSourceRegistry +from app.models.task import Task +from app.models.truth_node import TruthNode +from app.models.user import User +from app.models.user_workload import UserWorkload from app.services.executive_briefing_service import ExecutiveBriefingService def _use_sqlite_json_types() -> None: Integration.__table__.c.config.type = JSON() + User.__table__.c.assigned_domains.type = JSON() + TruthNode.__table__.c.content.type = JSON() + Task.__table__.c.ai_recommended_assignees.type = JSON() + Task.__table__.c.ai_breakdown_payload.type = JSON() + Task.__table__.c.manual_overrides.type = JSON() + Task.__table__.c.required_skill_tag_ids.type = JSON() + SignalSourceRegistry.__table__.c.config.type = JSON() + SignalSourceRegistry.__table__.c.raw_context.type = JSON() + Notification.__table__.c.payload.type = JSON() + FeedbackCenterRecord.__table__.c.before_value.type = JSON() + FeedbackCenterRecord.__table__.c.after_value.type = JSON() + FeedbackCenterRecord.__table__.c.ai_snapshot.type = JSON() + FeedbackCenterRecord.__table__.c.reason_candidates.type = JSON() + CognitiveConflict.__table__.c.involved_user_ids.type = JSON() + PortfolioSnapshot.__table__.c.summary.type = JSON() + PortfolioSnapshot.__table__.c.payload.type = JSON() @pytest_asyncio.fixture @@ -33,6 +61,18 @@ async def db_session(): sync_conn, tables=[ Integration.__table__, + User.__table__, + Project.__table__, + Signal.__table__, + TruthNode.__table__, + Task.__table__, + UserWorkload.__table__, + SignalSourceRegistry.__table__, + Notification.__table__, + FeedbackCenterRecord.__table__, + CognitiveConflict.__table__, + PortfolioSnapshot.__table__, + ProjectSignalLink.__table__, ], ) ) @@ -205,7 +245,7 @@ async def test_executive_briefing_service_formats_management_summary_text(db_ses { "brief_date": "2026-03-30", "hero": { - "title": "老板晨报", + "title": "简报中心", "decision_count": 2, "anomaly_count": 4, "baseline_generated_at": datetime(2026, 3, 30, 8, 30, 0).isoformat(), @@ -223,6 +263,363 @@ async def test_executive_briefing_service_formats_management_summary_text(db_ses } ) - assert "老板晨报" in text + assert "简报中心" in text assert "今日需拍板" in text assert "P0 信号无人接" in text + + +@pytest.mark.asyncio +async def test_executive_briefing_service_builds_detailed_signal_items_and_target_params(db_session) -> None: + owner = User( + tenant_id=1, + username="boss", + email="boss@example.com", + password_hash="hash", + name="李雷", + role="BOSS", + status="active", + is_active=True, + ) + db_session.add(owner) + await db_session.flush() + + project = Project( + tenant_id=1, + name="cashflow-forecast", + title="现金流平台", + description="现金流预测与预警", + owner_id=owner.id, + status="active", + ) + db_session.add(project) + await db_session.flush() + + signal = Signal( + tenant_id=1, + source="feishu", + title="现金流预测功能需求", + summary="需要尽快评估接入方案", + priority="high", + processed=False, + matched_project_id=project.id, + status="pending", + planner_status="pending", + planner_assignee_user_id=owner.id, + created_at=datetime(2026, 3, 30, 7, 50, 0), + ) + db_session.add(signal) + await db_session.commit() + + service = ExecutiveBriefingService(db_session, tenant_id=1) + payload = await service.build_brief_payload( + mode="live", + baseline_generated_at=None, + live_generated_at=datetime(2026, 3, 30, 8, 30, 0), + ) + + assert payload["hero"]["title"] == "简报中心" + decisions = next(section for section in payload["sections"] if section["key"] == "decisions") + signal_item = next(item for item in decisions["items"] if item["kind"] == "signal") + + assert signal_item["id"] == f"signal:{signal.id}" + assert signal_item["project_name"] == "现金流平台" + assert signal_item["owner_name"] == "李雷" + assert signal_item["occurred_at"] == "2026-03-30T07:50:00" + assert "优先级 高优" in signal_item["detail_lines"] + assert "状态 待处理" in signal_item["detail_lines"] + assert any(meta["label"] == "优先级" and meta["value"] == "高优" for meta in signal_item["meta"]) + assert any(meta["label"] == "负责人" and meta["value"] == "李雷" for meta in signal_item["meta"]) + assert signal_item["target"]["type"] == "project_board" + assert signal_item["target"]["params"]["stage"] == "signal" + assert signal_item["target"]["params"]["signal_keyword"] == "现金流预测功能需求" + assert signal_item["target"]["params"]["signal_status"] == "pending" + + +@pytest.mark.asyncio +async def test_executive_briefing_excludes_unassigned_signals_and_aligns_metrics(db_session) -> None: + owner = User( + tenant_id=1, + username="boss", + email="boss@example.com", + password_hash="hash", + name="李雷", + role="BOSS", + status="active", + is_active=True, + ) + db_session.add(owner) + await db_session.flush() + + project = Project( + tenant_id=1, + name="cashflow-forecast", + title="现金流平台", + description="现金流预测与预警", + owner_id=owner.id, + status="active", + ) + db_session.add(project) + await db_session.flush() + + db_session.add_all( + [ + Signal( + tenant_id=1, + source="feishu", + title="现金流预测功能需求", + summary="需要尽快评估接入方案", + priority="high", + processed=False, + matched_project_id=project.id, + status="pending", + planner_status="pending", + planner_assignee_user_id=owner.id, + created_at=datetime(2026, 3, 30, 7, 50, 0), + ), + Signal( + tenant_id=1, + source="feishu", + title="孤立高优信号", + summary="还没有归属项目", + priority="high", + processed=False, + matched_project_id=None, + status="pending", + planner_status="pending", + planner_assignee_user_id=None, + created_at=datetime(2026, 3, 30, 7, 45, 0), + ), + Signal( + tenant_id=1, + source="feishu", + title=None, + summary=None, + priority="high", + processed=False, + matched_project_id=None, + status="pending", + planner_status="pending", + planner_assignee_user_id=None, + created_at=datetime(2026, 3, 30, 7, 40, 0), + ), + ] + ) + await db_session.commit() + + service = ExecutiveBriefingService(db_session, tenant_id=1) + payload = await service.build_brief_payload( + mode="live", + baseline_generated_at=None, + live_generated_at=datetime(2026, 3, 30, 8, 30, 0), + ) + + decisions = next(section for section in payload["sections"] if section["key"] == "decisions") + input_health = next(section for section in payload["sections"] if section["key"] == "input_health") + + decision_signal_items = [item for item in decisions["items"] if item["kind"] == "signal"] + input_signal_items = [item for item in input_health["items"] if item["kind"] == "signal"] + + assert [item["title"] for item in decision_signal_items] == ["现金流预测功能需求"] + assert [item["title"] for item in input_signal_items] == ["现金流预测功能需求"] + assert all(item["project_id"] == project.id for item in decision_signal_items) + assert all(item["project_id"] == project.id for item in input_signal_items) + assert all(item["title"] != "待处理事项" for item in decision_signal_items + input_signal_items) + + high_priority_metric = next(metric for metric in decisions["metrics"] if metric["label"] == "高优信号") + recent_signal_metric = next(metric for metric in input_health["metrics"] if metric["label"] == "24h 新信号") + pending_signal_metric = next(metric for metric in input_health["metrics"] if metric["label"] == "待处理信号") + + assert high_priority_metric["value"] == 1 + assert recent_signal_metric["value"] == 1 + assert pending_signal_metric["value"] == 1 + + +@pytest.mark.asyncio +async def test_executive_briefing_snapshot_rebuilds_outdated_payload_for_detail_ui(db_session, monkeypatch) -> None: + fixed_now = datetime(2026, 3, 30, 9, 0, 0) + monkeypatch.setattr("app.services.executive_briefing_service.now_cn_naive", lambda: fixed_now) + + owner = User( + tenant_id=1, + username="boss", + email="boss@example.com", + password_hash="hash", + name="李雷", + role="BOSS", + status="active", + is_active=True, + ) + db_session.add(owner) + await db_session.flush() + + project = Project( + tenant_id=1, + name="cashflow-forecast", + title="现金流平台", + description="现金流预测与预警", + owner_id=owner.id, + status="active", + ) + db_session.add(project) + await db_session.flush() + + signal = Signal( + tenant_id=1, + source="feishu", + title="现金流预测功能需求", + summary="需要尽快评估接入方案", + priority="high", + processed=False, + matched_project_id=project.id, + status="pending", + planner_status="pending", + planner_assignee_user_id=owner.id, + created_at=datetime(2026, 3, 30, 7, 50, 0), + ) + db_session.add(signal) + await db_session.flush() + + outdated_snapshot = PortfolioSnapshot( + tenant_id=1, + scope_type="executive_brief", + scope_ref="tenant:1", + summary={}, + payload={ + "brief_date": "2026-03-30", + "mode": "snapshot", + "hero": { + "title": "老板晨报中心", + "decision_count": 1, + "anomaly_count": 1, + "baseline_generated_at": datetime(2026, 3, 30, 8, 30, 0).isoformat(), + }, + "sections": [ + { + "key": "decisions", + "title": "今日需拍板", + "summary": "旧快照", + "metrics": [], + "items": [ + { + "kind": "signal", + "tone": "warning", + "title": "📈 现金流预测功能需求", + "detail": "优先级 高优|状态 待处理|03-27 12:06", + "target": {"ref": "signals", "type": "ai_steward_tab", "label": "信号捕获"}, + "project_id": project.id, + } + ], + } + ], + }, + created_at=datetime(2026, 3, 30, 8, 30, 0), + ) + db_session.add(outdated_snapshot) + await db_session.commit() + + service = ExecutiveBriefingService(db_session, tenant_id=1) + payload = await service.get_briefing(mode="snapshot") + + assert payload["hero"]["title"] == "简报中心" + assert payload["mode"] == "snapshot" + assert payload["hero"]["baseline_generated_at"] == datetime(2026, 3, 30, 8, 30, 0).isoformat() + + decisions = next(section for section in payload["sections"] if section["key"] == "decisions") + signal_item = next(item for item in decisions["items"] if item["kind"] == "signal") + assert signal_item["id"] == f"signal:{signal.id}" + assert signal_item["target"]["type"] == "project_board" + assert signal_item["target"]["params"]["stage"] == "signal" + assert signal_item["detail_lines"] + assert signal_item["meta"] + + +@pytest.mark.asyncio +async def test_executive_briefing_signal_source_target_does_not_apply_mismatched_keyword_filter(db_session) -> None: + owner = User( + tenant_id=1, + username="boss", + email="boss@example.com", + password_hash="hash", + name="李雷", + role="BOSS", + status="active", + is_active=True, + ) + db_session.add(owner) + await db_session.flush() + + project = Project( + tenant_id=1, + name="cashflow-forecast", + title="现金流平台", + description="现金流预测与预警", + owner_id=owner.id, + status="active", + ) + db_session.add(project) + await db_session.flush() + + source = SignalSourceRegistry( + tenant_id=1, + project_id=project.id, + created_by_user_id=owner.id, + source_kind="scheduled", + source_name="每日现金流分析", + source_identifier="daily_cashflow_analysis", + onboarding_status="pending", + created_at=datetime(2026, 3, 30, 8, 10, 0), + ) + db_session.add(source) + await db_session.commit() + + service = ExecutiveBriefingService(db_session, tenant_id=1) + payload = await service.build_brief_payload( + mode="live", + baseline_generated_at=None, + live_generated_at=datetime(2026, 3, 30, 8, 30, 0), + ) + + input_health = next(section for section in payload["sections"] if section["key"] == "input_health") + source_item = next(item for item in input_health["items"] if item["kind"] == "signal_source") + assert source_item["target"]["type"] == "project_board" + assert source_item["target"]["params"] == {"stage": "signal"} + + +@pytest.mark.asyncio +async def test_executive_briefing_capacity_ratio_matches_displayed_hours(db_session) -> None: + user = User( + tenant_id=1, + username="owner", + email="owner@example.com", + password_hash="hash", + name="杨宇", + role="ADMIN", + status="active", + is_active=True, + ) + db_session.add(user) + await db_session.flush() + + db_session.add( + UserWorkload( + tenant_id=1, + user_id=user.id, + current_assigned_hours=95.6, + max_available_hours_per_week=60, + workload_ratio=797, + ) + ) + await db_session.commit() + + service = ExecutiveBriefingService(db_session, tenant_id=1) + payload = await service.build_brief_payload( + mode="live", + baseline_generated_at=None, + live_generated_at=datetime(2026, 3, 30, 8, 30, 0), + ) + + capacity = next(section for section in payload["sections"] if section["key"] == "team_capacity") + overload_item = next(item for item in capacity["items"] if item["kind"] == "capacity") + assert overload_item["title"] == "杨宇" + assert overload_item["detail_lines"][0] == "159% 负载" + assert any(meta["label"] == "负载" and meta["value"] == "159%" for meta in overload_item["meta"]) diff --git a/frontend/src/api/aiSteward.ts b/frontend/src/api/aiSteward.ts index 8c20ed4b..054b9713 100644 --- a/frontend/src/api/aiSteward.ts +++ b/frontend/src/api/aiSteward.ts @@ -120,11 +120,33 @@ export interface ExecutiveBriefingMetric { tone: string; } +export interface BriefingTarget { + type: "project_board" | "workload_detail" | "user_profile" | "ai_steward_tab"; + ref: string; + label?: string | null; + params?: Record; +} + +export interface BriefingMetaEntry { + label: string; + value: string; +} + export interface ExecutiveBriefingItem { + id: string; title: string; detail: string; + detail_lines: string[]; + meta: BriefingMetaEntry[]; tone: string; kind?: string | null; + target?: BriefingTarget | null; + project_id?: number | null; + project_name?: string | null; + user_id?: number | null; + owner_name?: string | null; + related_user_ids?: number[]; + occurred_at?: string | null; } export interface ExecutiveBriefingSection { @@ -166,6 +188,32 @@ export interface ExecutiveBriefingPayload { delivery: ExecutiveBriefingConfig; } +export interface DailyBriefingV2PersonalSummary { + headline: string; + action_count: number; + project_count: number; +} + +export interface DailyBriefingV2PersonalWorkload { + user_id: number; + user_name: string; + assigned_hours: number; + max_weekly_hours: number; + workload_ratio: number; + tone: string; +} + +export interface DailyBriefingV2Payload { + brief_date: string; + mode: "snapshot" | "live"; + viewer_scope: "tenant_full" | "personal"; + hero: ExecutiveBriefingHero; + sections: ExecutiveBriefingSection[]; + personal_summary?: DailyBriefingV2PersonalSummary | null; + personal_workload?: DailyBriefingV2PersonalWorkload | null; + export_title: string; +} + export type LLMProvider = "deepseek" | "glm" | "openai" | "ollama"; export interface LLMProviderPreset { @@ -249,6 +297,10 @@ export const aiStewardApi = { async getDailyBriefing(): Promise { return await apiRequest("/ai-steward/daily-briefing"); }, + async getDailyBriefingV2(mode: "snapshot" | "live"): Promise { + const raw = await apiRequest>(`/ai-steward/daily-briefing-v2?mode=${mode}`); + return unwrap(raw); + }, async startHnMonitor(intervalMinutes: number = 30): Promise> { return await apiRequest>( `/ai-steward/signal/start-hn-monitor?interval_minutes=${intervalMinutes}`, diff --git a/frontend/src/pages/ai-steward/AIStewardPage.tsx b/frontend/src/pages/ai-steward/AIStewardPage.tsx index e5a0bb1c..9c5e03a3 100644 --- a/frontend/src/pages/ai-steward/AIStewardPage.tsx +++ b/frontend/src/pages/ai-steward/AIStewardPage.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { useNavigate, useSearchParams } from "react-router-dom"; import { Sparkles } from "lucide-react"; import { getAuthToken } from "@/lib/api"; @@ -21,9 +21,10 @@ import { ENGINE_PROVIDER_FALLBACK_PRESETS, } from "./constants"; import type { + BriefingTarget, Blocker, CognitiveConflict, - DailyBriefing, + DailyBriefingV2Payload, ExecutiveBriefingConfig, ExecutiveBriefingPayload, FeedbackCenterSourceGroup, @@ -37,8 +38,8 @@ import type { AIStewardTab, } from "./types"; import FeatureOverviewCards from "./components/FeatureOverviewCards"; +import { printBriefingReport, type PrintableBriefingPayload } from "./components/BriefingPrintDocument"; import BlockersTab from "./sections/BlockersTab"; -import BriefingTab from "./sections/BriefingTab"; import ConflictsTab from "./sections/ConflictsTab"; import CreateProjectTab from "./sections/CreateProjectTab"; import EngineTab from "./sections/EngineTab"; @@ -71,6 +72,9 @@ const DEFAULT_EXECUTIVE_BRIEFING_CONFIG: ExecutiveBriefingConfig = { }; function normalizeTab(value: string | null): AIStewardTab { + if (value === "briefing") { + return "overview"; + } if (value && AIS_STEWARD_TABS.includes(value as AIStewardTab)) { return value as AIStewardTab; } @@ -95,6 +99,15 @@ function normalizeTrendSourceGroup(value: string | null): FeedbackCenterSourceGr return "all"; } +function serializeTargetParams(params?: BriefingTarget["params"]): string { + const query = new URLSearchParams(); + for (const [key, value] of Object.entries(params || {})) { + if (value === null || value === undefined || value === "") continue; + query.set(key, String(value)); + } + return query.toString(); +} + export default function AIStewardPage() { const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); @@ -120,12 +133,7 @@ export default function AIStewardPage() { ); const [conflicts, setConflicts] = useState([]); const [blockers, setBlockers] = useState([]); - const [briefing, setBriefing] = useState(null); - const [briefingError, setBriefingError] = useState(null); - const [executiveBriefing, setExecutiveBriefing] = useState(null); - const [executiveBriefingMode, setExecutiveBriefingMode] = useState<"snapshot" | "live">("snapshot"); - const [executiveBriefingError, setExecutiveBriefingError] = useState(null); - const [executiveBriefingLoading, setExecutiveBriefingLoading] = useState(false); + const [briefingMode, setBriefingMode] = useState<"snapshot" | "live">("snapshot"); const [executiveBriefingConfigDraft, setExecutiveBriefingConfigDraft] = useState( DEFAULT_EXECUTIVE_BRIEFING_CONFIG ); @@ -165,6 +173,31 @@ export default function AIStewardPage() { }, }); + const executiveBriefingQuery = useQuery({ + queryKey: ["ai-steward", "executive-briefing", briefingMode], + queryFn: () => aiStewardApi.getExecutiveBriefing(briefingMode), + enabled: canManageExecutiveBriefing, + staleTime: 30_000, + }); + + const dailyBriefingQuery = useQuery({ + queryKey: ["ai-steward", "daily-briefing-v2", briefingMode], + queryFn: () => aiStewardApi.getDailyBriefingV2(briefingMode), + enabled: Boolean(user) && (activeTab === "overview" || activeTab === "briefing" || canManageExecutiveBriefing), + staleTime: 30_000, + }); + + const executiveBriefing = executiveBriefingQuery.data ?? null; + const executiveBriefingLoading = executiveBriefingQuery.isLoading || executiveBriefingQuery.isFetching; + const executiveBriefingError = executiveBriefingQuery.error + ? getApiErrorMessage(executiveBriefingQuery.error, "获取简报失败") + : null; + const dailyBriefing = dailyBriefingQuery.data ?? null; + const dailyBriefingLoading = dailyBriefingQuery.isLoading || dailyBriefingQuery.isFetching; + const dailyBriefingError = dailyBriefingQuery.error + ? getApiErrorMessage(dailyBriefingQuery.error, "获取每日简报失败") + : null; + // ==================== API Calls ==================== const loadConflicts = async () => { @@ -191,35 +224,17 @@ export default function AIStewardPage() { } }; - const loadBriefing = async () => { - setFeatureLoading("briefing"); - setBriefingError(null); - try { - const data = await aiStewardApi.getDailyBriefing(); - setBriefing(data); - } catch (err) { - console.error("Failed to load briefing:", err); - setBriefing(null); - setBriefingError(getApiErrorMessage(err, "获取简报失败")); - } finally { - setFeatureLoading(null); - } - }; - - const loadExecutiveBriefing = async (mode: "snapshot" | "live") => { - setExecutiveBriefingLoading(true); - setExecutiveBriefingError(null); - setExecutiveBriefingMode(mode); - try { - const data = await aiStewardApi.getExecutiveBriefing(mode); - setExecutiveBriefing(data); - setExecutiveBriefingMode(data.mode); - } catch (err) { - console.error("Failed to load executive briefing:", err); - setExecutiveBriefingError(getApiErrorMessage(err, "获取老板晨报失败")); - } finally { - setExecutiveBriefingLoading(false); + const switchBriefingMode = (mode: "snapshot" | "live") => { + if (mode === briefingMode) { + if (canManageExecutiveBriefing) { + void executiveBriefingQuery.refetch(); + } + if (activeTab === "briefing" || canManageExecutiveBriefing) { + void dailyBriefingQuery.refetch(); + } + return; } + setBriefingMode(mode); }; const loadExecutiveBriefingConfig = async () => { @@ -244,8 +259,11 @@ export default function AIStewardPage() { const data = await aiStewardApi.updateExecutiveBriefingConfig(executiveBriefingConfigDraft); setExecutiveBriefingConfigDraft(data); setExecutiveBriefingSaveMessage({ type: "success", text: "晨报调度配置已保存" }); - if (activeTab === "overview") { - await loadExecutiveBriefing("snapshot"); + if (canManageExecutiveBriefing) { + await executiveBriefingQuery.refetch(); + } + if (dailyBriefingQuery.isFetched) { + await dailyBriefingQuery.refetch(); } } catch (err) { console.error("Failed to save executive briefing config:", err); @@ -541,6 +559,42 @@ export default function AIStewardPage() { syncUrlParams(nextTab, feedbackSection); }; + const openBriefingTarget = (target?: BriefingTarget | null, fallbackTab?: AIStewardTab) => { + if (!target?.type || !target.ref) { + if (fallbackTab) { + handleTabChange(fallbackTab); + } + return; + } + + const serializedParams = serializeTargetParams(target.params); + if (target.type === "project_board") { + navigate(`/projects/${target.ref}/board${serializedParams ? `?${serializedParams}` : ""}`); + return; + } + if (target.type === "workload_detail") { + navigate(`/workloads/${target.ref}${serializedParams ? `?${serializedParams}` : ""}`); + return; + } + if (target.type === "user_profile") { + navigate(`/users/${target.ref}/profile${serializedParams ? `?${serializedParams}` : ""}`); + return; + } + + const nextTab = normalizeTab(target.ref); + setActiveTab(nextTab); + const next = new URLSearchParams(searchParams); + next.set("tab", nextTab); + next.set("section", String(target.params?.section || feedbackSection)); + next.set("window", String(target.params?.window || trendWindowDays)); + next.set("scope", String(target.params?.scope || trendSourceGroup)); + for (const [key, value] of Object.entries(target.params || {})) { + if (value === null || value === undefined || value === "") continue; + next.set(key, String(value)); + } + setSearchParams(next, { replace: true }); + }; + const handleFeedbackSectionChange = (nextSection: FeedbackCenterSection) => { setFeedbackSection(nextSection); syncUrlParams(activeTab, nextSection); @@ -556,6 +610,31 @@ export default function AIStewardPage() { syncUrlParams(activeTab, feedbackSection, trendWindowDays, nextSourceGroup); }; + const buildExportPayload = (): PrintableBriefingPayload | null => { + if (canManageExecutiveBriefing && executiveBriefing) { + return { + brief_date: executiveBriefing.brief_date, + mode: executiveBriefing.mode, + hero: executiveBriefing.hero, + sections: executiveBriefing.sections, + export_title: executiveBriefing.hero.title || "简报中心", + viewer_scope: "tenant_full", + }; + } + if (dailyBriefing) { + return dailyBriefing; + } + return null; + }; + + const exportCurrentReport = () => { + const payload = buildExportPayload(); + if (!payload) { + return; + } + printBriefingReport(payload, user?.name || user?.username || undefined); + }; + // ==================== Initial Load ==================== useEffect(() => { @@ -580,12 +659,10 @@ export default function AIStewardPage() { useEffect(() => { if (activeTab === "overview" && canManageExecutiveBriefing) { - void loadExecutiveBriefing("snapshot"); void loadExecutiveBriefingConfig(); } if (activeTab === "conflicts") loadConflicts(); if (activeTab === "blockers") loadBlockers(); - if (activeTab === "briefing") loadBriefing(); if (activeTab === "engine") { loadEngineProviderPresets(); loadEngineConfigs(); @@ -602,13 +679,13 @@ export default function AIStewardPage() {

- {canManageExecutiveBriefing ? "老板晨报中心" : "AI 管家"} + 简报中心

{canManageExecutiveBriefing - ? "晨报快照、实时刷新与管理群推送共用同一套经营简报内核" - : "主动式 AI 管家 - 7x24 小时自动守护项目"} + ? "晨报快照、实时刷新与管理群推送共用同一套租户级简报内核" + : "与你相关的任务、风险、负载和对齐事项都集中在一个入口里"}

@@ -624,13 +701,12 @@ export default function AIStewardPage() { - {canManageExecutiveBriefing ? "晨报中心" : "总览"} + 简报中心 零配置创建 信号捕获 认知冲突 阻塞检测 - 每日简报 问答记录 {canViewFeedbackCenter && ( 反馈中心 @@ -640,20 +716,25 @@ export default function AIStewardPage() { void loadExecutiveBriefing("snapshot")} - onRefreshLive={() => void loadExecutiveBriefing("live")} + onLoadSnapshot={() => switchBriefingMode("snapshot")} + onRefreshLive={() => switchBriefingMode("live")} + onExportReport={exportCurrentReport} onConfigChange={updateExecutiveBriefingDraft} onSaveConfig={() => void saveExecutiveBriefingConfig()} - onSelectTab={(tab) => handleTabChange(tab)} + onOpenTarget={openBriefingTarget} /> void detectTaskBlockers()} /> - void loadBriefing()} - /> - {/* QA Records Tab */} diff --git a/frontend/src/pages/ai-steward/components/BriefingDetailDrawer.tsx b/frontend/src/pages/ai-steward/components/BriefingDetailDrawer.tsx new file mode 100644 index 00000000..6dbe2825 --- /dev/null +++ b/frontend/src/pages/ai-steward/components/BriefingDetailDrawer.tsx @@ -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>(new Set()); + const [expandedItemIds, setExpandedItemIds] = useState>(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 ( +
+ {actions.map((action) => ( + + ))} +
+ ); + }; + + return ( + + +
+ + + {section?.title || "简报详情"} + + + {section?.summary || "在这里查看当前区块的完整项目分组与条目详情。"} + +
+ + 共 {allItems.length} 条 + + + +
+
+ +
+
+ {groups.map((group) => { + const expanded = expandedGroupKeys.has(group.key); + return ( +
+ + + {expanded && ( +
+ {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 ( +
+ + + {expandedItem && ( +
+
+ {itemMeta.map((meta) => ( +
+

{meta.label}

+

{meta.value}

+
+ ))} +
+
+ {detailLines.map((line, lineIndex) => ( +
+ {line} +
+ ))} +
+ {renderActionButtons(item, runtimeKey)} +
+ )} +
+ ); + })} +
+ )} +
+ ); + })} + {!groups.length && ( +
+ 当前区块没有可展开的详细内容。 +
+ )} +
+
+
+
+
+ ); +} diff --git a/frontend/src/pages/ai-steward/components/BriefingPrintDocument.tsx b/frontend/src/pages/ai-steward/components/BriefingPrintDocument.tsx new file mode 100644 index 00000000..935131db --- /dev/null +++ b/frontend/src/pages/ai-steward/components/BriefingPrintDocument.tsx @@ -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, "'"); +} + +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 '

当前没有额外指标。

'; + } + return ` +
+ ${metrics + .map( + (metric) => ` +
+
${escapeHtml(metric.label)}
+
${escapeHtml(metric.value)}
+
+ ` + ) + .join("")} +
+ `; +} + +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 '
当前区块暂无需要补充到报告的异常或跟进项。
'; + } + return ` +
+ ${groups + .map( + (group) => ` +
+
+
+
${escapeHtml(group.title)}
+
${escapeHtml(group.categoryLabel)}
+
+
${escapeHtml(group.items.length)} 条
+
+
+ ${group.items + .map((item) => { + const meta = renderItemMeta(item); + const detailLines = renderItemDetailLines(item); + return ` +
+
+
+ ${escapeHtml(item.title)} + ${escapeHtml(itemKindLabel(item.kind))} +
+ ${toneLabel(item.tone)} +
+
${escapeHtml(item.detail)}
+
+ ${meta + .map( + (meta) => ` +
+
${escapeHtml(meta.label)}
+
${escapeHtml(meta.value)}
+
+ ` + ) + .join("")} +
+
+ ${detailLines + .map((line) => `
${escapeHtml(line)}
`) + .join("")} +
+
处理动作:${escapeHtml(renderTargetLabel(section.title, item.target))}
+
+ ` + }) + .join("")} +
+
+ ` + ) + .join("")} +
+ `; +} + +export function buildBriefingPrintHtml(payload: PrintableBriefingPayload, viewerName?: string): string { + const hero = payload.hero; + const summaryBlock = payload.personal_summary + ? ` +
+

今日结论

+

${escapeHtml(payload.personal_summary.headline)}

+
+ ` + : ""; + + const workloadBlock = payload.personal_workload + ? ` +
+
个人负载
+
${escapeHtml(payload.personal_workload.user_name)}
+
${escapeHtml(payload.personal_workload.workload_ratio)}%
+
+ 已分配 ${escapeHtml(payload.personal_workload.assigned_hours)} 小时 / 周上限 + ${escapeHtml(payload.personal_workload.max_weekly_hours)} 小时 +
+
+ ` + : ""; + + const hasWorkloadBlock = Boolean(payload.personal_workload); + + return ` + + + + ${escapeHtml(payload.export_title)} + + + +
+
+ ${escapeHtml(modeLabel(payload.mode))} +

${escapeHtml(payload.export_title)}

+

+ ${escapeHtml(hero.title)}${viewerName ? ` · 查看人:${escapeHtml(viewerName)}` : ""} +

+
+ 查看日期:${escapeHtml(payload.brief_date)} + 晨报快照:${escapeHtml(formatTimestamp(hero.baseline_generated_at))} + 实时刷新:${escapeHtml(formatTimestamp(hero.live_generated_at))} + 异常总数:${escapeHtml(hero.anomaly_count)} +
+
+
+
需关注事项
+
${escapeHtml(hero.decision_count)}
+
+
+
异常总数
+
${escapeHtml(hero.anomaly_count)}
+
+
+
查看范围
+
${escapeHtml(payload.viewer_scope === "personal" ? "个人" : "全租户")}
+
+
+
+ + ${summaryBlock} + +
+
+ ${payload.sections + .map( + (section) => ` +
+

${escapeHtml(section.title)}

+

${escapeHtml(section.summary)}

+ ${renderMetrics(section.metrics)} + ${renderItems(section)} +
+ ` + ) + .join("")} +
+ ${hasWorkloadBlock ? `` : ""} +
+
+ + + `; +} + +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(); +} diff --git a/frontend/src/pages/ai-steward/components/BriefingSectionCard.tsx b/frontend/src/pages/ai-steward/components/BriefingSectionCard.tsx new file mode 100644 index 00000000..8935e062 --- /dev/null +++ b/frontend/src/pages/ai-steward/components/BriefingSectionCard.tsx @@ -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(); + 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>(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 ( + + +
+
+

{section.title}

+

{section.summary}

+
+ +
+ +
+ {section.metrics.map((metric) => ( +
+

{metric.label}

+

+ {formatMetricValue(metric.value)} +

+
+ ))} +
+ +
+

+ 共 {section.items.length} 条,按项目、信号源、成员分组展示。卡片固定高度,更多内容可滚动查看。 +

+ +
+ +
+ {groups.length ? ( +
+ {groups.map((group) => { + const expanded = expandedKeys.has(group.key); + return ( +
+
+ + +
+ + {expanded && ( +
+ {group.items.map((item, index) => ( + + ))} +
+ )} +
+ ); + })} +
+ ) : ( +
+ {emptyMessage} +
+ )} +
+
+
+ ); +} diff --git a/frontend/src/pages/ai-steward/components/FeatureOverviewCards.tsx b/frontend/src/pages/ai-steward/components/FeatureOverviewCards.tsx index 94158d15..a3491cb0 100644 --- a/frontend/src/pages/ai-steward/components/FeatureOverviewCards.tsx +++ b/frontend/src/pages/ai-steward/components/FeatureOverviewCards.tsx @@ -53,6 +53,7 @@ export default function FeatureOverviewCards({ const Icon = meta.icon; const primaryMetric = section.metrics[0]; const value = primaryMetric ? String(primaryMetric.value) : "-"; + const metricLabel = primaryMetric?.label || "关注数"; const targetTab = (section.drilldown_tab as AIStewardTab | undefined) || meta.fallbackTab; return ( {section.title} -

{value}

-

{section.summary}

+
+
+

{metricLabel}

+

{value}

+
+ 点击查看 +
+

{section.summary}

); @@ -119,16 +126,16 @@ export default function FeatureOverviewCards({ - onSelectTab("briefing")}> + onSelectTab("overview")}>
- 智能简报 + 统一入口
- 进度同步 + 简报中心
-

个性化每日简报,只显示相关变更,替代日报

+

晨报与日报已合并到一个入口,点击后直接看与你相关的变化

diff --git a/frontend/src/pages/ai-steward/sections/BriefingTab.tsx b/frontend/src/pages/ai-steward/sections/BriefingTab.tsx index f12e218b..a42de393 100644 --- a/frontend/src/pages/ai-steward/sections/BriefingTab.tsx +++ b/frontend/src/pages/ai-steward/sections/BriefingTab.tsx @@ -1,257 +1,294 @@ -import { - AlertCircle, - AlertTriangle, - Bell, - CheckSquare, - FileText, - ListTodo, - Loader2, - Sparkles, - TrendingUp, - Users, -} from "lucide-react"; -import { toZhStatus } from "@/lib/display"; +import { useState } from "react"; +import { Bell, Download, FileText, Loader2, RefreshCcw, Sparkles } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent } from "@/components/ui/card"; import { TabsContent } from "@/components/ui/tabs"; -import type { DailyBriefing } from "../types"; +import type { + AIStewardTab, + BriefingTarget, + DailyBriefingV2Payload, + ExecutiveBriefingSection, +} from "../types"; +import BriefingDetailDrawer from "../components/BriefingDetailDrawer"; +import BriefingSectionCard from "../components/BriefingSectionCard"; interface BriefingTabProps { - featureLoading: string | null; - briefing: DailyBriefing | null; + tabValue?: string; + briefing: DailyBriefingV2Payload | null; briefingError?: string | null; + briefingLoading: boolean; + briefingMode: "snapshot" | "live"; username?: string; - onRefreshBriefing: () => void; + onLoadSnapshot: () => void; + onRefreshLive: () => void; + onExportReport: () => void; + onOpenTarget: (target?: BriefingTarget | null, fallbackTab?: AIStewardTab) => void; } -export default function BriefingTab({ featureLoading, briefing, briefingError, username, onRefreshBriefing }: BriefingTabProps) { - const hasContent = - !!briefing && - Boolean( - (briefing.your_tasks && briefing.your_tasks.length > 0) || - (briefing.action_items && briefing.action_items.length > 0) || - (briefing.risks && briefing.risks.length > 0) || - (briefing.conflicts && briefing.conflicts.length > 0) || - (briefing.signals && briefing.signals.length > 0) || - (briefing.team_updates && briefing.team_updates.length > 0) - ); +const SECTION_STYLE: Record = { + decisions: "border-amber-200 bg-amber-50/70 dark:border-amber-900 dark:bg-amber-950/30", + input_health: "border-sky-200 bg-sky-50/70 dark:border-sky-900 dark:bg-sky-950/30", + delivery_risk: "border-rose-200 bg-rose-50/70 dark:border-rose-900 dark:bg-rose-950/30", + team_capacity: "border-emerald-200 bg-emerald-50/70 dark:border-emerald-900 dark:bg-emerald-950/30", + ai_governance: "border-indigo-200 bg-indigo-50/70 dark:border-indigo-900 dark:bg-indigo-950/30", + external_metrics: "border-slate-200 bg-slate-50/70 dark:border-slate-800 dark:bg-slate-950/30", +}; + +function formatTimestamp(value?: string | null): string { + if (!value) return "-"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleString("zh-CN", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); +} + +function formatMetricValue(value: string | number): string { + return typeof value === "number" ? String(value) : value; +} + +function toneBadgeClass(tone: string): string { + switch (tone) { + case "critical": + return "bg-rose-100 text-rose-700 dark:bg-rose-950 dark:text-rose-200"; + case "warning": + return "bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-200"; + case "positive": + return "bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-200"; + case "info": + return "bg-sky-100 text-sky-700 dark:bg-sky-950 dark:text-sky-200"; + default: + return "bg-slate-100 text-slate-700 dark:bg-slate-900 dark:text-slate-200"; + } +} + +export default function BriefingTab({ + tabValue = "briefing", + briefing, + briefingError, + briefingLoading, + briefingMode, + username, + onLoadSnapshot, + onRefreshLive, + onExportReport, + onOpenTarget, +}: BriefingTabProps) { + const [drawerSection, setDrawerSection] = useState(null); + const [drawerProjectKey, setDrawerProjectKey] = useState(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 ( - +
-
-
-

- - 每日进度简报 -

-

{username} 的个性化简报 - 只显示相关内容

-
- -
+ + +
+
+
+ + {briefingMode === "live" ? "实时版" : "晨报快照"} + + {briefing?.viewer_scope === "tenant_full" ? ( + 全租户视角 + ) : ( + 个人视角 + )} +
- {featureLoading === "briefing" ? ( -
- -
- ) : briefingError ? ( - - - -

简报加载失败

-

{briefingError}

-

你可以稍后重试,或者先去看问答记录 / 反馈中心。

-
-
- ) : !briefing ? ( - - - -

点击"刷新简报"加载今日内容

-

AI 会根据您的角色筛选相关信息

-
-
- ) : ( -
- - -
- -
-

AI 摘要

-

{briefing.summary}

+
+

+ + {briefing?.export_title || "每日简报"} +

+

+ {personalSummary?.headline || + `${username || "当前成员"} 的日报视图。与晨报中心共享同一套快照与实时口径,先看异常,再看需要你处理的内容。`} +

+
+ +
+
+

需优先关注

+

{hero?.decision_count ?? 0}

+
+
+

相关更新

+

{totalItems}

+
+
+

查看日期

+

{briefing?.brief_date || "-"}

- - +
- {!hasContent && ( - - - -

最近 24 小时没有新的相关更新

-

- 这张简报只展示近期变化;如果项目这一天没有新增任务、风险、冲突或信号,页面就会显得很空。 -

-
-
- )} - -
- - - - - 你的任务 ({briefing.your_tasks?.length || 0}) - - - - {briefing.your_tasks && briefing.your_tasks.length > 0 ? ( -
- {briefing.your_tasks.map((task) => ( -
- {task.title} - {toZhStatus(task.status)} -
- ))} +
+
+
+ 晨报快照 + {formatTimestamp(hero?.baseline_generated_at)} +
+
+ 实时刷新 + {formatTimestamp(hero?.live_generated_at)} +
+
+ {personalWorkload && ( +
+
+
+

个人负载

+

{personalWorkload.user_name}

+

+ 已分配 {personalWorkload.assigned_hours} 小时 / 周上限 {personalWorkload.max_weekly_hours} 小时 +

+
+ {personalWorkload.workload_ratio}%
- ) : ( -

暂无任务

- )} - - - - - - - - 行动项 ({briefing.action_items?.length || 0}) - - - - {briefing.action_items && briefing.action_items.length > 0 ? ( -
- {briefing.action_items.map((item, index) => ( -
-
- {item.content} -
- ))} -
- ) : ( -

暂无行动项

- )} - - - - - - - - 风险 ({briefing.risks?.length || 0}) - - - - {briefing.risks && briefing.risks.length > 0 ? ( -
- {briefing.risks.map((risk, index) => ( -
- - {risk.task} -
- ))} -
- ) : ( -

暂无风险

- )} -
-
- - - - - - 需要对齐 ({briefing.conflicts?.length || 0}) - - - - {briefing.conflicts && briefing.conflicts.length > 0 ? ( -
- {briefing.conflicts.map((conflict) => ( -
-
- {conflict.type} -
-

{conflict.description.substring(0, 60)}...

-
- ))} -
- ) : ( -

暂无冲突

- )} -
-
+
+ )} +
+ + + +
+
- {briefing.signals && briefing.signals.length > 0 && ( - - - - - 高影响信号 ({briefing.signals.length}) - - - -
- {briefing.signals.map((signal) => ( -
-
- {signal.source} - 影响分: {signal.impact_score.toFixed(1)} -
-

{signal.content}

-
- ))} -
-
-
+ {briefingLoading && ( +
+ + 正在更新日报内容... +
)} + {briefingError && ( +

+ {briefingError} +

+ )} + + - {briefing.team_updates && briefing.team_updates.length > 0 && ( - - - - - 团队更新 ({briefing.team_updates.length}) - - - -
- {briefing.team_updates.map((update, index) => ( -
- {update.task} - {toZhStatus(update.status)} -
- ))} + {!briefingLoading && !briefingError && !briefing && ( + + + +

点击“实时刷新”生成今天的日报

+

系统会按照你当前角色自动筛选应关注的项目、风险、负载和治理信息。

+
+
+ )} + + {briefing && !briefingError && ( +
+
+ {sections.map((section) => ( + + ))} +
+ +
+ + +
+ +
+

日报摘要

+

导出会基于当前视角生成完整详细版,不只导出首页摘要。

+
+
+
+
+ 模式 + + {briefing.mode === "live" ? "实时版" : "晨报快照"} + +
+
+ 需关注事项 + {hero?.decision_count ?? 0} +
+
+ 相关项目 + {personalSummary?.project_count ?? "-"} +
+
+ 动作项 + {personalSummary?.action_count ?? hero?.decision_count ?? 0} +
- )} + + {(hero?.key_alerts?.length ?? 0) > 0 && ( + + +

关键提醒

+
+ {(hero?.key_alerts ?? []).map((item) => ( + + {item.label} {formatMetricValue(item.value)} + + ))} +
+
+
+ )} + + {personalSummary && ( + + +
+ +

AI 提炼结论

+
+

{personalSummary.headline}

+
+
+ )} +
)} + + { + if (!open) { + setDrawerSection(null); + setDrawerProjectKey(null); + } + }} + onOpenTarget={onOpenTarget} + />
); diff --git a/frontend/src/pages/ai-steward/sections/OverviewTab.tsx b/frontend/src/pages/ai-steward/sections/OverviewTab.tsx index 5c2f08cb..22d335f5 100644 --- a/frontend/src/pages/ai-steward/sections/OverviewTab.tsx +++ b/frontend/src/pages/ai-steward/sections/OverviewTab.tsx @@ -1,15 +1,5 @@ -import { - Activity, - AlertTriangle, - Bell, - CheckCircle2, - Clock, - MessageSquare, - Radar, - RefreshCcw, - Shield, - Sparkles, -} from "lucide-react"; +import { useState } from "react"; +import { Download, RefreshCcw, Sparkles } from "lucide-react"; import { TabsContent } from "@/components/ui/tabs"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -18,13 +8,23 @@ import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; import type { AIStewardTab, + BriefingTarget, + DailyBriefingV2Payload, EngineMessage, ExecutiveBriefingConfig, ExecutiveBriefingPayload, + ExecutiveBriefingSection, } from "@/pages/ai-steward/types"; +import BriefingDetailDrawer from "../components/BriefingDetailDrawer"; +import BriefingSectionCard from "../components/BriefingSectionCard"; +import BriefingTab from "./BriefingTab"; interface OverviewTabProps { canManageExecutiveBriefing: boolean; + dailyBriefing: DailyBriefingV2Payload | null; + dailyBriefingError: string | null; + dailyBriefingLoading: boolean; + username?: string; executiveBriefing: ExecutiveBriefingPayload | null; executiveBriefingError: string | null; executiveBriefingMode: "snapshot" | "live"; @@ -36,9 +36,10 @@ interface OverviewTabProps { executiveBriefingSaveMessage: EngineMessage | null; onLoadSnapshot: () => void; onRefreshLive: () => void; + onExportReport: () => void; onConfigChange: (patch: Partial) => void; onSaveConfig: () => void; - onSelectTab: (tab: AIStewardTab) => void; + onOpenTarget: (target?: BriefingTarget | null, fallbackTab?: AIStewardTab) => void; } const SECTION_STYLE: Record = { @@ -81,129 +82,12 @@ function toneBadgeClass(tone: string): string { } } -function LegacyOverview() { - return ( - -
-
-

AI 管家正在运行

-

4 大主动服务守护你的项目,无需手动触发

-
- -
-
-
-
- -
-
-

1. 主动抓取信号

-

无需手动录入,AI 自动捕获所有需求来源

-
-
-
    -
  • - - - 飞书群聊监控 - 配置完成,正在运行 -
  • -
  • - - - 技术资讯定时抓取 - 可启用 -
  • -
  • - - 浏览器标签监控 - 需安装扩展 -
  • -
-
- -
-
-
- -
-
-

2. 主动消解认知差

-

提前发现隐性分歧,避免返工

-
-
-
    -
  • - - 自动检测定义不一致(如"留存" vs "DAU") -
  • -
  • - - 任务与宪章一致性检查 -
  • -
  • - - AI 生成对齐方案 -
  • -
-
- -
-
-
- -
-
-

3. 主动解决阻塞

-

代码注释扫描,自动推荐替代方案

-
-
-
    -
  • - - 识别阻塞模式("等待X接口"、"达不到效果") -
  • -
  • - - AI 分析严重程度并生成解决方案 -
  • -
  • - - 自动创建追踪任务 -
  • -
-
- -
-
-
- -
-
-

4. 主动同步进度

-

个性化简报,彻底取消日报周报

-
-
-
    -
  • - - 按角色智能过滤信息 -
  • -
  • - - 只显示相关任务变更和风险 -
  • -
  • - - 推送行动项和对齐提醒 -
  • -
-
-
-
-
- ); -} - export default function OverviewTab({ canManageExecutiveBriefing, + dailyBriefing, + dailyBriefingError, + dailyBriefingLoading, + username, executiveBriefing, executiveBriefingError, executiveBriefingMode, @@ -215,16 +99,37 @@ export default function OverviewTab({ executiveBriefingSaveMessage, onLoadSnapshot, onRefreshLive, + onExportReport, onConfigChange, onSaveConfig, - onSelectTab, + onOpenTarget, }: OverviewTabProps) { + const [drawerSection, setDrawerSection] = useState(null); + const [drawerProjectKey, setDrawerProjectKey] = useState(null); + if (!canManageExecutiveBriefing) { - return ; + return ( + + ); } const sections = executiveBriefing?.sections ?? []; const hero = executiveBriefing?.hero; + const openDrawer = (section: ExecutiveBriefingSection, projectKey?: string) => { + setDrawerSection(section); + setDrawerProjectKey(projectKey || null); + }; return ( @@ -235,7 +140,7 @@ export default function OverviewTab({
- {executiveBriefingMode === "live" ? "实时模式" : "晨报快照"} + {executiveBriefingMode === "live" ? "实时版" : "晨报快照"} {hero?.snapshot_state === "missing" && ( @@ -246,27 +151,23 @@ export default function OverviewTab({

- 老板晨报中心 + 简报中心

- 用晨报快照做日常基线,用实时刷新做临时复盘。先看异常,再看输入、交付、团队和 AI 治理。 + 先看异常,再按项目展开预览,右侧抽屉里看完整详情并决定下一步去项目、成员还是工作台。

-

需拍板

-

- {hero?.decision_count ?? 0} -

+

需拍板事项

+

{hero?.decision_count ?? 0}

-

异常总数

-

- {hero?.anomaly_count ?? 0} -

+

异常总数

+

{hero?.anomaly_count ?? 0}

-

查看日期

+

查看日期

{executiveBriefing?.brief_date || "-"}

@@ -274,19 +175,15 @@ export default function OverviewTab({
-
+
晨报快照 - - {formatTimestamp(hero?.baseline_generated_at)} - + {formatTimestamp(hero?.baseline_generated_at)}
实时刷新 - - {formatTimestamp(hero?.live_generated_at)} - + {formatTimestamp(hero?.live_generated_at)}
@@ -310,9 +207,18 @@ export default function OverviewTab({ disabled={executiveBriefingLoading} className="bg-slate-900 text-white hover:bg-slate-800 dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-slate-200" > - + 实时刷新 +
@@ -329,48 +235,13 @@ export default function OverviewTab({
{sections.map((section) => ( - - -
-
-

{section.title}

-

{section.summary}

-
- {section.drilldown_tab && section.drilldown_tab !== "overview" && ( - - )} -
-
- {section.metrics.map((metric) => ( -
-

{metric.label}

-

- {formatMetricValue(metric.value)} -

-
- ))} -
-
- {section.items.length ? ( - section.items.slice(0, 3).map((item, index) => ( -
-
- {item.title} - {item.tone} -
-

{item.detail}

-
- )) - ) : ( -
- 当前区块暂无新增异常。 -
- )} -
-
-
+ ))}
@@ -380,7 +251,7 @@ export default function OverviewTab({

晨报调度配置

- 这份配置是租户级的。修改后,Web 快照、定时调度和管理群自动推送都会沿用同一套口径。 + 这份配置是租户级的。修改后,Web 快照、定时调度和管理群自动推送都会沿用同一套晨报口径。

@@ -467,6 +338,19 @@ export default function OverviewTab({
+ + { + if (!open) { + setDrawerSection(null); + setDrawerProjectKey(null); + } + }} + onOpenTarget={onOpenTarget} + />
); diff --git a/frontend/src/pages/ai-steward/types.ts b/frontend/src/pages/ai-steward/types.ts index b2cb1a85..fd7751fa 100644 --- a/frontend/src/pages/ai-steward/types.ts +++ b/frontend/src/pages/ai-steward/types.ts @@ -1,8 +1,13 @@ import type { LLMEngine, LLMEngineListResponse } from "@/api/aiSteward"; export type { + BriefingMetaEntry, + BriefingTarget, Blocker, CognitiveConflict, DailyBriefing, + DailyBriefingV2Payload, + DailyBriefingV2PersonalSummary, + DailyBriefingV2PersonalWorkload, ExecutiveBriefingConfig, ExecutiveBriefingHero, ExecutiveBriefingItem, diff --git a/frontend/src/pages/project-board/ProjectBoardPage.tsx b/frontend/src/pages/project-board/ProjectBoardPage.tsx index fc91a7d2..7adfd279 100644 --- a/frontend/src/pages/project-board/ProjectBoardPage.tsx +++ b/frontend/src/pages/project-board/ProjectBoardPage.tsx @@ -1,5 +1,5 @@ import { useDeferredValue, useEffect, useMemo, useState } from "react"; -import { useParams, Link, useNavigate } from "react-router-dom"; +import { useParams, Link, useNavigate, useSearchParams } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertTriangle, ArrowLeft, ChevronDown, ChevronRight, Plus } from "lucide-react"; import { aiPmoApi } from "@/api/aiPmo"; @@ -28,10 +28,47 @@ import { toReadableWorkItemTitle, } from "./helpers"; +function normalizeBoardStage(value: string | null): StageKey { + switch (value) { + case "charter": + case "task": + case "execution": + case "release": + case "review": + return value; + default: + return "signal"; + } +} + +function buildSignalFiltersFromSearch(searchParams: URLSearchParams): SignalFilterState { + return { + status: searchParams.get("signal_status") || DEFAULT_SIGNAL_FILTERS.status, + keyword: searchParams.get("signal_keyword") || "", + source: searchParams.get("signal_source") || "", + priority: searchParams.get("signal_priority") || "", + created_from: searchParams.get("signal_created_from") || "", + created_to: searchParams.get("signal_created_to") || "", + }; +} + +function sameSignalFilters(left: SignalFilterState, right: SignalFilterState): boolean { + return ( + left.status === right.status + && left.keyword === right.keyword + && left.source === right.source + && left.priority === right.priority + && left.created_from === right.created_from + && left.created_to === right.created_to + ); +} + export default function ProjectBoardPage() { const SIGNAL_PAGE_SIZE = 10; const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const boardSearchKey = searchParams.toString(); const queryClient = useQueryClient(); const { toast } = useToast(); const [isMobileViewport, setIsMobileViewport] = useState(() => @@ -207,6 +244,14 @@ export default function ProjectBoardPage() { setSignalFilters(DEFAULT_SIGNAL_FILTERS); }, [id]); + useEffect(() => { + const nextStage = normalizeBoardStage(searchParams.get("stage")); + setActiveStage((current) => (current === nextStage ? current : nextStage)); + const nextFilters = buildSignalFiltersFromSearch(searchParams); + setSignalFilters((current) => (sameSignalFilters(current, nextFilters) ? current : nextFilters)); + setSignalPage(1); + }, [boardSearchKey, id]); + const refreshAll = async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["sstc-board-overview", id] }), diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo index fa3f18cc..3f27d8c2 100644 --- a/frontend/tsconfig.tsbuildinfo +++ b/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/skillradarchart.tsx","./src/components/themeinit.tsx","./src/components/ai-config/configsubpageheader.tsx","./src/components/ai-config/featureintroguide.tsx","./src/components/ai-steward/floatingqachat.tsx","./src/components/ai-steward/promptrolesettings.tsx","./src/components/ai-steward/qahistorypanel.tsx","./src/components/budget/budgetcontrolpanel.tsx","./src/components/command/commanditems.ts","./src/components/inbox/inboxactionpanel.tsx","./src/components/inbox/inboxlistitem.tsx","./src/components/layout/layout.tsx","./src/components/layout/sidebar.tsx","./src/components/layout/topbar.tsx","./src/components/model-capability/costoptimizationpanel.tsx","./src/components/model-capability/experimentsyncpanel.tsx","./src/components/model-capability/modelcapabilitymatrix.tsx","./src/components/model-capability/modelupdateimpactpanel.tsx","./src/components/sstc/aivisibilitypanel.tsx","./src/components/sstc/feedbacksummarycard.tsx","./src/components/sstc/sstcchainview.tsx","./src/components/sstc/sstccharterstagepanel.tsx","./src/components/sstc/sstcexecutionboardpanel.tsx","./src/components/sstc/sstcglobalheader.tsx","./src/components/sstc/sstcreleasecreatedialog.tsx","./src/components/sstc/sstcreleasestagepanel.tsx","./src/components/sstc/sstcreviewcreatedialog.tsx","./src/components/sstc/sstcreviewstagepanel.tsx","./src/components/sstc/sstcriskfloatpanel.tsx","./src/components/sstc/sstcsignalstagepanel.tsx","./src/components/sstc/sstcstagenav.tsx","./src/components/sstc/sstctaskcreatedialog.tsx","./src/components/sstc/sstctaskstagepanel.tsx","./src/components/sstc/sstcworkspace.tsx","./src/components/sstc/signalcontenthovercard.tsx","./src/components/sstc/chartermodules.ts","./src/components/sstc/sstcutils.ts","./src/components/sstc/usesstcworkspaceorchestration.ts","./src/components/ui/rolebadge.tsx","./src/components/ui/skillbadge.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toast.tsx","./src/components/users/userformdialog.tsx","./src/hooks/usetheme.ts","./src/lib/api.ts","./src/lib/apierror.ts","./src/lib/datetime.ts","./src/lib/display.ts","./src/lib/feishueventmode.ts","./src/lib/formatinboxtime.ts","./src/lib/inbox.ts","./src/lib/inboxtextmap.ts","./src/lib/rbac.ts","./src/lib/sessionguard.ts","./src/lib/title.ts","./src/lib/utils.ts","./src/lib/zhcn.ts","./src/pages/aiconfigpage.tsx","./src/pages/aistewardpage.tsx","./src/pages/charterdetailpage.tsx","./src/pages/contractsyncboardpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/feishuconfigpage.tsx","./src/pages/inboxpage.tsx","./src/pages/knowledgegraphcontextboardpage.tsx","./src/pages/loginpage.tsx","./src/pages/modelroutingconfigpage.tsx","./src/pages/projectboardpage.tsx","./src/pages/projectspage.tsx","./src/pages/recipespage.tsx","./src/pages/schedulercenterpage.tsx","./src/pages/skilltaskspage.tsx","./src/pages/skillspage.tsx","./src/pages/taskbriefcenterpage.tsx","./src/pages/taskbriefrunspage.tsx","./src/pages/taskbrieftemplatepage.tsx","./src/pages/userprofilepage.tsx","./src/pages/userskillspage.tsx","./src/pages/userspage.tsx","./src/pages/workloaddetailpage.tsx","./src/pages/workloadspage.tsx","./src/pages/ai-config/aiconfigpage.tsx","./src/pages/ai-config/constants.ts","./src/pages/ai-config/types.ts","./src/pages/ai-config/components/governancetoolscard.tsx","./src/pages/ai-config/components/prioritybadge.tsx","./src/pages/ai-config/components/statusbadge.tsx","./src/pages/ai-config/sections/advancedtab.tsx","./src/pages/ai-config/sections/configuretab.tsx","./src/pages/ai-config/sections/knowledgetab.tsx","./src/pages/ai-config/sections/overviewtab.tsx","./src/pages/ai-steward/aistewardpage.tsx","./src/pages/ai-steward/constants.ts","./src/pages/ai-steward/types.ts","./src/pages/ai-steward/components/featureoverviewcards.tsx","./src/pages/ai-steward/components/feedback-center/feedbackdetail.tsx","./src/pages/ai-steward/components/feedback-center/feedbacklist.tsx","./src/pages/ai-steward/components/feedback-center/metricspanel.tsx","./src/pages/ai-steward/components/feedback-center/reasonpicker.tsx","./src/pages/ai-steward/components/feedback-center/trendpanel.tsx","./src/pages/ai-steward/sections/blockerstab.tsx","./src/pages/ai-steward/sections/briefingtab.tsx","./src/pages/ai-steward/sections/conflictstab.tsx","./src/pages/ai-steward/sections/createprojecttab.tsx","./src/pages/ai-steward/sections/enginetab.tsx","./src/pages/ai-steward/sections/feedbackcentertab.tsx","./src/pages/ai-steward/sections/overviewtab.tsx","./src/pages/ai-steward/sections/signalstab.tsx","./src/pages/charter-detail/charterdetailpage.tsx","./src/pages/contract-sync-board/contractsyncboardpage.tsx","./src/pages/dashboard/dashboardpage.tsx","./src/pages/feishu-config/feishuconfigpage.tsx","./src/pages/feishu-config/helpers.ts","./src/pages/feishu-config/components/setupguidecard.tsx","./src/pages/inbox/inboxpage.tsx","./src/pages/inbox/helpers.ts","./src/pages/inbox/components/inboxfilterspanel.tsx","./src/pages/knowledge-graph-context-board/knowledgegraphcontextboardpage.tsx","./src/pages/login/loginpage.tsx","./src/pages/model-routing-config/modelroutingconfigpage.tsx","./src/pages/project-board/projectboardpage.tsx","./src/pages/project-board/helpers.ts","./src/pages/project-board/components/createcharterdialog.tsx","./src/pages/project-board/components/projectsettingsdialog.tsx","./src/pages/projects/projectspage.tsx","./src/pages/projects/helpers.ts","./src/pages/projects/components/deleteprojectdialog.tsx","./src/pages/projects/components/projectquerydialog.tsx","./src/pages/recipes/recipespage.tsx","./src/pages/recipes/helpers.ts","./src/pages/recipes/components/analyzerecipedialog.tsx","./src/pages/recipes/components/createrecipedialog.tsx","./src/pages/scheduler-center/schedulercenterpage.tsx","./src/pages/skill-tasks/skilltaskspage.tsx","./src/pages/skills/skillspage.tsx","./src/pages/task-brief-center/taskbriefcenterpage.tsx","./src/pages/task-brief-runs/taskbriefrunspage.tsx","./src/pages/task-brief-template/taskbrieftemplatepage.tsx","./src/pages/user-profile/userprofilepage.tsx","./src/pages/user-skills/userskillspage.tsx","./src/pages/users/userspage.tsx","./src/pages/users/helpers.ts","./src/pages/users/components/bulkassigndialog.tsx","./src/pages/users/components/deleteuserdialog.tsx","./src/pages/users/components/importusersdialog.tsx","./src/pages/users/components/resetpassworddialog.tsx","./src/pages/workload-detail/workloaddetailpage.tsx","./src/pages/workloads/workloadspage.tsx","./src/router/index.tsx","./src/stores/auth.ts","./src/types/inbox.ts"],"version":"5.6.3"} \ No newline at end of file +{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/skillradarchart.tsx","./src/components/themeinit.tsx","./src/components/ai-config/configsubpageheader.tsx","./src/components/ai-config/featureintroguide.tsx","./src/components/ai-steward/floatingqachat.tsx","./src/components/ai-steward/promptrolesettings.tsx","./src/components/ai-steward/qahistorypanel.tsx","./src/components/budget/budgetcontrolpanel.tsx","./src/components/command/commanditems.ts","./src/components/inbox/inboxactionpanel.tsx","./src/components/inbox/inboxlistitem.tsx","./src/components/layout/layout.tsx","./src/components/layout/sidebar.tsx","./src/components/layout/topbar.tsx","./src/components/model-capability/costoptimizationpanel.tsx","./src/components/model-capability/experimentsyncpanel.tsx","./src/components/model-capability/modelcapabilitymatrix.tsx","./src/components/model-capability/modelupdateimpactpanel.tsx","./src/components/sstc/aivisibilitypanel.tsx","./src/components/sstc/feedbacksummarycard.tsx","./src/components/sstc/sstcchainview.tsx","./src/components/sstc/sstccharterstagepanel.tsx","./src/components/sstc/sstcexecutionboardpanel.tsx","./src/components/sstc/sstcglobalheader.tsx","./src/components/sstc/sstcreleasecreatedialog.tsx","./src/components/sstc/sstcreleasestagepanel.tsx","./src/components/sstc/sstcreviewcreatedialog.tsx","./src/components/sstc/sstcreviewstagepanel.tsx","./src/components/sstc/sstcriskfloatpanel.tsx","./src/components/sstc/sstcsignalstagepanel.tsx","./src/components/sstc/sstcstagenav.tsx","./src/components/sstc/sstctaskcreatedialog.tsx","./src/components/sstc/sstctaskstagepanel.tsx","./src/components/sstc/sstcworkspace.tsx","./src/components/sstc/signalcontenthovercard.tsx","./src/components/sstc/chartermodules.ts","./src/components/sstc/sstcutils.ts","./src/components/sstc/usesstcworkspaceorchestration.ts","./src/components/ui/rolebadge.tsx","./src/components/ui/skillbadge.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toast.tsx","./src/components/users/userformdialog.tsx","./src/hooks/usetheme.ts","./src/lib/api.ts","./src/lib/apierror.ts","./src/lib/datetime.ts","./src/lib/display.ts","./src/lib/feishueventmode.ts","./src/lib/formatinboxtime.ts","./src/lib/inbox.ts","./src/lib/inboxtextmap.ts","./src/lib/rbac.ts","./src/lib/sessionguard.ts","./src/lib/title.ts","./src/lib/utils.ts","./src/lib/zhcn.ts","./src/pages/aiconfigpage.tsx","./src/pages/aistewardpage.tsx","./src/pages/charterdetailpage.tsx","./src/pages/contractsyncboardpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/feishuconfigpage.tsx","./src/pages/inboxpage.tsx","./src/pages/knowledgegraphcontextboardpage.tsx","./src/pages/loginpage.tsx","./src/pages/modelroutingconfigpage.tsx","./src/pages/projectboardpage.tsx","./src/pages/projectspage.tsx","./src/pages/recipespage.tsx","./src/pages/schedulercenterpage.tsx","./src/pages/skilltaskspage.tsx","./src/pages/skillspage.tsx","./src/pages/taskbriefcenterpage.tsx","./src/pages/taskbriefrunspage.tsx","./src/pages/taskbrieftemplatepage.tsx","./src/pages/userprofilepage.tsx","./src/pages/userskillspage.tsx","./src/pages/userspage.tsx","./src/pages/workloaddetailpage.tsx","./src/pages/workloadspage.tsx","./src/pages/ai-config/aiconfigpage.tsx","./src/pages/ai-config/constants.ts","./src/pages/ai-config/types.ts","./src/pages/ai-config/components/governancetoolscard.tsx","./src/pages/ai-config/components/prioritybadge.tsx","./src/pages/ai-config/components/statusbadge.tsx","./src/pages/ai-config/sections/advancedtab.tsx","./src/pages/ai-config/sections/configuretab.tsx","./src/pages/ai-config/sections/knowledgetab.tsx","./src/pages/ai-config/sections/overviewtab.tsx","./src/pages/ai-steward/aistewardpage.tsx","./src/pages/ai-steward/constants.ts","./src/pages/ai-steward/types.ts","./src/pages/ai-steward/components/briefingdetaildrawer.tsx","./src/pages/ai-steward/components/briefingprintdocument.tsx","./src/pages/ai-steward/components/briefingsectioncard.tsx","./src/pages/ai-steward/components/featureoverviewcards.tsx","./src/pages/ai-steward/components/feedback-center/feedbackdetail.tsx","./src/pages/ai-steward/components/feedback-center/feedbacklist.tsx","./src/pages/ai-steward/components/feedback-center/metricspanel.tsx","./src/pages/ai-steward/components/feedback-center/reasonpicker.tsx","./src/pages/ai-steward/components/feedback-center/trendpanel.tsx","./src/pages/ai-steward/sections/blockerstab.tsx","./src/pages/ai-steward/sections/briefingtab.tsx","./src/pages/ai-steward/sections/conflictstab.tsx","./src/pages/ai-steward/sections/createprojecttab.tsx","./src/pages/ai-steward/sections/enginetab.tsx","./src/pages/ai-steward/sections/feedbackcentertab.tsx","./src/pages/ai-steward/sections/overviewtab.tsx","./src/pages/ai-steward/sections/signalstab.tsx","./src/pages/charter-detail/charterdetailpage.tsx","./src/pages/contract-sync-board/contractsyncboardpage.tsx","./src/pages/dashboard/dashboardpage.tsx","./src/pages/feishu-config/feishuconfigpage.tsx","./src/pages/feishu-config/helpers.ts","./src/pages/feishu-config/components/setupguidecard.tsx","./src/pages/inbox/inboxpage.tsx","./src/pages/inbox/helpers.ts","./src/pages/inbox/components/inboxfilterspanel.tsx","./src/pages/knowledge-graph-context-board/knowledgegraphcontextboardpage.tsx","./src/pages/login/loginpage.tsx","./src/pages/model-routing-config/modelroutingconfigpage.tsx","./src/pages/project-board/projectboardpage.tsx","./src/pages/project-board/helpers.ts","./src/pages/project-board/components/createcharterdialog.tsx","./src/pages/project-board/components/projectsettingsdialog.tsx","./src/pages/projects/projectspage.tsx","./src/pages/projects/helpers.ts","./src/pages/projects/components/deleteprojectdialog.tsx","./src/pages/projects/components/projectquerydialog.tsx","./src/pages/recipes/recipespage.tsx","./src/pages/recipes/helpers.ts","./src/pages/recipes/components/analyzerecipedialog.tsx","./src/pages/recipes/components/createrecipedialog.tsx","./src/pages/scheduler-center/schedulercenterpage.tsx","./src/pages/skill-tasks/skilltaskspage.tsx","./src/pages/skills/skillspage.tsx","./src/pages/task-brief-center/taskbriefcenterpage.tsx","./src/pages/task-brief-runs/taskbriefrunspage.tsx","./src/pages/task-brief-template/taskbrieftemplatepage.tsx","./src/pages/user-profile/userprofilepage.tsx","./src/pages/user-skills/userskillspage.tsx","./src/pages/users/userspage.tsx","./src/pages/users/helpers.ts","./src/pages/users/components/bulkassigndialog.tsx","./src/pages/users/components/deleteuserdialog.tsx","./src/pages/users/components/importusersdialog.tsx","./src/pages/users/components/resetpassworddialog.tsx","./src/pages/workload-detail/workloaddetailpage.tsx","./src/pages/workloads/workloadspage.tsx","./src/router/index.tsx","./src/stores/auth.ts","./src/types/inbox.ts"],"version":"5.6.3"} \ No newline at end of file diff --git a/工单数据_20260327.xlsx b/工单数据_20260327.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..5ba709ba5ad7952c1132fa041e4d51ed33c3bccb GIT binary patch literal 48533 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