diff --git a/package-lock.json b/package-lock.json index a667e21..7945b12 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index e8e7e69..eccb935 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/App.tsx b/src/App.tsx index cf3880b..94752d3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(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 ( +
{/* Title bar spacer for macOS */}
@@ -252,6 +255,7 @@ function App() { onClose={() => setIsQuickConnectOpen(false)} />
+
) } diff --git a/src/components/PortForwardView.tsx b/src/components/PortForwardView.tsx index 5f57363..44d6846 100644 --- a/src/components/PortForwardView.tsx +++ b/src/components/PortForwardView.tsx @@ -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 ( + <>
+ {/* 会话恢复 - 需要重新连接覆盖层 */} + {needsReconnect && currentTab?.sessionId && ( +
+
+
+ +
+
会话已恢复
+
+ 上次关闭时的端口转发标签已恢复
+ 需要重新建立 SSH 连接才能使用 +
+ +
+
+ )} + {/* Header */}
@@ -383,5 +418,7 @@ export default function PortForwardView({ tabId, isActive: _isActive = true }: P
)}
+ {ConfirmDialogComponent} + ) } diff --git a/src/components/SFTPView.tsx b/src/components/SFTPView.tsx index c824ab0..1d6c9d6 100644 --- a/src/components/SFTPView.tsx +++ b/src/components/SFTPView.tsx @@ -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('name') const [sortOrder, setSortOrder] = useState('asc') @@ -128,11 +132,13 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView icon: , 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 (
@@ -272,6 +282,7 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView onClose={() => setContextMenu(null)} /> )} + {PromptDialogComponent}
) } diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 17011d7..5327300 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -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>(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 ( + <>
{/* Header */}
@@ -200,5 +210,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }:
)}
+ {ConfirmDialogComponent} + ) } diff --git a/src/components/TerminalView.tsx b/src/components/TerminalView.tsx index 4d5da4f..c9f3de7 100644 --- a/src/components/TerminalView.tsx +++ b/src/components/TerminalView.tsx @@ -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(null) const xtermRef = useRef(null) const fitAddonRef = useRef(null) + const searchAddonRef = useRef(null) const shellIdRef = useRef(null) const connectionIdRef = useRef(null) const hasConnectedRef = useRef(false) @@ -28,6 +31,13 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro const [statusMessage, setStatusMessage] = useState('') const [showSuspendedOverlay, setShowSuspendedOverlay] = useState(false) + // 搜索状态 + const [showSearch, setShowSearch] = useState(false) + const [searchQuery, setSearchQuery] = useState('') + const [searchResultCount, setSearchResultCount] = useState(undefined) + const [searchResultIndex, setSearchResultIndex] = useState(undefined) + const searchInputRef = useRef(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) => { + 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 && ( +
+ + { + 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 && ( + + {searchResultIndex ?? 0}/{searchResultCount} + + )} + + + +
+ )} + + {/* 会话恢复 - 需要重新连接覆盖层 */} + {connectionState === 'disconnected' && getTabById(tabId)?.needsReconnect && ( +
+
+
+ +
+
会话已恢复
+
+ 上次关闭时的终端标签已恢复
+ 点击下方按钮重新建立 SSH 连接 +
+ +
+
+ )} + {/* 挂起状态覆盖层 */} {showSuspendedOverlay && (
diff --git a/src/components/common/ConfirmDialog.tsx b/src/components/common/ConfirmDialog.tsx new file mode 100644 index 0000000..7396063 --- /dev/null +++ b/src/components/common/ConfirmDialog.tsx @@ -0,0 +1,105 @@ +/** + * ConfirmDialog — 基于 Promise 的确认对话框 + * + * 用于替代 window.confirm(),支持异步等待结果 + * + * @example + * const { confirm, ConfirmDialogComponent } = useConfirmDialog() + * const confirmed = await confirm({ title: '确认删除', message: '确定删除?', danger: true }) + * + * // 在 JSX 中渲染: + * + */ + +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(initialState) + const resolverRef = useRef<((value: boolean) => void) | null>(null) + + const confirm = useCallback((options: ConfirmOptions): Promise => { + return new Promise((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 = ( + +
+ {state.danger && ( + + )} +

{state.message}

+
+
+ ) + + return { confirm, ConfirmDialogComponent } +} + +export default useConfirmDialog diff --git a/src/components/common/PromptDialog.tsx b/src/components/common/PromptDialog.tsx new file mode 100644 index 0000000..26be7e8 --- /dev/null +++ b/src/components/common/PromptDialog.tsx @@ -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(initialState) + const resolverRef = useRef<((value: string | null) => void) | null>(null) + const inputRef = useRef(null) + + const prompt = useCallback((options: PromptOptions): Promise => { + return new Promise((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 = ( + +
+

{state.message}

+ 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" + /> +
+
+ ) + + return { prompt, PromptDialogComponent } +} + +export default usePromptDialog diff --git a/src/components/common/Toast.tsx b/src/components/common/Toast.tsx new file mode 100644 index 0000000..aa63344 --- /dev/null +++ b/src/components/common/Toast.tsx @@ -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 = { + success: CheckCircle, + error: XCircle, + warning: AlertTriangle, + info: Info, +} + +/** 类型 → 颜色样式映射 */ +const colorMap: Record = { + 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 ( +
+ {/* 图标 */} + + + {/* 消息 */} +

+ {toast.message} +

+ + {/* 关闭按钮 */} + +
+ ) +} + +/** + * Toast 通知容器(Provider) + * + * 在应用根节点中使用,渲染右上角通知堆叠 + * + * @example + * // 在 App.tsx 中 + * + * + * + */ +export function ToastProvider({ children }: { children: React.ReactNode }) { + const toasts = useToastStore((s) => s.toasts) + const removeToast = useToastStore((s) => s.removeToast) + + return ( + <> + {children} +
+ {toasts.map((toast) => ( +
+ +
+ ))} +
+ + ) +} + +export default ToastProvider diff --git a/src/hooks/useSFTP.ts b/src/hooks/useSFTP.ts index ae14a38..b735e0d 100644 --- a/src/hooks/useSFTP.ts +++ b/src/hooks/useSFTP.ts @@ -75,6 +75,9 @@ export interface UseSFTPReturn { uploadFiles: (files: FileList | null) => Promise downloadFiles: () => Promise + // 远程文件操作 + deleteRemote: (files: FileItem[]) => Promise + // 排序 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 => { + 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, diff --git a/src/hooks/useToast.ts b/src/hooks/useToast.ts new file mode 100644 index 0000000..0a90a26 --- /dev/null +++ b/src/hooks/useToast.ts @@ -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 diff --git a/src/stores/tabStore.ts b/src/stores/tabStore.ts index cc63cc2..7791f66 100644 --- a/src/stores/tabStore.ts +++ b/src/stores/tabStore.ts @@ -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((set, get) => ({ - tabs: [ - { id: 'welcome', title: '欢迎', type: 'welcome', isActive: true } - ], - activeTabId: 'welcome', - - addTab: (tab) => { - const { tabs } = get() +export const useTabStore = create()( + 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 + + 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) - }, -})) + ) +) diff --git a/src/stores/toastStore.ts b/src/stores/toastStore.ts new file mode 100644 index 0000000..89f32c8 --- /dev/null +++ b/src/stores/toastStore.ts @@ -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((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: [] }) + }, +}))