diff --git a/ROADMAP.md b/ROADMAP.md index 55eb590..70f1256 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -40,10 +40,12 @@ v1.5.0 安全升级 ─── 1 周 ──→ 安全 55→85, 代码 80→85 --- -## v1.2.0 体验升级 (2 周) — 🚧 进行中 +## v1.2.0 体验升级 (2 周) — ✅ 已完成 > **目标**: 快捷键补全、视觉打磨、交互优化,接近 Termius 体验水平 +### ✅ 全部完成 (提交: caba5f4) + ### 🟡 P1 — 重要 | # | 任务 | 类型 | 预计 | 验收标准 | @@ -71,18 +73,21 @@ v1.5.0 安全升级 ─── 1 周 ──→ 安全 55→85, 代码 80→85 --- -## v1.3.0 测试覆盖 (2 周) +## v1.3.0 专业体验 (2 周) — 🚧 进行中 -> **目标**: 核心模块测试覆盖率从 10% 提升到 50% +> **目标**: 主题系统、命令面板、Snippets、UI 打磨,达到专业级体验 -详细计划见 [TDD_PLAN.md](./TDD_PLAN.md) +### 🟡 P1 — 重要 -| # | 模块 | 当前覆盖 | 目标覆盖 | 预计 | -|---|------|----------|----------|------| -| 1 | stores (tab/connection/settings/session) | 0% | 70% | 3天 | -| 2 | hooks (useTerminal/useSFTP/useConnection) | 0% | 50% | 3天 | -| 3 | electron services (ssh/sftp/database/crypto) | 0% | 60% | 4天 | -| 4 | 组件渲染测试 (WelcomeView/TabBar/SFTPView) | 5% | 40% | 3天 | +| # | 任务 | 类型 | 预计 | 验收标准 | +|---|------|------|------|----------| +| 1 | 主题系统 Dark/Light/System | 新增 | 3天 | CSS 变量驱动,跟随系统自动切换,设置页实时预览 | +| 2 | Vite 代码分割 | 优化 | 1天 | manualChunks 拆分 xterm/react/lucide,消除 500KB warning | +| 3 | 设置面板视觉重构 | 重构 | 2天 | 专业设置页布局 + 快捷键自定义 Tab + 版本号自动读取 | +| 4 | 命令片段库 Snippets | 新增 | 3天 | 创建/编辑/删除/搜索命令片段,快捷键插入到终端 | +| 5 | 全局命令面板 | 新增 | 2天 | Ctrl+Shift+P 快速搜索操作/会话/命令片段 | +| 6 | 连接状态面板 | 新增 | 2天 | 实时监控连接、流量统计、延迟显示、重连按钮 | +| 7 | UI 打磨 — 全局细节优化 | 完善 | 2天 | 过渡动画统一、微交互(hover/active 反馈)、视觉一致性 | --- diff --git a/src/App.tsx b/src/App.tsx index 4c9bb89..d9a04f7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,15 +3,18 @@ import Sidebar from './components/Sidebar' import TabBar from './components/TabBar' import TabContent from './components/TabContent' import StatusBar from './components/StatusBar' +import ConnectionPanel from './components/ConnectionPanel' import SessionDialog from './components/SessionDialog' import QuickConnectDialog from './components/QuickConnectDialog' +import { CommandPalette } from './components/common/CommandPalette' import { useTabStore } from './stores/tabStore' -import { useSessionStore, type Session } from './stores' +import { useSessionStore, useCommandPaletteStore, type Session } from './stores' import { createLogger } from './utils/logger' import { useToast } from './hooks/useToast' import { ToastContainer } from './components/common/ToastContainer' import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts' import { useIsMobile } from './hooks/useMediaQuery' +import { useTheme } from './hooks/useTheme' const logger = createLogger('App') @@ -21,7 +24,11 @@ function App() { const [editSession, setEditSession] = useState(null) const { activeTabId } = useTabStore() const { sessions } = useSessionStore() + const { close: closeCommandPalette } = useCommandPaletteStore() const { toasts, toast, removeToast } = useToast() + useTheme() + + // Keyboard shortcuts (Ctrl+Shift+P now handled internally via commandPaletteStore) useKeyboardShortcuts() // Responsive: detect mobile @@ -49,6 +56,12 @@ function App() { setMobileSidebarOpen(false) }, []) + // Open settings tab + const handleOpenSettings = useCallback(() => { + const { addTab } = useTabStore.getState() + addTab({ title: '设置', type: 'settings' }) + }, []) + // 导出会话 const handleExportSessions = async () => { try { @@ -305,6 +318,9 @@ function App() { {/* Status bar */} + {/* Connection management panel (self-contained via connectionPanelStore) */} + + {/* Dialogs */} setIsQuickConnectOpen(false)} /> + {/* Command Palette — isOpen/close managed via commandPaletteStore */} + { + closeCommandPalette() + setEditSession(null) + setIsSessionDialogOpen(true) + }} + onQuickConnect={() => { + closeCommandPalette() + setIsQuickConnectOpen(true) + }} + onOpenSettings={() => { + closeCommandPalette() + handleOpenSettings() + }} + onEditSession={(session) => { + closeCommandPalette() + setEditSession(session) + setIsSessionDialogOpen(true) + }} + /> + {/* Toast Notifications */} diff --git a/src/components/ConnectionPanel.tsx b/src/components/ConnectionPanel.tsx new file mode 100644 index 0000000..4c07b2e --- /dev/null +++ b/src/components/ConnectionPanel.tsx @@ -0,0 +1,335 @@ +/** + * ConnectionPanel - 连接状态管理面板 + * + * 右侧滑出面板,实时监控所有连接状态、流量统计、支持重连/断开操作。 + */ + +import { useState, useCallback, useMemo } from 'react' +import { + Wifi, + WifiOff, + RefreshCw, + X, + Clock, + HardDrive, + Activity, +} from 'lucide-react' +import { useConnectionStore } from '../stores/connection' +import { usePoolStore } from '../stores/connection/poolStore' +import { useConnectionPanelStore } from '../stores/connectionPanelStore' +import type { PooledConnection, ConnectionState } from '../stores/connection/types' +import EmptyState from './common/EmptyState' + +// ============== Helpers ============== + +/** 格式化字节 */ +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B' + const units = ['B', 'KB', 'MB', 'GB'] + const k = 1024 + const i = Math.floor(Math.log(bytes) / Math.log(k)) + const value = bytes / Math.pow(k, i) + return `${value.toFixed(i === 0 ? 0 : 1)} ${units[i]}` +} + +/** 格式化连接时长 (ms → readable) */ +function formatDuration(ms: number): string { + const seconds = Math.floor(ms / 1000) + if (seconds < 60) return `${seconds}s` + const minutes = Math.floor(seconds / 60) + if (minutes < 60) return `${minutes}m ${seconds % 60}s` + const hours = Math.floor(minutes / 60) + return `${hours}h ${minutes % 60}m` +} + +// ============== Status Dot ============== + +interface StatusDotProps { + state: ConnectionState +} + +const STATUS_DOT_COLORS: Record = { + connected: 'bg-green-400', + connecting: 'bg-yellow-400 animate-pulse', + background: 'bg-green-400 opacity-60', + suspended: 'bg-gray-400', + error: 'bg-red-400', + disconnected: 'bg-gray-500', + reconnecting: 'bg-yellow-400 animate-pulse', +} + +function StatusDot({ state }: StatusDotProps) { + return ( + + ) +} + +// ============== Status Label ============== + +const STATUS_LABELS: Record = { + connected: '已连接', + connecting: '连接中', + background: '后台', + suspended: '已挂起', + error: '错误', + disconnected: '已断开', + reconnecting: '重连中', +} + +// ============== Connection Card ============== + +interface ConnectionCardProps { + conn: PooledConnection + onReconnect: (id: string) => void + onDisconnect: (id: string) => void + reconnectingId: string | null +} + +function ConnectionCard({ conn, onReconnect, onDisconnect, reconnectingId }: ConnectionCardProps) { + const isReconnecting = reconnectingId === conn.connectionId + const canReconnect = + conn.state === 'suspended' || conn.state === 'error' || conn.state === 'disconnected' + const canDisconnect = + conn.state !== 'disconnected' && conn.state !== 'error' + + const durationMs = Date.now() - conn.stats.connectedAt + const sessionCount = conn.shellIds.size + conn.sftpIds.size + + return ( +
+ {/* Header: host + status */} +
+
+ + + {conn.config.username}@{conn.config.host} + +
+ + {STATUS_LABELS[conn.state]} + +
+ + {/* Details */} +
+ {/* Port */} + + + :{conn.config.port ?? 22} + + + {/* Duration */} + + + {formatDuration(durationMs)} + + + {/* Transfer */} + {conn.stats.bytesTransferred > 0 && ( + + + {formatBytes(conn.stats.bytesTransferred)} + + )} + + {/* Sessions */} + {sessionCount > 0 && ( + + {conn.shellIds.size} shell{conn.sftpIds.size > 0 ? ` / ${conn.sftpIds.size} sftp` : ''} + + )} + + {/* Ref count */} + {conn.refCount > 1 && ×{conn.refCount} 引用} +
+ + {/* Actions */} +
+ {canReconnect && ( + + )} + {canDisconnect && ( + + )} +
+ + {/* Error message */} + {conn.error && ( +

+ {conn.error} +

+ )} +
+ ) +} + +// ============== Stats Overview ============== + +interface StatsBarProps { + total: number + connected: number + background: number + error: number +} + +function StatsOverview({ total, connected, background, error }: StatsBarProps) { + const items = [ + { label: '总计', value: total, color: 'text-gray-300' }, + { label: '活跃', value: connected, color: 'text-green-400' }, + { label: '后台', value: background, color: 'text-yellow-400' }, + { label: '断开/错误', value: error, color: 'text-red-400' }, + ] + + return ( +
+ {items.map((item) => ( +
+
{item.value}
+
{item.label}
+
+ ))} +
+ ) +} + +// ============== Main Panel ============== + +export default function ConnectionPanel() { + const isOpen = useConnectionPanelStore((s) => s.isOpen) + const close = useConnectionPanelStore((s) => s.close) + + const connections = usePoolStore((s) => s.connections) + const { + resumeConnection, + disconnectConnection, + getStats, + } = useConnectionStore() + + const [reconnectingId, setReconnectingId] = useState(null) + + // Derive connections list and stats + const { connList, stats } = useMemo(() => { + const list = Array.from(connections.values()) + const s = getStats() + return { connList: list, stats: s } + }, [connections, getStats]) + + // Handlers + const handleReconnect = useCallback( + async (connectionId: string) => { + setReconnectingId(connectionId) + try { + await resumeConnection(connectionId) + } finally { + setReconnectingId(null) + } + }, + [resumeConnection], + ) + + const handleDisconnect = useCallback( + async (connectionId: string) => { + await disconnectConnection(connectionId) + }, + [disconnectConnection], + ) + + if (!isOpen) return null + + return ( + <> + {/* Backdrop */} +
+ + {/* Panel */} +
+ {/* Header */} +
+

+ + 连接管理 +

+ +
+ + {/* Stats Overview */} +
+ +
+ + {/* Connection List */} +
+ {connList.length === 0 ? ( + + ) : ( + connList.map((conn) => ( + + )) + )} +
+
+ + ) +} diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx index e7289ce..e7c04b6 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -1,29 +1,97 @@ -import { useEffect, useState } from 'react' +import { useEffect, useState, useMemo } from 'react' import { useSettingsStore } from '../stores/settingsStore' import { - Monitor, Palette, Keyboard, Shield, Database, Terminal + Monitor, Palette, Terminal, Keyboard, Shield, Database, Info, Search, X, } from 'lucide-react' import { themes } from '../themes/terminal' -type SettingsTab = 'general' | 'appearance' | 'terminal' | 'ssh' | 'security' | 'about' +// ─── Tab 类型 ─────────────────────────────────────────────── +type SettingsTab = 'general' | 'appearance' | 'terminal' | 'shortcuts' | 'ssh' | 'security' | 'about' +interface TabMeta { + id: SettingsTab + label: string + description: string + icon: React.ComponentType<{ className?: string }> +} + +const TAB_LIST: TabMeta[] = [ + { id: 'general', label: '常规', description: '应用启动与通知', icon: Monitor }, + { id: 'appearance', label: '外观', description: '主题与语言偏好', icon: Palette }, + { id: 'terminal', label: '终端', description: '字体与配色方案', icon: Terminal }, + { id: 'shortcuts', label: '快捷键', description: '键盘快捷键管理', icon: Keyboard }, + { id: 'ssh', label: 'SSH', description: '连接与认证设置', icon: Shield }, + { id: 'security', label: '安全', description: '加密与隐私设置', icon: Database }, + { id: 'about', label: '关于', description: '版本与许可信息', icon: Info }, +] + +// ─── 快捷键数据 ───────────────────────────────────────────── +interface ShortcutEntry { + action: string + keys: string + category: string +} + +const SHORTCUTS: ShortcutEntry[] = [ + { action: '切换到下一个标签', keys: 'Ctrl+Tab', category: '标签管理' }, + { action: '切换到上一个标签', keys: 'Ctrl+Shift+Tab', category: '标签管理' }, + { action: '跳转到第 N 个标签', keys: 'Ctrl+1 … Ctrl+9', category: '标签管理' }, + { action: '关闭当前标签', keys: 'Ctrl+W', category: '标签管理' }, + { action: '新建标签', keys: 'Ctrl+Shift+N', category: '标签管理' }, + { action: '切换全屏', keys: 'F11', category: '视图' }, + { action: '新建会话', keys: 'Ctrl+N', category: '会话' }, + { action: '快速连接', keys: 'Ctrl+K', category: '会话' }, + { action: '命令面板', keys: 'Ctrl+Shift+P', category: '工具' }, +] + +// ─── 主组件 ───────────────────────────────────────────────── export default function SettingsView() { const { settings, isLoaded, loadSettings, setSetting } = useSettingsStore() const [activeTab, setActiveTab] = useState('general') + const [searchQuery, setSearchQuery] = useState('') + const [appVersion, setAppVersion] = useState('—') + useEffect(() => { loadSettings() }, []) // eslint-disable-line react-hooks/exhaustive-deps + + // 获取版本号(参考 StatusBar 实现) useEffect(() => { - loadSettings() + if (window.electronAPI) { + window.electronAPI.getVersion().then((v: string) => setAppVersion(v)) + } }, []) - const tabs = [ - { id: 'general' as SettingsTab, label: '常规', icon: Monitor }, - { id: 'appearance' as SettingsTab, label: '外观', icon: Palette }, - { id: 'terminal' as SettingsTab, label: '终端', icon: Keyboard }, - { id: 'ssh' as SettingsTab, label: 'SSH', icon: Shield }, - { id: 'security' as SettingsTab, label: '安全', icon: Database }, - { id: 'about' as SettingsTab, label: '关于', icon: Monitor }, - ] + // 当前 tab 元信息 + const currentMeta = useMemo(() => TAB_LIST.find((t) => t.id === activeTab)!, [activeTab]) + // ── 搜索过滤 ────────────────────────────────────────────── + const filtered = useMemo((): SettingsTab[] | null => { + if (!searchQuery.trim()) return null // null 表示不过滤,正常显示 + const q = searchQuery.toLowerCase() + const keywords: Record = { + general: ['启动', '恢复', '会话', '自动保存', '通知', 'restore', 'notification'], + appearance: ['主题', '深色', '浅色', '语言', '中文', 'english', 'theme', 'language'], + terminal: ['字体', 'font', '配色', 'terminal', 'monokai', 'dracula'], + shortcuts: ['快捷键', '键盘', 'keyboard', 'shortcut', 'hotkey'], + ssh: ['ssh', '连接', '端口', '超时', '心跳', '连接池', '重连', '快照'], + security: ['加密', '安全', '密码', '锁屏', 'encrypt', 'lock'], + about: ['版本', '关于', '许可', 'version', 'about', 'license'], + } + const matchIds = (Object.entries(keywords) as [SettingsTab, string[]][]) + .filter(([, kws]) => kws.some((k: string) => k.includes(q) || q.includes(k))) + .map(([id]) => id) + return matchIds.length > 0 ? matchIds : [] + }, [searchQuery]) + + // 如果搜索过滤后有结果,自动跳转到第一个匹配 tab + useEffect(() => { + if (filtered && filtered.length > 0 && !filtered.includes(activeTab)) { + setActiveTab(filtered[0]) + } + }, [filtered]) // eslint-disable-line react-hooks/exhaustive-deps + + const visibleTabs = filtered ? TAB_LIST.filter((t) => filtered.includes(t.id)) : TAB_LIST + + // ── 加载态 ──────────────────────────────────────────────── if (!isLoaded) { return (
@@ -32,292 +100,369 @@ export default function SettingsView() { ) } + // ── 渲染内容区 ──────────────────────────────────────────── const renderContent = () => { switch (activeTab) { + // ──────── 常规 ───────── case 'general': return ( -
-

常规设置

-
+
+ setSetting('restoreOnStart', v)} /> + + setSetting('autoSave', v)} /> + + setSetting('showNotifications', v)} /> -
+
) + // ──────── 外观 ───────── case 'appearance': return ( -
-

外观设置

-
-
-
主题
-
- {[ - { value: 'dark', label: '深色' }, - { value: 'light', label: '浅色' }, - { value: 'system', label: '跟随系统' }, - ].map((theme) => ( - - ))} -
+
+ + 主题 +
+ {[ + { value: 'dark', label: '深色' }, + { value: 'light', label: '浅色' }, + { value: 'system', label: '跟随系统' }, + ].map((theme) => ( + + ))}
+
-
-
语言
- -
-
+ + 语言 + +
) + // ──────── 终端 ───────── case 'terminal': return ( -
-

终端设置

-
-
-
字体
-
-
- - -
-
- - -
+
+ + 字体 +
+
+ + +
+
+ +
+
-
-
终端配色
-
- {Object.entries(themes).map(([key, theme]) => ( - - ))} -
+ + 终端配色 +
+ {Object.entries(themes).map(([key, theme]) => ( + + ))}
-
+
) + // ──────── 快捷键 ─────── + case 'shortcuts': + return ( +
+ +
+ + + + + + + + + + {SHORTCUTS.map((s, i) => ( + + + + + + ))} + +
操作快捷键分类
{s.action} + + {s.keys} + + {s.category}
+
+

+ 快捷键自定义功能将在后续版本中支持。 +

+
+
+ ) + + // ──────── SSH ────────── case 'ssh': return ( -
-

SSH 设置

-
+
+ setSetting('keepAlive', v)} /> + -
-
默认端口
- setSetting('defaultPort', parseInt(e.target.value) || 22)} - className="w-32 px-3 py-2 bg-dark-900 border border-dark-700 rounded text-gray-300" - /> + + 默认端口 + setSetting('defaultPort', parseInt(e.target.value) || 22)} + className="w-32 mt-2 px-3 py-2 bg-dark-900 border border-dark-700 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue" + /> + + + + 超时设置 (秒) + setSetting('timeout', parseInt(e.target.value) || 30)} + className="w-32 mt-2 px-3 py-2 bg-dark-900 border border-dark-700 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue" + /> + + + {/* 专业连接管理 */} + +
+ Pro + 连接管理
+

配置连接池行为,优化资源使用和性能

-
-
超时设置 (秒)
- setSetting('timeout', parseInt(e.target.value) || 30)} - className="w-32 px-3 py-2 bg-dark-900 border border-dark-700 rounded text-gray-300" - /> -
- - {/* 专业连接管理设置 */} -
-
- 专业版 - 连接管理 -
-
- 配置连接池行为,优化资源使用和性能 +
+
+
+ + setSetting('maxConnections', parseInt(e.target.value) || 10)} + className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue" + /> +
最大并发 SSH 连接数
+
+
+ + setSetting('maxBackgroundConnections', parseInt(e.target.value) || 5)} + className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue" + /> +
非活跃标签保持连接数
+
-
-
-
- - setSetting('maxConnections', parseInt(e.target.value) || 10)} - className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700 rounded text-gray-300" - /> -
最大并发 SSH 连接数
-
-
- - setSetting('maxBackgroundConnections', parseInt(e.target.value) || 5)} - className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700 rounded text-gray-300" - /> -
非活跃标签保持连接数
-
+
+
+ + setSetting('idleTimeoutMs', (parseInt(e.target.value) || 5) * 60000)} + className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue" + /> +
无操作后进入挂起
- -
-
- - setSetting('idleTimeoutMs', (parseInt(e.target.value) || 5) * 60000)} - className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700 rounded text-gray-300" - /> -
无操作后进入挂起
-
-
- - setSetting('suspendTimeoutMs', (parseInt(e.target.value) || 30) * 60000)} - className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700 rounded text-gray-300" - /> -
挂起后自动断开
-
+
+ + setSetting('suspendTimeoutMs', (parseInt(e.target.value) || 30) * 60000)} + className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue" + /> +
挂起后自动断开
+
+
setSetting('enableSnapshots', v)} /> - setSetting('autoReconnect', v)} />
-
+
) + // ──────── 安全 ───────── case 'security': return ( -
-

安全设置

-
+
+ setSetting('encryptPasswords', v)} /> - + + setSetting('lockOnSleep', v)} /> -
+
) + // ──────── 关于 ───────── case 'about': return ( -
-

关于 OpsXShell

-
-
- +
+ +
+
+ +
+

OpsXShell

+

版本 {appVersion}

+

+ 跨平台 SSH/SFTP 终端工具 · 简洁高效 · 安全可靠 · 操作便捷人性化 +

-

OpsXShell

-

版本 1.0.0

-

- 跨平台 SSH/SFTP 终端工具 · 简洁高效 · 安全可靠 · 操作便捷人性化 + + + + 技术栈 +

+ {[ + ['Electron', '28+'], + ['React', '18'], + ['TypeScript', '5'], + ['Vite', '5'], + ['Zustand', '4.4+'], + ['XTerm.js', '5.5+'], + ['Tailwind CSS', '3.3+'], + ['Lucide React', '0.294+'], + ].map(([name, ver]) => ( +
+ {name} + {ver} +
+ ))} +
+
+ + + 开源许可 +

+ OpsXShell 基于 MIT 许可证开源。本项目使用了以下开源组件:

-
+
    +
  • ssh2 — MIT License
  • +
  • xterm.js — MIT License
  • +
  • zmodem-ts — MIT License
  • +
  • better-sqlite3 — MIT License
  • +
  • lucide-react — ISC License
  • +
+
) @@ -326,40 +471,117 @@ export default function SettingsView() { } } + // ── 主布局 ──────────────────────────────────────────────── return (
- {/* Sidebar */} -
-