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:
OG T
2026-03-25 10:31:35 +08:00
parent 170102a4ee
commit b13b063282
11 changed files with 1341 additions and 9 deletions

View File

@@ -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)
# =============================================================================

View File

@@ -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 的處理結果")