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

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

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

No files matched your search

+284 -169
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',
addTab: (tab) => {
const { tabs } = get()
export const useTabStore = create<TabState>()(
persist(
(set, get) => ({
tabs: [
{ id: 'welcome', title: '欢迎', type: 'welcome', isActive: true }
],
activeTabId: 'welcome',
addTab: (tab) => {
const { tabs } = get()
// 检查是否是唯一类型的标签(settings, welcome),如果有则激活而不是创建新的
if (tab.type === 'settings' || tab.type === 'welcome') {
const existing = tabs.find(t => t.type === tab.type)
if (existing) {
// 激活已存在的标签
// 检查是否是唯一类型的标签(settings, welcome),如果有则激活而不是创建新的
if (tab.type === 'settings' || tab.type === 'welcome') {
const existing = tabs.find(t => t.type === tab.type)
if (existing) {
// 激活已存在的标签
const updatedTabs = tabs.map(t => ({
...t,
isActive: t.id === existing.id,
}))
set({ tabs: updatedTabs, activeTabId: existing.id })
return
}
}
const newTab: Tab = {
...tab,
id: `tab-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
isActive: true,
}
// Deactivate all other tabs
const updatedTabs = tabs.map(t => ({ ...t, isActive: false }))
set({
tabs: [...updatedTabs, newTab],
activeTabId: newTab.id,
})
},
updateTab: (tabId, updates) => {
const { tabs } = get()
const updatedTabs = tabs.map(t =>
t.id === tabId ? { ...t, ...updates } : t
)
set({ tabs: updatedTabs })
},
updateTabConnectionInstance: (tabId, instance) => {
const { tabs } = get()
const updatedTabs = tabs.map(t =>
t.id === tabId ? { ...t, connectionInstance: instance } : t
)
set({ tabs: updatedTabs })
},
updateTabStatus: (tabId, status) => {
const { tabs } = get()
const updatedTabs = tabs.map(t =>
t.id === tabId ? { ...t, connectionStatus: status, needsReconnect: status === 'disconnected' ? false : t.needsReconnect } : t
)
set({ tabs: updatedTabs })
},
closeTab: (tabId) => {
const { tabs, activeTabId } = get()
if (tabs.length <= 1) return null
const closedTab = tabs.find(t => t.id === tabId) || null
const newTabs = tabs.filter(t => t.id !== tabId)
// If closing active tab, activate another one
let newActiveId = activeTabId
if (activeTabId === tabId) {
const closedTabIndex = tabs.findIndex(t => t.id === tabId)
const newActiveTab = newTabs[Math.min(closedTabIndex, newTabs.length - 1)]
if (newActiveTab) {
newActiveTab.isActive = true
newActiveId = newActiveTab.id
}
}
set({ tabs: newTabs, activeTabId: newActiveId })
return closedTab
},
closeOtherTabs: (tabId) => {
const { tabs } = get()
const keepTab = tabs.find(t => t.id === tabId)
if (!keepTab) return []
const closedTabs = tabs.filter(t => t.id !== tabId)
keepTab.isActive = true
set({
tabs: [keepTab],
activeTabId: tabId,
})
return closedTabs
},
closeRightTabs: (tabId) => {
const { tabs, activeTabId } = get()
const tabIndex = tabs.findIndex(t => t.id === tabId)
if (tabIndex === -1) return []
// 保留当前标签及其左侧所有标签
const closedTabs = tabs.slice(tabIndex + 1)
const newTabs = tabs.slice(0, tabIndex + 1)
// 如果关闭的是当前活动标签,激活最后一个
if (activeTabId && tabs.findIndex(t => t.id === activeTabId) > tabIndex) {
newTabs[newTabs.length - 1].isActive = true
set({ tabs: newTabs, activeTabId: newTabs[newTabs.length - 1].id })
} else {
set({ tabs: newTabs })
}
return closedTabs
},
closeLeftTabs: (tabId) => {
const { tabs, activeTabId } = get()
const tabIndex = tabs.findIndex(t => t.id === tabId)
if (tabIndex === -1) return []
// 保留当前标签及其右侧所有标签
const closedTabs = tabs.slice(0, tabIndex)
const newTabs = tabs.slice(tabIndex)
// 如果关闭的是当前活动标签,激活第一个
if (activeTabId && tabs.findIndex(t => t.id === activeTabId) < tabIndex) {
newTabs[0].isActive = true
set({ tabs: newTabs, activeTabId: newTabs[0].id })
} else {
set({ tabs: newTabs, activeTabId: tabId })
}
return closedTabs
},
closeAllTabs: () => {
const { tabs } = get()
const welcomeTab: Tab = {
id: 'welcome',
title: '欢迎',
type: 'welcome',
isActive: true,
}
set({
tabs: [welcomeTab],
activeTabId: 'welcome',
})
return tabs
},
activateTab: (tabId) => {
const { tabs } = get()
const updatedTabs = tabs.map(t => ({
...t,
isActive: t.id === existing.id,
isActive: t.id === tabId,
}))
set({ tabs: updatedTabs, activeTabId: existing.id })
return
}
set({
tabs: updatedTabs,
activeTabId: tabId,
})
},
updateTabTitle: (tabId, title) => {
const { tabs } = get()
const updatedTabs = tabs.map(t =>
t.id === tabId ? { ...t, title } : t
)
set({ tabs: updatedTabs })
},
getTabById: (tabId) => {
return get().tabs.find(t => t.id === tabId)
},
}),
{
name: 'opsxshell-tabs',
// 只持久化需要恢复的字段
partialize: (state) => {
// 过滤掉 welcome 标签和 settings 标签(这些在启动时总会自动创建)
// 只持久化有实际会话关联的标签(terminal, sftp, portforward)
const tabsToPersist = state.tabs
.filter(tab => {
// 持久化有 sessionId 的标签或非 welcome/settings 类型
return tab.type === 'terminal' || tab.type === 'sftp' || tab.type === 'portforward'
})
.map(tab => ({
id: tab.id,
title: tab.title,
type: tab.type,
sessionId: tab.sessionId,
// 不保存 connectionConfig(含密码等敏感信息)
// 不保存 connectionInstance(运行时状态)
// 不保存 connectionStatus(运行时状态)
// 不保存 isActive(由 activeTabId 决定)
})) as PersistedTab[]
// 只持久化非 welcome 的 activeTabId
const activeTab = state.tabs.find(t => t.id === state.activeTabId)
let persistActiveTabId = state.activeTabId
if (activeTab?.type === 'welcome') {
persistActiveTabId = null
}
return {
tabs: tabsToPersist,
activeTabId: persistActiveTabId,
}
},
// 从持久化数据恢复时的合并策略
merge: (persistedState, currentState) => {
const persisted = persistedState as Partial<TabState>
if (!persisted?.tabs || persisted.tabs.length === 0) {
// 没有持久化数据,使用默认状态
return currentState
}
logger.info('Restoring tabs from persisted state:', persisted.tabs.length, 'tabs')
// 将持久化的标签转换为运行时标签
const restoredTabs: Tab[] = persisted.tabs.map((tab: any) => ({
...tab,
isActive: false,
connectionStatus: 'disconnected' as ConnectionStatus,
needsReconnect: true, // 标记需要重新连接
}))
// 确定活动标签
let activeTabId = persisted.activeTabId || null
if (activeTabId) {
const activeTab = restoredTabs.find(t => t.id === activeTabId)
if (activeTab) {
activeTab.isActive = true
} else {
// 活动标签不存在,选择第一个
if (restoredTabs.length > 0) {
restoredTabs[0].isActive = true
activeTabId = restoredTabs[0].id
}
}
} else if (restoredTabs.length > 0) {
restoredTabs[0].isActive = true
activeTabId = restoredTabs[0].id
}
// 始终在开头添加 welcome 标签
const welcomeTab: Tab = {
id: 'welcome',
title: '欢迎',
type: 'welcome',
isActive: false,
}
const finalTabs = [welcomeTab, ...restoredTabs]
// 如果活动标签不存在,默认激活 welcome
if (!activeTabId) {
welcomeTab.isActive = true
activeTabId = 'welcome'
}
return {
...currentState,
tabs: finalTabs,
activeTabId,
}
},
}
const newTab: Tab = {
...tab,
id: `tab-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
isActive: true,
}
// Deactivate all other tabs
const updatedTabs = tabs.map(t => ({ ...t, isActive: false }))
set({
tabs: [...updatedTabs, newTab],
activeTabId: newTab.id,
})
},
updateTab: (tabId, updates) => {
const { tabs } = get()
const updatedTabs = tabs.map(t =>
t.id === tabId ? { ...t, ...updates } : t
)
set({ tabs: updatedTabs })
},
updateTabConnectionInstance: (tabId, instance) => {
const { tabs } = get()
const updatedTabs = tabs.map(t =>
t.id === tabId ? { ...t, connectionInstance: instance } : t
)
set({ tabs: updatedTabs })
},
updateTabStatus: (tabId, status) => {
const { tabs } = get()
const updatedTabs = tabs.map(t =>
t.id === tabId ? { ...t, connectionStatus: status } : t
)
set({ tabs: updatedTabs })
},
closeTab: (tabId) => {
const { tabs, activeTabId } = get()
if (tabs.length <= 1) return null
const closedTab = tabs.find(t => t.id === tabId) || null
const newTabs = tabs.filter(t => t.id !== tabId)
// If closing active tab, activate another one
let newActiveId = activeTabId
if (activeTabId === tabId) {
const closedTabIndex = tabs.findIndex(t => t.id === tabId)
const newActiveTab = newTabs[Math.min(closedTabIndex, newTabs.length - 1)]
if (newActiveTab) {
newActiveTab.isActive = true
newActiveId = newActiveTab.id
}
}
set({ tabs: newTabs, activeTabId: newActiveId })
return closedTab
},
closeOtherTabs: (tabId) => {
const { tabs } = get()
const keepTab = tabs.find(t => t.id === tabId)
if (!keepTab) return []
const closedTabs = tabs.filter(t => t.id !== tabId)
keepTab.isActive = true
set({
tabs: [keepTab],
activeTabId: tabId,
})
return closedTabs
},
closeRightTabs: (tabId) => {
const { tabs, activeTabId } = get()
const tabIndex = tabs.findIndex(t => t.id === tabId)
if (tabIndex === -1) return []
// 保留当前标签及其左侧所有标签
const closedTabs = tabs.slice(tabIndex + 1)
const newTabs = tabs.slice(0, tabIndex + 1)
// 如果关闭的是当前活动标签,激活最后一个
if (activeTabId && tabs.findIndex(t => t.id === activeTabId) > tabIndex) {
newTabs[newTabs.length - 1].isActive = true
set({ tabs: newTabs, activeTabId: newTabs[newTabs.length - 1].id })
} else {
set({ tabs: newTabs })
}
return closedTabs
},
closeLeftTabs: (tabId) => {
const { tabs, activeTabId } = get()
const tabIndex = tabs.findIndex(t => t.id === tabId)
if (tabIndex === -1) return []
// 保留当前标签及其右侧所有标签
const closedTabs = tabs.slice(0, tabIndex)
const newTabs = tabs.slice(tabIndex)
// 如果关闭的是当前活动标签,激活第一个
if (activeTabId && tabs.findIndex(t => t.id === activeTabId) < tabIndex) {
newTabs[0].isActive = true
set({ tabs: newTabs, activeTabId: newTabs[0].id })
} else {
set({ tabs: newTabs, activeTabId: tabId })
}
return closedTabs
},
closeAllTabs: () => {
const { tabs } = get()
const welcomeTab: Tab = {
id: 'welcome',
title: '欢迎',
type: 'welcome',
isActive: true,
}
set({
tabs: [welcomeTab],
activeTabId: 'welcome',
})
return tabs
},
activateTab: (tabId) => {
const { tabs } = get()
const updatedTabs = tabs.map(t => ({
...t,
isActive: t.id === tabId,
}))
set({
tabs: updatedTabs,
activeTabId: tabId,
})
},
updateTabTitle: (tabId, title) => {
const { tabs } = get()
const updatedTabs = tabs.map(t =>
t.id === tabId ? { ...t, title } : t
)
set({ tabs: updatedTabs })
},
getTabById: (tabId) => {
return get().tabs.find(t => t.id === tabId)
},
}))
)
)