feat(v1.1): Toast通知系统 + 自定义对话框 + 终端搜索 + SFTP真实实现 + 标签持久化

任务7: Toast通知系统 — ToastProvider+4类型(success/error/warning/info)+右上角堆叠动画
任务8: 替换alert/confirm/prompt — ConfirmDialog+PromptDialog+useToast替换7处原生对话框
任务9: 终端搜索 — xterm SearchAddon Ctrl+F搜索栏(上一个/下一个/关闭)
任务10: SFTP上传/删除 — useSFTP.ts upload/delete方法实现
任务11: 会话恢复 — tabStore持久化到localStorage,启动恢复tabs

14 files changed, 1218 insertions(+), 241 deletions(-)
This commit is contained in:
OpsXShell Dev committed 2026-04-18 16:21:10 +08:00
1 parent 53274e2291
commit d344900118
14 files changed
+1224 -247

No files matched your search

+40 -3
View File
@@ -8,6 +8,7 @@ import { useState, useEffect } from 'react'
import { useTabStore } from '../stores/tabStore'
import { useConnectionPool } from '../stores/connection'
import { ArrowRightLeft, Globe, Server, Plus, Trash2, Play, Square } from 'lucide-react'
import { useConfirmDialog } from './common/ConfirmDialog'
interface PortForwardViewProps {
tabId: string
@@ -25,10 +26,12 @@ interface ForwardRule {
}
export default function PortForwardView({ tabId, isActive: _isActive = true }: PortForwardViewProps) {
const { tabs } = useTabStore()
const { tabs, updateTab } = useTabStore()
const pool = useConnectionPool()
const { confirm, ConfirmDialogComponent } = useConfirmDialog()
const currentTab = tabs.find(t => t.id === tabId)
const needsReconnect = currentTab?.needsReconnect
const connectionId = currentTab?.connectionInstance?.connectionId
const connection = connectionId ? pool.getConnection(connectionId) : null
@@ -146,14 +149,46 @@ export default function PortForwardView({ tabId, isActive: _isActive = true }: P
}
// Delete rule
const deleteRule = (ruleId: string) => {
if (confirm('确定删除此转发规则?')) {
const deleteRule = async (ruleId: string) => {
const confirmed = await confirm({
title: '确认删除',
message: '确定删除此转发规则?',
danger: true,
confirmText: '删除',
})
if (confirmed) {
saveRules(rules.filter(r => r.id !== ruleId))
}
}
return (
<>
<div className="h-full flex flex-col bg-dark-900">
{/* 会话恢复 - 需要重新连接覆盖层 */}
{needsReconnect && currentTab?.sessionId && (
<div className="absolute inset-0 bg-dark-900/90 flex items-center justify-center z-50">
<div className="text-center">
<div className="w-16 h-16 bg-accent-blue/20 rounded-2xl flex items-center justify-center mx-auto mb-4">
<ArrowRightLeft className="w-8 h-8 text-accent-blue" />
</div>
<div className="text-gray-200 mb-2 text-lg">会话已恢复</div>
<div className="text-gray-400 text-sm mb-6">
上次关闭时的端口转发标签已恢复<br />
需要重新建立 SSH 连接才能使用
</div>
<button
onClick={() => {
updateTab(tabId, { needsReconnect: false })
// 刷新页面或触发连接逻辑
}}
className="px-6 py-2.5 bg-accent-blue hover:bg-blue-500 text-white rounded-lg transition-colors"
>
重新连接
</button>
</div>
</div>
)}
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-dark-700">
<div className="flex items-center gap-3">
@@ -383,5 +418,7 @@ export default function PortForwardView({ tabId, isActive: _isActive = true }: P
</div>
)}
</div>
{ConfirmDialogComponent}
</>
)
}
+16 -5
View File
@@ -13,6 +13,7 @@ import { Toolbar } from './sftp/Toolbar'
import { ConnectionPanel } from './sftp/ConnectionPanel'
import { ContextMenu, type MenuItem } from './sftp/ContextMenu'
import { sortFiles } from './sftp/utils'
import { usePromptDialog } from './common/PromptDialog'
import type { FileItem, SortField, SortOrder } from '../../shared/types/sftp.types'
interface SFTPViewProps {
@@ -49,9 +50,12 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView
transferQueue,
uploadFiles,
downloadFiles,
deleteRemote,
error,
} = useSFTP({ tabId })
const { prompt, PromptDialogComponent } = usePromptDialog()
// UI State
const [sortField, setSortField] = useState<SortField>('name')
const [sortOrder, setSortOrder] = useState<SortOrder>('asc')
@@ -128,11 +132,13 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView
icon: <Trash2 className="w-4 h-4" />,
label: '删除',
danger: true,
action: () => {},
action: () => {
deleteRemote([file])
},
},
]
setContextMenu({ x: e.clientX, y: e.clientY, items })
}, [navigateRemote, downloadFiles, setRemoteSelected])
}, [navigateRemote, downloadFiles, deleteRemote, setRemoteSelected])
// Navigate up
const navigateLocalUp = useCallback(() => {
@@ -170,14 +176,18 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView
}, [uploadFiles])
// Handle new folder
const handleNewFolder = useCallback(() => {
const name = prompt('输入文件夹名称:')
const handleNewFolder = useCallback(async () => {
const name = await prompt({
title: '新建文件夹',
message: '输入文件夹名称:',
placeholder: '文件夹名称',
})
if (name && window.electronAPI?.localMkdir) {
window.electronAPI.localMkdir(`${localPath}/${name}`).then(() => {
loadLocalDirectory(localPath)
})
}
}, [localPath, loadLocalDirectory])
}, [localPath, loadLocalDirectory, prompt])
return (
<div className="h-full flex flex-col bg-dark-900">
@@ -272,6 +282,7 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView
onClose={() => setContextMenu(null)}
/>
)}
{PromptDialogComponent}
</div>
)
}
+15 -3
View File
@@ -8,6 +8,7 @@ import { useSessionStore, Session } from '../stores'
import { useTabStore } from '../stores/tabStore'
import { SessionTree } from './sidebar/SessionTree'
import { SearchBox } from './sidebar/SearchBox'
import { useConfirmDialog } from './common/ConfirmDialog'
// Sidebar Component
interface SidebarProps {
@@ -19,6 +20,7 @@ interface SidebarProps {
export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }: SidebarProps) {
const { sessions, setSessions, removeSession } = useSessionStore()
const { addTab } = useTabStore()
const { confirm, ConfirmDialogComponent } = useConfirmDialog()
const [searchTerm, setSearchTerm] = useState('')
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set(['default']))
@@ -68,7 +70,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }:
setContextMenu({ x: e.clientX, y: e.clientY, session })
}
const handleContextMenuAction = (action: string) => {
const handleContextMenuAction = async (action: string) => {
if (!contextMenu) return
const session = contextMenu.session
@@ -99,17 +101,25 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }:
}
})
break
case 'delete':
if (confirm(`确定删除会话 "${session.name}"?`)) {
case 'delete': {
const confirmed = await confirm({
title: '确认删除',
message: `确定删除会话 "${session.name}"?`,
danger: true,
confirmText: '删除',
})
if (confirmed) {
removeSession(session.id)
window.electronAPI?.sessionDelete(session.id)
}
break
}
}
setContextMenu(null)
}
return (
<>
<div className="w-64 bg-dark-800 border-r border-dark-700 flex flex-col">
{/* Header */}
<div className="p-4 border-b border-dark-700">
@@ -200,5 +210,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }:
</div>
)}
</div>
{ConfirmDialogComponent}
</>
)
}
+178 -1
View File
@@ -1,7 +1,9 @@
import { useEffect, useRef, useState, useCallback } from 'react'
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { SearchAddon } from '@xterm/addon-search'
import '@xterm/xterm/css/xterm.css'
import { Search, ChevronUp, ChevronDown, X, Terminal as TerminalIcon } from 'lucide-react'
import { themes } from '../themes/terminal'
import { useSettingsStore } from '../stores/settingsStore'
import { useTabStore, type ConnectionInstance } from '../stores/tabStore'
@@ -16,6 +18,7 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
const terminalRef = useRef<HTMLDivElement>(null)
const xtermRef = useRef<Terminal | null>(null)
const fitAddonRef = useRef<FitAddon | null>(null)
const searchAddonRef = useRef<SearchAddon | null>(null)
const shellIdRef = useRef<string | null>(null)
const connectionIdRef = useRef<string | null>(null)
const hasConnectedRef = useRef(false)
@@ -28,6 +31,13 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
const [statusMessage, setStatusMessage] = useState<string>('')
const [showSuspendedOverlay, setShowSuspendedOverlay] = useState(false)
// 搜索状态
const [showSearch, setShowSearch] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [searchResultCount, setSearchResultCount] = useState<number | undefined>(undefined)
const [searchResultIndex, setSearchResultIndex] = useState<number | undefined>(undefined)
const searchInputRef = useRef<HTMLInputElement>(null)
// 全局状态
const { settings, loadSettings } = useSettingsStore()
const { updateTab, updateTabConnectionInstance, updateTabStatus, getTabById } = useTabStore()
@@ -40,6 +50,85 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
loadSettings()
}, [loadSettings])
// 搜索相关回调
const searchDecorations = {
matchBackground: '#3b82f633',
matchBorder: '#3b82f666',
matchOverviewRuler: '#3b82f6',
activeMatchBackground: '#3b82f6',
activeMatchBorder: '#60a5fa',
activeMatchColorOverviewRuler: '#60a5fa',
}
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const query = e.target.value
setSearchQuery(query)
if (query) {
searchAddonRef.current?.findNext(query, { decorations: searchDecorations, incremental: true })
} else {
searchAddonRef.current?.clearDecorations()
setSearchResultCount(undefined)
setSearchResultIndex(undefined)
}
}, [])
const handleSearchNext = useCallback(() => {
if (searchAddonRef.current && searchQuery) {
searchAddonRef.current.findNext(searchQuery, { decorations: searchDecorations })
}
}, [searchQuery])
const handleSearchPrevious = useCallback(() => {
if (searchAddonRef.current && searchQuery) {
searchAddonRef.current.findPrevious(searchQuery, { decorations: searchDecorations })
}
}, [searchQuery])
const handleCloseSearch = useCallback(() => {
setShowSearch(false)
searchAddonRef.current?.clearDecorations()
setSearchQuery('')
setSearchResultCount(undefined)
setSearchResultIndex(undefined)
xtermRef.current?.focus()
}, [])
// Ctrl+F / Cmd+F 打开搜索栏,Escape 关闭
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
// 只在当前标签激活时拦截
if (!isActive) return
e.preventDefault()
e.stopPropagation()
if (!showSearch) {
setShowSearch(true)
// 延迟聚焦输入框,等 DOM 更新
setTimeout(() => searchInputRef.current?.focus(), 50)
} else {
searchInputRef.current?.focus()
searchInputRef.current?.select()
}
}
if (e.key === 'Escape' && showSearch) {
handleCloseSearch()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isActive, showSearch, handleCloseSearch])
// 监听 SearchAddon 的 onDidChangeResults 事件
useEffect(() => {
const addon = searchAddonRef.current
if (!addon) return
const disposable = addon.onDidChangeResults((e) => {
setSearchResultCount(e.resultCount)
setSearchResultIndex(e.resultIndex !== undefined ? e.resultIndex + 1 : undefined)
})
return () => disposable.dispose()
}, [])
// 初始化 xterm
useEffect(() => {
// 修复:只检查 terminalRef,不检查 xtermRef
@@ -91,13 +180,16 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
})
const fitAddon = new FitAddon()
const searchAddon = new SearchAddon()
term.loadAddon(fitAddon)
term.loadAddon(searchAddon)
term.open(terminalRef.current)
fitAddon.fit()
xtermRef.current = term
fitAddonRef.current = fitAddon
searchAddonRef.current = searchAddon
// 处理终端输入
term.onData((data) => {
@@ -400,6 +492,16 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
// 初始连接
useEffect(() => {
if (!hasConnectedRef.current && isActive) {
// 检查标签是否从持久化恢复(需要手动重新连接)
const currentTab = getTabById(tabId)
if (currentTab?.needsReconnect) {
// 从持久化恢复的标签,不自动连接,显示「重新连接」界面
logger.info('[Terminal] Tab restored from session, waiting for manual reconnect')
setConnectionState('disconnected')
setStatusMessage('上次会话已恢复,点击重新连接')
return
}
// 检查是否有快照
const hasSnapshot = snapshotStore.hasSnapshot(tabId)
if (hasSnapshot && isActive) {
@@ -452,10 +554,12 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
hasConnectedRef.current = false
setConnectionState('disconnected')
setShowSuspendedOverlay(false)
// 清除 needsReconnect 标志
updateTab(tabId, { needsReconnect: false })
xtermRef.current?.clear()
connect()
}
}, [connectionState, connect])
}, [connectionState, connect, updateTab, tabId])
// 组件卸载时清理监听器
useEffect(() => {
@@ -477,6 +581,79 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
className="w-full h-full bg-dark-900 rounded"
/>
{/* 搜索栏 - 终端顶部覆盖 */}
{showSearch && (
<div className="absolute top-0 left-0 right-0 z-30 flex items-center gap-2 px-3 py-2 bg-dark-800/95 border-b border-dark-600 backdrop-blur-sm">
<Search size={14} className="text-gray-400 shrink-0" />
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={handleSearchChange}
onKeyDown={(e) => {
if (e.key === 'Enter') {
if (e.shiftKey) {
handleSearchPrevious()
} else {
handleSearchNext()
}
}
e.stopPropagation()
}}
placeholder="搜索..."
className="flex-1 bg-dark-900 text-gray-200 text-sm px-2 py-1 rounded border border-dark-600 focus:border-accent-blue focus:outline-none placeholder-gray-500"
/>
{searchResultCount !== undefined && (
<span className="text-xs text-gray-400 whitespace-nowrap min-w-[60px] text-center">
{searchResultIndex ?? 0}/{searchResultCount}
</span>
)}
<button
onClick={handleSearchPrevious}
className="p-1 hover:bg-dark-600 rounded text-gray-400 hover:text-gray-200 transition-colors"
title="上一个 (Shift+Enter)"
>
<ChevronUp size={16} />
</button>
<button
onClick={handleSearchNext}
className="p-1 hover:bg-dark-600 rounded text-gray-400 hover:text-gray-200 transition-colors"
title="下一个 (Enter)"
>
<ChevronDown size={16} />
</button>
<button
onClick={handleCloseSearch}
className="p-1 hover:bg-dark-600 rounded text-gray-400 hover:text-gray-200 transition-colors"
title="关闭 (Escape)"
>
<X size={16} />
</button>
</div>
)}
{/* 会话恢复 - 需要重新连接覆盖层 */}
{connectionState === 'disconnected' && getTabById(tabId)?.needsReconnect && (
<div className="absolute inset-0 bg-dark-900/90 flex items-center justify-center z-10">
<div className="text-center">
<div className="w-16 h-16 bg-accent-blue/20 rounded-2xl flex items-center justify-center mx-auto mb-4">
<TerminalIcon className="w-8 h-8 text-accent-blue" />
</div>
<div className="text-gray-200 mb-2 text-lg">会话已恢复</div>
<div className="text-gray-400 text-sm mb-6">
上次关闭时的终端标签已恢复<br />
点击下方按钮重新建立 SSH 连接
</div>
<button
onClick={handleReconnect}
className="px-6 py-2.5 bg-accent-blue hover:bg-blue-500 text-white rounded-lg transition-colors"
>
重新连接
</button>
</div>
</div>
)}
{/* 挂起状态覆盖层 */}
{showSuspendedOverlay && (
<div className="absolute inset-0 bg-dark-900/90 flex items-center justify-center z-10">
+105
View File
@@ -0,0 +1,105 @@
/**
* ConfirmDialog — 基于 Promise 的确认对话框
*
* 用于替代 window.confirm(),支持异步等待结果
*
* @example
* const { confirm, ConfirmDialogComponent } = useConfirmDialog()
* const confirmed = await confirm({ title: '确认删除', message: '确定删除?', danger: true })
*
* // 在 JSX 中渲染:
* <ConfirmDialogComponent />
*/
import { useState, useCallback, useRef } from 'react'
import Modal from './Modal'
import { AlertTriangle } from 'lucide-react'
export interface ConfirmOptions {
/** 对话框标题 */
title: string
/** 对话框内容 */
message: string
/** 是否为危险操作(红色确认按钮) */
danger?: boolean
/** 确认按钮文本 */
confirmText?: string
/** 取消按钮文本 */
cancelText?: string
}
interface ConfirmState extends ConfirmOptions {
/** 是否显示 */
isOpen: boolean
/** resolve 回调 */
resolver: ((value: boolean) => void) | null
}
const initialState: ConfirmState = {
isOpen: false,
title: '',
message: '',
danger: false,
confirmText: '确认',
cancelText: '取消',
resolver: null,
}
/**
* ConfirmDialog 组件 + Hook
*
* 返回 confirm 函数和需要渲染的组件
*/
export function useConfirmDialog() {
const [state, setState] = useState<ConfirmState>(initialState)
const resolverRef = useRef<((value: boolean) => void) | null>(null)
const confirm = useCallback((options: ConfirmOptions): Promise<boolean> => {
return new Promise<boolean>((resolve) => {
resolverRef.current = resolve
setState({
...options,
isOpen: true,
danger: options.danger ?? false,
confirmText: options.confirmText ?? '确认',
cancelText: options.cancelText ?? '取消',
resolver: null,
})
})
}, [])
const handleClose = useCallback(() => {
setState((prev) => ({ ...prev, isOpen: false }))
resolverRef.current?.(false)
resolverRef.current = null
}, [])
const handleConfirm = useCallback(() => {
setState((prev) => ({ ...prev, isOpen: false }))
resolverRef.current?.(true)
resolverRef.current = null
}, [])
const ConfirmDialogComponent = (
<Modal
isOpen={state.isOpen}
title={state.title}
onConfirm={handleConfirm}
onClose={handleClose}
confirmText={state.confirmText}
cancelText={state.cancelText}
isDanger={state.danger}
>
<div className="flex items-start gap-3">
{state.danger && (
<AlertTriangle className="w-5 h-5 text-red-400 mt-0.5 flex-shrink-0" />
)}
<p className="text-gray-300">{state.message}</p>
</div>
</Modal>
)
return { confirm, ConfirmDialogComponent }
}
export default useConfirmDialog
+124
View File
@@ -0,0 +1,124 @@
/**
* PromptDialog — 基于 Promise 的输入对话框
*
* 用于替代 window.prompt(),支持异步等待用户输入
*
* @example
* const { prompt, PromptDialogComponent } = usePromptDialog()
* const name = await prompt({ title: '新建文件夹', message: '输入文件夹名称:', placeholder: '文件夹名称' })
* if (name !== null) { ... 用户输入了内容 }
*/
import { useState, useCallback, useRef, useEffect } from 'react'
import Modal from './Modal'
export interface PromptOptions {
/** 对话框标题 */
title: string
/** 提示信息 */
message: string
/** 输入框占位符 */
placeholder?: string
/** 默认值 */
defaultValue?: string
/** 确认按钮文本 */
confirmText?: string
/** 取消按钮文本 */
cancelText?: string
}
interface PromptState extends PromptOptions {
/** 是否显示 */
isOpen: boolean
/** 当前输入值 */
value: string
}
const initialState: PromptState = {
isOpen: false,
title: '',
message: '',
placeholder: '',
defaultValue: '',
confirmText: '确认',
cancelText: '取消',
value: '',
}
/**
* PromptDialog 组件 + Hook
*/
export function usePromptDialog() {
const [state, setState] = useState<PromptState>(initialState)
const resolverRef = useRef<((value: string | null) => void) | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
const prompt = useCallback((options: PromptOptions): Promise<string | null> => {
return new Promise<string | null>((resolve) => {
resolverRef.current = resolve
setState({
...options,
isOpen: true,
placeholder: options.placeholder ?? '',
defaultValue: options.defaultValue ?? '',
confirmText: options.confirmText ?? '确认',
cancelText: options.cancelText ?? '取消',
value: options.defaultValue ?? '',
})
})
}, [])
// 自动聚焦输入框
useEffect(() => {
if (state.isOpen) {
setTimeout(() => inputRef.current?.focus(), 50)
}
}, [state.isOpen])
const handleClose = useCallback(() => {
setState((prev) => ({ ...prev, isOpen: false }))
resolverRef.current?.(null)
resolverRef.current = null
}, [])
const handleConfirm = useCallback(() => {
const value = state.value.trim()
setState((prev) => ({ ...prev, isOpen: false }))
resolverRef.current?.(value || null)
resolverRef.current = null
}, [state.value])
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleConfirm()
}
}, [handleConfirm])
const PromptDialogComponent = (
<Modal
isOpen={state.isOpen}
title={state.title}
onConfirm={handleConfirm}
onClose={handleClose}
confirmText={state.confirmText}
cancelText={state.cancelText}
>
<div>
<p className="text-gray-300 mb-3">{state.message}</p>
<input
ref={inputRef}
type="text"
value={state.value}
onChange={(e) => setState((prev) => ({ ...prev, value: e.target.value }))}
onKeyDown={handleKeyDown}
placeholder={state.placeholder}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-accent-blue"
/>
</div>
</Modal>
)
return { prompt, PromptDialogComponent }
}
export default usePromptDialog
+150
View File
@@ -0,0 +1,150 @@
/**
* Toast 通知组件
*
* 包含单个 Toast 条目渲染 + ToastProvider 全局容器
* 右上角堆叠显示,支持入场/退场动画
*/
import { useEffect, useState, useCallback } from 'react'
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,
}
/** 类型 → 颜色样式映射 */
const colorMap: Record<ToastType, { bg: string; border: string; icon: string; text: string }> = {
success: {
bg: 'bg-green-900/40',
border: 'border-green-700/60',
icon: 'text-green-400',
text: 'text-green-200',
},
error: {
bg: 'bg-red-900/40',
border: 'border-red-700/60',
icon: 'text-red-400',
text: 'text-red-200',
},
warning: {
bg: 'bg-amber-900/40',
border: 'border-amber-700/60',
icon: 'text-amber-400',
text: 'text-amber-200',
},
info: {
bg: 'bg-blue-900/40',
border: 'border-blue-700/60',
icon: 'text-blue-400',
text: 'text-blue-200',
},
}
interface ToastItemProps {
toast: ToastItem
onClose: (id: string) => void
}
/**
* 单个 Toast 条目
*
* 支持自动消失和手动关闭,带入场/退场动画
*/
function ToastItemView({ toast, onClose }: ToastItemProps) {
const [visible, setVisible] = useState(false)
const Icon = iconMap[toast.type]
const colors = colorMap[toast.type]
// 入场动画:挂载后下一帧设为可见
useEffect(() => {
const raf = requestAnimationFrame(() => setVisible(true))
return () => cancelAnimationFrame(raf)
}, [])
// 自动关闭
useEffect(() => {
if (toast.duration <= 0) return
const timer = setTimeout(() => {
handleClose()
}, toast.duration)
return () => clearTimeout(timer)
}, [toast.duration])
const handleClose = useCallback(() => {
setVisible(false)
// 等待退场动画结束后再移除
setTimeout(() => onClose(toast.id), 200)
}, [onClose, toast.id])
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="关闭通知"
>
<X className="w-4 h-4 text-gray-400 hover:text-gray-200" />
</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