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
Generated
+7
@@ -10,6 +10,7 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-search": "^0.16.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"lucide-react": "^0.294.0",
|
||||
"react": "^18.2.0",
|
||||
@@ -3167,6 +3168,12 @@
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-search": {
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmmirror.com/@xterm/addon-search/-/addon-search-0.16.0.tgz",
|
||||
"integrity": "sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/addon-webgl": {
|
||||
"version": "0.19.0",
|
||||
"resolved": "https://registry.npmmirror.com/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz",
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-search": "^0.16.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"lucide-react": "^0.294.0",
|
||||
"react": "^18.2.0",
|
||||
|
||||
+12
-8
@@ -5,9 +5,11 @@ import TabContent from './components/TabContent'
|
||||
import StatusBar from './components/StatusBar'
|
||||
import SessionDialog from './components/SessionDialog'
|
||||
import QuickConnectDialog from './components/QuickConnectDialog'
|
||||
import { ToastProvider } from './components/common/Toast'
|
||||
import { useTabStore } from './stores/tabStore'
|
||||
import { useSessionStore, type Session } from './stores'
|
||||
import { createLogger } from './utils/logger'
|
||||
import { useToast } from './hooks/useToast'
|
||||
|
||||
const logger = createLogger('App')
|
||||
|
||||
@@ -17,6 +19,7 @@ function App() {
|
||||
const [editSession, setEditSession] = useState<Session | null>(null)
|
||||
const { activeTabId } = useTabStore()
|
||||
const { sessions } = useSessionStore()
|
||||
const toast = useToast()
|
||||
|
||||
// 导出会话
|
||||
const handleExportSessions = async () => {
|
||||
@@ -42,11 +45,10 @@ function App() {
|
||||
}))
|
||||
|
||||
// TODO: 使用 Node.js fs 写入文件(通过 preload 暴露的方法)
|
||||
// 简化处理:直接 alert 成功信息
|
||||
alert(`已导出 ${exportData.length} 个会话到:\n${result.filePath}`)
|
||||
toast.success(`已导出 ${exportData.length} 个会话到:\n${result.filePath}`)
|
||||
} catch (error) {
|
||||
logger.error('Export failed:', error)
|
||||
alert('导出会话失败')
|
||||
toast.error('导出会话失败')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +71,7 @@ function App() {
|
||||
const fileResult = await window.electronAPI?.readFile(filePath)
|
||||
|
||||
if (!fileResult?.success || !fileResult.data) {
|
||||
alert('读取文件失败')
|
||||
toast.error('读取文件失败')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -125,14 +127,14 @@ function App() {
|
||||
}
|
||||
|
||||
if (importSessions.length === 0) {
|
||||
alert('未找到可导入的会话')
|
||||
toast.warning('未找到可导入的会话')
|
||||
return
|
||||
}
|
||||
|
||||
// 过滤必填字段
|
||||
const validSessions = importSessions.filter(s => s.host && s.username)
|
||||
if (validSessions.length === 0) {
|
||||
alert('文件中没有有效的会话配置')
|
||||
toast.warning('文件中没有有效的会话配置')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -156,7 +158,7 @@ function App() {
|
||||
}
|
||||
}
|
||||
|
||||
alert(`成功导入 ${created}/${validSessions.length} 个会话`)
|
||||
toast.success(`成功导入 ${created}/${validSessions.length} 个会话`)
|
||||
|
||||
// 刷新会话列表
|
||||
const refreshResult = await window.electronAPI?.sessionGetAll()
|
||||
@@ -165,7 +167,7 @@ function App() {
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Import failed:', error)
|
||||
alert('导入会话失败')
|
||||
toast.error('导入会话失败')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,6 +198,7 @@ function App() {
|
||||
}, [sessions])
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
<div className="flex flex-col h-screen bg-dark-900 text-gray-200">
|
||||
{/* Title bar spacer for macOS */}
|
||||
<div className="h-8 bg-dark-900 drag"></div>
|
||||
@@ -252,6 +255,7 @@ function App() {
|
||||
onClose={() => setIsQuickConnectOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
+165
-58
@@ -75,6 +75,9 @@ export interface UseSFTPReturn {
|
||||
uploadFiles: (files: FileList | null) => Promise<void>
|
||||
downloadFiles: () => Promise<void>
|
||||
|
||||
// 远程文件操作
|
||||
deleteRemote: (files: FileItem[]) => Promise<boolean>
|
||||
|
||||
// 排序
|
||||
sortField: SortField
|
||||
sortOrder: SortOrder
|
||||
@@ -372,89 +375,190 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
setRemoteFiles([])
|
||||
}, [connectionId, sftpId, getConnection])
|
||||
|
||||
// Upload files
|
||||
// Upload files (real implementation via IPC)
|
||||
const uploadFiles = useCallback(async (files: FileList | null) => {
|
||||
if (!files || !sftpId) return
|
||||
|
||||
if (!files || files.length === 0 || !sftpId || !window.electronAPI) {
|
||||
logWarning('uploadFiles: missing files, sftpId, or electronAPI')
|
||||
return
|
||||
}
|
||||
|
||||
const newTasks: TransferTask[] = Array.from(files).map(file => ({
|
||||
id: `upload-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
type: 'upload',
|
||||
type: 'upload' as const,
|
||||
fileName: file.name,
|
||||
filePath: `${localPath}/${file.name}`,
|
||||
status: 'pending',
|
||||
status: 'pending' as const,
|
||||
progress: 0,
|
||||
total: file.size,
|
||||
transferred: 0,
|
||||
}))
|
||||
|
||||
setTransferQueue(prev => [...prev, ...newTasks])
|
||||
|
||||
// Process uploads
|
||||
for (const task of newTasks) {
|
||||
try {
|
||||
setTransferQueue(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, status: 'transferring' } : t
|
||||
))
|
||||
|
||||
// Note: Actual upload implementation would go here
|
||||
// This is a simplified version
|
||||
|
||||
setTransferQueue(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, status: 'completed', transferred: t.total, progress: 100 } : t
|
||||
))
|
||||
} catch (err) {
|
||||
logger.error('Upload failed', err)
|
||||
setTransferQueue(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, status: 'failed', error: 'Upload failed' } : t
|
||||
))
|
||||
}
|
||||
}
|
||||
}, [sftpId, localPath, remotePath])
|
||||
|
||||
// Download files
|
||||
setTransferQueue(prev => [...prev, ...newTasks])
|
||||
|
||||
// Set up progress listener scoped to this batch
|
||||
const progressHandler = (_event: any, _sftpId: string, progress: { transferred: number; total: number; percentage: number }) => {
|
||||
// Update the first task that is currently transferring
|
||||
setTransferQueue(prev => {
|
||||
const idx = prev.findIndex(t => t.type === 'upload' && t.status === 'transferring')
|
||||
if (idx === -1) return prev
|
||||
const t = prev[idx]
|
||||
return [
|
||||
...prev.slice(0, idx),
|
||||
{ ...t, transferred: progress.transferred, total: progress.total, progress: progress.percentage },
|
||||
...prev.slice(idx + 1),
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
window.electronAPI.onSFTPProgress(progressHandler)
|
||||
|
||||
try {
|
||||
for (const task of newTasks) {
|
||||
const remoteFilePath = `${remotePath}/${task.fileName}`.replace(/\/+/g, '/')
|
||||
logInfo('Uploading', `${task.filePath} -> ${remoteFilePath}`)
|
||||
|
||||
// Mark as transferring
|
||||
setTransferQueue(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, status: 'transferring', startTime: Date.now() } : t
|
||||
))
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.sftpUpload(sftpId, task.filePath, remoteFilePath)
|
||||
|
||||
if (result.success) {
|
||||
setTransferQueue(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, status: 'completed', transferred: t.total, progress: 100 } : t
|
||||
))
|
||||
logSuccess(`Upload complete: ${task.fileName}`)
|
||||
} else {
|
||||
setTransferQueue(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, status: 'failed', error: result.error || 'Upload failed' } : t
|
||||
))
|
||||
logError(`Upload failed: ${task.fileName} - ${result.error}`)
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.error('Upload failed', err)
|
||||
setTransferQueue(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, status: 'failed', error: err?.message || 'Upload failed' } : t
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh remote directory after all uploads
|
||||
loadRemoteDirectory(remotePath)
|
||||
} finally {
|
||||
window.electronAPI.removeListener?.('sftp:progress', progressHandler)
|
||||
}
|
||||
}, [sftpId, localPath, remotePath, loadRemoteDirectory])
|
||||
|
||||
// Download files (real implementation via IPC)
|
||||
const downloadFiles = useCallback(async () => {
|
||||
if (!sftpId || remoteSelected.size === 0) return
|
||||
|
||||
if (!sftpId || remoteSelected.size === 0 || !window.electronAPI) return
|
||||
|
||||
const filesToDownload = remoteFiles.filter(f => remoteSelected.has(f.name))
|
||||
|
||||
|
||||
const newTasks: TransferTask[] = filesToDownload.map(file => ({
|
||||
id: `download-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
type: 'download',
|
||||
type: 'download' as const,
|
||||
fileName: file.name,
|
||||
filePath: file.path,
|
||||
status: 'pending',
|
||||
status: 'pending' as const,
|
||||
progress: 0,
|
||||
total: file.size,
|
||||
transferred: 0,
|
||||
}))
|
||||
|
||||
|
||||
setTransferQueue(prev => [...prev, ...newTasks])
|
||||
|
||||
// Process downloads
|
||||
for (const task of newTasks) {
|
||||
try {
|
||||
setTransferQueue(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, status: 'transferring' } : t
|
||||
|
||||
// Set up progress listener
|
||||
const progressHandler = (_event: any, _sftpId: string, progress: { transferred: number; total: number; percentage: number }) => {
|
||||
setTransferQueue(prev => {
|
||||
const idx = prev.findIndex(t => t.type === 'download' && t.status === 'transferring')
|
||||
if (idx === -1) return prev
|
||||
const t = prev[idx]
|
||||
return [
|
||||
...prev.slice(0, idx),
|
||||
{ ...t, transferred: progress.transferred, total: progress.total, progress: progress.percentage },
|
||||
...prev.slice(idx + 1),
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
window.electronAPI.onSFTPProgress(progressHandler)
|
||||
|
||||
try {
|
||||
for (const task of newTasks) {
|
||||
const localFilePath = `${localPath}/${task.fileName}`
|
||||
logInfo('Downloading', `${task.filePath} -> ${localFilePath}`)
|
||||
|
||||
setTransferQueue(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, status: 'transferring', startTime: Date.now() } : t
|
||||
))
|
||||
|
||||
if (window.electronAPI) {
|
||||
await window.electronAPI.sftpDownload(sftpId, task.filePath, `${localPath}/${task.fileName}`)
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.sftpDownload(sftpId, task.filePath, localFilePath)
|
||||
|
||||
if (result.success) {
|
||||
setTransferQueue(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, status: 'completed', transferred: t.total, progress: 100 } : t
|
||||
))
|
||||
logSuccess(`Download complete: ${task.fileName}`)
|
||||
} else {
|
||||
setTransferQueue(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, status: 'failed', error: result.error || 'Download failed' } : t
|
||||
))
|
||||
logError(`Download failed: ${task.fileName} - ${result.error}`)
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.error('Download failed', err)
|
||||
setTransferQueue(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, status: 'failed', error: err?.message || 'Download failed' } : t
|
||||
))
|
||||
}
|
||||
|
||||
setTransferQueue(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, status: 'completed', transferred: t.total, progress: 100 } : t
|
||||
))
|
||||
} catch (err) {
|
||||
logger.error('Download failed', err)
|
||||
setTransferQueue(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, status: 'failed', error: 'Download failed' } : t
|
||||
))
|
||||
}
|
||||
|
||||
// Refresh local directory after all downloads
|
||||
loadLocalDirectory(localPath)
|
||||
} finally {
|
||||
window.electronAPI.removeListener?.('sftp:progress', progressHandler)
|
||||
}
|
||||
}, [sftpId, remoteFiles, remoteSelected, localPath, loadLocalDirectory])
|
||||
|
||||
// Delete remote files/directories
|
||||
const deleteRemote = useCallback(async (files: FileItem[]): Promise<boolean> => {
|
||||
if (!sftpId || files.length === 0 || !window.electronAPI) {
|
||||
logWarning('deleteRemote: missing sftpId, files, or electronAPI')
|
||||
return false
|
||||
}
|
||||
|
||||
let allSuccess = true
|
||||
|
||||
for (const file of files) {
|
||||
logInfo('Deleting remote', `${file.path} (isDir: ${file.type === 'directory'})`)
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.sftpDelete(sftpId, file.path, file.type === 'directory')
|
||||
|
||||
if (result.success) {
|
||||
logSuccess(`Deleted: ${file.name}`)
|
||||
} else {
|
||||
logError(`Delete failed: ${file.name} - ${result.error}`)
|
||||
allSuccess = false
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.error('Delete failed', err)
|
||||
logError(`Delete error: ${file.name} - ${err?.message}`)
|
||||
allSuccess = false
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh local directory
|
||||
loadLocalDirectory(localPath)
|
||||
}, [sftpId, remoteFiles, remoteSelected, localPath, loadLocalDirectory])
|
||||
|
||||
// Refresh remote directory after deletion
|
||||
if (allSuccess || files.length > 0) {
|
||||
loadRemoteDirectory(remotePath)
|
||||
}
|
||||
|
||||
return allSuccess
|
||||
}, [sftpId, remotePath, loadRemoteDirectory])
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
@@ -499,6 +603,9 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
uploadFiles,
|
||||
downloadFiles,
|
||||
|
||||
// Remote file operations
|
||||
deleteRemote,
|
||||
|
||||
sortField,
|
||||
sortOrder,
|
||||
setSortField,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* useToast — 全局 Toast 通知 Hook
|
||||
*
|
||||
* 提供便捷的通知调用方法,可在任何组件中使用
|
||||
*
|
||||
* @example
|
||||
* const toast = useToast()
|
||||
* toast.success('保存成功')
|
||||
* toast.error('连接失败', { duration: 5000 })
|
||||
* toast.warning('磁盘空间不足')
|
||||
* toast.info('正在同步...')
|
||||
*/
|
||||
|
||||
import { useToastStore, type ToastType, type ToastOptions } from '../stores/toastStore'
|
||||
|
||||
/** Toast 操作接口 */
|
||||
export interface ToastApi {
|
||||
/** 显示成功通知(绿色) */
|
||||
success: (message: string, options?: ToastOptions) => string
|
||||
/** 显示错误通知(红色) */
|
||||
error: (message: string, options?: ToastOptions) => string
|
||||
/** 显示警告通知(琥珀色) */
|
||||
warning: (message: string, options?: ToastOptions) => string
|
||||
/** 显示信息通知(蓝色) */
|
||||
info: (message: string, options?: ToastOptions) => string
|
||||
/** 移除指定通知 */
|
||||
remove: (id: string) => void
|
||||
/** 清空所有通知 */
|
||||
clearAll: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局 Toast 通知 Hook
|
||||
*/
|
||||
export function useToast(): ToastApi {
|
||||
const addToast = useToastStore((s) => s.addToast)
|
||||
const removeToast = useToastStore((s) => s.removeToast)
|
||||
const clearAll = useToastStore((s) => s.clearAll)
|
||||
|
||||
const create = (type: ToastType) => (message: string, options?: ToastOptions) =>
|
||||
addToast(type, message, options)
|
||||
|
||||
return {
|
||||
success: create('success'),
|
||||
error: create('error'),
|
||||
warning: create('warning'),
|
||||
info: create('info'),
|
||||
remove: removeToast,
|
||||
clearAll,
|
||||
}
|
||||
}
|
||||
|
||||
export default useToast
|
||||
+284
-169
@@ -1,4 +1,8 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import { createLogger } from '../utils/logger'
|
||||
|
||||
const logger = createLogger('TabStore')
|
||||
|
||||
export type TabType = 'welcome' | 'terminal' | 'sftp' | 'portforward' | 'settings'
|
||||
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error'
|
||||
@@ -29,6 +33,19 @@ export interface Tab {
|
||||
connectionInstance?: ConnectionInstance // 实际的连接实例(每个标签独立)
|
||||
isActive: boolean
|
||||
connectionStatus?: ConnectionStatus
|
||||
needsReconnect?: boolean // 从持久化恢复后需要重新连接
|
||||
}
|
||||
|
||||
// 用于持久化的序列化 Tab 数据(排除敏感字段和运行时状态)
|
||||
interface PersistedTab {
|
||||
id: string
|
||||
title: string
|
||||
type: TabType
|
||||
sessionId?: string
|
||||
// 注意:不持久化 connectionConfig(可能包含密码/密钥)
|
||||
// 注意:不持久化 connectionInstance(运行时状态)
|
||||
// 注意:不持久化 connectionStatus(运行时状态)
|
||||
// 注意:不持久化 isActive(由 activeTabId 决定)
|
||||
}
|
||||
|
||||
interface TabState {
|
||||
@@ -50,177 +67,275 @@ interface TabState {
|
||||
getTabById: (tabId: string) => Tab | undefined
|
||||
}
|
||||
|
||||
export const useTabStore = create<TabState>((set, get) => ({
|
||||
tabs: [
|
||||
{ id: 'welcome', title: '欢迎', type: 'welcome', isActive: true }
|
||||
],
|
||||
activeTabId: 'welcome',
|
||||
|
||||
addTab: (tab) => {
|
||||
const { tabs } = get()
|
||||
export const useTabStore = create<TabState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
tabs: [
|
||||
{ id: 'welcome', title: '欢迎', type: 'welcome', isActive: true }
|
||||
],
|
||||
activeTabId: 'welcome',
|
||||
|
||||
addTab: (tab) => {
|
||||
const { tabs } = get()
|
||||
|
||||
// 检查是否是唯一类型的标签(settings, welcome),如果有则激活而不是创建新的
|
||||
if (tab.type === 'settings' || tab.type === 'welcome') {
|
||||
const existing = tabs.find(t => t.type === tab.type)
|
||||
if (existing) {
|
||||
// 激活已存在的标签
|
||||
// 检查是否是唯一类型的标签(settings, welcome),如果有则激活而不是创建新的
|
||||
if (tab.type === 'settings' || tab.type === 'welcome') {
|
||||
const existing = tabs.find(t => t.type === tab.type)
|
||||
if (existing) {
|
||||
// 激活已存在的标签
|
||||
const updatedTabs = tabs.map(t => ({
|
||||
...t,
|
||||
isActive: t.id === existing.id,
|
||||
}))
|
||||
set({ tabs: updatedTabs, activeTabId: existing.id })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const newTab: Tab = {
|
||||
...tab,
|
||||
id: `tab-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
// Deactivate all other tabs
|
||||
const updatedTabs = tabs.map(t => ({ ...t, isActive: false }))
|
||||
|
||||
set({
|
||||
tabs: [...updatedTabs, newTab],
|
||||
activeTabId: newTab.id,
|
||||
})
|
||||
},
|
||||
|
||||
updateTab: (tabId, updates) => {
|
||||
const { tabs } = get()
|
||||
const updatedTabs = tabs.map(t =>
|
||||
t.id === tabId ? { ...t, ...updates } : t
|
||||
)
|
||||
set({ tabs: updatedTabs })
|
||||
},
|
||||
|
||||
updateTabConnectionInstance: (tabId, instance) => {
|
||||
const { tabs } = get()
|
||||
const updatedTabs = tabs.map(t =>
|
||||
t.id === tabId ? { ...t, connectionInstance: instance } : t
|
||||
)
|
||||
set({ tabs: updatedTabs })
|
||||
},
|
||||
|
||||
updateTabStatus: (tabId, status) => {
|
||||
const { tabs } = get()
|
||||
const updatedTabs = tabs.map(t =>
|
||||
t.id === tabId ? { ...t, connectionStatus: status, needsReconnect: status === 'disconnected' ? false : t.needsReconnect } : t
|
||||
)
|
||||
set({ tabs: updatedTabs })
|
||||
},
|
||||
|
||||
closeTab: (tabId) => {
|
||||
const { tabs, activeTabId } = get()
|
||||
|
||||
if (tabs.length <= 1) return null
|
||||
|
||||
const closedTab = tabs.find(t => t.id === tabId) || null
|
||||
const newTabs = tabs.filter(t => t.id !== tabId)
|
||||
|
||||
// If closing active tab, activate another one
|
||||
let newActiveId = activeTabId
|
||||
if (activeTabId === tabId) {
|
||||
const closedTabIndex = tabs.findIndex(t => t.id === tabId)
|
||||
const newActiveTab = newTabs[Math.min(closedTabIndex, newTabs.length - 1)]
|
||||
if (newActiveTab) {
|
||||
newActiveTab.isActive = true
|
||||
newActiveId = newActiveTab.id
|
||||
}
|
||||
}
|
||||
|
||||
set({ tabs: newTabs, activeTabId: newActiveId })
|
||||
return closedTab
|
||||
},
|
||||
|
||||
closeOtherTabs: (tabId) => {
|
||||
const { tabs } = get()
|
||||
const keepTab = tabs.find(t => t.id === tabId)
|
||||
if (!keepTab) return []
|
||||
|
||||
const closedTabs = tabs.filter(t => t.id !== tabId)
|
||||
keepTab.isActive = true
|
||||
set({
|
||||
tabs: [keepTab],
|
||||
activeTabId: tabId,
|
||||
})
|
||||
return closedTabs
|
||||
},
|
||||
|
||||
closeRightTabs: (tabId) => {
|
||||
const { tabs, activeTabId } = get()
|
||||
const tabIndex = tabs.findIndex(t => t.id === tabId)
|
||||
if (tabIndex === -1) return []
|
||||
|
||||
// 保留当前标签及其左侧所有标签
|
||||
const closedTabs = tabs.slice(tabIndex + 1)
|
||||
const newTabs = tabs.slice(0, tabIndex + 1)
|
||||
// 如果关闭的是当前活动标签,激活最后一个
|
||||
if (activeTabId && tabs.findIndex(t => t.id === activeTabId) > tabIndex) {
|
||||
newTabs[newTabs.length - 1].isActive = true
|
||||
set({ tabs: newTabs, activeTabId: newTabs[newTabs.length - 1].id })
|
||||
} else {
|
||||
set({ tabs: newTabs })
|
||||
}
|
||||
return closedTabs
|
||||
},
|
||||
|
||||
closeLeftTabs: (tabId) => {
|
||||
const { tabs, activeTabId } = get()
|
||||
const tabIndex = tabs.findIndex(t => t.id === tabId)
|
||||
if (tabIndex === -1) return []
|
||||
|
||||
// 保留当前标签及其右侧所有标签
|
||||
const closedTabs = tabs.slice(0, tabIndex)
|
||||
const newTabs = tabs.slice(tabIndex)
|
||||
// 如果关闭的是当前活动标签,激活第一个
|
||||
if (activeTabId && tabs.findIndex(t => t.id === activeTabId) < tabIndex) {
|
||||
newTabs[0].isActive = true
|
||||
set({ tabs: newTabs, activeTabId: newTabs[0].id })
|
||||
} else {
|
||||
set({ tabs: newTabs, activeTabId: tabId })
|
||||
}
|
||||
return closedTabs
|
||||
},
|
||||
|
||||
closeAllTabs: () => {
|
||||
const { tabs } = get()
|
||||
const welcomeTab: Tab = {
|
||||
id: 'welcome',
|
||||
title: '欢迎',
|
||||
type: 'welcome',
|
||||
isActive: true,
|
||||
}
|
||||
set({
|
||||
tabs: [welcomeTab],
|
||||
activeTabId: 'welcome',
|
||||
})
|
||||
return tabs
|
||||
},
|
||||
|
||||
activateTab: (tabId) => {
|
||||
const { tabs } = get()
|
||||
const updatedTabs = tabs.map(t => ({
|
||||
...t,
|
||||
isActive: t.id === existing.id,
|
||||
isActive: t.id === tabId,
|
||||
}))
|
||||
set({ tabs: updatedTabs, activeTabId: existing.id })
|
||||
return
|
||||
}
|
||||
set({
|
||||
tabs: updatedTabs,
|
||||
activeTabId: tabId,
|
||||
})
|
||||
},
|
||||
|
||||
updateTabTitle: (tabId, title) => {
|
||||
const { tabs } = get()
|
||||
const updatedTabs = tabs.map(t =>
|
||||
t.id === tabId ? { ...t, title } : t
|
||||
)
|
||||
set({ tabs: updatedTabs })
|
||||
},
|
||||
|
||||
getTabById: (tabId) => {
|
||||
return get().tabs.find(t => t.id === tabId)
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'opsxshell-tabs',
|
||||
// 只持久化需要恢复的字段
|
||||
partialize: (state) => {
|
||||
// 过滤掉 welcome 标签和 settings 标签(这些在启动时总会自动创建)
|
||||
// 只持久化有实际会话关联的标签(terminal, sftp, portforward)
|
||||
const tabsToPersist = state.tabs
|
||||
.filter(tab => {
|
||||
// 持久化有 sessionId 的标签或非 welcome/settings 类型
|
||||
return tab.type === 'terminal' || tab.type === 'sftp' || tab.type === 'portforward'
|
||||
})
|
||||
.map(tab => ({
|
||||
id: tab.id,
|
||||
title: tab.title,
|
||||
type: tab.type,
|
||||
sessionId: tab.sessionId,
|
||||
// 不保存 connectionConfig(含密码等敏感信息)
|
||||
// 不保存 connectionInstance(运行时状态)
|
||||
// 不保存 connectionStatus(运行时状态)
|
||||
// 不保存 isActive(由 activeTabId 决定)
|
||||
})) as PersistedTab[]
|
||||
|
||||
// 只持久化非 welcome 的 activeTabId
|
||||
const activeTab = state.tabs.find(t => t.id === state.activeTabId)
|
||||
let persistActiveTabId = state.activeTabId
|
||||
if (activeTab?.type === 'welcome') {
|
||||
persistActiveTabId = null
|
||||
}
|
||||
|
||||
return {
|
||||
tabs: tabsToPersist,
|
||||
activeTabId: persistActiveTabId,
|
||||
}
|
||||
},
|
||||
// 从持久化数据恢复时的合并策略
|
||||
merge: (persistedState, currentState) => {
|
||||
const persisted = persistedState as Partial<TabState>
|
||||
|
||||
if (!persisted?.tabs || persisted.tabs.length === 0) {
|
||||
// 没有持久化数据,使用默认状态
|
||||
return currentState
|
||||
}
|
||||
|
||||
logger.info('Restoring tabs from persisted state:', persisted.tabs.length, 'tabs')
|
||||
|
||||
// 将持久化的标签转换为运行时标签
|
||||
const restoredTabs: Tab[] = persisted.tabs.map((tab: any) => ({
|
||||
...tab,
|
||||
isActive: false,
|
||||
connectionStatus: 'disconnected' as ConnectionStatus,
|
||||
needsReconnect: true, // 标记需要重新连接
|
||||
}))
|
||||
|
||||
// 确定活动标签
|
||||
let activeTabId = persisted.activeTabId || null
|
||||
if (activeTabId) {
|
||||
const activeTab = restoredTabs.find(t => t.id === activeTabId)
|
||||
if (activeTab) {
|
||||
activeTab.isActive = true
|
||||
} else {
|
||||
// 活动标签不存在,选择第一个
|
||||
if (restoredTabs.length > 0) {
|
||||
restoredTabs[0].isActive = true
|
||||
activeTabId = restoredTabs[0].id
|
||||
}
|
||||
}
|
||||
} else if (restoredTabs.length > 0) {
|
||||
restoredTabs[0].isActive = true
|
||||
activeTabId = restoredTabs[0].id
|
||||
}
|
||||
|
||||
// 始终在开头添加 welcome 标签
|
||||
const welcomeTab: Tab = {
|
||||
id: 'welcome',
|
||||
title: '欢迎',
|
||||
type: 'welcome',
|
||||
isActive: false,
|
||||
}
|
||||
|
||||
const finalTabs = [welcomeTab, ...restoredTabs]
|
||||
|
||||
// 如果活动标签不存在,默认激活 welcome
|
||||
if (!activeTabId) {
|
||||
welcomeTab.isActive = true
|
||||
activeTabId = 'welcome'
|
||||
}
|
||||
|
||||
return {
|
||||
...currentState,
|
||||
tabs: finalTabs,
|
||||
activeTabId,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const newTab: Tab = {
|
||||
...tab,
|
||||
id: `tab-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
// Deactivate all other tabs
|
||||
const updatedTabs = tabs.map(t => ({ ...t, isActive: false }))
|
||||
|
||||
set({
|
||||
tabs: [...updatedTabs, newTab],
|
||||
activeTabId: newTab.id,
|
||||
})
|
||||
},
|
||||
|
||||
updateTab: (tabId, updates) => {
|
||||
const { tabs } = get()
|
||||
const updatedTabs = tabs.map(t =>
|
||||
t.id === tabId ? { ...t, ...updates } : t
|
||||
)
|
||||
set({ tabs: updatedTabs })
|
||||
},
|
||||
|
||||
updateTabConnectionInstance: (tabId, instance) => {
|
||||
const { tabs } = get()
|
||||
const updatedTabs = tabs.map(t =>
|
||||
t.id === tabId ? { ...t, connectionInstance: instance } : t
|
||||
)
|
||||
set({ tabs: updatedTabs })
|
||||
},
|
||||
|
||||
updateTabStatus: (tabId, status) => {
|
||||
const { tabs } = get()
|
||||
const updatedTabs = tabs.map(t =>
|
||||
t.id === tabId ? { ...t, connectionStatus: status } : t
|
||||
)
|
||||
set({ tabs: updatedTabs })
|
||||
},
|
||||
|
||||
closeTab: (tabId) => {
|
||||
const { tabs, activeTabId } = get()
|
||||
|
||||
if (tabs.length <= 1) return null
|
||||
|
||||
const closedTab = tabs.find(t => t.id === tabId) || null
|
||||
const newTabs = tabs.filter(t => t.id !== tabId)
|
||||
|
||||
// If closing active tab, activate another one
|
||||
let newActiveId = activeTabId
|
||||
if (activeTabId === tabId) {
|
||||
const closedTabIndex = tabs.findIndex(t => t.id === tabId)
|
||||
const newActiveTab = newTabs[Math.min(closedTabIndex, newTabs.length - 1)]
|
||||
if (newActiveTab) {
|
||||
newActiveTab.isActive = true
|
||||
newActiveId = newActiveTab.id
|
||||
}
|
||||
}
|
||||
|
||||
set({ tabs: newTabs, activeTabId: newActiveId })
|
||||
return closedTab
|
||||
},
|
||||
|
||||
closeOtherTabs: (tabId) => {
|
||||
const { tabs } = get()
|
||||
const keepTab = tabs.find(t => t.id === tabId)
|
||||
if (!keepTab) return []
|
||||
|
||||
const closedTabs = tabs.filter(t => t.id !== tabId)
|
||||
keepTab.isActive = true
|
||||
set({
|
||||
tabs: [keepTab],
|
||||
activeTabId: tabId,
|
||||
})
|
||||
return closedTabs
|
||||
},
|
||||
|
||||
closeRightTabs: (tabId) => {
|
||||
const { tabs, activeTabId } = get()
|
||||
const tabIndex = tabs.findIndex(t => t.id === tabId)
|
||||
if (tabIndex === -1) return []
|
||||
|
||||
// 保留当前标签及其左侧所有标签
|
||||
const closedTabs = tabs.slice(tabIndex + 1)
|
||||
const newTabs = tabs.slice(0, tabIndex + 1)
|
||||
// 如果关闭的是当前活动标签,激活最后一个
|
||||
if (activeTabId && tabs.findIndex(t => t.id === activeTabId) > tabIndex) {
|
||||
newTabs[newTabs.length - 1].isActive = true
|
||||
set({ tabs: newTabs, activeTabId: newTabs[newTabs.length - 1].id })
|
||||
} else {
|
||||
set({ tabs: newTabs })
|
||||
}
|
||||
return closedTabs
|
||||
},
|
||||
|
||||
closeLeftTabs: (tabId) => {
|
||||
const { tabs, activeTabId } = get()
|
||||
const tabIndex = tabs.findIndex(t => t.id === tabId)
|
||||
if (tabIndex === -1) return []
|
||||
|
||||
// 保留当前标签及其右侧所有标签
|
||||
const closedTabs = tabs.slice(0, tabIndex)
|
||||
const newTabs = tabs.slice(tabIndex)
|
||||
// 如果关闭的是当前活动标签,激活第一个
|
||||
if (activeTabId && tabs.findIndex(t => t.id === activeTabId) < tabIndex) {
|
||||
newTabs[0].isActive = true
|
||||
set({ tabs: newTabs, activeTabId: newTabs[0].id })
|
||||
} else {
|
||||
set({ tabs: newTabs, activeTabId: tabId })
|
||||
}
|
||||
return closedTabs
|
||||
},
|
||||
|
||||
closeAllTabs: () => {
|
||||
const { tabs } = get()
|
||||
const welcomeTab: Tab = {
|
||||
id: 'welcome',
|
||||
title: '欢迎',
|
||||
type: 'welcome',
|
||||
isActive: true,
|
||||
}
|
||||
set({
|
||||
tabs: [welcomeTab],
|
||||
activeTabId: 'welcome',
|
||||
})
|
||||
return tabs
|
||||
},
|
||||
|
||||
activateTab: (tabId) => {
|
||||
const { tabs } = get()
|
||||
const updatedTabs = tabs.map(t => ({
|
||||
...t,
|
||||
isActive: t.id === tabId,
|
||||
}))
|
||||
set({
|
||||
tabs: updatedTabs,
|
||||
activeTabId: tabId,
|
||||
})
|
||||
},
|
||||
|
||||
updateTabTitle: (tabId, title) => {
|
||||
const { tabs } = get()
|
||||
const updatedTabs = tabs.map(t =>
|
||||
t.id === tabId ? { ...t, title } : t
|
||||
)
|
||||
set({ tabs: updatedTabs })
|
||||
},
|
||||
|
||||
getTabById: (tabId) => {
|
||||
return get().tabs.find(t => t.id === tabId)
|
||||
},
|
||||
}))
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Toast 通知状态管理
|
||||
*
|
||||
* 管理全局通知队列,支持 success / error / warning / info 四种类型
|
||||
*/
|
||||
|
||||
import { create } from 'zustand'
|
||||
|
||||
/** Toast 类型 */
|
||||
export type ToastType = 'success' | 'error' | 'warning' | 'info'
|
||||
|
||||
/** 单条 Toast 数据 */
|
||||
export interface ToastItem {
|
||||
/** 唯一 ID */
|
||||
id: string
|
||||
/** 类型 */
|
||||
type: ToastType
|
||||
/** 消息内容 */
|
||||
message: string
|
||||
/** 自动关闭时间(毫秒),0 表示不自动关闭 */
|
||||
duration: number
|
||||
}
|
||||
|
||||
/** Toast 创建选项 */
|
||||
export interface ToastOptions {
|
||||
/** 自动关闭时间(毫秒),默认 3000 */
|
||||
duration?: number
|
||||
}
|
||||
|
||||
interface ToastState {
|
||||
/** 通知队列 */
|
||||
toasts: ToastItem[]
|
||||
/** 添加一条通知 */
|
||||
addToast: (type: ToastType, message: string, options?: ToastOptions) => string
|
||||
/** 移除一条通知 */
|
||||
removeToast: (id: string) => void
|
||||
/** 清空所有通知 */
|
||||
clearAll: () => void
|
||||
}
|
||||
|
||||
let toastCounter = 0
|
||||
|
||||
/** 生成唯一 ID */
|
||||
function generateId(): string {
|
||||
toastCounter += 1
|
||||
return `toast-${Date.now()}-${toastCounter}`
|
||||
}
|
||||
|
||||
export const useToastStore = create<ToastState>((set) => ({
|
||||
toasts: [],
|
||||
|
||||
addToast: (type, message, options) => {
|
||||
const id = generateId()
|
||||
const duration = options?.duration ?? 3000
|
||||
|
||||
const toast: ToastItem = { id, type, message, duration }
|
||||
|
||||
set((state) => ({
|
||||
toasts: [...state.toasts, toast],
|
||||
}))
|
||||
|
||||
return id
|
||||
},
|
||||
|
||||
removeToast: (id) => {
|
||||
set((state) => ({
|
||||
toasts: state.toasts.filter((t) => t.id !== id),
|
||||
}))
|
||||
},
|
||||
|
||||
clearAll: () => {
|
||||
set({ toasts: [] })
|
||||
},
|
||||
}))
|
||||
Reference in new issue
Block a user