Add GLM-5 Planner 需求链路
|
After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 285 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 226 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 133 KiB |
13
CLAUDE.md
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
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()
|
||||
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)
|
||||
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()
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
*,
|
||||
|
|
|
|||
|
|
@ -853,10 +853,11 @@ class SignalSourceOrchestrationService:
|
|||
},
|
||||
last_signal_at=_utc_now_naive(),
|
||||
)
|
||||
self.db.add(row)
|
||||
try:
|
||||
# 只回滚本次插入尝试,避免误回滚外层事务里已创建的 signal 记录。
|
||||
async with self.db.begin_nested():
|
||||
self.db.add(row)
|
||||
await self.db.flush()
|
||||
await self.db.commit()
|
||||
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(
|
||||
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=operator_user,
|
||||
project_id=int(project.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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"], [])
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
@ -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);
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}`}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,18 +196,76 @@ 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 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>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>撤销状态:{item.can_undo ? `可撤销(截止 ${item.undo_deadline || "-"})` : "不可撤销"}</div>
|
||||
<div className="sm:col-span-2">完整时间戳:{fullTime}</div>
|
||||
</div>
|
||||
<div>撤销状态:{undoDeadlineText}</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{inlinePanel}
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
{!isActionRow && item.primary_action.kind !== "none" && (
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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" && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,7 +379,8 @@ 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">
|
||||
<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}
|
||||
|
|
@ -321,6 +399,7 @@ export default function InboxPage() {
|
|||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{isLoading ? (
|
||||
|
|
@ -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)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -66,21 +66,22 @@ export default function SkillTasksPage() {
|
|||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto [-webkit-overflow-scrolling:touch]">
|
||||
<>
|
||||
<div className="hidden overflow-x-auto [-webkit-overflow-scrolling:touch] md:block">
|
||||
<Table className="min-w-[720px]">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>任务</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>优先级</TableHead>
|
||||
<TableHead className="text-right">工时 (h)</TableHead>
|
||||
<TableHead>更新时间</TableHead>
|
||||
<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>
|
||||
<TableCell className="min-w-[240px]">
|
||||
<div>
|
||||
<p className="font-medium">{task.title}</p>
|
||||
{task.description && (
|
||||
|
|
@ -90,14 +91,14 @@ export default function SkillTasksPage() {
|
|||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{toZhStatus(task.status)}</TableCell>
|
||||
<TableCell>{toZhPriority(task.priority)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<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>
|
||||
<TableCell className="min-w-[170px] whitespace-nowrap">
|
||||
{task.updated_at
|
||||
? formatBeijingDateTime(task.updated_at)
|
||||
: "-"}
|
||||
|
|
@ -107,6 +108,27 @@ export default function SkillTasksPage() {
|
|||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="space-y-3 md:hidden">
|
||||
{items.map((task) => (
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -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,15 +162,17 @@ 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"
|
||||
/>
|
||||
<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 w-full rounded-md border border-input bg-background px-3 py-2 text-sm sm:w-auto"
|
||||
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)}
|
||||
>
|
||||
|
|
@ -160,6 +185,8 @@ export default function SkillsPage() {
|
|||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
|
|
@ -169,21 +196,22 @@ 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]">
|
||||
<>
|
||||
<div className="hidden overflow-x-auto [-webkit-overflow-scrolling:touch] md:block">
|
||||
<Table className="min-w-[760px]">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>分类</TableHead>
|
||||
<TableHead>关联人数</TableHead>
|
||||
<TableHead>描述</TableHead>
|
||||
{canManage && <TableHead className="w-12" />}
|
||||
<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>
|
||||
<TableCell className="min-w-[180px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{tag.name}</span>
|
||||
{tag.is_system && (
|
||||
|
|
@ -193,43 +221,46 @@ export default function SkillsPage() {
|
|||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<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>{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>
|
||||
)}
|
||||
<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>
|
||||
{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>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -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,146 +267,7 @@ export default function UsersPage() {
|
|||
const allSelected = users.length > 0 && users.every((u) => selectedIds.includes(u.id));
|
||||
|
||||
const renderedUsers = useMemo(() => users, [users]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">用户管理</h1>
|
||||
<p className="text-muted-foreground">筛选、导入、状态管理、批量删除</p>
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<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>
|
||||
</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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<p className="py-8 text-center text-muted-foreground">加载中…</p>
|
||||
) : error ? (
|
||||
<p className="py-8 text-center text-destructive">{String(error)}</p>
|
||||
) : 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>
|
||||
{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">
|
||||
{u.name || u.username}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{u.username}
|
||||
{u.email && ` · ${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>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className={workloadClass(ratio)}>{ratio.toFixed(1)}%</span>
|
||||
</TableCell>
|
||||
<TableCell>{getStatusLabel((u.status || "offline") as UserStatus)}</TableCell>
|
||||
{canManage && (
|
||||
<TableCell>
|
||||
const renderUserActions = (u: User) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
|
|
@ -451,36 +305,69 @@ export default function UsersPage() {
|
|||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className={filterCollapsed ? "w-14" : ""}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
{!filterCollapsed && (
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<CardTitle>筛选</CardTitle>
|
||||
<CardDescription>角色/状态/项目/负载/技能</CardDescription>
|
||||
<h1 className="text-2xl font-bold tracking-tight">用户管理</h1>
|
||||
<p className="text-muted-foreground">筛选、导入、状态管理、批量删除</p>
|
||||
</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>
|
||||
{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>
|
||||
)}
|
||||
<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">
|
||||
</div>
|
||||
|
||||
<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 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
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) => (
|
||||
|
|
@ -493,7 +380,7 @@ export default function UsersPage() {
|
|||
<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"
|
||||
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>
|
||||
|
|
@ -501,35 +388,160 @@ export default function UsersPage() {
|
|||
<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"
|
||||
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">闲置 (<70%)</option>
|
||||
<option value="normal">正常 (70-100%)</option>
|
||||
<option value="overloaded">过载 (>100%)</option>
|
||||
</select>
|
||||
|
||||
<Input
|
||||
placeholder="技能编号(逗号分隔)"
|
||||
value={skillIdsFilter}
|
||||
onChange={(e) => setSkillIdsFilter(e.target.value)}
|
||||
/>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<p className="py-8 text-center text-muted-foreground">加载中…</p>
|
||||
) : error ? (
|
||||
<p className="py-8 text-center text-destructive">{String(error)}</p>
|
||||
) : renderedUsers.length === 0 ? (
|
||||
<p className="py-8 text-center text-muted-foreground">暂无用户</p>
|
||||
) : (
|
||||
<>
|
||||
<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 (
|
||||
<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="truncate text-xs text-muted-foreground">{u.email || "-"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-sm font-semibold ${workloadClass(ratio)}`}>{ratio.toFixed(1)}%</span>
|
||||
{canManage && renderUserActions(u)}
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{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}
|
||||
|
|
|
|||
|
|
@ -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,34 +158,71 @@ export default function WorkloadsPage() {
|
|||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<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">
|
||||
<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="项目编号"
|
||||
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 w-full rounded-md border border-input bg-background px-3 py-2 text-sm sm:w-auto"
|
||||
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")}
|
||||
>
|
||||
|
|
@ -189,10 +232,25 @@ export default function WorkloadsPage() {
|
|||
</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)} />
|
||||
<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,31 +275,40 @@ 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]">
|
||||
<>
|
||||
<div className="hidden overflow-x-auto [-webkit-overflow-scrolling:touch] md:block">
|
||||
<Table className="min-w-[720px]">
|
||||
<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>
|
||||
<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>{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)}`}>
|
||||
<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="text-right">{row.remaining_hours.toFixed(1)}</TableCell>
|
||||
<TableCell>
|
||||
<TableCell className="min-w-[80px] whitespace-nowrap">
|
||||
<Link to={`/workloads/${row.user_id}`} className="text-primary hover:underline">
|
||||
查看详情
|
||||
</Link>
|
||||
|
|
@ -311,6 +318,41 @@ export default function WorkloadsPage() {
|
|||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="space-y-3 md:hidden">
|
||||
{items.map((row) => (
|
||||
<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>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||