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:
79
apps/web/src/components/ui/dialog.tsx
Normal file
79
apps/web/src/components/ui/dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
149
apps/web/src/components/ui/slide-panel.tsx
Normal file
149
apps/web/src/components/ui/slide-panel.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user