feat(v1.1): Toast通知系统 + 自定义对话框 + 终端搜索 + SFTP真实实现 + 标签持久化
任务7: Toast通知系统 — ToastProvider+4类型(success/error/warning/info)+右上角堆叠动画 任务8: 替换alert/confirm/prompt — ConfirmDialog+PromptDialog+useToast替换7处原生对话框 任务9: 终端搜索 — xterm SearchAddon Ctrl+F搜索栏(上一个/下一个/关闭) 任务10: SFTP上传/删除 — useSFTP.ts upload/delete方法实现 任务11: 会话恢复 — tabStore持久化到localStorage,启动恢复tabs 14 files changed, 1218 insertions(+), 241 deletions(-)
This commit is contained in:
1 parent
53274e2291
commit
d344900118
14 files changed
+1224
-247
No files matched your search
+284
-169
@@ -1,4 +1,8 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import { createLogger } from '../utils/logger'
|
||||
|
||||
const logger = createLogger('TabStore')
|
||||
|
||||
export type TabType = 'welcome' | 'terminal' | 'sftp' | 'portforward' | 'settings'
|
||||
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error'
|
||||
@@ -29,6 +33,19 @@ export interface Tab {
|
||||
connectionInstance?: ConnectionInstance // 实际的连接实例(每个标签独立)
|
||||
isActive: boolean
|
||||
connectionStatus?: ConnectionStatus
|
||||
needsReconnect?: boolean // 从持久化恢复后需要重新连接
|
||||
}
|
||||
|
||||
// 用于持久化的序列化 Tab 数据(排除敏感字段和运行时状态)
|
||||
interface PersistedTab {
|
||||
id: string
|
||||
title: string
|
||||
type: TabType
|
||||
sessionId?: string
|
||||
// 注意:不持久化 connectionConfig(可能包含密码/密钥)
|
||||
// 注意:不持久化 connectionInstance(运行时状态)
|
||||
// 注意:不持久化 connectionStatus(运行时状态)
|
||||
// 注意:不持久化 isActive(由 activeTabId 决定)
|
||||
}
|
||||
|
||||
interface TabState {
|
||||
@@ -50,177 +67,275 @@ interface TabState {
|
||||
getTabById: (tabId: string) => Tab | undefined
|
||||
}
|
||||
|
||||
export const useTabStore = create<TabState>((set, get) => ({
|
||||
tabs: [
|
||||
{ id: 'welcome', title: '欢迎', type: 'welcome', isActive: true }
|
||||
],
|
||||
activeTabId: 'welcome',
|
||||
|
||||
addTab: (tab) => {
|
||||
const { tabs } = get()
|
||||
export const useTabStore = create<TabState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
tabs: [
|
||||
{ id: 'welcome', title: '欢迎', type: 'welcome', isActive: true }
|
||||
],
|
||||
activeTabId: 'welcome',
|
||||
|
||||
addTab: (tab) => {
|
||||
const { tabs } = get()
|
||||
|
||||
// 检查是否是唯一类型的标签(settings, welcome),如果有则激活而不是创建新的
|
||||
if (tab.type === 'settings' || tab.type === 'welcome') {
|
||||
const existing = tabs.find(t => t.type === tab.type)
|
||||
if (existing) {
|
||||
// 激活已存在的标签
|
||||
// 检查是否是唯一类型的标签(settings, welcome),如果有则激活而不是创建新的
|
||||
if (tab.type === 'settings' || tab.type === 'welcome') {
|
||||
const existing = tabs.find(t => t.type === tab.type)
|
||||
if (existing) {
|
||||
// 激活已存在的标签
|
||||
const updatedTabs = tabs.map(t => ({
|
||||
...t,
|
||||
isActive: t.id === existing.id,
|
||||
}))
|
||||
set({ tabs: updatedTabs, activeTabId: existing.id })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const newTab: Tab = {
|
||||
...tab,
|
||||
id: `tab-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
// Deactivate all other tabs
|
||||
const updatedTabs = tabs.map(t => ({ ...t, isActive: false }))
|
||||
|
||||
set({
|
||||
tabs: [...updatedTabs, newTab],
|
||||
activeTabId: newTab.id,
|
||||
})
|
||||
},
|
||||
|
||||
updateTab: (tabId, updates) => {
|
||||
const { tabs } = get()
|
||||
const updatedTabs = tabs.map(t =>
|
||||
t.id === tabId ? { ...t, ...updates } : t
|
||||
)
|
||||
set({ tabs: updatedTabs })
|
||||
},
|
||||
|
||||
updateTabConnectionInstance: (tabId, instance) => {
|
||||
const { tabs } = get()
|
||||
const updatedTabs = tabs.map(t =>
|
||||
t.id === tabId ? { ...t, connectionInstance: instance } : t
|
||||
)
|
||||
set({ tabs: updatedTabs })
|
||||
},
|
||||
|
||||
updateTabStatus: (tabId, status) => {
|
||||
const { tabs } = get()
|
||||
const updatedTabs = tabs.map(t =>
|
||||
t.id === tabId ? { ...t, connectionStatus: status, needsReconnect: status === 'disconnected' ? false : t.needsReconnect } : t
|
||||
)
|
||||
set({ tabs: updatedTabs })
|
||||
},
|
||||
|
||||
closeTab: (tabId) => {
|
||||
const { tabs, activeTabId } = get()
|
||||
|
||||
if (tabs.length <= 1) return null
|
||||
|
||||
const closedTab = tabs.find(t => t.id === tabId) || null
|
||||
const newTabs = tabs.filter(t => t.id !== tabId)
|
||||
|
||||
// If closing active tab, activate another one
|
||||
let newActiveId = activeTabId
|
||||
if (activeTabId === tabId) {
|
||||
const closedTabIndex = tabs.findIndex(t => t.id === tabId)
|
||||
const newActiveTab = newTabs[Math.min(closedTabIndex, newTabs.length - 1)]
|
||||
if (newActiveTab) {
|
||||
newActiveTab.isActive = true
|
||||
newActiveId = newActiveTab.id
|
||||
}
|
||||
}
|
||||
|
||||
set({ tabs: newTabs, activeTabId: newActiveId })
|
||||
return closedTab
|
||||
},
|
||||
|
||||
closeOtherTabs: (tabId) => {
|
||||
const { tabs } = get()
|
||||
const keepTab = tabs.find(t => t.id === tabId)
|
||||
if (!keepTab) return []
|
||||
|
||||
const closedTabs = tabs.filter(t => t.id !== tabId)
|
||||
keepTab.isActive = true
|
||||
set({
|
||||
tabs: [keepTab],
|
||||
activeTabId: tabId,
|
||||
})
|
||||
return closedTabs
|
||||
},
|
||||
|
||||
closeRightTabs: (tabId) => {
|
||||
const { tabs, activeTabId } = get()
|
||||
const tabIndex = tabs.findIndex(t => t.id === tabId)
|
||||
if (tabIndex === -1) return []
|
||||
|
||||
// 保留当前标签及其左侧所有标签
|
||||
const closedTabs = tabs.slice(tabIndex + 1)
|
||||
const newTabs = tabs.slice(0, tabIndex + 1)
|
||||
// 如果关闭的是当前活动标签,激活最后一个
|
||||
if (activeTabId && tabs.findIndex(t => t.id === activeTabId) > tabIndex) {
|
||||
newTabs[newTabs.length - 1].isActive = true
|
||||
set({ tabs: newTabs, activeTabId: newTabs[newTabs.length - 1].id })
|
||||
} else {
|
||||
set({ tabs: newTabs })
|
||||
}
|
||||
return closedTabs
|
||||
},
|
||||
|
||||
closeLeftTabs: (tabId) => {
|
||||
const { tabs, activeTabId } = get()
|
||||
const tabIndex = tabs.findIndex(t => t.id === tabId)
|
||||
if (tabIndex === -1) return []
|
||||
|
||||
// 保留当前标签及其右侧所有标签
|
||||
const closedTabs = tabs.slice(0, tabIndex)
|
||||
const newTabs = tabs.slice(tabIndex)
|
||||
// 如果关闭的是当前活动标签,激活第一个
|
||||
if (activeTabId && tabs.findIndex(t => t.id === activeTabId) < tabIndex) {
|
||||
newTabs[0].isActive = true
|
||||
set({ tabs: newTabs, activeTabId: newTabs[0].id })
|
||||
} else {
|
||||
set({ tabs: newTabs, activeTabId: tabId })
|
||||
}
|
||||
return closedTabs
|
||||
},
|
||||
|
||||
closeAllTabs: () => {
|
||||
const { tabs } = get()
|
||||
const welcomeTab: Tab = {
|
||||
id: 'welcome',
|
||||
title: '欢迎',
|
||||
type: 'welcome',
|
||||
isActive: true,
|
||||
}
|
||||
set({
|
||||
tabs: [welcomeTab],
|
||||
activeTabId: 'welcome',
|
||||
})
|
||||
return tabs
|
||||
},
|
||||
|
||||
activateTab: (tabId) => {
|
||||
const { tabs } = get()
|
||||
const updatedTabs = tabs.map(t => ({
|
||||
...t,
|
||||
isActive: t.id === existing.id,
|
||||
isActive: t.id === tabId,
|
||||
}))
|
||||
set({ tabs: updatedTabs, activeTabId: existing.id })
|
||||
return
|
||||
}
|
||||
set({
|
||||
tabs: updatedTabs,
|
||||
activeTabId: tabId,
|
||||
})
|
||||
},
|
||||
|
||||
updateTabTitle: (tabId, title) => {
|
||||
const { tabs } = get()
|
||||
const updatedTabs = tabs.map(t =>
|
||||
t.id === tabId ? { ...t, title } : t
|
||||
)
|
||||
set({ tabs: updatedTabs })
|
||||
},
|
||||
|
||||
getTabById: (tabId) => {
|
||||
return get().tabs.find(t => t.id === tabId)
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'opsxshell-tabs',
|
||||
// 只持久化需要恢复的字段
|
||||
partialize: (state) => {
|
||||
// 过滤掉 welcome 标签和 settings 标签(这些在启动时总会自动创建)
|
||||
// 只持久化有实际会话关联的标签(terminal, sftp, portforward)
|
||||
const tabsToPersist = state.tabs
|
||||
.filter(tab => {
|
||||
// 持久化有 sessionId 的标签或非 welcome/settings 类型
|
||||
return tab.type === 'terminal' || tab.type === 'sftp' || tab.type === 'portforward'
|
||||
})
|
||||
.map(tab => ({
|
||||
id: tab.id,
|
||||
title: tab.title,
|
||||
type: tab.type,
|
||||
sessionId: tab.sessionId,
|
||||
// 不保存 connectionConfig(含密码等敏感信息)
|
||||
// 不保存 connectionInstance(运行时状态)
|
||||
// 不保存 connectionStatus(运行时状态)
|
||||
// 不保存 isActive(由 activeTabId 决定)
|
||||
})) as PersistedTab[]
|
||||
|
||||
// 只持久化非 welcome 的 activeTabId
|
||||
const activeTab = state.tabs.find(t => t.id === state.activeTabId)
|
||||
let persistActiveTabId = state.activeTabId
|
||||
if (activeTab?.type === 'welcome') {
|
||||
persistActiveTabId = null
|
||||
}
|
||||
|
||||
return {
|
||||
tabs: tabsToPersist,
|
||||
activeTabId: persistActiveTabId,
|
||||
}
|
||||
},
|
||||
// 从持久化数据恢复时的合并策略
|
||||
merge: (persistedState, currentState) => {
|
||||
const persisted = persistedState as Partial<TabState>
|
||||
|
||||
if (!persisted?.tabs || persisted.tabs.length === 0) {
|
||||
// 没有持久化数据,使用默认状态
|
||||
return currentState
|
||||
}
|
||||
|
||||
logger.info('Restoring tabs from persisted state:', persisted.tabs.length, 'tabs')
|
||||
|
||||
// 将持久化的标签转换为运行时标签
|
||||
const restoredTabs: Tab[] = persisted.tabs.map((tab: any) => ({
|
||||
...tab,
|
||||
isActive: false,
|
||||
connectionStatus: 'disconnected' as ConnectionStatus,
|
||||
needsReconnect: true, // 标记需要重新连接
|
||||
}))
|
||||
|
||||
// 确定活动标签
|
||||
let activeTabId = persisted.activeTabId || null
|
||||
if (activeTabId) {
|
||||
const activeTab = restoredTabs.find(t => t.id === activeTabId)
|
||||
if (activeTab) {
|
||||
activeTab.isActive = true
|
||||
} else {
|
||||
// 活动标签不存在,选择第一个
|
||||
if (restoredTabs.length > 0) {
|
||||
restoredTabs[0].isActive = true
|
||||
activeTabId = restoredTabs[0].id
|
||||
}
|
||||
}
|
||||
} else if (restoredTabs.length > 0) {
|
||||
restoredTabs[0].isActive = true
|
||||
activeTabId = restoredTabs[0].id
|
||||
}
|
||||
|
||||
// 始终在开头添加 welcome 标签
|
||||
const welcomeTab: Tab = {
|
||||
id: 'welcome',
|
||||
title: '欢迎',
|
||||
type: 'welcome',
|
||||
isActive: false,
|
||||
}
|
||||
|
||||
const finalTabs = [welcomeTab, ...restoredTabs]
|
||||
|
||||
// 如果活动标签不存在,默认激活 welcome
|
||||
if (!activeTabId) {
|
||||
welcomeTab.isActive = true
|
||||
activeTabId = 'welcome'
|
||||
}
|
||||
|
||||
return {
|
||||
...currentState,
|
||||
tabs: finalTabs,
|
||||
activeTabId,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const newTab: Tab = {
|
||||
...tab,
|
||||
id: `tab-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
// Deactivate all other tabs
|
||||
const updatedTabs = tabs.map(t => ({ ...t, isActive: false }))
|
||||
|
||||
set({
|
||||
tabs: [...updatedTabs, newTab],
|
||||
activeTabId: newTab.id,
|
||||
})
|
||||
},
|
||||
|
||||
updateTab: (tabId, updates) => {
|
||||
const { tabs } = get()
|
||||
const updatedTabs = tabs.map(t =>
|
||||
t.id === tabId ? { ...t, ...updates } : t
|
||||
)
|
||||
set({ tabs: updatedTabs })
|
||||
},
|
||||
|
||||
updateTabConnectionInstance: (tabId, instance) => {
|
||||
const { tabs } = get()
|
||||
const updatedTabs = tabs.map(t =>
|
||||
t.id === tabId ? { ...t, connectionInstance: instance } : t
|
||||
)
|
||||
set({ tabs: updatedTabs })
|
||||
},
|
||||
|
||||
updateTabStatus: (tabId, status) => {
|
||||
const { tabs } = get()
|
||||
const updatedTabs = tabs.map(t =>
|
||||
t.id === tabId ? { ...t, connectionStatus: status } : t
|
||||
)
|
||||
set({ tabs: updatedTabs })
|
||||
},
|
||||
|
||||
closeTab: (tabId) => {
|
||||
const { tabs, activeTabId } = get()
|
||||
|
||||
if (tabs.length <= 1) return null
|
||||
|
||||
const closedTab = tabs.find(t => t.id === tabId) || null
|
||||
const newTabs = tabs.filter(t => t.id !== tabId)
|
||||
|
||||
// If closing active tab, activate another one
|
||||
let newActiveId = activeTabId
|
||||
if (activeTabId === tabId) {
|
||||
const closedTabIndex = tabs.findIndex(t => t.id === tabId)
|
||||
const newActiveTab = newTabs[Math.min(closedTabIndex, newTabs.length - 1)]
|
||||
if (newActiveTab) {
|
||||
newActiveTab.isActive = true
|
||||
newActiveId = newActiveTab.id
|
||||
}
|
||||
}
|
||||
|
||||
set({ tabs: newTabs, activeTabId: newActiveId })
|
||||
return closedTab
|
||||
},
|
||||
|
||||
closeOtherTabs: (tabId) => {
|
||||
const { tabs } = get()
|
||||
const keepTab = tabs.find(t => t.id === tabId)
|
||||
if (!keepTab) return []
|
||||
|
||||
const closedTabs = tabs.filter(t => t.id !== tabId)
|
||||
keepTab.isActive = true
|
||||
set({
|
||||
tabs: [keepTab],
|
||||
activeTabId: tabId,
|
||||
})
|
||||
return closedTabs
|
||||
},
|
||||
|
||||
closeRightTabs: (tabId) => {
|
||||
const { tabs, activeTabId } = get()
|
||||
const tabIndex = tabs.findIndex(t => t.id === tabId)
|
||||
if (tabIndex === -1) return []
|
||||
|
||||
// 保留当前标签及其左侧所有标签
|
||||
const closedTabs = tabs.slice(tabIndex + 1)
|
||||
const newTabs = tabs.slice(0, tabIndex + 1)
|
||||
// 如果关闭的是当前活动标签,激活最后一个
|
||||
if (activeTabId && tabs.findIndex(t => t.id === activeTabId) > tabIndex) {
|
||||
newTabs[newTabs.length - 1].isActive = true
|
||||
set({ tabs: newTabs, activeTabId: newTabs[newTabs.length - 1].id })
|
||||
} else {
|
||||
set({ tabs: newTabs })
|
||||
}
|
||||
return closedTabs
|
||||
},
|
||||
|
||||
closeLeftTabs: (tabId) => {
|
||||
const { tabs, activeTabId } = get()
|
||||
const tabIndex = tabs.findIndex(t => t.id === tabId)
|
||||
if (tabIndex === -1) return []
|
||||
|
||||
// 保留当前标签及其右侧所有标签
|
||||
const closedTabs = tabs.slice(0, tabIndex)
|
||||
const newTabs = tabs.slice(tabIndex)
|
||||
// 如果关闭的是当前活动标签,激活第一个
|
||||
if (activeTabId && tabs.findIndex(t => t.id === activeTabId) < tabIndex) {
|
||||
newTabs[0].isActive = true
|
||||
set({ tabs: newTabs, activeTabId: newTabs[0].id })
|
||||
} else {
|
||||
set({ tabs: newTabs, activeTabId: tabId })
|
||||
}
|
||||
return closedTabs
|
||||
},
|
||||
|
||||
closeAllTabs: () => {
|
||||
const { tabs } = get()
|
||||
const welcomeTab: Tab = {
|
||||
id: 'welcome',
|
||||
title: '欢迎',
|
||||
type: 'welcome',
|
||||
isActive: true,
|
||||
}
|
||||
set({
|
||||
tabs: [welcomeTab],
|
||||
activeTabId: 'welcome',
|
||||
})
|
||||
return tabs
|
||||
},
|
||||
|
||||
activateTab: (tabId) => {
|
||||
const { tabs } = get()
|
||||
const updatedTabs = tabs.map(t => ({
|
||||
...t,
|
||||
isActive: t.id === tabId,
|
||||
}))
|
||||
set({
|
||||
tabs: updatedTabs,
|
||||
activeTabId: tabId,
|
||||
})
|
||||
},
|
||||
|
||||
updateTabTitle: (tabId, title) => {
|
||||
const { tabs } = get()
|
||||
const updatedTabs = tabs.map(t =>
|
||||
t.id === tabId ? { ...t, title } : t
|
||||
)
|
||||
set({ tabs: updatedTabs })
|
||||
},
|
||||
|
||||
getTabById: (tabId) => {
|
||||
return get().tabs.find(t => t.id === tabId)
|
||||
},
|
||||
}))
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Toast 通知状态管理
|
||||
*
|
||||
* 管理全局通知队列,支持 success / error / warning / info 四种类型
|
||||
*/
|
||||
|
||||
import { create } from 'zustand'
|
||||
|
||||
/** Toast 类型 */
|
||||
export type ToastType = 'success' | 'error' | 'warning' | 'info'
|
||||
|
||||
/** 单条 Toast 数据 */
|
||||
export interface ToastItem {
|
||||
/** 唯一 ID */
|
||||
id: string
|
||||
/** 类型 */
|
||||
type: ToastType
|
||||
/** 消息内容 */
|
||||
message: string
|
||||
/** 自动关闭时间(毫秒),0 表示不自动关闭 */
|
||||
duration: number
|
||||
}
|
||||
|
||||
/** Toast 创建选项 */
|
||||
export interface ToastOptions {
|
||||
/** 自动关闭时间(毫秒),默认 3000 */
|
||||
duration?: number
|
||||
}
|
||||
|
||||
interface ToastState {
|
||||
/** 通知队列 */
|
||||
toasts: ToastItem[]
|
||||
/** 添加一条通知 */
|
||||
addToast: (type: ToastType, message: string, options?: ToastOptions) => string
|
||||
/** 移除一条通知 */
|
||||
removeToast: (id: string) => void
|
||||
/** 清空所有通知 */
|
||||
clearAll: () => void
|
||||
}
|
||||
|
||||
let toastCounter = 0
|
||||
|
||||
/** 生成唯一 ID */
|
||||
function generateId(): string {
|
||||
toastCounter += 1
|
||||
return `toast-${Date.now()}-${toastCounter}`
|
||||
}
|
||||
|
||||
export const useToastStore = create<ToastState>((set) => ({
|
||||
toasts: [],
|
||||
|
||||
addToast: (type, message, options) => {
|
||||
const id = generateId()
|
||||
const duration = options?.duration ?? 3000
|
||||
|
||||
const toast: ToastItem = { id, type, message, duration }
|
||||
|
||||
set((state) => ({
|
||||
toasts: [...state.toasts, toast],
|
||||
}))
|
||||
|
||||
return id
|
||||
},
|
||||
|
||||
removeToast: (id) => {
|
||||
set((state) => ({
|
||||
toasts: state.toasts.filter((t) => t.id !== id),
|
||||
}))
|
||||
},
|
||||
|
||||
clearAll: () => {
|
||||
set({ toasts: [] })
|
||||
},
|
||||
}))
|
||||
Reference in new issue
Block a user