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:
1 parent
53274e2291
commit
d344900118
14 files changed
+1224
-247
No files matched your search
@@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user