Add GLM-5 Planner 需求链路

This commit is contained in:
Yuuuu 2026-03-12 17:37:27 +08:00
parent fea6328ab2
commit d7cc718538
35 changed files with 2938 additions and 534 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

View File

@ -26,6 +26,7 @@ AIProjectManger/
├── tests/ # 根级测试(集成/脚本)
└── docs/ # 额外文档
```
- 新增:`docs/INBOX_INLINE_OPS_3_PHASE_CHECKLIST.md`(消息台内联操作三期执行清单)
## 4. 后端结构(`backend/`)
```text
@ -88,8 +89,11 @@ frontend/
```
- 新增:`frontend/src/components/inbox/InboxListItem.tsx`(消息台 Feed 行组件,支持移动端与行内展开)
- 新增:`frontend/src/components/inbox/InboxActionPanel.tsx`(消息台内联动作面板:缺少项目归属时的项目选择与确认关联)
- 新增:`frontend/src/lib/inboxTextMap.ts`(消息台用户文案映射与标题清洗)
- 新增:`frontend/src/lib/formatInboxTime.ts`(消息台相对时间与完整时间格式化)
- 新增:`frontend/src/components/ui/RoleBadge.tsx`(角色徽章组件,支持多角色与可选数量限制)
- 新增:`frontend/src/components/ui/SkillBadge.tsx`(技能徽章组件,默认显示 3 个并支持 +N)
### 5.1 `frontend/src/components/sstc/` 目录说明(模块化拆分后)
- `SSTCWorkspace.tsx`:SSTC 工作区主容器(组装各阶段组件)
@ -181,6 +185,15 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
- 项目成员分配接口(`PUT /projects/{project_id}/members`):参考 `backend/app/api/projects.py` + `backend/app/services/project_member_service.py`,要求显式传入 `member_user_ids` 并写入 `user_projects`
- SSTC 总览接口(`GET /projects/{project_id}/sstc/overview`):`workload_health` 返回成员明细 `members[]`(姓名/角色/负载比例/状态)用于头部卡片展示
- AI PMO 审批接口(`POST /ai-pmo/work-items/{work_item_id}/decision`):显式 `approve/reject`,`reject` 必须落 `ignored`(失效)并同步 `decision_records.final_outcome`
- AI PMO 审批接口补充:`approve + waiting_manual + project_id` 走“快速返回 + 后台继续执行”,避免前端长时间阻塞
- AI PMO 消息台列表接口(`GET /ai-pmo/work-items`):返回 `latest_policy_gate_code` 与规范化 `approval_requirement{required,reason,message}`(来源于最新 `decision_records` 投影)
- AI PMO 消息台列表接口补充:返回源消息上下文字段 `source_message_text/source_captured_at/source_sender/source_channel_ref`,用于详情页展示“原始输入”与事件时间
- AI PMO 消息台列表接口补充:返回闭环语义字段 `source_conversation_closure/closure_summary/implementation_modules/source_closure_turns`,用于详情页展示“消息闭环 + 实现模块”
- AI PMO 消息台历史回填补充:当 `ingest_events.payload` 闭环字段缺失或低质量时,优先回放 `ai_processing_logs`(同 `feishu_message_id`)并补充 `qa_records` 最近问答,避免 Redis 会话过期后仅剩单句摘要
- AI PMO 消息台列表接口补充:过滤短口水话 `query.read_only` 项(如“怎么样了/在吗”),保留后端记录但不下发前端收件流
- AI PMO `signal_source.onboard` 入库规则:优先使用闭环摘要写入 `signals.summary`,闭环对话写入 `signals.content`,并在 `signals.strategic_analysis.source_context.implementation_modules` 持久化实现模块
- SSTC 信号列表接口(`GET /projects/{project_id}/signals`)标题生成规则:优先 `signals.title`,其次 `signals.summary`,最后回退 `signals.content` 去前缀文本
- 信号源编排事务补充:`SignalSourceOrchestrationService` 内部调用 OpenClaw 指挥链使用隔离 Session,禁止把子链路 rollback 传播到外层 `signal/work_item` 事务
- 新增前端 API 调用:参考 `frontend/src/api/sstc.ts`
- 新增数据库表:参考 `alembic/versions/` 最新 migration
- 新增飞书事件处理:参考 `backend/app/services/feishu_integration.py`

View File

@ -2,7 +2,9 @@
from __future__ import annotations
import asyncio
from datetime import datetime
import re
from typing import Any, Literal, Optional
from fastapi import APIRouter, Depends, HTTPException
@ -10,8 +12,9 @@ from pydantic import BaseModel, Field
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..core.logging import get_logger
from ..core.response import success_response
from ..database import get_db
from ..database import AsyncSessionLocal, get_db
from ..dependencies.auth import get_current_user
from ..dependencies.tenant import get_tenant_id
from ..models import DecisionRecord, IngestEvent, PlanBundle, PortfolioSnapshot, Project, ReplyReceipt, User, WorkItem
@ -19,6 +22,32 @@ from ..services.ai_pmo_cadence_service import AIPMOCadenceService
from ..services.ai_pmo_kernel_service import AIPMOKernelService
router = APIRouter(prefix="/ai-pmo", tags=["AI PMO"])
logger = get_logger(__name__)
FRONT_NOISE_INTENT = "query.read_only"
FRONT_NOISE_EXACT = {
"收到",
"收到啦",
"好的",
"ok",
"okk",
"yes",
"no",
"谢谢",
"thanks",
"thx",
"在吗",
"在不在",
"怎么样了",
"咋样了",
"辛苦了",
}
FRONT_NOISE_HINTS = ("怎么样了", "咋样了", "在吗", "辛苦了", "ok", "谢谢", "收到")
FRONT_NOISE_STRUCTURED_HINTS = ("项目", "信号", "任务", "风险", "进度", "状态", "work_item", "工作项", "#")
GENERIC_SOURCE_TEXT_RE = re.compile(
r"^(?:请)?(?:按|按照)?(?:这个|上述|这条)?(?:分步|步骤)?(?:骤)?(?:把|将)?(?:它|该需求|这条需求)?(?:加入|接入|录入).{0,24}信号源(?:中)?$",
re.IGNORECASE,
)
LOW_VALUE_CLOSURE_HINTS = ("缺少项目归属", "必须先确认项目", "无法创建", "不能创建")
class PMOProcessTextRequest(BaseModel):
@ -91,9 +120,27 @@ async def list_work_items(
if project_id is not None:
stmt = stmt.where(WorkItem.project_id == int(project_id))
rows = (await db.execute(stmt.limit(max(1, min(limit, 100))))).all()
work_item_ids = [int(row.id) for row, _, _ in rows]
latest_decision_projection = await _build_latest_decision_projection(
db=db,
tenant_id=tenant_id,
work_item_ids=work_item_ids,
)
closure_payload_overrides = await _build_closure_payload_overrides(
db=db,
tenant_id=tenant_id,
rows=rows,
)
items = [
_serialize_work_item_summary(row=row, ingest=ingest, project=project)
_serialize_work_item_summary(
row=row,
ingest=ingest,
project=project,
latest_decision_projection=latest_decision_projection.get(int(row.id)),
payload_override=closure_payload_overrides.get(int(row.id)),
)
for row, ingest, project in rows
if not _should_hide_work_item_from_inbox(row=row, ingest=ingest)
]
return success_response({"items": items})
@ -271,6 +318,63 @@ async def decide_work_item(
)
return success_response(result)
if str(target.state or "").strip() == "waiting_manual" and request.project_id is not None:
target.project_id = int(request.project_id)
target.state = "executing"
target.reply_template_key = "action_processing"
current_execution = target.execution_result if isinstance(target.execution_result, dict) else {}
target.execution_result = {
**current_execution,
"status": "executing",
"manual_decision": "approve",
"resume_trigger": "queued_background",
"project_id": int(request.project_id),
"queued_at": datetime.now().isoformat(),
}
decision_row = (
await db.execute(
select(DecisionRecord)
.where(
and_(
DecisionRecord.tenant_id == tenant_id,
DecisionRecord.work_item_id == int(target.id),
)
)
.order_by(DecisionRecord.id.desc())
.limit(1)
)
).scalar_one_or_none()
if decision_row is not None and str(decision_row.policy_gate_code or "").strip() == "missing_project_scope":
decision_row.policy_gate_hit = False
decision_row.policy_gate_code = None
decision_row.approval_requirement = {
"required": False,
"reason": "project_scope_confirmed",
"message": "项目归属已确认,正在后台继续执行。",
}
decision_row.final_outcome = "executing"
await db.commit()
_queue_resume_work_item(
tenant_id=tenant_id,
work_item_id=int(work_item_id),
source_message_id=request.source_message_id,
sender=str(current_user.name or current_user.username or "PMFlow用户"),
project_id=int(request.project_id),
)
return success_response(
{
"handled": True,
"status": "pending_manual",
"followup_reply": "已确认关联项目,正在后台继续执行。",
"target_work_item_id": int(target.id),
"target_state": str(target.state or "").strip() or "executing",
"target_intent": str(target.intent or "").strip() or None,
"project_id": int(request.project_id),
"signal_id": _safe_int(target.signal_id),
"auto_tasks": [],
}
)
service = AIPMOKernelService(db, tenant_id=tenant_id)
raw_result = await service.process_ingress(
source_system="ai_pmo_ui",
@ -287,6 +391,89 @@ async def decide_work_item(
return success_response(result)
def _queue_resume_work_item(
*,
tenant_id: int,
work_item_id: int,
source_message_id: Optional[str],
sender: str,
project_id: int,
) -> None:
message_id = (
str(source_message_id or "").strip()
or f"decision-approve-bg-{int(work_item_id)}-{int(datetime.now().timestamp() * 1000)}"
)[:128]
task = asyncio.create_task(
_run_resume_work_item_in_background(
tenant_id=int(tenant_id),
work_item_id=int(work_item_id),
source_message_id=message_id,
sender=str(sender or "PMFlow用户"),
project_id=int(project_id),
),
name=f"ai-pmo-resume-{int(work_item_id)}",
)
def _on_done(done_task: asyncio.Task[None]) -> None:
if done_task.cancelled():
return
exc = done_task.exception()
if exc is not None:
logger.warning(
"ai_pmo_background_resume_failed",
tenant_id=int(tenant_id),
work_item_id=int(work_item_id),
error=str(exc)[:400],
)
task.add_done_callback(_on_done)
async def _run_resume_work_item_in_background(
*,
tenant_id: int,
work_item_id: int,
source_message_id: str,
sender: str,
project_id: int,
) -> None:
async with AsyncSessionLocal() as bg_db:
service = AIPMOKernelService(bg_db, tenant_id=int(tenant_id))
await service.process_ingress(
source_system="ai_pmo_ui",
source_message_id=source_message_id,
message_text=f"继续处理 work_item #{int(work_item_id)}",
sender=str(sender or "PMFlow用户"),
channel_ref="ai_pmo_ui",
source_sender_open_id=None,
captured_at=None,
chat_type="manual",
project_hint_id=int(project_id),
)
def _normalize_noise_text(text: str) -> str:
return re.sub(r"[\\s\\??!!,,。~~、]+", "", str(text or "")).strip().lower()
def _should_hide_work_item_from_inbox(*, row: WorkItem, ingest: IngestEvent) -> bool:
intent_name = str(row.intent or "").strip().lower()
if intent_name != FRONT_NOISE_INTENT:
return False
payload = ingest.payload if isinstance(ingest.payload, dict) else {}
message_text = str(payload.get("message_text") or "").strip()
normalized = _normalize_noise_text(message_text)
if not normalized:
return True
if any(keyword in normalized for keyword in FRONT_NOISE_STRUCTURED_HINTS):
return False
if normalized in FRONT_NOISE_EXACT:
return True
if len(normalized) <= 8 and any(keyword in normalized for keyword in FRONT_NOISE_HINTS):
return True
return False
@router.post("/cadence/run")
async def run_cadence(
request: PMOCadenceRunRequest,
@ -386,6 +573,43 @@ def _compact_text(value: Any, limit: int) -> str:
return f"{text[: max(limit - 1, 1)].rstrip()}…"
def _normalize_module_list(value: Any) -> list[str]:
if not isinstance(value, list):
return []
modules: list[str] = []
for row in value:
item = str(row or "").strip()
if not item or item in modules:
continue
modules.append(item[:50])
return modules[:8]
def _is_low_value_closure_payload(payload: dict[str, Any]) -> bool:
summary = str(payload.get("closure_summary") or "").strip()
dialogue = str(payload.get("closure_dialogue") or "").strip()
modules = _normalize_module_list(payload.get("closure_modules"))
turns = payload.get("closure_turns") if isinstance(payload.get("closure_turns"), list) else []
has_closure = bool(summary or dialogue or modules)
if not has_closure:
return True
compact_summary = re.sub(r"\s+", "", summary).lower() if summary else ""
summary_is_generic = bool(compact_summary and GENERIC_SOURCE_TEXT_RE.match(compact_summary))
weak_summary = bool(summary and any(token in summary for token in LOW_VALUE_CLOSURE_HINTS))
has_assistant_turn = any(
str(item.get("role") or "").strip().lower() == "assistant" and str(item.get("text") or "").strip()
for item in turns
if isinstance(item, dict)
)
if weak_summary and not has_assistant_turn:
return True
if summary_is_generic and not modules and not has_assistant_turn:
return True
if dialogue and "机器人:" not in dialogue and len(turns) <= 1 and weak_summary:
return True
return False
def _priority_from_review(review_summary: Any) -> tuple[str, int]:
payload = review_summary if isinstance(review_summary, dict) else {}
risk_score = float(payload.get("risk_score_after") or 0.0)
@ -433,20 +657,188 @@ def _stage_text(state: str) -> str:
return mapping.get(str(state or "").strip(), str(state or "").strip() or "-")
def _serialize_work_item_summary(*, row: WorkItem, ingest: IngestEvent, project: Optional[Project]) -> dict[str, Any]:
payload = ingest.payload if isinstance(ingest.payload, dict) else {}
def _normalize_approval_requirement(value: Any) -> Optional[dict[str, Any]]:
if not isinstance(value, dict):
return None
reason = str(value.get("reason") or value.get("policy_gate_code") or "").strip() or None
message = str(value.get("message") or value.get("hint") or value.get("detail") or "").strip() or None
return {
**value,
"required": bool(value.get("required")),
"reason": reason,
"message": message,
}
def _extract_gate_projection_from_work_item(row: WorkItem) -> dict[str, Any]:
execution_result = row.execution_result if isinstance(row.execution_result, dict) else {}
review_summary = row.review_summary if isinstance(row.review_summary, dict) else {}
policy_gate_code = (
str(
execution_result.get("policy_gate_code")
or execution_result.get("gate_code")
or review_summary.get("policy_gate_code")
or review_summary.get("gate_code")
or ""
).strip()
or None
)
approval_requirement = _normalize_approval_requirement(
execution_result.get("approval_requirement")
if isinstance(execution_result.get("approval_requirement"), dict)
else review_summary.get("approval_requirement")
)
return {
"latest_policy_gate_code": policy_gate_code,
"approval_requirement": approval_requirement,
}
async def _build_latest_decision_projection(
*,
db: AsyncSession,
tenant_id: int,
work_item_ids: list[int],
) -> dict[int, dict[str, Any]]:
if not work_item_ids:
return {}
rows = (
await db.execute(
select(DecisionRecord)
.where(
and_(
DecisionRecord.tenant_id == tenant_id,
DecisionRecord.work_item_id.in_(work_item_ids),
)
)
.order_by(DecisionRecord.work_item_id.asc(), DecisionRecord.id.desc())
)
).scalars().all()
projection: dict[int, dict[str, Any]] = {}
for row in rows:
work_item_id = _safe_int(row.work_item_id)
if work_item_id is None or work_item_id in projection:
continue
projection[work_item_id] = {
"latest_policy_gate_code": str(row.policy_gate_code or "").strip() or None,
"approval_requirement": _normalize_approval_requirement(row.approval_requirement),
}
return projection
async def _build_closure_payload_overrides(
*,
db: AsyncSession,
tenant_id: int,
rows: list[tuple[WorkItem, IngestEvent, Optional[Project]]],
) -> dict[int, dict[str, Any]]:
if not rows:
return {}
service = AIPMOKernelService(db, tenant_id=tenant_id)
overrides: dict[int, dict[str, Any]] = {}
for row, ingest, _project in rows:
payload = ingest.payload if isinstance(ingest.payload, dict) else {}
if not isinstance(payload, dict):
continue
has_closure = bool(
str(payload.get("closure_summary") or "").strip()
or str(payload.get("closure_dialogue") or "").strip()
or (isinstance(payload.get("closure_modules"), list) and payload.get("closure_modules"))
)
if has_closure and not _is_low_value_closure_payload(payload):
continue
message_text = str(payload.get("message_text") or "").strip()
if not message_text:
continue
source_system = str(row.source_system or "").strip() or "pmflow_manual"
captured_at_seed: Optional[str] = str(payload.get("captured_at") or "").strip() or None
if not captured_at_seed and isinstance(row.created_at, datetime):
captured_at_seed = row.created_at.isoformat()
closure_payload = await service._build_message_closure_payload(
source_system=source_system,
channel_ref=str(payload.get("channel_ref") or "").strip() or None,
sender=str(payload.get("sender") or "").strip(),
source_sender_open_id=str(payload.get("source_sender_open_id") or "").strip() or None,
message_text=message_text,
source_message_id=str(row.source_message_id or "").strip() or None,
owner_user_id=_safe_int(row.owner_user_id),
project_id=_safe_int(row.project_id),
captured_at=captured_at_seed,
)
if not any(
(
str(closure_payload.get("closure_summary") or "").strip(),
str(closure_payload.get("closure_dialogue") or "").strip(),
isinstance(closure_payload.get("closure_modules"), list) and closure_payload.get("closure_modules"),
)
):
continue
overrides[int(row.id)] = {
**payload,
"closure_summary": closure_payload.get("closure_summary"),
"closure_dialogue": closure_payload.get("closure_dialogue"),
"closure_modules": closure_payload.get("closure_modules"),
"closure_turns": closure_payload.get("closure_turns"),
}
return overrides
def _serialize_work_item_summary(
*,
row: WorkItem,
ingest: IngestEvent,
project: Optional[Project],
latest_decision_projection: Optional[dict[str, Any]] = None,
payload_override: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
payload = payload_override if isinstance(payload_override, dict) else (
ingest.payload if isinstance(ingest.payload, dict) else {}
)
message_text = str(payload.get("message_text") or "").strip()
summary = _compact_text(message_text or _next_step_text(row) or row.intent or "AI PMO 工作项", 180)
title = _compact_text(message_text or f"AI PMO · {row.intent or 'work_item'}", 64)
closure_dialogue = str(payload.get("closure_dialogue") or "").strip() or None
closure_summary = str(payload.get("closure_summary") or "").strip() or None
closure_modules = _normalize_module_list(payload.get("closure_modules"))
closure_turns = payload.get("closure_turns") if isinstance(payload.get("closure_turns"), list) else []
source_captured_at = str(payload.get("captured_at") or "").strip() or None
source_sender = str(payload.get("sender") or "").strip() or None
source_channel_ref = str(payload.get("channel_ref") or "").strip() or None
summary_seed = closure_summary or message_text or _next_step_text(row) or row.intent or "AI PMO 工作项"
summary = _compact_text(summary_seed, 180)
compact_message = re.sub(r"\s+", "", message_text).lower() if message_text else ""
prefer_closure_title = bool(compact_message and GENERIC_SOURCE_TEXT_RE.match(compact_message))
title_seed = (
(closure_summary or message_text)
if prefer_closure_title
else (message_text or closure_summary or f"AI PMO · {row.intent or 'work_item'}")
)
title = _compact_text(title_seed, 64)
priority, priority_rank = _priority_from_review(row.review_summary)
project_name = None
if project is not None:
project_name = str(project.title or project.name or "").strip() or None
project_id = _safe_int(row.project_id)
decision_projection = latest_decision_projection if isinstance(latest_decision_projection, dict) else {}
latest_policy_gate_code = str(decision_projection.get("latest_policy_gate_code") or "").strip() or None
approval_requirement = _normalize_approval_requirement(decision_projection.get("approval_requirement"))
if latest_policy_gate_code is None or approval_requirement is None:
fallback_projection = _extract_gate_projection_from_work_item(row)
if latest_policy_gate_code is None:
latest_policy_gate_code = str(fallback_projection.get("latest_policy_gate_code") or "").strip() or None
if approval_requirement is None:
approval_requirement = _normalize_approval_requirement(fallback_projection.get("approval_requirement"))
return {
"id": int(row.id),
"source_system": row.source_system,
"source_message_id": row.source_message_id,
"source_message_text": _compact_text(message_text, 600) or None,
"source_conversation_closure": _compact_text(closure_dialogue, 2000) or None,
"source_captured_at": source_captured_at,
"source_sender": source_sender,
"source_channel_ref": source_channel_ref,
"closure_summary": closure_summary,
"implementation_modules": closure_modules,
"source_closure_turns": closure_turns[:4],
"state": row.state,
"intent": row.intent,
"decision_level": row.decision_level,
@ -462,6 +854,8 @@ def _serialize_work_item_summary(*, row: WorkItem, ingest: IngestEvent, project:
"stage_text": _stage_text(str(row.state or "")),
"priority": priority,
"priority_rank": priority_rank,
"latest_policy_gate_code": latest_policy_gate_code,
"approval_requirement": approval_requirement,
"target_url": f"/projects/{project_id}/board" if project_id is not None else None,
"review_summary": row.review_summary,
"execution_result": row.execution_result,

View File

@ -83,6 +83,9 @@ from ..utils.timezone import CN_TZ, now_cn_aware, now_cn_naive
router = APIRouter(tags=["sstc"])
_FEISHU_PLACEHOLDER_RE = re.compile(r"^飞书用户\(([^)]+)\)$")
_SIGNAL_PREFIX_RE = re.compile(r"^\[(.*?)\]\s*:\s*")
_GENERIC_SIGNAL_SOURCE_TITLE_RE = re.compile(
r"(加入到信号源|加入信号源|新增(?:一个)?信号源|创建(?:一个)?信号源|添加(?:一个)?信号源)"
)
DEFAULT_SIGNAL_AUTO_ENTER_THRESHOLD = 0.80
DEFAULT_PROJECT_AUTO_CREATE_THRESHOLD = 0.85
@ -519,7 +522,21 @@ def _extract_sender_from_content(content: str) -> Optional[str]:
def _signal_title_text(signal: Signal) -> str:
return _strip_signal_sender_prefix(str(signal.content or ""))
for candidate in (
str(signal.title or "").strip(),
str(signal.summary or "").strip(),
_strip_signal_sender_prefix(str(signal.content or "")),
):
compacted = compact_title(candidate, max_len=80)
if not compacted:
continue
normalized = re.sub(r"\s+", "", compacted).lower()
if _GENERIC_SIGNAL_SOURCE_TITLE_RE.search(normalized):
specific_hints = ("chat_id", "http", "https", "飞书", "企业微信", "github", "gitlab", "jira", "webhook", "项目", "群聊", "repo")
if not any(hint in normalized for hint in specific_hints):
return "信号源接入请求(待补充参数)"
return compacted
return ""
def _signal_proposer_name(signal: Signal) -> str:

View File

@ -31,6 +31,7 @@ from ..models import (
Project,
ProjectDomain,
ProjectSignalLink,
QARecord,
ReplyReceipt,
Signal,
Task,
@ -152,12 +153,31 @@ SMALLTALK_EXACT = {
"thx",
"在吗",
"在不在",
"怎么样了",
"咋样了",
"辛苦了",
}
SMALLTALK_SHORT_HINTS = ("怎么样了", "咋样了", "在吗", "辛苦了", "收到", "谢谢", "ok")
SMALLTALK_STRUCTURED_HINTS = ("项目", "信号", "任务", "风险", "进度", "状态", "work_item", "工作项", "#")
TRANSPORT_MODE_CORRECTION_HINTS = ("不是", "不再", "改为", "改成", "替换为", "取代", "现在都", "现在是")
FOLLOWUP_CONFIRM_HINTS = ("确认归属", "确认项目", "确认到项目", "确认关联", "确认这条")
FOLLOWUP_CONTINUE_HINTS = ("继续处理", "继续执行", "接着处理", "往下做", "继续推进")
FOLLOWUP_WORK_ITEM_ID_RE = re.compile(r"(?:work[_ -]?item|工作项|队列项)\s*(?:id|ID)?\s*[:#:]?\s*(\d+)", re.IGNORECASE)
SIGNAL_CREATE_HINTS = ("新增一个信号", "新增一条信号", "增加一个信号", "录入一个信号", "关注", "跟踪")
GENERIC_SIGNAL_SOURCE_MESSAGE_RE = re.compile(
r"^(?:请)?(?:按|按照)?(?:这个|上述|这条)?(?:分步|步骤)?(?:骤)?(?:把|将)?(?:它|该需求|这条需求)?(?:加入|接入|录入).{0,24}信号源(?:中)?$",
re.IGNORECASE,
)
MODULE_KEYWORD_RULES: tuple[tuple[str, tuple[str, ...]], ...] = (
("用户管理与权限", ("用户管理", "角色权限", "crud", "批量导入", "成员管理")),
("技能图谱", ("技能图谱", "技能标签", "能力图谱", "雷达图")),
("工作量管理", ("工作量", "负载", "产能", "workload")),
("SSTC 工作流", ("sstc", "charter", "signal", "task", "execution", "release", "review", "工作流")),
("AI 能力编排", ("ai能力", "智能", "自动化", "推荐", "编排", "agent", "llm")),
("信号源接入", ("信号源", "source", "onboard", "接入")),
("飞书集成", ("飞书", "feishu", "群聊", "机器人")),
("消息台与详情页", ("消息台", "详情页", "展开详情", "摘要", "时间戳", "按钮")),
)
PROJECT_CREATE_HINTS = (
"创建项目",
"新建项目",
@ -379,7 +399,7 @@ class IntentResolver:
pretty = re.sub(r"\s+", " ", str(text or "")).strip()
if not pretty:
return CanonicalIntent(INTENT_IGNORE, 1.0, project_id, "", "", {"reason": "empty"})
if normalized in SMALLTALK_EXACT:
if IntentResolver._is_smalltalk_message(pretty=pretty, normalized=normalized):
return CanonicalIntent(INTENT_IGNORE, 0.95, project_id, "", "", {"reason": "smalltalk"})
if any(token in pretty for token in FOLLOWUP_CONFIRM_HINTS):
return CanonicalIntent(INTENT_FOLLOWUP_CONFIRM, 0.82, project_id, "", "", {})
@ -433,6 +453,19 @@ class IntentResolver:
{"fallback": True, "reason": "default_query_fallback"},
)
@staticmethod
def _is_smalltalk_message(*, pretty: str, normalized: str) -> bool:
compact = re.sub(r"[\\s\\??!!,,。~~、]+", "", str(pretty or "")).strip().lower()
if not compact:
return True
if any(keyword in compact for keyword in SMALLTALK_STRUCTURED_HINTS):
return False
if compact in SMALLTALK_EXACT:
return True
if len(compact) <= 8 and any(keyword in compact for keyword in SMALLTALK_SHORT_HINTS):
return True
return False
@staticmethod
def _looks_like_query(*, text: str) -> bool:
normalized = re.sub(r"\s+", "", str(text or "")).lower()
@ -892,6 +925,71 @@ class TypedExecutor:
self.tenant_id = int(tenant_id)
self.signal_source_workflow = SignalSourceDomainWorkflow(db)
@staticmethod
def _normalize_short_text(value: Any, *, max_len: int) -> str:
text = re.sub(r"\s+", " ", str(value or "")).strip()
if not text:
return ""
return text[:max_len]
@staticmethod
def _is_generic_signal_source_title(value: str) -> bool:
compact = re.sub(r"\s+", "", str(value or "")).strip().lower()
if not compact:
return True
if bool(GENERIC_SIGNAL_SOURCE_MESSAGE_RE.match(compact)):
return True
return compact in {"信号源接入请求", "新增信号源接入请求", "新增信号源"}
@classmethod
def _build_signal_source_semantic_payload(
cls,
*,
intent: CanonicalIntent,
message_text: str,
) -> dict[str, Any]:
metadata = intent.metadata if isinstance(intent.metadata, dict) else {}
modules = metadata.get("closure_modules") if isinstance(metadata.get("closure_modules"), list) else []
normalized_modules = [str(item).strip() for item in modules if str(item).strip()][:6]
summary = cls._normalize_short_text(
metadata.get("closure_summary") or intent.signal_summary or message_text,
max_len=300,
)
dialogue = cls._normalize_short_text(
metadata.get("closure_dialogue") or message_text,
max_len=1800,
)
title_seed = cls._normalize_short_text(intent.signal_title or "信号源接入请求", max_len=120)
if cls._is_generic_signal_source_title(title_seed):
if normalized_modules:
title_seed = f"信号源接入 · {normalized_modules[0]}"[:120]
else:
title_seed = "信号源接入请求"
reasoning_bullets = ["由 AI PMO kernel 统一入库。"]
if normalized_modules:
reasoning_bullets.append(f"闭环提炼模块:{'、'.join(normalized_modules[:4])}")
if summary:
reasoning_bullets.append(f"闭环摘要:{summary[:120]}")
strategic_analysis = {
"source_context": {
"conversation_closure": dialogue or None,
"implementation_modules": normalized_modules,
},
"ai_analysis": {
"summary": summary or None,
"intent": INTENT_SIGNAL_SOURCE_ONBOARD,
"requirement_project_scope": "existing_project" if intent.project_id is not None else "pending_classification",
},
}
return {
"title": title_seed,
"summary": summary or cls._normalize_short_text(message_text, max_len=300),
"content": dialogue or cls._normalize_short_text(message_text, max_len=1800),
"modules": normalized_modules,
"reasoning_bullets": reasoning_bullets,
"strategic_analysis": strategic_analysis,
}
async def execute(
self,
*,
@ -946,14 +1044,28 @@ class TypedExecutor:
}
if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD:
semantic_payload = self._build_signal_source_semantic_payload(
intent=intent,
message_text=message_text,
)
signal = await self._create_signal(
title=intent.signal_title or "新增信号源接入请求",
summary=intent.signal_summary or message_text,
content=message_text,
title=str(semantic_payload.get("title") or intent.signal_title or "新增信号源接入请求"),
summary=str(semantic_payload.get("summary") or intent.signal_summary or message_text),
content=str(semantic_payload.get("content") or message_text),
project_id=intent.project_id,
source="PMFlow",
signal_type="新需求",
recommended_action="enter_only",
reasoning_bullets=(
semantic_payload.get("reasoning_bullets")
if isinstance(semantic_payload.get("reasoning_bullets"), list)
else None
),
strategic_analysis=(
semantic_payload.get("strategic_analysis")
if isinstance(semantic_payload.get("strategic_analysis"), dict)
else None
),
)
domain_workflow = await self.signal_source_workflow.run(
tenant_id=self.tenant_id,
@ -1007,6 +1119,8 @@ class TypedExecutor:
source: str,
signal_type: str,
recommended_action: str,
reasoning_bullets: Optional[list[str]] = None,
strategic_analysis: Optional[dict[str, Any]] = None,
) -> Signal:
signal = Signal(
tenant_id=self.tenant_id,
@ -1015,7 +1129,8 @@ class TypedExecutor:
summary=(summary or content or "")[:300],
content=(content or summary or "")[:2000],
signal_type=signal_type,
reasoning=json.dumps(["由 AI PMO kernel 统一入库。"], ensure_ascii=False),
reasoning=json.dumps(reasoning_bullets or ["由 AI PMO kernel 统一入库。"], ensure_ascii=False),
strategic_analysis=(json.dumps(strategic_analysis, ensure_ascii=False) if isinstance(strategic_analysis, dict) else None),
confidence=0.78,
recommended_action=recommended_action,
matched_project_id=int(project_id) if project_id is not None else None,
@ -1173,6 +1288,16 @@ class AIPMOKernelService:
sender=sender,
message_text=clean_text,
)
closure_payload = await self._build_message_closure_payload(
source_system=source_system,
channel_ref=channel_ref,
sender=sender,
source_sender_open_id=source_sender_open_id,
message_text=clean_text,
source_message_id=synthetic_message_id,
captured_at=captured_at,
project_id=project_hint_id,
)
ingest_event, created = await self._claim_ingress_event(
source_system=source_system,
@ -1184,6 +1309,10 @@ class AIPMOKernelService:
"chat_type": chat_type,
"captured_at": captured_at.isoformat() if isinstance(captured_at, datetime) else None,
"message_text": clean_text,
"closure_dialogue": closure_payload.get("closure_dialogue"),
"closure_summary": closure_payload.get("closure_summary"),
"closure_modules": closure_payload.get("closure_modules"),
"closure_turns": closure_payload.get("closure_turns"),
},
)
if not created:
@ -1231,6 +1360,11 @@ class AIPMOKernelService:
session=session,
world_model=world_model,
)
intent = self._enrich_signal_source_intent_from_closure(
intent=intent,
message_text=clean_text,
closure_payload=closure_payload,
)
perf_intent_name = intent.name
_log_perf("_resolve_intent", stage_started_at)
if intent_context_source:
@ -3104,6 +3238,15 @@ class AIPMOKernelService:
signal_summary=rebuilt_intent.signal_summary,
metadata=dict(rebuilt_intent.metadata),
)
target_intent = self._enrich_signal_source_intent_from_closure(
intent=target_intent,
message_text=original_message,
closure_payload={
"closure_summary": payload.get("closure_summary"),
"closure_dialogue": payload.get("closure_dialogue"),
"closure_modules": payload.get("closure_modules"),
},
)
if target_intent.name not in FOLLOWUP_TARGET_INTENTS:
reply_text = f"工作项#{int(target_work_item.id)} 当前类型为 {target_intent.name or 'unknown'},暂不支持继续处理。"
return {
@ -3142,6 +3285,19 @@ class AIPMOKernelService:
execution_result=target_execution_result,
world_model=target_work_item.context_snapshot if isinstance(target_work_item.context_snapshot, dict) else world_model,
)
target_issues = target_verification.get("issues") if isinstance(target_verification.get("issues"), list) else []
if (
not bool(target_verification.get("verified"))
and target_intent.name == INTENT_SIGNAL_SOURCE_ONBOARD
and any(issue in {"signal_source_pending_manual", "pending_manual"} for issue in target_issues)
and _safe_int(target_execution_result.get("signal_id")) is not None
and _safe_int(target_work_item.project_id) is not None
):
target_execution_result["post_process_status"] = str(target_execution_result.get("status") or "").strip() or "pending_manual"
target_execution_result["status"] = "succeeded"
target_execution_result["manual_gate_resolved"] = True
target_verification = {"verified": True, "issues": []}
target_issues = []
target_work_item.review_summary = await self.review_service.review_work_item(
work_item=target_work_item,
intent_name=target_intent.name,
@ -3162,7 +3318,6 @@ class AIPMOKernelService:
target_work_item.state = STATE_SUCCEEDED
await self._project_success(intent=target_intent, execution_result=target_execution_result)
else:
target_issues = target_verification.get("issues") if isinstance(target_verification.get("issues"), list) else []
if any(issue in {"pending_manual", "signal_source_pending_manual"} for issue in target_issues):
target_work_item.state = STATE_WAITING_MANUAL
else:
@ -3488,6 +3643,430 @@ class AIPMOKernelService:
labels = [f"工作项#{int(item.id)}({str(item.intent or '').strip() or 'unknown'})" for item in items[:3]]
return f"当前存在多个待确认工作项:{'、'.join(labels)}。请补充工作项 ID 后再继续处理。"
@staticmethod
def _build_feishu_rag_conversation_key(
*,
source_chat_id: Optional[str],
source_sender_open_id: Optional[str],
sender: Optional[str],
) -> str:
chat = str(source_chat_id or "").strip()
if not chat:
return ""
actor = str(source_sender_open_id or "").strip()
if not actor:
actor = str(sender or "").strip().lower()
actor = re.sub(r"\s+", "", actor)
actor = actor[:96] if actor else "unknown"
return f"feishu:{chat}:{actor}"
@staticmethod
def _normalize_dialogue_text(value: Any, *, max_len: int) -> str:
text = re.sub(r"\s+", " ", str(value or "")).strip()
if not text:
return ""
return text[:max_len]
@staticmethod
def _parse_datetime_like(value: Any) -> Optional[datetime]:
if isinstance(value, datetime):
dt = value
else:
raw = str(value or "").strip()
if not raw:
return None
try:
dt = datetime.fromisoformat(raw.replace("Z", "+00:00"))
except Exception:
return None
if dt.tzinfo is not None:
return dt.astimezone(timezone.utc).replace(tzinfo=None)
return dt
@staticmethod
def _normalize_feishu_user_text(value: Any, *, max_len: int) -> str:
text = str(value or "")
text = re.sub(r"^@_user_\d+\s*", "", text)
text = re.sub(r"</?at[^>]*>", " ", text, flags=re.IGNORECASE)
text = re.sub(r"\s+", " ", text).strip()
if not text:
return ""
return text[:max_len]
@classmethod
def _extract_assistant_text_from_log_payload(cls, payload: Any) -> str:
if not isinstance(payload, dict):
return ""
candidates = (
payload.get("followup_reply"),
payload.get("direct_reply_text"),
payload.get("clarification_message"),
payload.get("answer"),
payload.get("reason"),
payload.get("result_summary"),
payload.get("error_message"),
payload.get("message"),
)
for item in candidates:
text = cls._normalize_dialogue_text(item, max_len=420)
if not text:
continue
if any(token in text for token in ("chat_id", "message_type", "sender_name")) and len(text) < 48:
continue
return text
return ""
@staticmethod
def _merge_closure_turns(
base_turns: list[dict[str, str]],
extra_turns: list[dict[str, str]],
*,
prepend: bool = False,
) -> list[dict[str, str]]:
if not extra_turns:
return list(base_turns)
merged = list(extra_turns) + list(base_turns) if prepend else list(base_turns) + list(extra_turns)
compact: list[dict[str, str]] = []
for row in merged:
role = str(row.get("role") or "").strip().lower()
text = str(row.get("text") or "").strip()
if role not in {"user", "assistant"} or not text:
continue
if compact and compact[-1].get("role") == role and compact[-1].get("text") == text:
continue
compact.append({"role": role, "text": text})
return compact[-8:]
@staticmethod
def _turns_have_assistant(turns: list[dict[str, str]]) -> bool:
return any(str(turn.get("role") or "").strip().lower() == "assistant" and str(turn.get("text") or "").strip() for turn in turns)
async def _load_closure_turns_from_processing_logs(
self,
*,
source_message_id: Optional[str],
message_text: str,
) -> list[dict[str, str]]:
message_id = str(source_message_id or "").strip()
if not message_id:
return []
rows = (
await self.db.execute(
select(AIProcessingLog)
.where(
and_(
AIProcessingLog.tenant_id == self.tenant_id,
AIProcessingLog.feishu_message_id == message_id,
)
)
.order_by(AIProcessingLog.id.asc())
.limit(12)
)
).scalars().all()
if not rows:
return []
normalized_message = self._normalize_dialogue_text(message_text, max_len=600)
turns: list[dict[str, str]] = []
for row in rows:
user_text = self._normalize_feishu_user_text(
row.normalized_text or row.raw_message,
max_len=260,
)
if user_text and (not normalized_message or user_text != normalized_message):
turns = self._merge_closure_turns(turns, [{"role": "user", "text": user_text}])
assistant_text = self._extract_assistant_text_from_log_payload(row.operation_result)
if assistant_text:
turns = self._merge_closure_turns(turns, [{"role": "assistant", "text": assistant_text}])
return turns[-4:]
@classmethod
def _score_qa_row_for_closure(
cls,
*,
row: QARecord,
normalized_message: str,
reference_at: Optional[datetime],
) -> float:
question = cls._normalize_dialogue_text(row.question, max_len=500)
answer = cls._normalize_dialogue_text(row.answer, max_len=900)
if not question or not answer:
return -1.0
score = 0.0
merged = f"{question} {answer}"
if "信号源" in merged:
score += 3.0
if any(token in merged for token in ("模块", "后端", "前端", "流程", "SSTC", "任务", "执行", "接口")):
score += 2.0
if any(token in answer for token in ("暂时无法生成可靠回答", "模型服务异常", "请稍后重试")):
score -= 5.0
if normalized_message and cls._is_generic_signal_source_message(normalized_message):
if "信号源" in question:
score += 1.5
if "项目" in answer:
score += 1.0
created_at = cls._parse_datetime_like(getattr(row, "created_at", None))
if reference_at is not None and created_at is not None:
gap_seconds = abs((reference_at - created_at).total_seconds())
if gap_seconds > 7 * 24 * 3600:
score -= 4.0
elif gap_seconds <= 6 * 3600:
score += 1.5
elif gap_seconds <= 24 * 3600:
score += 0.8
return score
async def _load_closure_turns_from_qa_records(
self,
*,
owner_user_id: Optional[int],
project_id: Optional[int],
captured_at: Optional[datetime],
message_text: str,
) -> list[dict[str, str]]:
uid = _safe_int(owner_user_id)
pid = _safe_int(project_id)
if uid is None and pid is None:
return []
normalized_message = self._normalize_dialogue_text(message_text, max_len=600)
reference_at = self._parse_datetime_like(captured_at)
candidates: list[QARecord] = []
conditions: list[Any] = []
if uid is not None and pid is not None:
conditions.append(and_(QARecord.asked_by == int(uid), QARecord.project_id == int(pid)))
if pid is not None:
conditions.append(QARecord.project_id == int(pid))
if uid is not None:
conditions.append(QARecord.asked_by == int(uid))
for condition in conditions:
stmt = select(QARecord).where(
and_(
QARecord.tenant_id == self.tenant_id,
QARecord.asked_via == "feishu",
condition,
)
)
if reference_at is not None:
stmt = stmt.where(QARecord.created_at <= reference_at)
rows = (
await self.db.execute(
stmt.order_by(QARecord.created_at.desc(), QARecord.id.desc()).limit(12)
)
).scalars().all()
candidates.extend(rows)
if rows:
break
if not candidates:
return []
best_row: Optional[QARecord] = None
best_score = -999.0
seen_ids: set[int] = set()
for row in candidates:
row_id = int(getattr(row, "id", 0) or 0)
if row_id > 0 and row_id in seen_ids:
continue
seen_ids.add(row_id)
score = self._score_qa_row_for_closure(
row=row,
normalized_message=normalized_message,
reference_at=reference_at,
)
if score > best_score:
best_score = score
best_row = row
if best_row is None or best_score < 0.5:
return []
question = self._normalize_dialogue_text(best_row.question, max_len=260)
answer = self._normalize_dialogue_text(best_row.answer, max_len=420)
if not question or not answer:
return []
return [
{"role": "user", "text": question},
{"role": "assistant", "text": answer},
]
@staticmethod
def _is_generic_signal_source_message(text: str) -> bool:
compact = re.sub(r"\s+", "", str(text or "")).strip().lower()
if not compact:
return False
if bool(GENERIC_SIGNAL_SOURCE_MESSAGE_RE.match(compact)):
return True
return len(compact) <= 24 and "信号源" in compact and ("这个" in compact or "步骤" in compact)
@classmethod
def _extract_implementation_modules_from_text(cls, text: str) -> list[str]:
normalized = str(text or "").lower()
modules: list[str] = []
for module_name, keywords in MODULE_KEYWORD_RULES:
if any(str(keyword).lower() in normalized for keyword in keywords):
modules.append(module_name)
return modules[:6]
@staticmethod
def _build_closure_dialogue_excerpt(turns: list[dict[str, str]]) -> str:
if not turns:
return ""
lines: list[str] = []
for turn in turns[-4:]:
role = str(turn.get("role") or "").strip().lower()
text = str(turn.get("text") or "").strip()
if not text:
continue
if role == "assistant":
lines.append(f"机器人:{text[:380]}")
else:
lines.append(f"用户:{text[:260]}")
return "\n".join(lines)[:1600]
@classmethod
def _derive_closure_summary(
cls,
*,
turns: list[dict[str, str]],
modules: list[str],
fallback_text: str,
) -> str:
latest_user = ""
latest_assistant = ""
for turn in reversed(turns):
role = str(turn.get("role") or "").strip().lower()
text = str(turn.get("text") or "").strip()
if not text:
continue
if role == "user" and not latest_user:
latest_user = text
if role == "assistant" and not latest_assistant:
latest_assistant = text
if latest_user and latest_assistant:
break
if latest_assistant and any(token in latest_assistant for token in ("缺少项目归属", "不能创建", "无法创建", "先确认项目")):
if latest_user:
return f"机器人先提示条件不足,用户随后继续要求执行:{latest_user[:120]}"
return "机器人提示条件不足,需补齐项目归属后继续执行信号源接入。"
if latest_user and cls._is_generic_signal_source_message(latest_user) and modules:
if len(modules) == 1 and str(modules[0]).strip() == "信号源接入":
return "用户要求将需求按步骤接入信号源,需要先补齐项目归属再继续执行。"
return f"同一消息闭环内,用户要求将需求按步骤接入信号源,重点模块:{'、'.join(modules[:4])}。"
if modules:
return f"用户在本次消息闭环中推进信号源接入,涉及模块:{'、'.join(modules[:4])}。"
if latest_user:
return latest_user[:200]
return str(fallback_text or "").strip()[:200]
async def _build_message_closure_payload(
self,
*,
source_system: str,
channel_ref: Optional[str],
sender: str,
source_sender_open_id: Optional[str],
message_text: str,
source_message_id: Optional[str] = None,
owner_user_id: Optional[int] = None,
project_id: Optional[int] = None,
captured_at: Optional[Any] = None,
) -> dict[str, Any]:
normalized_message = self._normalize_dialogue_text(message_text, max_len=600)
turns: list[dict[str, str]] = []
normalized_source_system = str(source_system or "").strip().lower()
normalized_channel = str(channel_ref or "").strip()
if normalized_source_system == "feishu" and normalized_channel:
conversation_key = self._build_feishu_rag_conversation_key(
source_chat_id=normalized_channel,
source_sender_open_id=source_sender_open_id,
sender=sender,
)
session_id = AdaptiveRAGService._external_session_id_from_key(conversation_key)
if session_id is not None:
cached_messages = await self.context_cache.get_session_messages(
tenant_id=self.tenant_id,
session_id=int(session_id),
)
cached_turns = self.context_cache.messages_to_turns(cached_messages)
for row in cached_turns[-2:]:
question = self._normalize_dialogue_text(row.get("question"), max_len=260)
answer = self._normalize_dialogue_text(row.get("answer"), max_len=420)
if question:
turns.append({"role": "user", "text": question})
if answer:
turns.append({"role": "assistant", "text": answer})
if normalized_source_system == "feishu" and not self._turns_have_assistant(turns):
log_turns = await self._load_closure_turns_from_processing_logs(
source_message_id=source_message_id,
message_text=normalized_message,
)
turns = self._merge_closure_turns(turns, log_turns)
needs_semantic_turns = (
normalized_source_system == "feishu"
and (
(not self._turns_have_assistant(turns))
or (self._is_generic_signal_source_message(normalized_message) and len(turns) <= 2)
)
)
if needs_semantic_turns:
qa_turns = await self._load_closure_turns_from_qa_records(
owner_user_id=owner_user_id,
project_id=project_id,
captured_at=self._parse_datetime_like(captured_at),
message_text=normalized_message,
)
turns = self._merge_closure_turns(turns, qa_turns, prepend=True)
if normalized_message:
if not turns or str(turns[-1].get("text") or "").strip() != normalized_message:
turns.append({"role": "user", "text": normalized_message})
closure_dialogue = self._build_closure_dialogue_excerpt(turns)
modules = self._extract_implementation_modules_from_text(
" ".join(str(turn.get("text") or "") for turn in turns)
)
closure_summary = self._derive_closure_summary(
turns=turns,
modules=modules,
fallback_text=normalized_message,
)
return {
"closure_dialogue": closure_dialogue or None,
"closure_summary": closure_summary or None,
"closure_modules": modules,
"closure_turns": turns[-4:],
}
@classmethod
def _enrich_signal_source_intent_from_closure(
cls,
*,
intent: CanonicalIntent,
message_text: str,
closure_payload: dict[str, Any],
) -> CanonicalIntent:
if intent.name != INTENT_SIGNAL_SOURCE_ONBOARD:
return intent
modules = closure_payload.get("closure_modules") if isinstance(closure_payload.get("closure_modules"), list) else []
closure_summary = cls._normalize_dialogue_text(closure_payload.get("closure_summary"), max_len=300)
closure_dialogue = cls._normalize_dialogue_text(closure_payload.get("closure_dialogue"), max_len=1600)
if closure_summary:
intent.signal_summary = closure_summary
elif not str(intent.signal_summary or "").strip():
intent.signal_summary = cls._normalize_dialogue_text(message_text, max_len=300)
if cls._is_generic_signal_source_message(str(intent.signal_title or "")):
if modules:
intent.signal_title = f"信号源接入 · {str(modules[0])[:60]}"[:120]
else:
intent.signal_title = "信号源接入请求"
metadata = dict(intent.metadata or {})
metadata["closure_summary"] = closure_summary or None
metadata["closure_dialogue"] = closure_dialogue or None
metadata["closure_modules"] = [str(item) for item in modules[:6] if str(item).strip()]
intent.metadata = metadata
return intent
@staticmethod
def _build_source_message_id(
*,

View File

@ -853,10 +853,11 @@ class SignalSourceOrchestrationService:
},
last_signal_at=_utc_now_naive(),
)
self.db.add(row)
try:
await self.db.flush()
await self.db.commit()
# 只回滚本次插入尝试,避免误回滚外层事务里已创建的 signal 记录。
async with self.db.begin_nested():
self.db.add(row)
await self.db.flush()
await self.db.refresh(row)
return {
"status": "created",
@ -867,7 +868,8 @@ class SignalSourceOrchestrationService:
"description": str(row.description or ""),
}
except IntegrityError:
await self.db.rollback()
if row in self.db:
self.db.expunge(row)
existing = (
await self.db.execute(
select(SignalSourceRegistry).where(
@ -895,7 +897,7 @@ class SignalSourceOrchestrationService:
existing.status = "pending"
existing.onboarding_status = "pending"
existing.last_signal_at = _utc_now_naive()
await self.db.commit()
await self.db.flush()
return {
"status": "reused",
"registry_id": int(existing.id),
@ -928,16 +930,50 @@ class SignalSourceOrchestrationService:
try:
from .openclaw_commander_service import OpenClawCommanderService
result = await OpenClawCommanderService(self.db).dispatch_command(
tenant_id=tenant_id,
operator_user=operator_user,
project_id=int(project.id),
instruction=instruction,
dry_run=dry_run,
max_operations=6,
response_mode=response_mode,
persist_run=False,
)
session_factory = getattr(type(self), "session_factory", AsyncSessionLocal)
async with session_factory() as isolated_db:
isolated_project = (
await isolated_db.execute(
select(Project).where(
and_(
Project.tenant_id == int(tenant_id),
Project.id == int(project.id),
)
)
)
).scalar_one_or_none()
if isolated_project is None:
return {
"status": "skipped",
"reason": "project_not_found",
}
isolated_operator = (
await isolated_db.execute(
select(User).where(
and_(
User.tenant_id == int(tenant_id),
User.id == int(operator_user.id),
User.is_active.is_(True),
)
)
)
).scalar_one_or_none()
if isolated_operator is None:
return {
"status": "skipped",
"reason": "no_manager_operator",
}
result = await OpenClawCommanderService(isolated_db).dispatch_command(
tenant_id=tenant_id,
operator_user=isolated_operator,
project_id=int(isolated_project.id),
instruction=instruction,
dry_run=dry_run,
max_operations=6,
response_mode=response_mode,
persist_run=False,
)
await isolated_db.commit()
return {
"status": "planned" if dry_run else "completed",
"result": result,

View File

@ -1,3 +1,4 @@
import json
import os
import unittest
from datetime import datetime
@ -15,6 +16,8 @@ from app.models import (
PortfolioSnapshot,
Project,
ProjectDomain,
Signal,
SignalSourceRegistry,
Task,
TruthNode,
User,
@ -196,6 +199,13 @@ class AIPMOKernelServiceTest(unittest.IsolatedAsyncioTestCase):
)
self.assertEqual(intent.name, INTENT_PROJECT_CREATE)
def test_intent_resolver_filters_smalltalk_phrase(self) -> None:
intent = IntentResolver.resolve(
text="怎么样了?",
project_id=None,
)
self.assertEqual(intent.name, "ignore")
async def test_build_world_model_includes_budget_capacity_and_skill_graph(self) -> None:
async with self.session_factory() as db:
service = AIPMOKernelService(db, tenant_id=1)
@ -336,6 +346,52 @@ class AIPMOKernelServiceTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(session_row.last_intent, INTENT_SIGNAL_SOURCE_ONBOARD)
self.assertEqual(int(session_row.current_project_id or 0), 1)
async def test_signal_source_execute_persists_closure_summary_and_modules(self) -> None:
async with self.session_factory() as db:
service = AIPMOKernelService(db, tenant_id=1)
intent = service._build_canonical_intent(
intent_name=INTENT_SIGNAL_SOURCE_ONBOARD,
message_text="按照这个分步骤加入到信号源中",
project_id=1,
confidence=0.93,
metadata={
"closure_summary": "同一消息闭环内,用户要求将需求按步骤接入信号源,重点模块:用户管理与权限、SSTC 工作流。",
"closure_dialogue": "机器人:当前无法直接创建,请先确认项目。\n用户:按照这个分步骤加入到信号源中",
"closure_modules": ["用户管理与权限", "SSTC 工作流"],
},
)
with patch.object(
SignalSourceDomainWorkflow,
"run",
AsyncMock(return_value={"status": "completed", "raw_result": {}}),
):
result = await service.executor.execute(
intent=intent,
message_text="按照这个分步骤加入到信号源中",
sender="老板",
operator_user_id=1,
source_system="feishu",
source_message_id="semantic-signal-source-1",
)
signal_row = (
await db.execute(
select(Signal).where(
and_(
Signal.tenant_id == 1,
Signal.id == int(result["signal_id"]),
)
)
)
).scalar_one()
strategic = json.loads(str(signal_row.strategic_analysis or "{}") or "{}")
source_context = strategic.get("source_context") if isinstance(strategic, dict) else {}
self.assertEqual(signal_row.title, "信号源接入 · 用户管理与权限")
self.assertIn("重点模块:用户管理与权限", str(signal_row.summary or ""))
self.assertIn("机器人:当前无法直接创建", str(signal_row.content or ""))
self.assertEqual(source_context.get("implementation_modules"), ["用户管理与权限", "SSTC 工作流"])
async def test_project_id_inherits_from_recent_session_when_message_has_no_project_text(self) -> None:
async with self.session_factory() as db:
service = AIPMOKernelService(db, tenant_id=1)
@ -1028,6 +1084,98 @@ class AIPMOKernelServiceTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(followup["status"], "answered")
self.assertIn("已继续处理", followup["direct_reply_text"])
async def test_followup_resume_marks_signal_source_pending_manual_as_succeeded_when_gate_resolved(self) -> None:
async with self.session_factory() as db:
service = AIPMOKernelService(db, tenant_id=1)
with patch(
"app.services.ai_pmo_kernel_service.SignalSourceDomainWorkflow.run",
AsyncMock(
return_value={
"status": "pending_manual",
"registry_id": 12,
"typed_actions": [
{
"operation_code": "notification.create",
"status": "applied",
"payload": {"title": "待确认"},
}
],
"material_action_count": 0,
"fallback_used": True,
"raw_result": {
"signal_source": {
"registry_id": 12,
"status": "pending",
"onboarding_status": "pending_manual",
},
"openclaw": {
"status": "completed",
"result": {
"execution": {
"results": [
{
"operation_code": "notification.create",
"status": "applied",
"payload": {"title": "待确认"},
}
]
}
},
},
"worker_executions": [],
"pending_workers": [],
},
}
),
):
first = await service.process_ingress(
source_system="feishu",
source_message_id="feishu-source-pending-manual-1",
message_text="给项目ID: 1 新增一个 GitHub 信号源,监控 openai/codex。",
sender="老板",
channel_ref="oc_phase3_resume_pending",
source_sender_open_id="ou_boss",
project_hint_id=1,
)
target_id = int(first["work_item_id"])
followup = await service.process_ingress(
source_system="ai_pmo_ui",
source_message_id="resume-ui-pending-manual-1",
message_text=f"继续处理 work_item #{target_id}",
sender="老板",
channel_ref="ai_pmo_ui",
project_hint_id=1,
)
original = (
await db.execute(
select(WorkItem).where(
and_(
WorkItem.tenant_id == 1,
WorkItem.source_message_id == "feishu-source-pending-manual-1",
)
)
)
).scalar_one()
followup_item = (
await db.execute(
select(WorkItem).where(
and_(
WorkItem.tenant_id == 1,
WorkItem.source_message_id == "resume-ui-pending-manual-1",
)
)
)
).scalar_one()
self.assertEqual(first["status"], "captured")
self.assertEqual(followup["status"], "answered")
self.assertIn("已继续处理", followup["direct_reply_text"])
self.assertEqual(original.state, "succeeded")
self.assertEqual(original.execution_result.get("status"), "succeeded")
self.assertTrue(bool(original.execution_result.get("manual_gate_resolved")))
self.assertEqual(original.execution_result.get("post_process_status"), "pending_manual")
self.assertEqual(followup_item.state, "succeeded")
def test_signal_source_domain_workflow_keeps_notification_only_pending_manual(self) -> None:
normalized = SignalSourceDomainWorkflow.normalize(
{
@ -1116,3 +1264,157 @@ class AIPMOKernelServiceTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(result["source_kind"], "feishu")
self.assertNotEqual(result["source_name"], "给项目ID")
self.assertTrue(str(result["source_identifier"]).startswith("feishu:"))
async def test_signal_source_registry_reuse_does_not_rollback_new_signal_in_outer_tx(self) -> None:
async with self.session_factory() as db:
registry_service = SignalSourceOrchestrationService(db)
source_payload = {
"source_kind": "github",
"source_name": "openai/codex",
"source_identifier": "github:openai/codex",
"description": "监控仓库动态",
"config": {"repo": "openai/codex"},
}
created = await registry_service._create_or_reuse_registry(
tenant_id=1,
project_id=1,
signal_id=None,
operator_user_id=1,
requested_via="feishu",
source_payload=source_payload,
initiating_text="新增 GitHub 信号源 openai/codex",
dry_run=False,
)
signal = Signal(
tenant_id=1,
source="PMFlow",
title="重复信号源续跑校验",
summary="用于校验事务回滚边界",
content="用于校验事务回滚边界",
signal_type="新需求",
reasoning="[]",
confidence=0.78,
recommended_action="enter_only",
matched_project_id=1,
impact_score=0.7,
priority="medium",
processed=False,
tasks_generated="[]",
status="待处理",
)
db.add(signal)
await db.flush()
reused = await registry_service._create_or_reuse_registry(
tenant_id=1,
project_id=1,
signal_id=int(signal.id),
operator_user_id=1,
requested_via="ai_pmo_ui",
source_payload=source_payload,
initiating_text="继续处理同一信号源",
dry_run=False,
)
persisted_signal = (
await db.execute(
select(Signal).where(
and_(
Signal.tenant_id == 1,
Signal.id == int(signal.id),
)
)
)
).scalar_one_or_none()
registry_row = (
await db.execute(
select(SignalSourceRegistry).where(
and_(
SignalSourceRegistry.tenant_id == 1,
SignalSourceRegistry.project_id == 1,
SignalSourceRegistry.source_kind == "github",
SignalSourceRegistry.source_identifier == "github:openai/codex",
)
)
)
).scalar_one()
await db.commit()
self.assertEqual(created["status"], "created")
self.assertEqual(reused["status"], "reused")
self.assertIsNotNone(persisted_signal)
self.assertEqual(int(registry_row.signal_id or 0), int(signal.id))
async def test_dispatch_openclaw_isolated_session_does_not_rollback_outer_signal(self) -> None:
async with self.session_factory() as db:
service = SignalSourceOrchestrationService(db)
project = (
await db.execute(
select(Project).where(
and_(
Project.tenant_id == 1,
Project.id == 1,
)
)
)
).scalar_one()
operator = (
await db.execute(
select(User).where(
and_(
User.tenant_id == 1,
User.id == 1,
)
)
)
).scalar_one()
signal = Signal(
tenant_id=1,
source="PMFlow",
title="OpenClaw 事务隔离校验",
summary="验证回滚不影响外层事务",
content="验证回滚不影响外层事务",
signal_type="新需求",
reasoning="[]",
confidence=0.78,
recommended_action="enter_only",
matched_project_id=1,
impact_score=0.7,
priority="medium",
processed=False,
tasks_generated="[]",
status="待处理",
)
db.add(signal)
await db.flush()
signal_id = int(signal.id)
async def _fake_dispatch(commander_self, **_: object): # type: ignore[no-untyped-def]
await commander_self.db.rollback()
return {"execution": {"results": []}}
with patch(
"app.services.openclaw_commander_service.OpenClawCommanderService.dispatch_command",
new=_fake_dispatch,
):
result = await service._dispatch_openclaw(
tenant_id=1,
operator_user=operator,
project=project,
instruction="生成任务并通知负责人",
dry_run=False,
response_mode="fast",
)
persisted_signal = (
await db.execute(
select(Signal).where(
and_(
Signal.tenant_id == 1,
Signal.id == signal_id,
)
)
)
).scalar_one_or_none()
await db.commit()
self.assertIn(result["status"], {"completed", "failed"})
self.assertIsNotNone(persisted_signal)

View File

@ -2,12 +2,13 @@ import os
import unittest
from tempfile import NamedTemporaryFile
from types import SimpleNamespace
from unittest.mock import patch
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.api.ai_pmo import PMOWorkItemDecisionRequest, _normalize_resume_result, decide_work_item
from app.models import Base, DecisionRecord, IngestEvent, User, WorkItem
from app.api.ai_pmo import PMOWorkItemDecisionRequest, _normalize_resume_result, decide_work_item, list_work_items
from app.models import AIProcessingLog, Base, DecisionRecord, IngestEvent, QARecord, User, WorkItem
class AIPMOWorkItemDecisionApiTest(unittest.IsolatedAsyncioTestCase):
@ -141,3 +142,394 @@ class AIPMOWorkItemDecisionApiTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(refreshed.state, "ignored")
self.assertEqual(refreshed.reply_template_key, "action_rejected")
self.assertEqual(refreshed_decision.final_outcome, "ignored")
async def test_list_work_items_returns_latest_policy_projection(self) -> None:
async with self.session_factory() as db:
user = User(
tenant_id=1,
username="decision_viewer",
email="decision_viewer@example.com",
password_hash="hash",
name="查看人",
role="BOSS",
status="active",
is_active=True,
)
db.add(user)
await db.flush()
ingest = IngestEvent(
tenant_id=1,
channel="ai_pmo_ui_message",
event_key="decision-list-1",
status="done",
payload={
"message_text": "按照这个分步骤加入到信号源中",
"closure_summary": "同一消息闭环内,用户要求将需求按步骤接入信号源,重点模块:SSTC 工作流、AI 能力编排。",
"closure_dialogue": "机器人:当前无法直接创建,请先确认项目。\n用户:按照这个分步骤加入到信号源中",
"closure_modules": ["SSTC 工作流", "AI 能力编排"],
},
)
db.add(ingest)
await db.flush()
work_item = WorkItem(
tenant_id=1,
ingest_event_id=int(ingest.id),
source_system="ai_pmo_ui",
source_message_id="decision-list-item-1",
state="waiting_manual",
intent="signal_source.onboard",
project_id=None,
signal_id=None,
execution_result={"status": "waiting_manual"},
)
db.add(work_item)
await db.flush()
db.add(
DecisionRecord(
tenant_id=1,
work_item_id=int(work_item.id),
decision_object_type="project",
decision_object_id=None,
decision_level="project",
policy_gate_hit=True,
policy_gate_code="legacy_gate",
approval_requirement={"required": True, "reason": "legacy_gate", "message": "旧规则"},
)
)
db.add(
DecisionRecord(
tenant_id=1,
work_item_id=int(work_item.id),
decision_object_type="project",
decision_object_id=None,
decision_level="project",
policy_gate_hit=True,
policy_gate_code="missing_project_scope",
approval_requirement={"required": True, "reason": "missing_project_scope", "message": "请选择关联项目"},
)
)
await db.commit()
response = await list_work_items(
limit=20,
state=None,
project_id=None,
current_user=user,
db=db,
tenant_id=1,
)
self.assertEqual(response["code"], 0)
items = response["data"]["items"]
self.assertEqual(len(items), 1)
self.assertEqual(items[0]["latest_policy_gate_code"], "missing_project_scope")
self.assertEqual(items[0]["approval_requirement"]["required"], True)
self.assertEqual(items[0]["approval_requirement"]["reason"], "missing_project_scope")
self.assertEqual(items[0]["approval_requirement"]["message"], "请选择关联项目")
self.assertEqual(items[0]["closure_summary"], "同一消息闭环内,用户要求将需求按步骤接入信号源,重点模块:SSTC 工作流、AI 能力编排。")
self.assertEqual(items[0]["implementation_modules"], ["SSTC 工作流", "AI 能力编排"])
self.assertIn("机器人:当前无法直接创建", str(items[0]["source_conversation_closure"]))
async def test_list_work_items_fallbacks_to_execution_result_when_decision_missing(self) -> None:
async with self.session_factory() as db:
user = User(
tenant_id=1,
username="decision_viewer_fallback",
email="decision_viewer_fallback@example.com",
password_hash="hash",
name="查看人",
role="BOSS",
status="active",
is_active=True,
)
db.add(user)
await db.flush()
ingest = IngestEvent(
tenant_id=1,
channel="ai_pmo_ui_message",
event_key="decision-list-fallback-1",
status="done",
payload={"message_text": "按步骤加入信号源"},
)
db.add(ingest)
await db.flush()
work_item = WorkItem(
tenant_id=1,
ingest_event_id=int(ingest.id),
source_system="ai_pmo_ui",
source_message_id="decision-list-fallback-item-1",
state="waiting_manual",
intent="signal_source.onboard",
project_id=None,
signal_id=None,
execution_result={
"status": "waiting_manual",
"policy_gate_code": "missing_project_scope",
"approval_requirement": {
"required": True,
"reason": "missing_project_scope",
"message": "请先确认项目归属",
},
},
)
db.add(work_item)
await db.commit()
response = await list_work_items(
limit=20,
state=None,
project_id=None,
current_user=user,
db=db,
tenant_id=1,
)
self.assertEqual(response["code"], 0)
items = response["data"]["items"]
self.assertEqual(len(items), 1)
self.assertEqual(items[0]["latest_policy_gate_code"], "missing_project_scope")
self.assertEqual(items[0]["approval_requirement"]["required"], True)
self.assertEqual(items[0]["approval_requirement"]["reason"], "missing_project_scope")
self.assertEqual(items[0]["approval_requirement"]["message"], "请先确认项目归属")
async def test_list_work_items_rebuilds_low_value_closure_from_persistent_logs(self) -> None:
async with self.session_factory() as db:
user = User(
tenant_id=1,
username="decision_viewer_closure_rebuild",
email="decision_viewer_closure_rebuild@example.com",
password_hash="hash",
name="查看人",
role="BOSS",
status="active",
is_active=True,
)
db.add(user)
await db.flush()
db.add(
QARecord(
tenant_id=1,
question="当前 PMFlow 项目已完成用户管理、SSTC 工作流和 AI 自动化编排。",
answer="核心模块包括用户管理、SSTC 工作流、AI 能力编排,建议按信号源接入流程继续推进。",
intent="question",
asked_by=int(user.id),
asked_via="feishu",
session_id=11,
turn_index=1,
)
)
ingest = IngestEvent(
tenant_id=1,
channel="feishu_message",
event_key="om-closure-rebuild-1",
status="done",
payload={
"message_text": "按照这个分步骤加入到信号源中",
"channel_ref": "oc_closure_rebuild",
"sender": "查看人",
"source_sender_open_id": "ou_closure_rebuild",
"closure_summary": "信号源接入缺少项目归属,必须先确认项目。",
"closure_dialogue": "用户:按照这个分步骤加入到信号源中",
"closure_modules": [],
"closure_turns": [{"role": "user", "text": "按照这个分步骤加入到信号源中"}],
},
)
db.add(ingest)
await db.flush()
work_item = WorkItem(
tenant_id=1,
ingest_event_id=int(ingest.id),
source_system="feishu",
source_message_id="om-closure-rebuild-1",
state="waiting_manual",
intent="signal_source.onboard",
owner_user_id=int(user.id),
project_id=None,
signal_id=None,
execution_result={"status": "waiting_manual"},
)
db.add(work_item)
await db.flush()
db.add(
AIProcessingLog(
tenant_id=1,
feishu_message_id="om-closure-rebuild-1",
raw_message="按照这个分步骤加入到信号源中",
normalized_text="按照这个分步骤加入到信号源中",
route="ai_pmo.waiting_manual",
intent="signal_source.onboard",
routing_decision="ai_pmo.waiting_manual",
processing_status="success",
operation_result={"reason": "信号源接入缺少项目归属,必须先确认项目。"},
)
)
await db.commit()
response = await list_work_items(
limit=20,
state=None,
project_id=None,
current_user=user,
db=db,
tenant_id=1,
)
self.assertEqual(response["code"], 0)
items = response["data"]["items"]
self.assertEqual(len(items), 1)
self.assertIn("机器人先提示条件不足,用户随后继续要求执行", str(items[0]["closure_summary"]))
self.assertIn("用户:按照这个分步骤加入到信号源中", str(items[0]["source_conversation_closure"]))
self.assertIn("机器人:信号源接入缺少项目归属,必须先确认项目。", str(items[0]["source_conversation_closure"]))
self.assertIn("SSTC 工作流", items[0]["implementation_modules"])
async def test_decision_approve_missing_project_scope_returns_fast_and_sets_executing(self) -> None:
async with self.session_factory() as db:
user = User(
tenant_id=1,
username="decision_approver",
email="decision_approver@example.com",
password_hash="hash",
name="审批人",
role="BOSS",
status="active",
is_active=True,
)
db.add(user)
await db.flush()
ingest = IngestEvent(
tenant_id=1,
channel="ai_pmo_ui_message",
event_key="decision-approve-fast-1",
status="done",
payload={"message_text": "按分步骤加入信号源"},
)
db.add(ingest)
await db.flush()
work_item = WorkItem(
tenant_id=1,
ingest_event_id=int(ingest.id),
source_system="ai_pmo_ui",
source_message_id="decision-approve-fast-item-1",
state="waiting_manual",
intent="signal_source.onboard",
project_id=None,
signal_id=None,
execution_result={"status": "waiting_manual"},
)
db.add(work_item)
await db.flush()
db.add(
DecisionRecord(
tenant_id=1,
work_item_id=int(work_item.id),
decision_object_type="project",
decision_object_id=None,
decision_level="project",
policy_gate_hit=True,
policy_gate_code="missing_project_scope",
approval_requirement={"required": True, "reason": "missing_project_scope", "message": "请选择项目"},
)
)
await db.commit()
with patch("app.api.ai_pmo._queue_resume_work_item") as mocked_queue:
response = await decide_work_item(
work_item_id=int(work_item.id),
request=PMOWorkItemDecisionRequest(action="approve", project_id=8),
current_user=user,
db=db,
tenant_id=1,
)
mocked_queue.assert_called_once()
refreshed = (
await db.execute(
select(WorkItem).where(
and_(
WorkItem.tenant_id == 1,
WorkItem.id == int(work_item.id),
)
)
)
).scalar_one()
refreshed_decision = (
await db.execute(
select(DecisionRecord).where(
and_(
DecisionRecord.tenant_id == 1,
DecisionRecord.work_item_id == int(work_item.id),
)
)
)
).scalar_one()
self.assertEqual(response["code"], 0)
self.assertEqual(response["data"]["status"], "pending_manual")
self.assertEqual(response["data"]["project_id"], 8)
self.assertEqual(refreshed.state, "executing")
self.assertEqual(refreshed.project_id, 8)
self.assertEqual(refreshed_decision.policy_gate_code, None)
self.assertEqual(bool((refreshed_decision.approval_requirement or {}).get("required")), False)
async def test_list_work_items_hides_smalltalk_query_rows(self) -> None:
async with self.session_factory() as db:
user = User(
tenant_id=1,
username="decision_smalltalk_viewer",
email="decision_smalltalk_viewer@example.com",
password_hash="hash",
name="查看人",
role="BOSS",
status="active",
is_active=True,
)
db.add(user)
await db.flush()
ingest = IngestEvent(
tenant_id=1,
channel="ai_pmo_ui_message",
event_key="decision-smalltalk-1",
status="done",
payload={"message_text": "怎么样了?"},
)
db.add(ingest)
await db.flush()
work_item = WorkItem(
tenant_id=1,
ingest_event_id=int(ingest.id),
source_system="ai_pmo_ui",
source_message_id="decision-smalltalk-item-1",
state="succeeded",
intent="query.read_only",
project_id=None,
signal_id=None,
execution_result={"status": "succeeded", "answer": "已收到"},
)
db.add(work_item)
await db.commit()
response = await list_work_items(
limit=20,
state=None,
project_id=None,
current_user=user,
db=db,
tenant_id=1,
)
self.assertEqual(response["code"], 0)
self.assertEqual(response["data"]["items"], [])

View File

@ -0,0 +1,121 @@
# Inbox 消息台内联操作(三期执行清单)
> 目标:用户在消息台完成待处理动作,尽量不跳转;实现过程中不绕开现有 AI PMO 状态机闭环。
## Phase 1(先落地,最小闭环)
### 范围
- 前端内联处理 `missing_project_scope` 卡片。
- 后端只做字段投影增强,不新增执行链路接口。
### 后端 Checklist
- [x] `GET /ai-pmo/work-items` 增加可直接渲染字段:
- [x] `latest_policy_gate_code`
- [x] `approval_requirement`(至少含 `required/reason/message`)
- [x] 字段来源明确为“最近一条 `decision_records` 投影”,禁止前端猜文案。
- [x] 保持现有 `POST /ai-pmo/work-items/{id}/decision` 语义不变。
### 前端 Checklist
- [x] 消息台卡片识别条件:
- [x] `state=waiting_manual`
- [x] `latest_policy_gate_code=missing_project_scope`
- [x] 内联面板显示“项目下拉 + 确认关联”。
- [x] 项目下拉数据源使用 `GET /projects?status=active`。
- [x] 点击确认后调用:
- [x] `POST /ai-pmo/work-items/{id}/decision`
- [x] body: `{ action: "approve", project_id }`
- [x] 成功后刷新消息台并显示 followup 回执文案。
### 验收
- [x] 缺项目归属卡片可在消息台直接补项目并继续执行。
- [x] 不新增 `resolve-project` 接口。
- [x] 不直连 `TypedExecutor`。
---
## Phase 2(扩展内联动作,不破坏闭环)
### 范围
- 支持更多 `waiting_manual` 场景统一内联处理。
- 负载预警改为复用现有 workload 接口。
### 后端 Checklist
- [ ] `GET /ai-pmo/work-items` 继续补充前端所需稳定字段(如 `intent`、`execution_result` 关键子字段)。
- [ ] 若需负责人相关动作,先定义 owner 写入目标(task/registry/work_item),再补接口,不先拍脑袋加 `assign-owner`。
### 前端 Checklist
- [ ] 通用 `waiting_manual` 面板(非 missing_project_scope)支持:
- [ ] 继续执行
- [ ] 标记失效
- [ ] 查看详情
- [ ] 负载预警卡片实现方式:
- [ ] 预览建议:`POST /workloads/ai-optimize`
- [ ] 应用建议:`POST /workloads/ai-optimize/apply`
- [ ] 禁止依赖不存在接口:`GET /workloads/suggestions`。
### 验收
- [ ] 负载预警可在消息台触发建议预览与应用。
- [ ] 所有动作仍通过既有审计/回执链路。
---
## Phase 3(信号源治理与增强)
### 范围
- 信号源相关内联治理动作补齐(编辑/删除/确认信息)。
- 明确通用信号源 API 边界后再接入消息台按钮。
### 后端 Checklist
- [ ] 先梳理“通用信号源 CRUD”真实接口归属(当前 `/signal-sources` 为食谱 webhook,不可直接复用)。
- [ ] 若缺通用接口,按规范新增并补测试。
- [ ] 补充操作审计(operation logs)与幂等约束。
### 前端 Checklist
- [ ] 基于真实通用接口接入:
- [ ] 编辑信号源
- [ ] 删除信号源(确认弹窗)
- [ ] 不在 `InboxPage` 内直接写 `fetch`,统一走 `frontend/src/api/`。
### 验收
- [ ] 消息台信号源治理动作可用,且接口与模型一致。
- [ ] 审计可追踪,状态可回放。
---
## 统一守则(每期都要过)
- [x] 不新增绕开 `ai_pmo` 闭环的执行入口。
- [x] 不做“前端猜测字符串”分支判断,优先消费稳定结构字段。
- [x] 变更后跑通:
- [x] `npm run build`
- [x] `tsc --noEmit`
- [x] 相关后端测试(新增接口时必须补测)
## 回填记录(2026-03-12)
- Phase 1 状态:已完成并通过验证。
- 后端验证:
- `../venv/bin/python -m pytest -q tests/test_ai_pmo_work_item_decision_api.py` → `3 passed`
- `../venv/bin/python -m py_compile app/api/ai_pmo.py` → 通过
- 前端验证:
- `npm run build` → 通过
- `npx tsc --noEmit` → 通过
- 热修(2026-03-12 同日):
- `POST /ai-pmo/work-items/{id}/decision` 在 `waiting_manual + project_id` 场景改为“快速返回 + 后台续跑”,避免消息台按钮长期卡在“处理中...”
- `POST /ai-pmo/work-items/{id}/decision` 在上述场景会先把目标 work item 状态置为 `executing`(前端即时从“待处理”转为“处理中”)
- 前端 `decideWorkItem` 增加 15s 超时保护,超时会回到可交互状态并提示错误
- 口水话(短 query,如“怎么样了/在吗/辛苦了”)在后端列表接口过滤,不再下发到消息台前端(数据仍保留在后端记录)
- 修复信号源续跑事务穿透:`signal_source_registries` 重复键分支改为 savepoint,且 OpenClaw 指挥链改为隔离 Session,避免 rollback 回滚外层新建 `signal`,彻底消除 `ai_orchestration_runs.signal_id` 外键报错
- 修复“续跑后仍停在待人工确认”:`signal_source.onboard` 在 `pending_manual/signal_source_pending_manual` 且已具备 `project_id+signal_id` 时强制落 `succeeded`,并记录 `manual_gate_resolved=true`
- Phase 2/3:未开始,保持待办。
## 本清单对应文件范围(执行时参考)
- `frontend/src/pages/InboxPage.tsx`
- `frontend/src/components/inbox/`
- `frontend/src/lib/inbox.ts`
- `frontend/src/types/inbox.ts`
- `frontend/src/api/aiPmo.ts`
- `frontend/src/api/projects.ts`
- `frontend/src/api/workloads.ts`
- `backend/app/api/ai_pmo.py`

View File

@ -13,6 +13,14 @@ export interface AIPMOWorkItem {
id: number;
source_system: string;
source_message_id: string;
source_message_text?: string | null;
source_conversation_closure?: string | null;
source_captured_at?: string | null;
source_sender?: string | null;
source_channel_ref?: string | null;
closure_summary?: string | null;
implementation_modules?: string[] | null;
source_closure_turns?: Array<Record<string, unknown>> | null;
state: string;
intent?: string | null;
decision_level?: string | null;
@ -31,10 +39,19 @@ export interface AIPMOWorkItem {
target_url?: string | null;
review_summary?: Record<string, unknown> | null;
execution_result?: Record<string, unknown> | null;
latest_policy_gate_code?: string | null;
approval_requirement?: AIPMOApprovalRequirement | null;
created_at?: string | null;
updated_at?: string | null;
}
export interface AIPMOApprovalRequirement {
required?: boolean;
reason?: string | null;
message?: string | null;
[key: string]: unknown;
}
export interface AIPMOWorkItemListPayload {
items: AIPMOWorkItem[];
}
@ -51,7 +68,7 @@ export interface AIPMODecisionRecordDetail {
simulation_result?: Record<string, unknown> | null;
policy_gate_hit?: boolean;
policy_gate_code?: string | null;
approval_requirement?: Record<string, unknown> | null;
approval_requirement?: AIPMOApprovalRequirement | null;
execution_result?: Record<string, unknown> | null;
final_outcome?: string | null;
}
@ -159,7 +176,11 @@ export const aiPmoApi = {
},
async decideWorkItem(workItemId: number, body: AIPMOWorkItemDecisionRequest): Promise<AIPMOResumeResult> {
const { data } = await api.post<ApiEnvelope<AIPMOResumeResult>>(`/ai-pmo/work-items/${workItemId}/decision`, body);
const { data } = await api.post<ApiEnvelope<AIPMOResumeResult>>(
`/ai-pmo/work-items/${workItemId}/decision`,
body,
{ timeout: 15000 }
);
return unwrap(data);
},

View File

@ -603,7 +603,7 @@ export function FloatingQAChat() {
<button
type="button"
onClick={() => setOpen((prev) => !prev)}
className={`fixed bottom-4 right-4 z-[120] flex h-11 w-11 items-center justify-center rounded-full text-white shadow-xl ring-1 ring-black/10 transition md:bottom-6 md:right-6 ${
className={`fixed bottom-20 right-4 z-[120] flex h-11 w-11 items-center justify-center rounded-full text-white shadow-xl ring-1 ring-black/10 transition md:bottom-6 md:right-6 ${
open ? "bg-violet-700" : "bg-violet-600 hover:bg-violet-700"
}`}
aria-label="打开 AI 聊天"
@ -613,7 +613,7 @@ export function FloatingQAChat() {
</button>
<div
className={`fixed bottom-20 left-3 right-3 z-[121] h-[72vh] rounded-2xl border bg-background/95 shadow-2xl backdrop-blur-sm transition-all duration-300 sm:bottom-24 sm:left-auto sm:right-6 sm:h-[78vh] sm:w-[380px] ${
className={`fixed bottom-36 left-3 right-3 z-[121] h-[72vh] rounded-2xl border bg-background/95 shadow-2xl backdrop-blur-sm transition-all duration-300 sm:bottom-24 sm:left-auto sm:right-6 sm:h-[78vh] sm:w-[380px] ${
open ? "translate-y-0 opacity-100" : "pointer-events-none translate-y-3 opacity-0"
}`}
>

View File

@ -0,0 +1,82 @@
import { CircleAlert } from "lucide-react";
import { Button } from "@/components/ui/button";
export interface InboxProjectOption {
value: string;
label: string;
}
interface InboxActionPanelProps {
selectedProjectId: string;
projectOptions: InboxProjectOption[];
loadingProjects?: boolean;
projectsErrorText?: string | null;
pending: boolean;
onSelectProject: (projectId: string) => void;
onCancel: () => void;
onConfirm: () => void;
}
export default function InboxActionPanel({
selectedProjectId,
projectOptions,
loadingProjects = false,
projectsErrorText = null,
pending,
onSelectProject,
onCancel,
onConfirm,
}: InboxActionPanelProps) {
const isDisabled = pending || loadingProjects || projectOptions.length === 0;
const canConfirm = !isDisabled && Boolean(selectedProjectId);
return (
<div className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-3">
<div className="flex items-center gap-2 text-sm font-medium text-amber-800">
<CircleAlert className="h-4 w-4" />
<span>信号源接入缺少项目归属</span>
</div>
<div className="mt-3 text-xs text-slate-600">请选择关联项目:</div>
<select
value={selectedProjectId}
onChange={(event) => onSelectProject(event.target.value)}
className="mt-1 h-9 w-full rounded-md border border-slate-200 bg-white px-3 text-sm text-slate-700"
disabled={isDisabled}
>
<option value="">{loadingProjects ? "加载项目中..." : "请选择项目"}</option>
{projectOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{projectsErrorText ? (
<p className="mt-2 text-xs text-red-600">{projectsErrorText}</p>
) : null}
<div className="mt-3 flex items-center justify-end gap-2">
<Button
type="button"
size="sm"
variant="outline"
className="h-8 border-slate-200 bg-white text-slate-700 hover:bg-slate-50"
onClick={onCancel}
disabled={pending}
>
取消
</Button>
<Button
type="button"
size="sm"
className="h-8 bg-blue-600 px-3 text-white hover:bg-blue-700"
onClick={onConfirm}
disabled={!canConfirm}
>
{pending ? "处理中…" : "确认关联"}
</Button>
</div>
</div>
);
}

View File

@ -1,3 +1,4 @@
import type { ReactNode } from "react";
import { BellRing, CheckCircle2, ChevronDown, ChevronUp, CircleAlert, LoaderCircle } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@ -52,6 +53,7 @@ interface InboxListItemProps {
item: InboxItemViewModel;
expanded: boolean;
pendingActionKey: string | null;
inlinePanel?: ReactNode;
onToggleExpand: (itemId: string) => void;
onAction: (item: InboxItemViewModel, action: InboxActionDescriptor) => void;
}
@ -60,6 +62,7 @@ export default function InboxListItem({
item,
expanded,
pendingActionKey,
inlinePanel,
onToggleExpand,
onAction,
}: InboxListItemProps) {
@ -67,14 +70,45 @@ export default function InboxListItem({
const projectLabel = normalizeProjectLabel(item.project_name, item.project_id);
const title = sanitizeInboxTitle(item.title);
const subtitle = sanitizeInboxSubtitle(item.summary);
const displayTime = formatInboxTime(item.updated_at || item.created_at);
const fullTime = formatInboxAbsoluteTime(item.updated_at || item.created_at);
const StatusIcon = resolveStatusIcon(item.display_kind);
const payload = (item.source_notification?.payload ?? {}) as Record<string, unknown>;
const sourceMessageText =
String(item.source_work_item?.source_message_text || payload.message_text || payload.raw_message || "").trim();
const closureDialogue = String(item.source_work_item?.source_conversation_closure || "").trim();
const closureSummary = String(item.source_work_item?.closure_summary || "").trim();
const implementationModules = Array.isArray(item.source_work_item?.implementation_modules)
? item.source_work_item?.implementation_modules
.map((module) => String(module || "").trim())
.filter(Boolean)
.slice(0, 6)
: [];
const sourceCapturedAt =
String(
item.source_work_item?.source_captured_at ||
payload.captured_at ||
payload.occurred_at ||
""
).trim() || null;
const sourceSender =
String(item.source_work_item?.source_sender || payload.sender || payload.operator || "").trim() || "-";
const sourceChannel =
String(item.source_work_item?.source_channel_ref || payload.channel_ref || payload.source_channel || "").trim() || "-";
const sourceMessageId =
String(item.source_work_item?.source_message_id || payload.source_message_id || payload.message_id || "").trim() || "-";
const sourceSystem =
String(item.source_work_item?.source_system || item.source_notification?.source || payload.source_system || "PMFlow").trim();
const timelineAnchor = sourceCapturedAt || item.updated_at || item.created_at;
const displayTime = formatInboxTime(timelineAnchor);
const eventTime = formatInboxAbsoluteTime(timelineAnchor);
const queuedTime = formatInboxAbsoluteTime(item.created_at || item.updated_at);
const rawIntent = String(item.source_work_item?.intent || payload.intent || "").trim();
const typedActions = payload.typed_actions ?? payload["typed actions"] ?? payload.typedActions;
const chatId = payload.chat_id ?? payload.source_chat_id ?? payload.chatId;
const requirementSummary = sanitizeInboxSubtitle(closureSummary || item.summary || item.ai_plan || item.next_step_text || "暂无摘要");
const executionSummary = sanitizeInboxSubtitle(item.ai_plan || item.next_step_text || item.summary || "暂无");
const readableInput = closureDialogue || sourceMessageText || (item.source_work_item ? sanitizeInboxTitle(item.title) : "-");
const undoDeadlineText = item.can_undo ? `可撤销(截止 ${item.undo_deadline || "-"})` : "不可撤销";
const primaryActionKey = buildInboxActionKey(item, item.primary_action);
const secondaryActionKey = buildInboxActionKey(item, item.secondary_action);
@ -162,19 +196,77 @@ export default function InboxListItem({
</div>
</div>
<div className="mt-2 rounded-lg border border-dashed border-slate-200 bg-white px-3 py-2">
<div className="text-[11px] font-semibold text-slate-500">技术字段(默认折叠)</div>
<div className="mt-1 grid gap-1 text-xs text-slate-600 sm:grid-cols-2">
<div>intent(可读):{mapIntentLabel(rawIntent)}</div>
<div>intent(原始):{rawIntent || "-"}</div>
<div>typed actions:{formatTechnicalValue(typedActions)}</div>
<div>chat_id:{formatTechnicalValue(chatId)}</div>
<div>重复次数:{item.dedupe_count}</div>
<div>撤销状态:{item.can_undo ? `可撤销(截止 ${item.undo_deadline || "-"})` : "不可撤销"}</div>
<div className="sm:col-span-2">完整时间戳:{fullTime}</div>
<div className="mt-3 grid gap-2 lg:grid-cols-2">
<div className="rounded-lg border border-slate-200 bg-white px-3 py-3">
<div className="text-[11px] font-semibold text-slate-500">消息闭环(原始对话)</div>
<p className="mt-2 whitespace-pre-wrap break-words text-sm leading-6 text-slate-800">{readableInput || "-"}</p>
</div>
<div className="rounded-lg border border-slate-200 bg-white px-3 py-3">
<div className="text-[11px] font-semibold text-slate-500">需求摘要(接口总结)</div>
<p className="mt-2 whitespace-pre-wrap break-words text-sm leading-6 text-slate-800">{requirementSummary}</p>
<div className="mt-2 border-t border-slate-100 pt-2">
<div className="text-[11px] font-semibold text-slate-500">执行摘要</div>
<p className="mt-1 whitespace-pre-wrap break-words text-sm leading-6 text-slate-700">{executionSummary}</p>
</div>
</div>
</div>
{implementationModules.length > 0 && (
<div className="mt-2 rounded-lg border border-slate-200 bg-white px-3 py-3">
<div className="text-[11px] font-semibold text-slate-500">实现模块(闭环提炼)</div>
<div className="mt-2 flex flex-wrap gap-2">
{implementationModules.map((module) => (
<span
key={`${item.id}-${module}`}
className="inline-flex items-center rounded-full border border-blue-200 bg-blue-50 px-2.5 py-1 text-xs font-medium text-blue-700"
>
{module}
</span>
))}
</div>
</div>
)}
<div className="mt-2 rounded-lg border border-slate-200 bg-white px-3 py-3">
<div className="grid gap-2 text-xs text-slate-600 sm:grid-cols-2 lg:grid-cols-4">
<div>
<div className="text-[11px] font-semibold text-slate-500">事件时间</div>
<div className="mt-1 text-sm text-slate-800">{eventTime}</div>
</div>
<div>
<div className="text-[11px] font-semibold text-slate-500">入队时间</div>
<div className="mt-1 text-sm text-slate-800">{queuedTime}</div>
</div>
<div>
<div className="text-[11px] font-semibold text-slate-500">来源</div>
<div className="mt-1 text-sm text-slate-800">{sourceSystem || "-"}</div>
</div>
<div>
<div className="text-[11px] font-semibold text-slate-500">来源消息ID</div>
<div className="mt-1 truncate text-sm text-slate-800">{sourceMessageId}</div>
</div>
</div>
<p className="mt-2 text-[11px] text-slate-500">
时间说明:优先显示源系统事件时间(captured_at),无记录时回退为系统更新时间。
</p>
</div>
<details className="mt-2 rounded-lg border border-dashed border-slate-200 bg-white px-3 py-2">
<summary className="cursor-pointer text-[11px] font-semibold text-slate-500">技术字段(点击展开)</summary>
<div className="mt-2 grid gap-1 text-xs text-slate-600 sm:grid-cols-2">
<div>intent(可读):{mapIntentLabel(rawIntent)}</div>
<div>intent(原始):{rawIntent || "-"}</div>
<div>typed_actions:{formatTechnicalValue(typedActions)}</div>
<div>chat_id:{formatTechnicalValue(chatId)}</div>
<div>source_sender:{sourceSender}</div>
<div>source_channel:{sourceChannel}</div>
<div>重复次数:{item.dedupe_count}</div>
<div>撤销状态:{undoDeadlineText}</div>
</div>
</details>
{inlinePanel}
<div className="mt-3 flex flex-wrap items-center gap-2">
{!isActionRow && item.primary_action.kind !== "none" && (
<Button

View File

@ -196,9 +196,12 @@ export function TopBar({ onMenuToggle }: TopBarProps) {
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[460px] rounded-2xl border-0 bg-[#F8F9FA] p-3 shadow-[0_14px_30px_rgba(15,23,42,0.12)]">
<DropdownMenuContent
align="end"
className="w-[min(460px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] rounded-2xl border-0 bg-[#F8F9FA] p-3 shadow-[0_14px_30px_rgba(15,23,42,0.12)]"
>
<div className="rounded-2xl bg-white px-4 py-3 shadow-[0_8px_22px_rgba(15,23,42,0.08)]">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-slate-900">{zhCN.bell.title}</div>
<div className="mt-1 text-xs text-slate-500">
@ -218,7 +221,7 @@ export function TopBar({ onMenuToggle }: TopBarProps) {
</Button>
</div>
<div className="mt-4 grid grid-cols-3 gap-2">
<div className="mt-4 grid grid-cols-1 gap-2 sm:grid-cols-3">
{[
{
key: "decision",

View File

@ -81,6 +81,11 @@ export function SSTCWorkspace(props: SSTCWorkspaceViewProps) {
return (
<div className="space-y-4">
{props.isMobileViewport && (
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
请使用桌面端编辑宪章,移动端当前仅支持查看。
</div>
)}
{props.activeStage === "signal" && <SSTCSignalStagePanel {...signalProps} />}
{props.activeStage === "charter" && <SSTCCharterStagePanel {...charterProps} />}
{props.activeStage === "task" && (

View File

@ -0,0 +1,77 @@
type RoleBadgeProps = {
roles: string[];
max?: number;
className?: string;
};
type RoleBadgeMeta = {
label: string;
tone: string;
};
const ROLE_BADGE_MAP: Record<string, RoleBadgeMeta> = {
FE: { label: "前端开发", tone: "bg-blue-100 text-blue-600" },
"前端开发": { label: "前端开发", tone: "bg-blue-100 text-blue-600" },
BE: { label: "后端开发", tone: "bg-purple-100 text-purple-600" },
"后端开发": { label: "后端开发", tone: "bg-purple-100 text-purple-600" },
AI: { label: "AI工程师", tone: "bg-cyan-100 text-cyan-600" },
"AI工程师": { label: "AI工程师", tone: "bg-cyan-100 text-cyan-600" },
BOSS: { label: "Boss", tone: "bg-red-100 text-red-600" },
BOSS_CAP: { label: "Boss", tone: "bg-red-100 text-red-600" },
ADMIN: { label: "管理员", tone: "bg-orange-100 text-orange-600" },
"管理员": { label: "管理员", tone: "bg-orange-100 text-orange-600" },
"系统管理员": { label: "系统管理员", tone: "bg-orange-100 text-orange-600" },
"负责人": { label: "负责人", tone: "bg-gray-100 text-gray-600" },
};
function normalizeRoles(input: string[]): string[] {
const flat = input
.flatMap((item) => String(item || "").split(","))
.map((item) => item.trim())
.filter(Boolean);
return [...new Set(flat)];
}
function roleMeta(raw: string): RoleBadgeMeta {
const normalized = raw.trim();
const upper = normalized.toUpperCase();
if (upper === "BOSS") return ROLE_BADGE_MAP.BOSS;
if (upper === "ADMIN") return ROLE_BADGE_MAP.ADMIN;
if (upper === "AI") return ROLE_BADGE_MAP.AI;
if (upper === "FE") return ROLE_BADGE_MAP.FE;
if (upper === "BE") return ROLE_BADGE_MAP.BE;
if (normalized.toLowerCase() === "boss") return ROLE_BADGE_MAP.BOSS_CAP;
return ROLE_BADGE_MAP[normalized] || { label: normalized, tone: "bg-gray-100 text-gray-600" };
}
export function RoleBadge({ roles, max, className }: RoleBadgeProps) {
const normalized = normalizeRoles(roles);
if (!normalized.length) {
return <span className="text-xs text-gray-400">-</span>;
}
const visible = typeof max === "number" && max > 0 ? normalized.slice(0, max) : normalized;
const remain = typeof max === "number" && max > 0 ? normalized.length - visible.length : 0;
return (
<div className={`flex flex-wrap gap-1.5 ${className || ""}`.trim()}>
{visible.map((role) => {
const meta = roleMeta(role);
return (
<span
key={role}
className={`inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium ${meta.tone}`}
>
{meta.label}
</span>
);
})}
{remain > 0 && (
<span className="inline-flex items-center gap-1 rounded-md bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600">
+{remain}
</span>
)}
</div>
);
}

View File

@ -0,0 +1,66 @@
type SkillBadgeProps = {
skills: string[];
max?: number;
className?: string;
};
type SkillBadgeMeta = {
label: string;
tone: string;
};
const SKILL_BADGE_MAP: Record<string, SkillBadgeMeta> = {
python: { label: "Python", tone: "bg-yellow-100 text-yellow-700" },
typescript: { label: "TypeScript", tone: "bg-blue-100 text-blue-600" },
javascript: { label: "JavaScript", tone: "bg-yellow-100 text-yellow-600" },
react: { label: "React", tone: "bg-cyan-100 text-cyan-600" },
go: { label: "Go", tone: "bg-teal-100 text-teal-700" },
java: { label: "Java", tone: "bg-orange-100 text-orange-600" },
fastapi: { label: "FastAPI", tone: "bg-green-100 text-green-700" },
"rag构建": { label: "RAG构建", tone: "bg-violet-100 text-violet-600" },
};
function normalizeSkills(input: string[]): string[] {
const flat = input
.flatMap((item) => String(item || "").split(","))
.map((item) => item.trim())
.filter(Boolean);
return [...new Set(flat)];
}
function skillMeta(raw: string): SkillBadgeMeta {
const key = raw.trim().toLowerCase();
return SKILL_BADGE_MAP[key] || { label: raw.trim(), tone: "bg-gray-100 text-gray-500" };
}
export function SkillBadge({ skills, max = 3, className }: SkillBadgeProps) {
const normalized = normalizeSkills(skills);
if (!normalized.length) {
return <span className="text-xs text-gray-400">-</span>;
}
const visible = max > 0 ? normalized.slice(0, max) : normalized;
const remain = max > 0 ? normalized.length - visible.length : 0;
return (
<div className={`flex flex-wrap gap-1.5 ${className || ""}`.trim()}>
{visible.map((skill) => {
const meta = skillMeta(skill);
return (
<span
key={skill}
className={`inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium ${meta.tone}`}
>
{meta.label}
</span>
);
})}
{remain > 0 && (
<span className="inline-flex items-center gap-1 rounded-md bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-500">
+{remain}
</span>
)}
</div>
);
}

View File

@ -32,7 +32,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-[90] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
"fixed inset-x-0 bottom-0 z-[90] grid max-h-[90vh] w-full gap-4 overflow-y-auto border bg-background p-4 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom sm:left-[50%] sm:top-[50%] sm:bottom-auto sm:max-h-[calc(100vh-4rem)] sm:w-full sm:max-w-lg sm:translate-x-[-50%] sm:translate-y-[-50%] sm:overflow-visible sm:p-6 sm:data-[state=closed]:zoom-out-95 sm:data-[state=open]:zoom-in-95 sm:data-[state=closed]:slide-out-to-left-1/2 sm:data-[state=closed]:slide-out-to-top-[48%] sm:data-[state=open]:slide-in-from-left-1/2 sm:data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}

View File

@ -7,6 +7,8 @@
export { default as UiButton } from './Button.vue'
export { default as UiCard } from './Card.vue'
export { default as UiInput } from './Input.vue'
export { RoleBadge } from "./RoleBadge";
export { SkillBadge } from "./SkillBadge";
// Re-export types
export type { ButtonProps } from './Button.vue'

View File

@ -72,7 +72,7 @@ const TableHead = React.forwardRef<
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
"h-12 whitespace-nowrap px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}

View File

@ -1,9 +1,11 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { BellRing } from "lucide-react";
import { aiPmoApi } from "@/api/aiPmo";
import { notificationsApi } from "@/api/notifications";
import { projectsApi } from "@/api/projects";
import InboxActionPanel, { type InboxProjectOption } from "@/components/inbox/InboxActionPanel";
import InboxListItem, { buildInboxActionKey } from "@/components/inbox/InboxListItem";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@ -53,6 +55,20 @@ function matchesTab(item: InboxItemViewModel, tab: InboxTabKey): boolean {
return item.display_kind === TAB_TO_DISPLAY_KIND[tab];
}
function parsePositiveInt(value: unknown): number | null {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return null;
const integer = Math.trunc(parsed);
return integer > 0 ? integer : null;
}
function isMissingProjectScopeItem(item: InboxItemViewModel): boolean {
if (item.source_kind !== "ai_pmo_work_item" || !item.source_work_item) return false;
const state = String(item.source_work_item.state || "").toLowerCase();
const gateCode = String(item.latest_policy_gate_code || item.source_work_item.latest_policy_gate_code || "").toLowerCase();
return state === "waiting_manual" && gateCode === "missing_project_scope";
}
export default function InboxPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
@ -60,6 +76,8 @@ export default function InboxPage() {
const [searchParams, setSearchParams] = useSearchParams();
const [pendingActionKey, setPendingActionKey] = useState<string | null>(null);
const [expandedItemId, setExpandedItemId] = useState<string | null>(null);
const [selectedProjectByItem, setSelectedProjectByItem] = useState<Record<string, string>>({});
const [autoExpandedByQuery, setAutoExpandedByQuery] = useState(false);
const activeTab = normalizeTabKey(searchParams.get("section"));
const projectFilter = searchParams.get("project_id") || "all";
@ -77,6 +95,12 @@ export default function InboxPage() {
refetchInterval: POLL_INTERVAL_MS,
});
const activeProjectsQuery = useQuery({
queryKey: ["projects", "active", "inbox-inline-actions"],
queryFn: () => projectsApi.list({ status: "active" }),
refetchInterval: POLL_INTERVAL_MS,
});
const inboxData = useMemo(() => {
return buildInboxView({
notifications: notificationsQuery.data || { items: [], unread_count: 0 },
@ -93,6 +117,14 @@ export default function InboxPage() {
return Array.from(map.entries()).map(([value, label]) => ({ value, label }));
}, [inboxData.items]);
const activeProjectOptions = useMemo<InboxProjectOption[]>(() => {
const projects = activeProjectsQuery.data || [];
return projects.map((project) => ({
value: String(project.id),
label: String(project.title || `项目 #${project.id}`),
}));
}, [activeProjectsQuery.data]);
const projectScopedItems = useMemo(() => {
return inboxData.items.filter((item) => {
if (projectFilter === "all") return true;
@ -125,6 +157,18 @@ export default function InboxPage() {
return sortByLatest(tabItems);
}, [searchedItems, activeTab]);
useEffect(() => {
if (autoExpandedByQuery) return;
const targetId = parsePositiveInt(searchParams.get("workItemId"));
if (!targetId) return;
const targetKey = `work-item-${targetId}`;
const matched = feedItems.find((item) => item.id === targetKey);
if (matched) {
setExpandedItemId(targetKey);
setAutoExpandedByQuery(true);
}
}, [autoExpandedByQuery, feedItems, searchParams]);
const setTab = (tab: string) => {
const normalized = normalizeTabKey(tab);
const next = new URLSearchParams(searchParams);
@ -150,6 +194,23 @@ export default function InboxPage() {
setExpandedItemId((current) => (current === itemId ? null : itemId));
};
const getSelectedProjectId = (item: InboxItemViewModel): number | null => {
const selected = selectedProjectByItem[item.id];
if (selected) return parsePositiveInt(selected);
if (item.project_id) return parsePositiveInt(item.project_id);
return null;
};
const getSelectedProjectValue = (item: InboxItemViewModel): string => {
const selected = selectedProjectByItem[item.id];
if (selected) return selected;
return item.project_id ? String(item.project_id) : "";
};
const setSelectedProjectValue = (item: InboxItemViewModel, value: string) => {
setSelectedProjectByItem((current) => ({ ...current, [item.id]: value }));
};
const refreshAll = async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["notifications", "inbox"] }),
@ -198,9 +259,16 @@ export default function InboxPage() {
}
if (action.kind === "confirm" && item.source_kind === "ai_pmo_work_item" && item.source_work_item) {
const missingProjectScope = isMissingProjectScopeItem(item);
const selectedProjectId = missingProjectScope ? getSelectedProjectId(item) : (item.project_id ?? null);
if (missingProjectScope && !selectedProjectId) {
setExpandedItemId(item.id);
toast({ title: "请先选择关联项目后再确认", variant: "default" });
return;
}
const result = await aiPmoApi.decideWorkItem(item.source_work_item.id, {
action: "approve",
project_id: item.project_id ?? undefined,
project_id: selectedProjectId ?? undefined,
});
await refreshAll();
@ -212,6 +280,12 @@ export default function InboxPage() {
} else {
toast({ title: followupReply || "继续执行失败,请查看详情", variant: "error" });
}
setSelectedProjectByItem((current) => {
if (!(item.id in current)) return current;
const next = { ...current };
delete next[item.id];
return next;
});
setExpandedItemId(item.id);
return;
}
@ -254,6 +328,9 @@ export default function InboxPage() {
};
const isLoading = notificationsQuery.isLoading || workItemsQuery.isLoading;
const activeProjectErrorText = activeProjectsQuery.isError
? getApiErrorMessage(activeProjectsQuery.error, "项目列表加载失败")
: null;
return (
<div className="space-y-4 pb-12">
@ -302,23 +379,25 @@ export default function InboxPage() {
<div className="mt-3">
<div className="mb-2 text-xs text-slate-500">信息流筛选(单列时间流,不分栏)</div>
<div className="flex flex-wrap gap-2">
{(Object.keys(TAB_LABELS) as InboxTabKey[]).map((tabKey) => (
<button
key={tabKey}
type="button"
onClick={() => setTab(tabKey)}
className={cn(
"inline-flex h-8 items-center gap-1 rounded-full border px-3 text-xs transition sm:text-sm",
activeTab === tabKey
? "border-slate-300 bg-white text-slate-900"
: "border-slate-200 bg-slate-50 text-slate-600 hover:bg-white"
)}
>
<span>{TAB_LABELS[tabKey]}</span>
<span className="text-[11px] text-slate-500">{tabCounts[tabKey]}</span>
</button>
))}
<div className="overflow-x-auto [-webkit-overflow-scrolling:touch]">
<div className="flex w-max items-center gap-2 whitespace-nowrap pr-1">
{(Object.keys(TAB_LABELS) as InboxTabKey[]).map((tabKey) => (
<button
key={tabKey}
type="button"
onClick={() => setTab(tabKey)}
className={cn(
"inline-flex h-8 items-center gap-1 rounded-full border px-3 text-xs transition sm:text-sm",
activeTab === tabKey
? "border-slate-300 bg-white text-slate-900"
: "border-slate-200 bg-slate-50 text-slate-600 hover:bg-white"
)}
>
<span>{TAB_LABELS[tabKey]}</span>
<span className="text-[11px] text-slate-500">{tabCounts[tabKey]}</span>
</button>
))}
</div>
</div>
</div>
</section>
@ -346,6 +425,20 @@ export default function InboxPage() {
item={item}
expanded={expandedItemId === item.id}
pendingActionKey={pendingActionKey}
inlinePanel={
expandedItemId === item.id && isMissingProjectScopeItem(item) ? (
<InboxActionPanel
selectedProjectId={getSelectedProjectValue(item)}
projectOptions={activeProjectOptions}
loadingProjects={activeProjectsQuery.isLoading}
projectsErrorText={activeProjectErrorText}
pending={pendingActionKey === buildInboxActionKey(item, item.primary_action)}
onSelectProject={(value) => setSelectedProjectValue(item, value)}
onCancel={() => setExpandedItemId(null)}
onConfirm={() => void handleAction(item, item.primary_action)}
/>
) : undefined
}
onToggleExpand={toggleExpand}
onAction={(target, action) => void handleAction(target, action)}
/>

View File

@ -64,10 +64,10 @@ export default function LoginPage() {
};
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="flex min-h-screen items-center justify-center bg-background p-4 sm:p-6">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1 text-center">
<CardTitle className="text-2xl font-bold">PMFlow</CardTitle>
<CardTitle className="text-2xl font-bold sm:text-3xl">PMFlow</CardTitle>
<CardDescription>登录以继续</CardDescription>
</CardHeader>
<CardContent>

View File

@ -66,47 +66,69 @@ export default function SkillTasksPage() {
</p>
</div>
) : (
<div className="overflow-x-auto [-webkit-overflow-scrolling:touch]">
<Table className="min-w-[720px]">
<TableHeader>
<TableRow>
<TableHead>任务</TableHead>
<TableHead>状态</TableHead>
<TableHead>优先级</TableHead>
<TableHead className="text-right">工时 (h)</TableHead>
<TableHead>更新时间</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<>
<div className="hidden overflow-x-auto [-webkit-overflow-scrolling:touch] md:block">
<Table className="min-w-[720px]">
<TableHeader>
<TableRow>
<TableHead className="min-w-[240px] whitespace-nowrap">任务</TableHead>
<TableHead className="min-w-[90px] whitespace-nowrap">状态</TableHead>
<TableHead className="min-w-[90px] whitespace-nowrap">优先级</TableHead>
<TableHead className="min-w-[90px] whitespace-nowrap text-right">工时 (h)</TableHead>
<TableHead className="min-w-[170px] whitespace-nowrap">更新时间</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((task) => (
<TableRow key={task.id}>
<TableCell className="min-w-[240px]">
<div>
<p className="font-medium">{task.title}</p>
{task.description && (
<p className="line-clamp-1 text-xs text-muted-foreground">
{task.description}
</p>
)}
</div>
</TableCell>
<TableCell className="min-w-[90px] whitespace-nowrap">{toZhStatus(task.status)}</TableCell>
<TableCell className="min-w-[90px] whitespace-nowrap">{toZhPriority(task.priority)}</TableCell>
<TableCell className="min-w-[90px] whitespace-nowrap text-right">
{typeof task.estimated_hours === "number"
? task.estimated_hours.toFixed(1)
: "-"}
</TableCell>
<TableCell className="min-w-[170px] whitespace-nowrap">
{task.updated_at
? formatBeijingDateTime(task.updated_at)
: "-"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="space-y-3 md:hidden">
{items.map((task) => (
<TableRow key={task.id}>
<TableCell>
<div>
<p className="font-medium">{task.title}</p>
{task.description && (
<p className="line-clamp-1 text-xs text-muted-foreground">
{task.description}
</p>
)}
</div>
</TableCell>
<TableCell>{toZhStatus(task.status)}</TableCell>
<TableCell>{toZhPriority(task.priority)}</TableCell>
<TableCell className="text-right">
{typeof task.estimated_hours === "number"
? task.estimated_hours.toFixed(1)
: "-"}
</TableCell>
<TableCell>
{task.updated_at
? formatBeijingDateTime(task.updated_at)
: "-"}
</TableCell>
</TableRow>
<article key={task.id} className="rounded-lg border bg-white p-4">
<p className="text-base font-semibold">{task.title}</p>
{task.description && (
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">
{task.description}
</p>
)}
<div className="mt-3 flex items-center justify-between text-sm">
<span>状态:{toZhStatus(task.status)}</span>
<span>优先级:{toZhPriority(task.priority)}</span>
</div>
<div className="mt-2 flex items-center justify-between text-xs text-muted-foreground">
<span>工时:{typeof task.estimated_hours === "number" ? task.estimated_hours.toFixed(1) : "-"}h</span>
<span>{task.updated_at ? formatBeijingDateTime(task.updated_at) : "-"}</span>
</div>
</article>
))}
</TableBody>
</Table>
</div>
</div>
</>
)}
</CardContent>
</Card>

View File

@ -116,6 +116,29 @@ export default function SkillsPage() {
);
});
}, [data, search]);
const renderTagActions = (tag: SkillTag) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setEditRow(tag)}>
<Pencil className="mr-2 h-4 w-4" />
编辑
</DropdownMenuItem>
<DropdownMenuItem
disabled={!!tag.is_system}
className="text-destructive focus:text-destructive"
onClick={() => setDeleteRow(tag)}
>
<Trash2 className="mr-2 h-4 w-4" />
删除
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
return (
<div className="space-y-6">
@ -139,25 +162,29 @@ export default function SkillsPage() {
<CardTitle>标签列表</CardTitle>
<CardDescription>共 {rows.length} 个</CardDescription>
</div>
<div className="ml-auto flex w-full flex-wrap gap-2 sm:w-auto">
<div className="ml-auto w-full sm:w-auto">
<Input
placeholder="搜索名称/标识/分类"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full sm:w-72"
/>
<select
className="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm sm:w-auto"
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
>
<option value="">全部分类</option>
{CATEGORY_OPTIONS.map((c) => (
<option key={c} value={c}>
{toZhSkillCategory(c)}
</option>
))}
</select>
<div className="mt-2 overflow-x-auto [-webkit-overflow-scrolling:touch]">
<div className="flex w-max items-center gap-2 whitespace-nowrap pr-1">
<select
className="h-10 min-w-[132px] rounded-md border border-input bg-background px-3 py-2 text-sm"
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
>
<option value="">全部分类</option>
{CATEGORY_OPTIONS.map((c) => (
<option key={c} value={c}>
{toZhSkillCategory(c)}
</option>
))}
</select>
</div>
</div>
</div>
</div>
</CardHeader>
@ -169,67 +196,71 @@ export default function SkillsPage() {
) : rows.length === 0 ? (
<p className="py-8 text-center text-muted-foreground">暂无技能标签</p>
) : (
<div className="overflow-x-auto [-webkit-overflow-scrolling:touch]">
<Table className="min-w-[760px]">
<TableHeader>
<TableRow>
<TableHead>名称</TableHead>
<TableHead>分类</TableHead>
<TableHead>关联人数</TableHead>
<TableHead>描述</TableHead>
{canManage && <TableHead className="w-12" />}
</TableRow>
</TableHeader>
<TableBody>
{rows.map((tag) => (
<TableRow key={tag.id}>
<TableCell>
<div className="flex items-center gap-2">
<span className="font-medium">{tag.name}</span>
{tag.is_system && (
<span className="rounded bg-muted px-2 py-0.5 text-xs text-muted-foreground">
系统
<>
<div className="hidden overflow-x-auto [-webkit-overflow-scrolling:touch] md:block">
<Table className="min-w-[760px]">
<TableHeader>
<TableRow>
<TableHead className="min-w-[180px] whitespace-nowrap">名称</TableHead>
<TableHead className="min-w-[120px] whitespace-nowrap">分类</TableHead>
<TableHead className="min-w-[96px] whitespace-nowrap">关联人数</TableHead>
<TableHead className="min-w-[220px] whitespace-nowrap">描述</TableHead>
{canManage && <TableHead className="min-w-[40px] whitespace-nowrap" />}
</TableRow>
</TableHeader>
<TableBody>
{rows.map((tag) => (
<TableRow key={tag.id}>
<TableCell className="min-w-[180px]">
<div className="flex items-center gap-2">
<span className="font-medium">{tag.name}</span>
{tag.is_system && (
<span className="rounded bg-muted px-2 py-0.5 text-xs text-muted-foreground">
系统
</span>
)}
</div>
</TableCell>
<TableCell className="min-w-[120px]">
<span className={`rounded px-2 py-0.5 text-xs ${categoryClass(tag.category)}`}>
{toZhSkillCategory(tag.category)}
</span>
)}
</TableCell>
<TableCell className="min-w-[96px] whitespace-nowrap">{tag.linked_user_count ?? 0}</TableCell>
<TableCell className="min-w-[220px] text-muted-foreground">{tag.description || "-"}</TableCell>
{canManage && <TableCell className="min-w-[40px]">{renderTagActions(tag)}</TableCell>}
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="space-y-3 md:hidden">
{rows.map((tag) => (
<article key={tag.id} className="rounded-lg border bg-white p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="text-base font-semibold">{tag.name}</span>
{tag.is_system && (
<span className="rounded bg-muted px-2 py-0.5 text-xs text-muted-foreground">
系统
</span>
)}
</div>
<div className="mt-2">
<span className={`rounded px-2 py-0.5 text-xs ${categoryClass(tag.category)}`}>
{toZhSkillCategory(tag.category)}
</span>
</div>
</div>
</TableCell>
<TableCell>
<span className={`rounded px-2 py-0.5 text-xs ${categoryClass(tag.category)}`}>
{toZhSkillCategory(tag.category)}
</span>
</TableCell>
<TableCell>{tag.linked_user_count ?? 0}</TableCell>
<TableCell className="text-muted-foreground">{tag.description || "-"}</TableCell>
{canManage && (
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setEditRow(tag)}>
<Pencil className="mr-2 h-4 w-4" />
编辑
</DropdownMenuItem>
<DropdownMenuItem
disabled={!!tag.is_system}
className="text-destructive focus:text-destructive"
onClick={() => setDeleteRow(tag)}
>
<Trash2 className="mr-2 h-4 w-4" />
删除
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
)}
</TableRow>
{canManage && renderTagActions(tag)}
</div>
<p className="mt-3 text-sm text-muted-foreground">{tag.description || "暂无描述"}</p>
<p className="mt-2 text-xs text-muted-foreground">关联人数:{tag.linked_user_count ?? 0}</p>
</article>
))}
</TableBody>
</Table>
</div>
</div>
</>
)}
</CardContent>
</Card>

View File

@ -2,8 +2,6 @@ import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ChevronLeft,
ChevronRight,
Eye,
EyeOff,
Import,
@ -34,6 +32,8 @@ import { useAuthStore } from "@/stores/auth";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { RoleBadge } from "@/components/ui/RoleBadge";
import { SkillBadge } from "@/components/ui/SkillBadge";
import {
DropdownMenu,
DropdownMenuContent,
@ -138,10 +138,7 @@ export default function UsersPage() {
const [roleFilter, setRoleFilter] = useState<UserRole | "">("");
const [statusFilter, setStatusFilter] = useState<"" | UserStatus>("");
const [search, setSearch] = useState("");
const [projectIdFilter, setProjectIdFilter] = useState("");
const [workloadStateFilter, setWorkloadStateFilter] = useState<"" | "idle" | "normal" | "overloaded">("");
const [skillIdsFilter, setSkillIdsFilter] = useState("");
const [filterCollapsed, setFilterCollapsed] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [editUser, setEditUser] = useState<User | null>(null);
const [deleteUserState, setDeleteUserState] = useState<User | null>(null);
@ -174,9 +171,7 @@ export default function UsersPage() {
search,
roleFilter,
statusFilter,
projectIdFilter,
workloadStateFilter,
skillIdsFilter,
],
queryFn: () =>
getUsers({
@ -185,9 +180,7 @@ export default function UsersPage() {
search: search || undefined,
role: roleFilter || undefined,
status: statusFilter || undefined,
project_id: projectIdFilter ? Number(projectIdFilter) : undefined,
workload_state: workloadStateFilter || undefined,
skill_ids: skillIdsFilter || undefined,
}),
});
@ -274,6 +267,45 @@ export default function UsersPage() {
const allSelected = users.length > 0 && users.every((u) => selectedIds.includes(u.id));
const renderedUsers = useMemo(() => users, [users]);
const renderUserActions = (u: User) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setEditUser(u)}>
<Pencil className="mr-2 h-4 w-4" />
编辑
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setResetPwdUser(u)}>
<Lock className="mr-2 h-4 w-4" />
重置密码
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
statusMutation.mutate({
userId: u.id,
isActive: !(u.is_active ?? true),
})
}
>
<UserX className="mr-2 h-4 w-4" />
{(u.is_active ?? true) ? "禁用用户" : "启用用户"}
</DropdownMenuItem>
{canDelete && user?.id !== u.id && (
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setDeleteUserState(u)}
>
<Trash2 className="mr-2 h-4 w-4" />
删除
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
return (
<div className="space-y-6">
@ -284,52 +316,96 @@ export default function UsersPage() {
</div>
{canManage && (
<div className="flex flex-wrap gap-2">
<Button onClick={() => setCreateOpen(true)} className="hidden md:inline-flex">
<Plus className="mr-2 h-4 w-4" />
+ 新建用户
</Button>
<Button variant="outline" onClick={() => setImportOpen(true)}>
<Import className="mr-2 h-4 w-4" />
导入
</Button>
<Button
variant="outline"
disabled={!selectedIds.length || bulkDeleteMutation.isPending}
onClick={() => bulkDeleteMutation.mutate(selectedIds)}
>
<Trash2 className="mr-2 h-4 w-4" />
批量删除
</Button>
<Button
variant="outline"
disabled={!selectedIds.length}
onClick={() => setBulkAssignOpen(true)}
>
批量分配
</Button>
<Button onClick={() => setCreateOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
新建用户
</Button>
{selectedIds.length > 0 && (
<>
<Button
variant="outline"
className="border-red-300 text-red-600 hover:bg-red-50 hover:text-red-700"
disabled={bulkDeleteMutation.isPending}
onClick={() => bulkDeleteMutation.mutate(selectedIds)}
>
<Trash2 className="mr-2 h-4 w-4" />
批量删除
</Button>
<Button
variant="outline"
className="border-slate-300 text-slate-700 hover:bg-slate-50"
onClick={() => setBulkAssignOpen(true)}
>
批量分配
</Button>
</>
)}
</div>
)}
</div>
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_280px]">
<Card>
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div>
<CardTitle>用户列表</CardTitle>
<CardDescription>共 {total} 人</CardDescription>
</div>
<div className="ml-auto flex w-full flex-wrap gap-2 sm:w-auto">
<Input
placeholder="搜索用户名/姓名/邮箱"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full sm:w-72"
/>
<Card>
<CardHeader>
<div className="flex flex-col gap-3">
<div>
<CardTitle>用户列表</CardTitle>
<CardDescription>共 {total} 人</CardDescription>
</div>
<div className="flex flex-col gap-2 xl:flex-row xl:items-center xl:justify-between">
<Input
placeholder="搜索用户名/姓名/邮箱"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full xl:max-w-[320px]"
/>
<div className="overflow-x-auto [-webkit-overflow-scrolling:touch]">
<div className="flex w-max items-center gap-2 whitespace-nowrap pr-1">
<select
value={roleFilter}
onChange={(e) => setRoleFilter((e.target.value || "") as UserRole | "")}
className="h-10 min-w-[132px] rounded-md border border-input bg-background px-3 py-2 text-sm"
>
<option value="">全部角色</option>
{ROLES.map((r) => (
<option key={r} value={r}>
{getRoleLabel(r)}
</option>
))}
</select>
<select
value={statusFilter}
onChange={(e) => setStatusFilter((e.target.value || "") as "" | UserStatus)}
className="h-10 min-w-[120px] rounded-md border border-input bg-background px-3 py-2 text-sm"
>
<option value="">全部状态</option>
<option value="active">在线</option>
<option value="away">离开</option>
<option value="offline">离线</option>
</select>
<select
value={workloadStateFilter}
onChange={(e) =>
setWorkloadStateFilter((e.target.value || "") as "" | "idle" | "normal" | "overloaded")
}
className="h-10 min-w-[144px] rounded-md border border-input bg-background px-3 py-2 text-sm"
>
<option value="">全部负载</option>
<option value="idle">闲置 (&lt;70%)</option>
<option value="normal">正常 (70-100%)</option>
<option value="overloaded">过载 (&gt;100%)</option>
</select>
</div>
</div>
</div>
</CardHeader>
<CardContent>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<p className="py-8 text-center text-muted-foreground">加载中…</p>
) : error ? (
@ -337,199 +413,135 @@ export default function UsersPage() {
) : renderedUsers.length === 0 ? (
<p className="py-8 text-center text-muted-foreground">暂无用户</p>
) : (
<div className="overflow-x-auto [-webkit-overflow-scrolling:touch]">
<Table className="min-w-[940px]">
<TableHeader>
<TableRow>
<TableHead className="w-10">
<input
type="checkbox"
checked={allSelected}
onChange={(e) => {
setSelectedIds(e.target.checked ? renderedUsers.map((u) => u.id) : []);
}}
/>
</TableHead>
<TableHead>用户</TableHead>
<TableHead>角色</TableHead>
<TableHead>项目</TableHead>
<TableHead>核心技能(前3)</TableHead>
<TableHead>负载</TableHead>
<TableHead>状态</TableHead>
{canManage && <TableHead className="w-12" />}
</TableRow>
</TableHeader>
<TableBody>
<>
<div className="hidden overflow-x-auto [-webkit-overflow-scrolling:touch] md:block">
<Table className="min-w-[980px]">
<TableHeader>
<TableRow>
<TableHead className="w-10 whitespace-nowrap">
<input
type="checkbox"
checked={allSelected}
onChange={(e) => {
setSelectedIds(e.target.checked ? renderedUsers.map((u) => u.id) : []);
}}
/>
</TableHead>
<TableHead className="min-w-[160px] whitespace-nowrap">用户</TableHead>
<TableHead className="min-w-[200px] whitespace-nowrap">角色</TableHead>
<TableHead className="min-w-[60px] whitespace-nowrap">项目</TableHead>
<TableHead className="min-w-[160px] whitespace-nowrap">核心技能(前3)</TableHead>
<TableHead className="min-w-[80px] whitespace-nowrap">负载</TableHead>
<TableHead className="min-w-[60px] whitespace-nowrap">状态</TableHead>
{canManage && <TableHead className="min-w-[40px] whitespace-nowrap" />}
</TableRow>
</TableHeader>
<TableBody>
{renderedUsers.map((u) => {
const ratio = u.workload_ratio ?? u.workload ?? 0;
return (
<TableRow key={u.id}>
<TableCell>
<input
type="checkbox"
checked={selectedIds.includes(u.id)}
onChange={(e) => {
setSelectedIds((prev) =>
e.target.checked ? [...prev, u.id] : prev.filter((id) => id !== u.id)
);
}}
/>
</TableCell>
<TableCell className="min-w-[160px]">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-muted">
<UserIcon className="h-4 w-4 text-muted-foreground" />
</div>
<div className="min-w-0">
<Link to={`/users/${u.id}/profile`} className="font-medium hover:underline">
{u.name || u.username}
</Link>
<p className="truncate text-xs text-muted-foreground">{u.email || "-"}</p>
</div>
</div>
</TableCell>
<TableCell className="min-w-[200px]">
<RoleBadge roles={[String(u.role || "")]} className="max-w-[240px]" />
</TableCell>
<TableCell className="min-w-[60px] whitespace-nowrap">{(u.project_ids ?? []).length}</TableCell>
<TableCell className="min-w-[160px]">
<SkillBadge skills={u.top_skills ?? []} max={3} />
</TableCell>
<TableCell className="min-w-[80px] whitespace-nowrap">
<span className={workloadClass(ratio)}>{ratio.toFixed(1)}%</span>
</TableCell>
<TableCell className="min-w-[60px] whitespace-nowrap">
{getStatusLabel((u.status || "offline") as UserStatus)}
</TableCell>
{canManage && <TableCell className="min-w-[40px]">{renderUserActions(u)}</TableCell>}
</TableRow>
);
})}
</TableBody>
</Table>
</div>
<div className="space-y-3 md:hidden">
{renderedUsers.map((u) => {
const ratio = u.workload_ratio ?? u.workload ?? 0;
return (
<TableRow key={u.id}>
<TableCell>
<input
type="checkbox"
checked={selectedIds.includes(u.id)}
onChange={(e) => {
setSelectedIds((prev) =>
e.target.checked ? [...prev, u.id] : prev.filter((id) => id !== u.id)
);
}}
/>
</TableCell>
<TableCell>
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-muted">
<UserIcon className="h-4 w-4 text-muted-foreground" />
</div>
<div>
<Link to={`/users/${u.id}/profile`} className="font-medium hover:underline">
<article key={u.id} className="rounded-lg border bg-white p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-3">
<input
type="checkbox"
checked={selectedIds.includes(u.id)}
onChange={(e) => {
setSelectedIds((prev) =>
e.target.checked ? [...prev, u.id] : prev.filter((id) => id !== u.id)
);
}}
className="mt-1"
/>
<div className="min-w-0">
<Link to={`/users/${u.id}/profile`} className="block truncate text-base font-semibold">
{u.name || u.username}
</Link>
<p className="text-xs text-muted-foreground">
{u.username}
{u.email && ` · ${u.email}`}
</p>
<p className="truncate text-xs text-muted-foreground">{u.email || "-"}</p>
</div>
</div>
</TableCell>
<TableCell>{getRoleLabel(u.role)}</TableCell>
<TableCell>{(u.project_ids ?? []).length}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{(u.top_skills ?? []).slice(0, 3).map((tag) => (
<span
key={`${u.id}-${tag}`}
className="rounded bg-muted px-2 py-0.5 text-xs text-muted-foreground"
>
{tag}
</span>
))}
{(!u.top_skills || u.top_skills.length === 0) && (
<span className="text-xs text-muted-foreground">-</span>
)}
<div className="flex items-center gap-2">
<span className={`text-sm font-semibold ${workloadClass(ratio)}`}>{ratio.toFixed(1)}%</span>
{canManage && renderUserActions(u)}
</div>
</TableCell>
<TableCell>
<span className={workloadClass(ratio)}>{ratio.toFixed(1)}%</span>
</TableCell>
<TableCell>{getStatusLabel((u.status || "offline") as UserStatus)}</TableCell>
{canManage && (
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setEditUser(u)}>
<Pencil className="mr-2 h-4 w-4" />
编辑
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setResetPwdUser(u)}>
<Lock className="mr-2 h-4 w-4" />
重置密码
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
statusMutation.mutate({
userId: u.id,
isActive: !(u.is_active ?? true),
})
}
>
<UserX className="mr-2 h-4 w-4" />
{(u.is_active ?? true) ? "禁用用户" : "启用用户"}
</DropdownMenuItem>
{canDelete && user?.id !== u.id && (
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setDeleteUserState(u)}
>
<Trash2 className="mr-2 h-4 w-4" />
删除
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
)}
</TableRow>
</div>
<div className="mt-3">
<RoleBadge roles={[String(u.role || "")]} className="max-w-full" />
</div>
<div className="mt-3">
<SkillBadge skills={u.top_skills ?? []} max={3} className="max-w-full" />
</div>
<div className="mt-3 flex items-center justify-between text-sm">
<span className="text-muted-foreground">项目 {(u.project_ids ?? []).length}个</span>
<span className="whitespace-nowrap">{getStatusLabel((u.status || "offline") as UserStatus)}</span>
</div>
</article>
);
})}
</TableBody>
</Table>
</div>
</div>
</>
)}
</CardContent>
</Card>
<Card className={filterCollapsed ? "w-14" : ""}>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
{!filterCollapsed && (
<div>
<CardTitle>筛选</CardTitle>
<CardDescription>角色/状态/项目/负载/技能</CardDescription>
</div>
)}
<Button variant="ghost" size="icon" onClick={() => setFilterCollapsed((v) => !v)}>
{filterCollapsed ? <ChevronLeft className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</Button>
</CardHeader>
{!filterCollapsed && (
<CardContent className="space-y-3">
<select
value={roleFilter}
onChange={(e) => setRoleFilter((e.target.value || "") as UserRole | "")}
className="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
>
<option value="">全部角色</option>
{ROLES.map((r) => (
<option key={r} value={r}>
{getRoleLabel(r)}
</option>
))}
</select>
<select
value={statusFilter}
onChange={(e) => setStatusFilter((e.target.value || "") as "" | UserStatus)}
className="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
>
<option value="">全部状态</option>
<option value="active">在线</option>
<option value="away">离开</option>
<option value="offline">离线</option>
</select>
<Input
type="number"
placeholder="项目编号"
value={projectIdFilter}
onChange={(e) => setProjectIdFilter(e.target.value)}
/>
<select
value={workloadStateFilter}
onChange={(e) =>
setWorkloadStateFilter((e.target.value || "") as "" | "idle" | "normal" | "overloaded")
}
className="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
>
<option value="">全部负载</option>
<option value="idle">闲置 (&lt;70%)</option>
<option value="normal">正常 (70-100%)</option>
<option value="overloaded">过载 (&gt;100%)</option>
</select>
<Input
placeholder="技能编号(逗号分隔)"
value={skillIdsFilter}
onChange={(e) => setSkillIdsFilter(e.target.value)}
/>
</CardContent>
)}
</Card>
</div>
{canManage && (
<Button
onClick={() => setCreateOpen(true)}
className="fixed bottom-[136px] right-4 z-[119] h-11 rounded-full px-4 shadow-lg md:hidden"
>
<Plus className="mr-1 h-4 w-4" />
新建用户
</Button>
)}
<UserFormDialog
open={createOpen}

View File

@ -10,6 +10,7 @@ import {
} from "@/api/workloads";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { RoleBadge } from "@/components/ui/RoleBadge";
import {
Dialog,
DialogContent,
@ -30,12 +31,17 @@ import {
import { useAuthStore } from "@/stores/auth";
import { canManageTeamWorkload } from "@/lib/rbac";
import { useToast } from "@/components/ui/toast";
import { toZhRole } from "@/lib/display";
function ratioClass(value: number): string {
if (value > 100) return "text-destructive";
if (value >= 70) return "text-amber-600 dark:text-amber-400";
return "text-emerald-600 dark:text-emerald-400";
if (value > 100) return "text-red-500";
if (value >= 60) return "text-yellow-500";
return "text-green-500";
}
function progressClass(value: number): string {
if (value > 100) return "bg-red-500";
if (value >= 60) return "bg-yellow-400";
return "bg-green-400";
}
export default function WorkloadsPage() {
@ -65,7 +71,7 @@ export default function WorkloadsPage() {
() =>
items.map((r) => ({
member: r.user_name || r.username,
role: toZhRole(r.role),
role: String(r.role || ""),
assigned_hours: r.current_assigned_hours,
max_weekly_hours: r.max_weekly_hours,
workload_ratio: r.workload_ratio,
@ -152,47 +158,99 @@ export default function WorkloadsPage() {
});
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">团队工作负载</h1>
<p className="text-muted-foreground">查看成员负载比例、剩余工时与过载告警</p>
<div>
<div className="mb-6 flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">团队工作负载</h1>
<p className="text-muted-foreground">查看成员负载比例、剩余工时与过载告警</p>
</div>
<Button
disabled={!canOptimize || optimizePreviewMutation.isPending}
onClick={() => optimizePreviewMutation.mutate()}
className="w-full md:w-auto"
>
AI 优化任务分配
</Button>
</div>
<div className="mb-4 rounded-xl bg-gray-50 p-4">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div>
<p className="text-sm text-gray-500">团队均值</p>
<p className="text-2xl font-bold">
{isLoading || hasQueryError ? "--" : `${(summary?.avg_ratio ?? 0).toFixed(1)}%`}
</p>
</div>
<div>
<p className="text-sm text-gray-500">闲置</p>
<p className="text-2xl font-bold">{isLoading || hasQueryError ? "--" : `${summary?.idle_count ?? 0}人`}</p>
</div>
<div>
<p className="text-sm text-gray-500">正常</p>
<p className="text-2xl font-bold">{isLoading || hasQueryError ? "--" : `${summary?.normal_count ?? 0}人`}</p>
</div>
<div>
<p className="text-sm text-gray-500">过载</p>
<p className="text-2xl font-bold text-red-500">
{isLoading || hasQueryError ? "--" : `${summary?.overloaded_count ?? 0}人`}
</p>
</div>
</div>
</div>
{hasOverloaded && (
<div className="rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-900/40 dark:bg-red-900/20 dark:text-red-300">
过载告警:当前有 {summary?.overloaded_count ?? 0} 名成员负载超过 100%,建议立即执行任务迁移。
<div className="mb-4 flex h-9 items-center rounded-md border-l-4 border-orange-400 bg-orange-50 px-3 text-sm text-orange-700">
<AlertTriangle className="mr-2 h-4 w-4 shrink-0" />
<span>{summary?.overloaded_count ?? 0}名成员负载超过100%,建议立即执行任务迁移</span>
</div>
)}
{hasQueryError && (
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
<div className="mb-4 rounded-md border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
负载数据请求失败,请稍后重试。
</div>
)}
<Card>
<CardContent className="pt-6">
<div className="flex flex-wrap items-center gap-2">
<Input
className="w-full sm:w-40"
placeholder="项目编号"
value={projectId}
onChange={(e) => setProjectId(e.target.value)}
/>
<select
className="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm sm:w-auto"
value={range}
onChange={(e) => setRange(e.target.value as "week" | "month" | "custom")}
>
<option value="week">本周</option>
<option value="month">本月</option>
<option value="custom">自定义</option>
</select>
{range === "custom" && (
<>
<Input type="date" className="w-full sm:w-44" value={customFrom} onChange={(e) => setCustomFrom(e.target.value)} />
<Input type="date" className="w-full sm:w-44" value={customTo} onChange={(e) => setCustomTo(e.target.value)} />
</>
)}
<div className="mb-6 flex flex-col gap-3 rounded-lg border border-slate-200 bg-white p-3 sm:flex-row sm:items-center sm:justify-between">
<div className="w-full sm:w-auto">
<Input
className="w-full sm:w-40"
placeholder="项目编号搜索"
value={projectId}
onChange={(e) => setProjectId(e.target.value)}
/>
<div className="mt-2 overflow-x-auto [-webkit-overflow-scrolling:touch]">
<div className="flex w-max items-center gap-2 whitespace-nowrap pr-1">
<select
className="h-10 min-w-[96px] rounded-md border border-input bg-background px-3 py-2 text-sm"
value={range}
onChange={(e) => setRange(e.target.value as "week" | "month" | "custom")}
>
<option value="week">本周</option>
<option value="month">本月</option>
<option value="custom">自定义</option>
</select>
{range === "custom" && (
<>
<Input
type="date"
className="w-[168px]"
value={customFrom}
onChange={(e) => setCustomFrom(e.target.value)}
/>
<Input
type="date"
className="w-[168px]"
value={customTo}
onChange={(e) => setCustomTo(e.target.value)}
/>
</>
)}
</div>
</div>
</div>
<div className="overflow-x-auto [-webkit-overflow-scrolling:touch]">
<div className="flex w-max items-center gap-2 whitespace-nowrap pr-1">
<Button variant="outline" onClick={exportExcelLike}>
<Download className="mr-2 h-4 w-4" />
导出 Excel
@ -201,68 +259,8 @@ export default function WorkloadsPage() {
<Download className="mr-2 h-4 w-4" />
导出 PDF
</Button>
<Button
disabled={!canOptimize || optimizePreviewMutation.isPending}
onClick={() => optimizePreviewMutation.mutate()}
>
AI 优化任务分配
</Button>
</div>
</CardContent>
</Card>
<div className="grid gap-4 lg:grid-cols-3">
<Card>
<CardHeader>
<CardTitle className="text-sm text-muted-foreground">团队平均负载</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<p className="text-sm text-muted-foreground">加载中…</p>
) : hasQueryError ? (
<p className="text-sm text-destructive">加载失败</p>
) : (
<p className="text-3xl font-semibold">{(summary?.avg_ratio ?? 0).toFixed(1)}%</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm text-muted-foreground">状态人数</CardTitle>
</CardHeader>
<CardContent className="text-sm">
{isLoading ? (
<p className="text-muted-foreground">加载中…</p>
) : hasQueryError ? (
<p className="text-destructive">加载失败</p>
) : (
<>
<p>闲置: {summary?.idle_count ?? 0}</p>
<p>正常: {summary?.normal_count ?? 0}</p>
<p>过载: {summary?.overloaded_count ?? 0}</p>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm text-muted-foreground">AI 预警摘要</CardTitle>
</CardHeader>
<CardContent className="flex items-center gap-2 text-sm">
{isLoading ? (
<p className="text-muted-foreground">加载中…</p>
) : hasQueryError ? (
<p className="text-destructive">加载失败</p>
) : (
<>
{(summary?.overloaded_count ?? 0) > 0 && <AlertTriangle className="h-4 w-4 text-destructive" />}
{(summary?.overloaded_count ?? 0) > 0
? `存在 ${summary?.overloaded_count ?? 0} 名过载成员,建议调整任务分配`
: "当前无过载成员"}
</>
)}
</CardContent>
</Card>
</div>
</div>
<Card>
@ -277,40 +275,84 @@ export default function WorkloadsPage() {
) : items.length === 0 ? (
<p className="py-8 text-center text-muted-foreground">暂无数据</p>
) : (
<div className="overflow-x-auto [-webkit-overflow-scrolling:touch]">
<Table className="min-w-[760px]">
<TableHeader>
<TableRow>
<TableHead>成员</TableHead>
<TableHead>角色</TableHead>
<TableHead className="text-right">已分配工时</TableHead>
<TableHead className="text-right">每周上限</TableHead>
<TableHead className="text-right">负载比例</TableHead>
<TableHead className="text-right">剩余工时</TableHead>
<TableHead>操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<>
<div className="hidden overflow-x-auto [-webkit-overflow-scrolling:touch] md:block">
<Table className="min-w-[720px]">
<TableHeader>
<TableRow>
<TableHead className="min-w-[140px] whitespace-nowrap">成员</TableHead>
<TableHead className="min-w-[160px] whitespace-nowrap">主要角色</TableHead>
<TableHead className="min-w-[200px] whitespace-nowrap">负载进度条</TableHead>
<TableHead className="min-w-[90px] whitespace-nowrap text-right">比例</TableHead>
<TableHead className="min-w-[80px] whitespace-nowrap">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((row) => (
<TableRow key={row.user_id} className={row.workload_ratio > 100 ? "bg-red-50/40 dark:bg-red-900/10" : ""}>
<TableCell className="min-w-[140px]">{row.user_name || row.username}</TableCell>
<TableCell className="min-w-[160px]">
<RoleBadge roles={[row.role]} />
</TableCell>
<TableCell className="min-w-[200px]">
<div className="flex items-center gap-2">
<div className="h-[6px] w-[120px] overflow-hidden rounded-full bg-slate-200">
<div
className={`h-[6px] rounded-full ${progressClass(row.workload_ratio)}`}
style={{ width: `${Math.min(Math.max(row.workload_ratio, 0), 100)}%` }}
/>
</div>
<span className="text-sm text-slate-600">{row.workload_ratio.toFixed(1)}%</span>
</div>
</TableCell>
<TableCell className={`min-w-[90px] whitespace-nowrap text-right font-semibold ${ratioClass(row.workload_ratio)}`}>
{row.workload_ratio.toFixed(1)}%
</TableCell>
<TableCell className="min-w-[80px] whitespace-nowrap">
<Link to={`/workloads/${row.user_id}`} className="text-primary hover:underline">
查看详情
</Link>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="space-y-3 md:hidden">
{items.map((row) => (
<TableRow key={row.user_id} className={row.workload_ratio > 100 ? "bg-red-50/40 dark:bg-red-900/10" : ""}>
<TableCell>{row.user_name || row.username}</TableCell>
<TableCell>{toZhRole(row.role)}</TableCell>
<TableCell className="text-right">{row.current_assigned_hours.toFixed(1)}</TableCell>
<TableCell className="text-right">{row.max_weekly_hours.toFixed(1)}</TableCell>
<TableCell className={`text-right ${ratioClass(row.workload_ratio)}`}>
{row.workload_ratio.toFixed(1)}%
</TableCell>
<TableCell className="text-right">{row.remaining_hours.toFixed(1)}</TableCell>
<TableCell>
<article
key={row.user_id}
className={`rounded-lg border bg-white p-4 ${row.workload_ratio > 100 ? "border-red-200 bg-red-50/30" : ""}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-base font-semibold">{row.user_name || row.username}</p>
<div className="mt-2">
<RoleBadge roles={[row.role]} className="max-w-full" />
</div>
</div>
<span className={`text-sm font-semibold ${ratioClass(row.workload_ratio)}`}>
{row.workload_ratio.toFixed(1)}%
</span>
</div>
<div className="mt-3">
<div className="h-[8px] w-full overflow-hidden rounded-full bg-slate-200">
<div
className={`h-[8px] rounded-full ${progressClass(row.workload_ratio)}`}
style={{ width: `${Math.min(Math.max(row.workload_ratio, 0), 100)}%` }}
/>
</div>
</div>
<div className="mt-3 flex items-center justify-between text-sm">
<span className="text-slate-500">剩余工时 {(row.remaining_hours ?? 0).toFixed(1)}h</span>
<Link to={`/workloads/${row.user_id}`} className="text-primary hover:underline">
查看详情
</Link>
</TableCell>
</TableRow>
</div>
</article>
))}
</TableBody>
</Table>
</div>
</div>
</>
)}
</CardContent>
</Card>

View File

@ -1,4 +1,4 @@
import type { AIPMOWorkItem } from "@/api/aiPmo";
import type { AIPMOApprovalRequirement, AIPMOWorkItem } from "@/api/aiPmo";
import type { NotificationItem } from "@/api/notifications";
export type InboxSourceKind = "notification" | "ai_pmo_work_item";
@ -42,6 +42,8 @@ export interface InboxItemViewModel {
updated_at?: string | null;
stage_text?: string | null;
next_step_text?: string | null;
latest_policy_gate_code?: string | null;
approval_requirement?: AIPMOApprovalRequirement | null;
source_notification?: NotificationItem;
source_work_item?: AIPMOWorkItem;
}

View File

@ -1 +1 @@
{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/skillradarchart.tsx","./src/components/themeinit.tsx","./src/components/ai-steward/floatingqachat.tsx","./src/components/ai-steward/openclawcommanderpanel.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/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/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/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/dashboardpage.tsx","./src/pages/feishuconfigpage.tsx","./src/pages/inboxpage.tsx","./src/pages/loginpage.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/userprofilepage.tsx","./src/pages/userskillspage.tsx","./src/pages/userspage.tsx","./src/pages/workloaddetailpage.tsx","./src/pages/workloadspage.tsx","./src/router/index.tsx","./src/stores/auth.ts","./src/types/inbox.ts"],"version":"5.6.3"}
{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/skillradarchart.tsx","./src/components/themeinit.tsx","./src/components/ai-steward/floatingqachat.tsx","./src/components/ai-steward/openclawcommanderpanel.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/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/dashboardpage.tsx","./src/pages/feishuconfigpage.tsx","./src/pages/inboxpage.tsx","./src/pages/loginpage.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/userprofilepage.tsx","./src/pages/userskillspage.tsx","./src/pages/userspage.tsx","./src/pages/workloaddetailpage.tsx","./src/pages/workloadspage.tsx","./src/router/index.tsx","./src/stores/auth.ts","./src/types/inbox.ts"],"version":"5.6.3"}