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
+1210 -233

No files matched your search

+7
View File
@@ -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",
+1
View File
@@ -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
View File
@@ -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>
)
}
+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
+152 -45
View File
@@ -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,16 +375,19 @@ 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,
@@ -389,40 +395,74 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
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
))
}
// 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),
]
})
}
}, [sftpId, localPath, remotePath])
// Download files
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,
@@ -430,31 +470,95 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
setTransferQueue(prev => [...prev, ...newTasks])
// Process downloads
for (const task of newTasks) {
try {
// 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' } : 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,
+53
View File
@@ -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
+283 -168
View File
@@ -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',
export const useTabStore = create<TabState>()(
persist(
(set, get) => ({
tabs: [
{ id: 'welcome', title: '欢迎', type: 'welcome', isActive: true }
],
activeTabId: 'welcome',
addTab: (tab) => {
const { tabs } = get()
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)
},
}))
)
)
+74
View File
@@ -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: [] })
},
}))