feat(web): Phase 11 對話式 AI UI/UX (#47-59)
Phase 11.1 對話式容器: - ConversationalView 雙欄佈局 (左側列表 + 右側詳情) - ApprovalThreadItem 風險等級 + 相對時間顯示 - SSE 即時更新整合 Phase 11.2 批次處理: - BatchModeSelector 組件 (全部接受/逐一審核/CRITICAL Only) - POST /api/v1/approvals/bulk-approve API 端點 - CRITICAL + DESTRUCTIVE 安全過濾 (禁止批次核准) Phase 11.4 鍵盤快捷鍵: - useKeyboardShortcuts hook (Y/N/方向鍵/Esc) - Y 鍵長按 2 秒核准 + 頂部進度指示器 - 快捷鍵說明 Modal (Y/N 高亮顯示) i18n: 100% next-intl 覆蓋 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -46,6 +46,9 @@ from src.models.approval import (
|
||||
ApprovalRequest,
|
||||
ApprovalRequestCreate,
|
||||
ApprovalRequestResponse,
|
||||
BulkApproveRequest,
|
||||
BulkApproveResponse,
|
||||
BulkApproveResult,
|
||||
PendingApprovalsResponse,
|
||||
RejectRequest,
|
||||
SignRequest,
|
||||
@@ -775,6 +778,181 @@ async def reject_approval(
|
||||
return ApprovalRequestResponse.from_approval(approval)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# POST /api/v1/approvals/bulk-approve (Phase 11: 批次處理)
|
||||
# =============================================================================
|
||||
|
||||
@router.post(
|
||||
"/bulk-approve",
|
||||
response_model=BulkApproveResponse,
|
||||
summary="批次簽核",
|
||||
description="批次簽核多個授權請求。CRITICAL 風險或 DESTRUCTIVE 資料影響的請求將被跳過。",
|
||||
)
|
||||
async def bulk_approve(
|
||||
request: BulkApproveRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
) -> BulkApproveResponse:
|
||||
"""
|
||||
批次簽核授權請求 (Phase 11)
|
||||
|
||||
安全限制 (不可繞過):
|
||||
- CRITICAL 風險: 跳過,需單獨審核
|
||||
- DESTRUCTIVE 資料影響: 跳過,需單獨審核
|
||||
|
||||
Args:
|
||||
request: 批次簽核請求
|
||||
|
||||
Returns:
|
||||
BulkApproveResponse: 批次處理結果
|
||||
"""
|
||||
from src.models.approval import RiskLevel
|
||||
|
||||
service = get_approval_service()
|
||||
timeline = get_timeline_service()
|
||||
|
||||
results: list[BulkApproveResult] = []
|
||||
succeeded = 0
|
||||
failed = 0
|
||||
skipped = 0
|
||||
|
||||
for approval_id_str in request.approval_ids:
|
||||
try:
|
||||
approval_id = UUID(approval_id_str)
|
||||
|
||||
# 取得 Approval 詳情
|
||||
approval = await service.get_approval_by_id(approval_id)
|
||||
|
||||
if approval is None:
|
||||
results.append(BulkApproveResult(
|
||||
approval_id=approval_id_str,
|
||||
success=False,
|
||||
message="Not found",
|
||||
))
|
||||
failed += 1
|
||||
continue
|
||||
|
||||
# 🔴 安全限制: CRITICAL 禁止批次核准
|
||||
if approval.risk_level == RiskLevel.CRITICAL:
|
||||
results.append(BulkApproveResult(
|
||||
approval_id=approval_id_str,
|
||||
success=False,
|
||||
message="CRITICAL risk requires individual review",
|
||||
))
|
||||
skipped += 1
|
||||
logger.warning(
|
||||
"bulk_approve_skipped_critical",
|
||||
approval_id=approval_id_str,
|
||||
risk_level=approval.risk_level.value,
|
||||
)
|
||||
continue
|
||||
|
||||
# 🔴 安全限制: DESTRUCTIVE 禁止批次核准
|
||||
if approval.blast_radius and approval.blast_radius.get("data_impact") == "DESTRUCTIVE":
|
||||
results.append(BulkApproveResult(
|
||||
approval_id=approval_id_str,
|
||||
success=False,
|
||||
message="DESTRUCTIVE data impact requires individual review",
|
||||
))
|
||||
skipped += 1
|
||||
logger.warning(
|
||||
"bulk_approve_skipped_destructive",
|
||||
approval_id=approval_id_str,
|
||||
data_impact="DESTRUCTIVE",
|
||||
)
|
||||
continue
|
||||
|
||||
# 執行簽核
|
||||
signed_approval, message, execution_triggered = await service.sign_approval(
|
||||
approval_id=approval_id,
|
||||
signer_id=request.signer_id,
|
||||
signer_name=request.signer_name,
|
||||
comment=request.comment or "Bulk approved",
|
||||
)
|
||||
|
||||
if signed_approval is None or "Cannot sign" in message or "already signed" in message:
|
||||
results.append(BulkApproveResult(
|
||||
approval_id=approval_id_str,
|
||||
success=False,
|
||||
message=message,
|
||||
))
|
||||
failed += 1
|
||||
continue
|
||||
|
||||
# 成功簽核
|
||||
results.append(BulkApproveResult(
|
||||
approval_id=approval_id_str,
|
||||
success=True,
|
||||
message=message,
|
||||
execution_triggered=execution_triggered,
|
||||
))
|
||||
succeeded += 1
|
||||
|
||||
# Timeline event
|
||||
await timeline.add_event(
|
||||
event_type="human",
|
||||
status="success",
|
||||
title=f"[批次] {request.signer_name} 簽核成功",
|
||||
actor=request.signer_name,
|
||||
actor_role="signer",
|
||||
risk_level=signed_approval.risk_level.value,
|
||||
approval_id=approval_id_str,
|
||||
)
|
||||
|
||||
# 如果觸發執行,加入背景任務
|
||||
if execution_triggered:
|
||||
background_tasks.add_task(execute_approved_action, signed_approval)
|
||||
|
||||
# 更新關聯的 Incident 狀態
|
||||
incident_id = signed_approval.metadata.get("incident_id") if signed_approval.metadata else None
|
||||
if incident_id:
|
||||
proposal_svc = get_proposal_service()
|
||||
await proposal_svc.resolve_incident_after_approval(
|
||||
incident_id=incident_id,
|
||||
approval_id=approval_id_str,
|
||||
)
|
||||
|
||||
# SSE: 發布事件
|
||||
event_action = "approved" if execution_triggered else "signed"
|
||||
asyncio.create_task(_publish_approval_event(event_action, signed_approval))
|
||||
|
||||
except ValueError:
|
||||
results.append(BulkApproveResult(
|
||||
approval_id=approval_id_str,
|
||||
success=False,
|
||||
message="Invalid UUID format",
|
||||
))
|
||||
failed += 1
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"bulk_approve_error",
|
||||
approval_id=approval_id_str,
|
||||
error=str(e),
|
||||
)
|
||||
results.append(BulkApproveResult(
|
||||
approval_id=approval_id_str,
|
||||
success=False,
|
||||
message=f"Error: {str(e)}",
|
||||
))
|
||||
failed += 1
|
||||
|
||||
logger.info(
|
||||
"bulk_approve_completed",
|
||||
total=len(request.approval_ids),
|
||||
succeeded=succeeded,
|
||||
failed=failed,
|
||||
skipped=skipped,
|
||||
signer_name=request.signer_name,
|
||||
)
|
||||
|
||||
return BulkApproveResponse(
|
||||
total=len(request.approval_ids),
|
||||
succeeded=succeeded,
|
||||
failed=failed,
|
||||
skipped=skipped,
|
||||
results=results,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SSE Event Publishing (Phase 15: Polling → SSE)
|
||||
# =============================================================================
|
||||
|
||||
@@ -270,3 +270,38 @@ class PendingApprovalsResponse(BaseModel):
|
||||
"""待簽核清單回應"""
|
||||
count: int
|
||||
approvals: list[ApprovalRequestResponse]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Phase 11: 批次處理 (Bulk Approval)
|
||||
# =============================================================================
|
||||
|
||||
class BulkApproveRequest(BaseModel):
|
||||
"""
|
||||
批次簽核請求 (Phase 11)
|
||||
|
||||
安全限制:
|
||||
- CRITICAL 風險禁止批次核准
|
||||
- DESTRUCTIVE 資料影響禁止批次核准
|
||||
"""
|
||||
approval_ids: list[str] = Field(..., description="要批次簽核的 Approval ID 列表")
|
||||
signer_id: str = Field(..., description="簽核者 ID")
|
||||
signer_name: str = Field(..., description="簽核者名稱")
|
||||
comment: str | None = Field(default=None, description="批次簽核備註")
|
||||
|
||||
|
||||
class BulkApproveResult(BaseModel):
|
||||
"""單個批次簽核結果"""
|
||||
approval_id: str
|
||||
success: bool
|
||||
message: str
|
||||
execution_triggered: bool = False
|
||||
|
||||
|
||||
class BulkApproveResponse(BaseModel):
|
||||
"""批次簽核回應"""
|
||||
total: int = Field(..., description="請求處理的總數")
|
||||
succeeded: int = Field(..., description="成功簽核數")
|
||||
failed: int = Field(..., description="失敗數")
|
||||
skipped: int = Field(..., description="跳過數 (CRITICAL/DESTRUCTIVE)")
|
||||
results: list[BulkApproveResult] = Field(..., description="各個 Approval 的處理結果")
|
||||
|
||||
Reference in New Issue
Block a user