openTabContextMenu(tab.workspaceId, e)"
- >
-
-
{{ tab.title }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
未打开工作区。点击左侧连接可创建新实例。
+
+
+
+
+
+
+
+
+
未打开工作区。点击左侧连接可创建新实例。
+
+
diff --git a/frontend/src/components/WorkspacePanel.vue b/frontend/src/components/WorkspacePanel.vue
index 173303a..bff2503 100644
--- a/frontend/src/components/WorkspacePanel.vue
+++ b/frontend/src/components/WorkspacePanel.vue
@@ -65,7 +65,7 @@ watch(
>
workspaceStore.sidebarOpen)
+const sidebarWidth = computed(() => workspaceStore.sidebarWidth)
const transfersModalOpen = computed(() => workspaceStore.transfersModalOpen)
const sessionModalOpen = computed(() => workspaceStore.sessionModalOpen)
const sessionModalMode = computed(() => workspaceStore.sessionModalMode)
const forcePasswordChange = computed(() => authStore.passwordChangeRequired)
+const isSidebarResizing = ref(false)
const currentEditingConnection = computed(() => {
if (sessionModalMode.value !== 'edit' || workspaceStore.editingConnectionId == null) {
return null
@@ -57,6 +59,10 @@ const currentEditingConnection = computed(() => {
return connectionsStore.getConnection(workspaceStore.editingConnectionId) || null
})
+const desktopSidebarStyle = computed(() => ({
+ width: `${sidebarWidth.value}px`,
+}))
+
// Enable bidirectional sync
useConnectionSync()
@@ -158,6 +164,7 @@ onMounted(() => {
})
onUnmounted(() => {
+ stopSidebarResize()
window.removeEventListener('keydown', handleKeydown)
})
@@ -191,6 +198,36 @@ function closeSidebar() {
workspaceStore.closeSidebar()
}
+function clampSidebarWidth(width: number) {
+ return Math.max(256, Math.min(420, Math.round(width)))
+}
+
+function handleSidebarResize(event: MouseEvent) {
+ if (!isSidebarResizing.value) return
+ workspaceStore.updateSidebarWidth(clampSidebarWidth(event.clientX))
+}
+
+function stopSidebarResize() {
+ if (!isSidebarResizing.value) return
+
+ isSidebarResizing.value = false
+ document.body.style.userSelect = ''
+ document.body.style.cursor = ''
+ window.removeEventListener('mousemove', handleSidebarResize)
+ window.removeEventListener('mouseup', stopSidebarResize)
+}
+
+function startSidebarResize(event: MouseEvent) {
+ if (window.innerWidth < 1024) return
+
+ event.preventDefault()
+ isSidebarResizing.value = true
+ document.body.style.userSelect = 'none'
+ document.body.style.cursor = 'col-resize'
+ window.addEventListener('mousemove', handleSidebarResize)
+ window.addEventListener('mouseup', stopSidebarResize)
+}
+
function closeSessionModal() {
workspaceStore.closeSessionModal()
}
@@ -304,13 +341,31 @@ function resolveErrorMessage(error: unknown, fallback: string) {
/>
-
diff --git a/frontend/src/stores/productStatus.ts b/frontend/src/stores/productStatus.ts
index 50b2755..687af73 100644
--- a/frontend/src/stores/productStatus.ts
+++ b/frontend/src/stores/productStatus.ts
@@ -1,5 +1,5 @@
import { defineStore } from 'pinia'
-import { computed, ref } from 'vue'
+import { ref } from 'vue'
const STORAGE_KEY = 'ssh-manager.product-status'
@@ -7,26 +7,8 @@ type ProductStatusSnapshot = {
firstLaunchedAt: number
}
-function hashString(input: string) {
- let hash = 0
- for (let i = 0; i < input.length; i += 1) {
- hash = (hash * 31 + input.charCodeAt(i)) >>> 0
- }
- return hash.toString(16).padStart(8, '0')
-}
-
export const useProductStatusStore = defineStore('productStatus', () => {
const firstLaunchedAt = ref(Date.now())
- const licenseStatusText = computed(() => '源码交付版(无激活限制)')
- const machineFingerprint = computed(() => {
- const source = [
- window.location.host,
- navigator.userAgent,
- navigator.language,
- navigator.platform,
- ].join('|')
- return hashString(source).toUpperCase()
- })
function restore() {
const raw = localStorage.getItem(STORAGE_KEY)
@@ -59,8 +41,6 @@ export const useProductStatusStore = defineStore('productStatus', () => {
return {
firstLaunchedAt,
- licenseStatusText,
- machineFingerprint,
restore,
}
})
diff --git a/frontend/src/stores/settings.ts b/frontend/src/stores/settings.ts
index 17155d4..854d211 100644
--- a/frontend/src/stores/settings.ts
+++ b/frontend/src/stores/settings.ts
@@ -6,7 +6,6 @@ const STORAGE_KEY = 'ssh-manager.settings'
const DEFAULT_SETTINGS: AppSettingsState = {
terminalFontFamily: 'Menlo, Monaco, "Courier New", monospace',
terminalFontSize: 14,
- defaultSplitRatio: 0.5,
uploadConflictStrategy: 'ask',
downloadNamingStrategy: 'original',
}
@@ -16,11 +15,6 @@ function normalizeFontSize(value: unknown) {
return Math.max(12, Math.min(24, size))
}
-function normalizeSplitRatio(value: unknown) {
- const ratio = typeof value === 'number' ? value : DEFAULT_SETTINGS.defaultSplitRatio
- return Math.max(0.2, Math.min(0.8, ratio))
-}
-
function normalizeUploadConflictStrategy(value: unknown): UploadConflictStrategy {
return value === 'overwrite' || value === 'skip' ? value : 'ask'
}
@@ -45,7 +39,6 @@ export const useSettingsStore = defineStore('settings', {
? parsed.terminalFontFamily
: DEFAULT_SETTINGS.terminalFontFamily
this.terminalFontSize = normalizeFontSize(parsed.terminalFontSize)
- this.defaultSplitRatio = normalizeSplitRatio(parsed.defaultSplitRatio)
this.uploadConflictStrategy = normalizeUploadConflictStrategy(parsed.uploadConflictStrategy)
this.downloadNamingStrategy = normalizeDownloadNamingStrategy(parsed.downloadNamingStrategy)
} catch (error) {
@@ -60,9 +53,6 @@ export const useSettingsStore = defineStore('settings', {
if (next.terminalFontSize != null) {
this.terminalFontSize = normalizeFontSize(next.terminalFontSize)
}
- if (next.defaultSplitRatio != null) {
- this.defaultSplitRatio = normalizeSplitRatio(next.defaultSplitRatio)
- }
if (next.uploadConflictStrategy != null) {
this.uploadConflictStrategy = normalizeUploadConflictStrategy(next.uploadConflictStrategy)
}
@@ -81,7 +71,6 @@ export const useSettingsStore = defineStore('settings', {
localStorage.setItem(STORAGE_KEY, JSON.stringify({
terminalFontFamily: this.terminalFontFamily,
terminalFontSize: this.terminalFontSize,
- defaultSplitRatio: this.defaultSplitRatio,
uploadConflictStrategy: this.uploadConflictStrategy,
downloadNamingStrategy: this.downloadNamingStrategy,
}))
diff --git a/frontend/src/stores/workspace.ts b/frontend/src/stores/workspace.ts
index 560b528..a6e76cf 100644
--- a/frontend/src/stores/workspace.ts
+++ b/frontend/src/stores/workspace.ts
@@ -1,8 +1,11 @@
import { defineStore } from 'pinia'
import type { WorkspaceInstanceState, WorkspaceState } from '../types/workspace'
-import { useSettingsStore } from './settings'
const STORAGE_KEY = 'ssh-manager.workspace'
+const DEFAULT_SIDEBAR_WIDTH = 288
+const MIN_SIDEBAR_WIDTH = 256
+const MAX_SIDEBAR_WIDTH = 420
+const MIN_SFTP_SPLIT_RATIO = 0.8
type LegacyPanelState = {
connectionId: number
@@ -41,10 +44,15 @@ function createWorkspace(connectionId: number, instanceNumber: number, splitRati
}
function normalizeSplitRatio(value: unknown) {
- const ratio = typeof value === 'number' ? value : 0.5
+ const ratio = typeof value === 'number' ? value : MIN_SFTP_SPLIT_RATIO
return Math.max(0.2, Math.min(0.8, ratio))
}
+function normalizeSidebarWidth(value: unknown) {
+ const width = typeof value === 'number' ? value : DEFAULT_SIDEBAR_WIDTH
+ return Math.max(MIN_SIDEBAR_WIDTH, Math.min(MAX_SIDEBAR_WIDTH, Math.round(width)))
+}
+
function normalizeWorkspace(candidate: Partial, fallbackId: string): WorkspaceInstanceState | null {
if (typeof candidate.connectionId !== 'number' || candidate.connectionId <= 0) {
return null
@@ -73,15 +81,24 @@ export const useWorkspaceStore = defineStore('workspace', {
sessionModalMode: 'create',
editingConnectionId: null,
sidebarOpen: false,
+ sidebarWidth: DEFAULT_SIDEBAR_WIDTH,
}),
getters: {
activeWorkspace: (state): WorkspaceInstanceState | null => {
return state.activeWorkspaceId ? state.workspaces[state.activeWorkspaceId] || null : null
},
+
+ firstWorkspaceIdByConnection: (state) => (connectionId: number): string | null => {
+ return state.workspaceOrder.find((workspaceId) => state.workspaces[workspaceId]?.connectionId === connectionId) ?? null
+ },
},
actions: {
+ applyNewWorkspaceLayoutDefaults() {
+ this.sidebarWidth = MIN_SIDEBAR_WIDTH
+ },
+
nextInstanceNumber(connectionId: number) {
return Object.values(this.workspaces)
.filter((workspace) => workspace.connectionId === connectionId)
@@ -89,8 +106,8 @@ export const useWorkspaceStore = defineStore('workspace', {
},
openWorkspace(connectionId: number) {
- const settingsStore = useSettingsStore()
- const workspace = createWorkspace(connectionId, this.nextInstanceNumber(connectionId), settingsStore.defaultSplitRatio)
+ const workspace = createWorkspace(connectionId, this.nextInstanceNumber(connectionId), MIN_SFTP_SPLIT_RATIO)
+ this.applyNewWorkspaceLayoutDefaults()
this.workspaces[workspace.id] = workspace
this.workspaceOrder.push(workspace.id)
this.activeWorkspaceId = workspace.id
@@ -98,17 +115,27 @@ export const useWorkspaceStore = defineStore('workspace', {
return workspace.id
},
+ openOrActivateWorkspace(connectionId: number) {
+ const existingWorkspaceId = this.firstWorkspaceIdByConnection(connectionId)
+ if (existingWorkspaceId) {
+ this.activateWorkspace(existingWorkspaceId)
+ return existingWorkspaceId
+ }
+
+ return this.openWorkspace(connectionId)
+ },
+
duplicateWorkspace(workspaceId: string) {
const source = this.workspaces[workspaceId]
if (!source) return null
- const duplicate = createWorkspace(source.connectionId, this.nextInstanceNumber(source.connectionId), source.splitRatio)
- duplicate.splitRatio = source.splitRatio
+ const duplicate = createWorkspace(source.connectionId, this.nextInstanceNumber(source.connectionId), MIN_SFTP_SPLIT_RATIO)
duplicate.terminalVisible = source.terminalVisible
duplicate.sftpVisible = source.sftpVisible
duplicate.currentPath = source.currentPath
duplicate.selectedFiles = [...source.selectedFiles]
+ this.applyNewWorkspaceLayoutDefaults()
this.workspaces[duplicate.id] = duplicate
this.workspaceOrder.push(duplicate.id)
this.activeWorkspaceId = duplicate.id
@@ -234,8 +261,7 @@ export const useWorkspaceStore = defineStore('workspace', {
resetSplitRatio(workspaceId: string) {
const workspace = this.workspaces[workspaceId]
if (!workspace) return
- const settingsStore = useSettingsStore()
- workspace.splitRatio = settingsStore.defaultSplitRatio
+ workspace.splitRatio = MIN_SFTP_SPLIT_RATIO
this.persist()
},
@@ -279,6 +305,11 @@ export const useWorkspaceStore = defineStore('workspace', {
this.sidebarOpen = !this.sidebarOpen
},
+ updateSidebarWidth(width: number) {
+ this.sidebarWidth = normalizeSidebarWidth(width)
+ this.persist()
+ },
+
persist() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.$state))
},
@@ -368,6 +399,7 @@ export const useWorkspaceStore = defineStore('workspace', {
this.sessionModalMode = data.sessionModalMode === 'edit' ? 'edit' : 'create'
this.editingConnectionId = typeof data.editingConnectionId === 'number' ? data.editingConnectionId : null
this.sidebarOpen = false
+ this.sidebarWidth = normalizeSidebarWidth(data.sidebarWidth)
this.transfersModalOpen = false
this.sessionModalOpen = false
diff --git a/frontend/src/style.css b/frontend/src/style.css
index feec71c..f2d0a58 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -19,6 +19,39 @@
linear-gradient(180deg, var(--app-bg-0), var(--app-bg-1));
}
+ html {
+ scrollbar-width: thin;
+ scrollbar-color: rgba(103, 232, 249, 0.38) rgba(15, 23, 42, 0.78);
+ }
+
+ * {
+ scrollbar-width: thin;
+ scrollbar-color: rgba(103, 232, 249, 0.38) rgba(15, 23, 42, 0.42);
+ }
+
+ *::-webkit-scrollbar {
+ width: 10px;
+ height: 10px;
+ }
+
+ *::-webkit-scrollbar-track {
+ background: rgba(15, 23, 42, 0.5);
+ }
+
+ *::-webkit-scrollbar-thumb {
+ border: 2px solid rgba(15, 23, 42, 0.4);
+ border-radius: 9999px;
+ background: linear-gradient(180deg, rgba(103, 232, 249, 0.34), rgba(34, 211, 238, 0.22));
+ }
+
+ *::-webkit-scrollbar-thumb:hover {
+ background: linear-gradient(180deg, rgba(125, 211, 252, 0.58), rgba(34, 211, 238, 0.4));
+ }
+
+ *::-webkit-scrollbar-corner {
+ background: rgba(15, 23, 42, 0.4);
+ }
+
code,
kbd,
samp,
diff --git a/frontend/src/types/settings.ts b/frontend/src/types/settings.ts
index 15650f5..15c06a3 100644
--- a/frontend/src/types/settings.ts
+++ b/frontend/src/types/settings.ts
@@ -4,7 +4,6 @@ export type DownloadNamingStrategy = 'original' | 'connectionPrefix'
export interface AppSettingsState {
terminalFontFamily: string
terminalFontSize: number
- defaultSplitRatio: number
uploadConflictStrategy: UploadConflictStrategy
downloadNamingStrategy: DownloadNamingStrategy
}
diff --git a/frontend/src/types/workspace.ts b/frontend/src/types/workspace.ts
index f7ebdc1..59888b3 100644
--- a/frontend/src/types/workspace.ts
+++ b/frontend/src/types/workspace.ts
@@ -19,4 +19,5 @@ export interface WorkspaceState {
sessionModalMode: 'create' | 'edit'
editingConnectionId: number | null
sidebarOpen: boolean
+ sidebarWidth: number
}
diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue
index bb09b34..84da705 100644
--- a/frontend/src/views/LoginView.vue
+++ b/frontend/src/views/LoginView.vue
@@ -25,18 +25,18 @@ const loading = ref(false)
const highlights = [
{
icon: SquareTerminal,
- title: 'Moba 工作区',
- description: '多实例标签、终端与 SFTP 分屏,直接面对日常 SSH 运维场景。',
+ title: 'SSH / SFTP 工作区',
+ description: '一个界面里直接打开终端和 SFTP,适合日常服务器管理。',
},
{
icon: FolderInput,
- title: '完整备份恢复',
- description: '支持连接和会话树整体导入导出,迁移客户环境更省事。',
+ title: '备份恢复',
+ description: '支持连接和会话树整体导入导出,迁移环境更省事。',
},
{
icon: History,
- title: '历史与日志',
- description: '传输历史、操作日志、诊断信息都能留住,售后定位更快。',
+ title: '日志排查',
+ description: '传输历史、操作日志和诊断信息都保留,出问题更容易定位。',
},
]
@@ -74,16 +74,16 @@ async function handleSubmit() {
- SOURCE DELIVERY EDITION
+ 源码交付 + Docker 部署
- 面向源码交付与二开的
+ 面向源码交付的
SSH / SFTP
工作区项目
- 把终端、文件传输、批量命令、备份恢复和诊断入口整合进一个统一工作区。适合源码交付、私有部署和后续二开。
+ 适合按源码 + Docker 方式交付。终端、SFTP、批量命令、备份恢复都放在一个统一工作区里。
@@ -105,9 +105,9 @@ async function handleSubmit() {
交付方式
- - 源码仓库 + 部署说明文档
- - Docker 版一键启动
- - 首次启动引导、关于与交付信息、诊断摘要
+ - 仓库源码
+ - README 一份主文档
+ - Docker 启动方式
@@ -117,9 +117,9 @@ async function handleSubmit() {
当前版本能力
- - 源码交付说明与环境诊断入口
- - 终端自动重连、批量命令执行
- - 传输历史、操作日志、备份恢复
+ - SSH 终端 + SFTP 文件管理
+ - 批量命令 + 历史日志
+ - 备份恢复 + 基础诊断