Finder-launched terminals now inherit a login PATH so tools like ollama resolve, destructive SFTP/editor actions require confirmation, and terminal search plus window dragging work again. SSH forwards and RDP sessions keep listeners and survive reconnect without FastPath crashes. Co-authored-by: Cursor <cursoragent@cursor.com>
153 lines
4.0 KiB
TypeScript
153 lines
4.0 KiB
TypeScript
/**
|
||
* Toast 通知组件
|
||
*
|
||
* 包含单个 Toast 条目渲染 + ToastProvider 全局容器
|
||
* 右上角堆叠显示,支持入场/退场动画
|
||
*/
|
||
|
||
import { useEffect, useState, useCallback } from 'react'
|
||
import { useTranslation } from 'react-i18next'
|
||
import { CheckCircle, XCircle, AlertTriangle, Info, X } from 'lucide-react'
|
||
import { useToastStore, type ToastItem, type ToastType } from '../../stores/toastStore'
|
||
|
||
/** 类型 → 图标映射 */
|
||
const iconMap: Record<ToastType, typeof CheckCircle> = {
|
||
success: CheckCircle,
|
||
error: XCircle,
|
||
warning: AlertTriangle,
|
||
info: Info,
|
||
}
|
||
|
||
/** 类型 → 颜色样式映射(theme tokens,浅色/深色都可读) */
|
||
const colorMap: Record<ToastType, { bg: string; border: string; icon: string; text: string }> = {
|
||
success: {
|
||
bg: 'bg-feedback-success-bg',
|
||
border: 'border-feedback-success/35',
|
||
icon: 'text-feedback-success',
|
||
text: 'text-content-primary',
|
||
},
|
||
error: {
|
||
bg: 'bg-feedback-error-bg',
|
||
border: 'border-feedback-error/35',
|
||
icon: 'text-feedback-error',
|
||
text: 'text-content-primary',
|
||
},
|
||
warning: {
|
||
bg: 'bg-feedback-warning-bg',
|
||
border: 'border-feedback-warning/35',
|
||
icon: 'text-feedback-warning',
|
||
text: 'text-content-primary',
|
||
},
|
||
info: {
|
||
bg: 'bg-feedback-info-bg',
|
||
border: 'border-feedback-info/35',
|
||
icon: 'text-feedback-info',
|
||
text: 'text-content-primary',
|
||
},
|
||
}
|
||
|
||
interface ToastItemProps {
|
||
toast: ToastItem
|
||
onClose: (id: string) => void
|
||
}
|
||
|
||
/**
|
||
* 单个 Toast 条目
|
||
*
|
||
* 支持自动消失和手动关闭,带入场/退场动画
|
||
*/
|
||
function ToastItemView({ toast, onClose }: ToastItemProps) {
|
||
const { t } = useTranslation('common')
|
||
const [visible, setVisible] = useState(false)
|
||
const Icon = iconMap[toast.type]
|
||
const colors = colorMap[toast.type]
|
||
|
||
// 入场动画:挂载后下一帧设为可见
|
||
useEffect(() => {
|
||
const raf = requestAnimationFrame(() => setVisible(true))
|
||
return () => cancelAnimationFrame(raf)
|
||
}, [])
|
||
|
||
// 自动关闭
|
||
const handleClose = useCallback(() => {
|
||
setVisible(false)
|
||
// 等待退场动画结束后再移除
|
||
setTimeout(() => onClose(toast.id), 200)
|
||
}, [onClose, toast.id])
|
||
|
||
useEffect(() => {
|
||
if (toast.duration <= 0) return
|
||
const timer = setTimeout(() => {
|
||
handleClose()
|
||
}, toast.duration)
|
||
return () => clearTimeout(timer)
|
||
}, [toast.duration, handleClose])
|
||
|
||
return (
|
||
<div
|
||
className={`
|
||
flex items-start gap-3 w-80 px-4 py-3 rounded-lg border
|
||
shadow-lg backdrop-blur-sm
|
||
transition-all duration-200 ease-out
|
||
${colors.bg} ${colors.border}
|
||
${visible
|
||
? 'translate-x-0 opacity-100'
|
||
: 'translate-x-4 opacity-0'
|
||
}
|
||
`}
|
||
role="alert"
|
||
>
|
||
{/* 图标 */}
|
||
<Icon className={`w-5 h-5 mt-0.5 shrink-0 ${colors.icon}`} />
|
||
|
||
{/* 消息 */}
|
||
<p className={`flex-1 text-sm font-medium ${colors.text}`}>
|
||
{toast.message}
|
||
</p>
|
||
|
||
{/* 关闭按钮 */}
|
||
<button
|
||
onClick={handleClose}
|
||
className="p-0.5 rounded hover:bg-white/10 transition-colors shrink-0"
|
||
aria-label={t('toast.closeAria')}
|
||
>
|
||
<X className="w-4 h-4 text-txt-muted hover:text-content-primary" />
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Toast 通知容器(Provider)
|
||
*
|
||
* 在应用根节点中使用,渲染右上角通知堆叠
|
||
*
|
||
* @example
|
||
* // 在 App.tsx 中
|
||
* <ToastProvider>
|
||
* <Router />
|
||
* </ToastProvider>
|
||
*/
|
||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||
const toasts = useToastStore((s) => s.toasts)
|
||
const removeToast = useToastStore((s) => s.removeToast)
|
||
|
||
return (
|
||
<>
|
||
{children}
|
||
<div
|
||
className="fixed top-4 right-4 z-50 flex flex-col gap-2 pointer-events-none"
|
||
aria-live="polite"
|
||
>
|
||
{toasts.map((toast) => (
|
||
<div key={toast.id} className="pointer-events-auto">
|
||
<ToastItemView toast={toast} onClose={removeToast} />
|
||
</div>
|
||
))}
|
||
</div>
|
||
</>
|
||
)
|
||
}
|
||
|
||
export default ToastProvider
|