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 00000000..5ba709ba Binary files /dev/null and b/工单数据_20260327.xlsx differ