feat(web): Professional UX for approval workflow

Industry-standard AIOps UX patterns:
- Compact approval list in right panel
- SlidePanel for details (PagerDuty/ServiceNow style)
- Keyboard navigation (←/→ for prev/next, ESC to close)
- Quick access to approve/reject
- Maintains context while reviewing details

Fixes large empty space issue and endless scrolling.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-03-25 00:22:35 +08:00
parent 5d03a82c7a
commit 8b7a1186ab
5 changed files with 547 additions and 14 deletions

View File

@@ -0,0 +1,79 @@
'use client'
/**
* Dialog - 專業級彈窗組件
* ========================
* Nothing.tech 設計語言,支援 ApprovalCard 詳情顯示
*/
import { useEffect, useCallback } from 'react'
import { cn } from '@/lib/utils'
import { X } from 'lucide-react'
interface DialogProps {
open: boolean
onClose: () => void
children: React.ReactNode
title?: string
className?: string
}
export function Dialog({ open, onClose, children, title, className }: DialogProps) {
// ESC 關閉
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}, [onClose])
useEffect(() => {
if (open) {
document.addEventListener('keydown', handleKeyDown)
document.body.style.overflow = 'hidden'
}
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.body.style.overflow = ''
}
}, [open, handleKeyDown])
if (!open) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* Dialog Panel */}
<div
className={cn(
'relative z-10 w-full max-w-lg max-h-[85vh] overflow-y-auto',
'bg-white rounded-2xl shadow-2xl',
'animate-in fade-in zoom-in-95 duration-200',
className
)}
>
{/* Header */}
{title && (
<div className="sticky top-0 z-10 flex items-center justify-between px-6 py-4 bg-white border-b border-nothing-gray-100">
<h2 className="font-heading text-lg font-bold text-nothing-gray-900">
{title}
</h2>
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-nothing-gray-100 transition-colors"
>
<X className="w-5 h-5 text-nothing-gray-500" />
</button>
</div>
)}
{/* Content */}
<div className={cn(!title && 'pt-6')}>
{children}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,149 @@
'use client'
/**
* SlidePanel - 專業級側邊滑入面板
* ================================
* 業界標準 AIOps UX
* - PagerDuty / ServiceNow 風格
* - 不遮蔽主內容,保持上下文
* - 固定底部操作按鈕
*/
import { useEffect, useCallback } from 'react'
import { cn } from '@/lib/utils'
import { X, ChevronLeft, ChevronRight } from 'lucide-react'
interface SlidePanelProps {
open: boolean
onClose: () => void
children: React.ReactNode
title?: string
/** 上一個/下一個導航 */
onPrev?: () => void
onNext?: () => void
/** 當前位置 (1-based) */
current?: number
total?: number
/** 寬度 */
width?: 'sm' | 'md' | 'lg' | 'xl'
}
const widthMap = {
sm: 'w-80',
md: 'w-96',
lg: 'w-[480px]',
xl: 'w-[560px]',
}
export function SlidePanel({
open,
onClose,
children,
title,
onPrev,
onNext,
current,
total,
width = 'lg',
}: SlidePanelProps) {
// ESC 關閉
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
// 方向鍵導航
if (e.key === 'ArrowLeft' && onPrev) onPrev()
if (e.key === 'ArrowRight' && onNext) onNext()
}, [onClose, onPrev, onNext])
useEffect(() => {
if (open) {
document.addEventListener('keydown', handleKeyDown)
}
return () => {
document.removeEventListener('keydown', handleKeyDown)
}
}, [open, handleKeyDown])
return (
<>
{/* Backdrop - 半透明,點擊關閉 */}
<div
className={cn(
'fixed inset-0 z-40 bg-black/20 transition-opacity duration-300',
open ? 'opacity-100' : 'opacity-0 pointer-events-none'
)}
onClick={onClose}
/>
{/* Panel */}
<div
className={cn(
'fixed top-0 right-0 z-50 h-full bg-white shadow-2xl',
'flex flex-col',
'transition-transform duration-300 ease-out',
widthMap[width],
open ? 'translate-x-0' : 'translate-x-full'
)}
>
{/* Header - 固定 */}
<div className="flex-shrink-0 flex items-center justify-between px-4 py-3 border-b border-nothing-gray-100 bg-nothing-gray-50">
<div className="flex items-center gap-3">
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-nothing-gray-200 transition-colors"
title="關閉 (ESC)"
>
<X className="w-5 h-5 text-nothing-gray-500" />
</button>
{title && (
<h2 className="font-heading text-base font-bold text-nothing-gray-900">
{title}
</h2>
)}
</div>
{/* 導航控制 */}
{(onPrev || onNext) && current && total && (
<div className="flex items-center gap-2">
<span className="text-xs font-mono text-nothing-gray-400">
{current} / {total}
</span>
<div className="flex items-center gap-1">
<button
onClick={onPrev}
disabled={current <= 1}
className={cn(
'p-1.5 rounded-lg transition-colors',
current <= 1
? 'text-nothing-gray-300 cursor-not-allowed'
: 'text-nothing-gray-500 hover:bg-nothing-gray-200'
)}
title="上一個 (←)"
>
<ChevronLeft className="w-4 h-4" />
</button>
<button
onClick={onNext}
disabled={current >= total}
className={cn(
'p-1.5 rounded-lg transition-colors',
current >= total
? 'text-nothing-gray-300 cursor-not-allowed'
: 'text-nothing-gray-500 hover:bg-nothing-gray-200'
)}
title="下一個 (→)"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
)}
</div>
{/* Content - 可滾動 */}
<div className="flex-1 overflow-y-auto">
{children}
</div>
</div>
</>
)
}