feat: release OpsXShell 2.1.0 native Tauri edition
This commit is contained in:
1 parent
e9a0d21758
commit
a3dba0c6ed
311 files changed
+31748
-14897
No files matched your search
@@ -0,0 +1,211 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { AlertCircle, CheckCircle2, Clock3, Loader2, Play, TerminalSquare } from 'lucide-react'
|
||||
import type { Session } from '../../stores/sessionStore'
|
||||
import { getSessionConfig } from '../../stores/connection'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import Modal from '../common/Modal'
|
||||
import Button from '../common/Button'
|
||||
|
||||
type BatchCommandStatus = 'idle' | 'running' | 'success' | 'error'
|
||||
|
||||
interface BatchCommandResult {
|
||||
sessionId: string
|
||||
status: BatchCommandStatus
|
||||
output?: string
|
||||
error?: string
|
||||
durationMs?: number
|
||||
}
|
||||
|
||||
interface BatchCommandDialogProps {
|
||||
isOpen: boolean
|
||||
sessions: Session[]
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const MAX_CONCURRENCY = 5
|
||||
|
||||
export default function BatchCommandDialog({ isOpen, sessions, onClose }: BatchCommandDialogProps) {
|
||||
const [command, setCommand] = useState('')
|
||||
const [running, setRunning] = useState(false)
|
||||
const [results, setResults] = useState<BatchCommandResult[]>([])
|
||||
const timeout = useSettingsStore((state) => state.settings.timeout)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
setResults(sessions.map((session) => ({ sessionId: session.id, status: 'idle' })))
|
||||
}, [isOpen, sessions])
|
||||
|
||||
const updateResult = useCallback((sessionId: string, updates: Partial<BatchCommandResult>) => {
|
||||
setResults((current) => current.map((result) =>
|
||||
result.sessionId === sessionId ? { ...result, ...updates } : result
|
||||
))
|
||||
}, [])
|
||||
|
||||
const executeForSession = useCallback(async (session: Session, commandText: string) => {
|
||||
const startedAt = Date.now()
|
||||
let connectionId: string | undefined
|
||||
updateResult(session.id, { status: 'running', output: undefined, error: undefined })
|
||||
|
||||
try {
|
||||
const config = await getSessionConfig(session.id)
|
||||
if (!config) throw new Error('无法读取会话配置')
|
||||
|
||||
const connected = await window.opsxApi.sshConnect({
|
||||
...config,
|
||||
timeoutSeconds: config.timeoutSeconds ?? timeout,
|
||||
keepAlive: false,
|
||||
})
|
||||
if (!connected.success || !connected.connectionId) {
|
||||
throw new Error(connected.error || '连接失败')
|
||||
}
|
||||
connectionId = connected.connectionId
|
||||
|
||||
const executed = await window.opsxApi.sshExec(connectionId, commandText)
|
||||
if (!executed.success) throw new Error(executed.error || '命令执行失败')
|
||||
|
||||
updateResult(session.id, {
|
||||
status: 'success',
|
||||
output: executed.result?.trimEnd() || '(无输出)',
|
||||
durationMs: Date.now() - startedAt,
|
||||
})
|
||||
} catch (error) {
|
||||
updateResult(session.id, {
|
||||
status: 'error',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
durationMs: Date.now() - startedAt,
|
||||
})
|
||||
} finally {
|
||||
if (connectionId) {
|
||||
await window.opsxApi.sshDisconnect(connectionId).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
}, [timeout, updateResult])
|
||||
|
||||
const execute = useCallback(async () => {
|
||||
const commandText = command.trim()
|
||||
if (!commandText || running || sessions.length === 0) return
|
||||
|
||||
setRunning(true)
|
||||
setResults(sessions.map((session) => ({ sessionId: session.id, status: 'idle' })))
|
||||
|
||||
let nextIndex = 0
|
||||
const worker = async () => {
|
||||
while (nextIndex < sessions.length) {
|
||||
const session = sessions[nextIndex]
|
||||
nextIndex += 1
|
||||
await executeForSession(session, commandText)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(MAX_CONCURRENCY, sessions.length) }, () => worker())
|
||||
)
|
||||
} finally {
|
||||
setRunning(false)
|
||||
}
|
||||
}, [command, executeForSession, running, sessions])
|
||||
|
||||
const summary = useMemo(() => ({
|
||||
success: results.filter((result) => result.status === 'success').length,
|
||||
error: results.filter((result) => result.status === 'error').length,
|
||||
}), [results])
|
||||
|
||||
const handleClose = () => {
|
||||
if (!running) onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
title="批量命令"
|
||||
description={`${sessions.length} 台主机 · 最大并发 ${MAX_CONCURRENCY}`}
|
||||
icon={<TerminalSquare className="h-5 w-5" />}
|
||||
onClose={handleClose}
|
||||
showFooter
|
||||
showConfirm={false}
|
||||
disableBackdropClose={running}
|
||||
size="full"
|
||||
footer={
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
<span className="text-xs text-txt-muted">
|
||||
{summary.success > 0 && <span className="text-accent-green">成功 {summary.success}</span>}
|
||||
{summary.success > 0 && summary.error > 0 && ' · '}
|
||||
{summary.error > 0 && <span className="text-red-400">失败 {summary.error}</span>}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={handleClose} disabled={running}>关闭</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={<Play className="h-3.5 w-3.5" />}
|
||||
onClick={() => void execute()}
|
||||
disabled={!command.trim()}
|
||||
loading={running}
|
||||
>
|
||||
{results.some((result) => result.status === 'success' || result.status === 'error')
|
||||
? '再次执行'
|
||||
: '执行命令'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="batch-command" className="mb-1.5 block text-xs font-medium text-content-secondary">
|
||||
远程命令
|
||||
</label>
|
||||
<textarea
|
||||
id="batch-command"
|
||||
value={command}
|
||||
onChange={(event) => setCommand(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
void execute()
|
||||
}
|
||||
}}
|
||||
disabled={running}
|
||||
rows={3}
|
||||
autoFocus
|
||||
spellCheck={false}
|
||||
placeholder="例如:date"
|
||||
className="w-full resize-y rounded-lg border border-dark-600 bg-dark-900 px-3 py-2 font-mono text-sm text-content-primary outline-none transition-colors focus:border-accent-blue disabled:opacity-60"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[48vh] space-y-2 overflow-y-auto pr-1">
|
||||
{sessions.map((session) => {
|
||||
const result = results.find((item) => item.sessionId === session.id)
|
||||
const status = result?.status ?? 'idle'
|
||||
return (
|
||||
<div key={session.id} className="overflow-hidden rounded-lg border border-dark-700 bg-dark-900/65">
|
||||
<div className="flex min-h-10 items-center gap-2 border-b border-dark-700/70 px-3 py-2">
|
||||
{status === 'running' && <Loader2 className="h-4 w-4 animate-spin text-accent-blue" />}
|
||||
{status === 'success' && <CheckCircle2 className="h-4 w-4 text-accent-green" />}
|
||||
{status === 'error' && <AlertCircle className="h-4 w-4 text-red-400" />}
|
||||
{status === 'idle' && <Clock3 className="h-4 w-4 text-txt-faint" />}
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium text-content-primary">{session.name}</span>
|
||||
<span className="truncate font-mono text-[11px] text-txt-muted">
|
||||
{session.username}@{session.host}:{session.port || 22}
|
||||
</span>
|
||||
{result?.durationMs !== undefined && (
|
||||
<span className="text-[10px] tabular-nums text-txt-faint">{result.durationMs} ms</span>
|
||||
)}
|
||||
</div>
|
||||
{(result?.output || result?.error) && (
|
||||
<pre className={`max-h-40 overflow-auto whitespace-pre-wrap break-words px-3 py-2 text-xs leading-relaxed ${
|
||||
result.error ? 'text-red-300' : 'text-content-secondary'
|
||||
}`}>
|
||||
{result.error || result.output}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* 快速连接 — 尽量像 ssh 命令一样简单
|
||||
* 支持:host | user@host | user@host:port | host:port
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Zap, ArrowRight, AlertCircle } from 'lucide-react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import Modal from '../common/Modal'
|
||||
import Button from '../common/Button'
|
||||
|
||||
interface QuickConnectDialogProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
initialTarget?: string
|
||||
}
|
||||
|
||||
/** 解析 user@host:port / host:port / host */
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function parseSshTarget(raw: string): {
|
||||
host: string
|
||||
port: number
|
||||
username: string
|
||||
} | null {
|
||||
const s = raw.trim()
|
||||
if (!s) return null
|
||||
|
||||
// strip optional ssh://
|
||||
const cleaned = s.replace(/^ssh:\/\//i, '')
|
||||
|
||||
let username = 'root'
|
||||
let hostPort = cleaned
|
||||
|
||||
const at = cleaned.lastIndexOf('@')
|
||||
if (at > 0) {
|
||||
username = cleaned.slice(0, at).trim() || 'root'
|
||||
hostPort = cleaned.slice(at + 1).trim()
|
||||
}
|
||||
|
||||
// IPv6 in brackets [2001:db8::1]:22
|
||||
let host = hostPort
|
||||
let port = 22
|
||||
const bracket = /^\[([^\]]+)\](?::(\d+))?$/.exec(hostPort)
|
||||
if (bracket) {
|
||||
host = bracket[1]
|
||||
if (bracket[2]) port = parseInt(bracket[2], 10) || 22
|
||||
} else {
|
||||
// last :port — avoid splitting bare IPv6 without brackets
|
||||
const colon = hostPort.lastIndexOf(':')
|
||||
if (colon > 0 && hostPort.indexOf(':') === colon) {
|
||||
const maybePort = hostPort.slice(colon + 1)
|
||||
if (/^\d+$/.test(maybePort)) {
|
||||
host = hostPort.slice(0, colon)
|
||||
port = parseInt(maybePort, 10) || 22
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
host = host.trim()
|
||||
if (!host || !username) return null
|
||||
return { host, port, username }
|
||||
}
|
||||
|
||||
export default function QuickConnectDialog({ isOpen, onClose, initialTarget = '' }: QuickConnectDialogProps) {
|
||||
const { addTab } = useTabStore()
|
||||
const [target, setTarget] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [isConnecting, setIsConnecting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && initialTarget) {
|
||||
setTarget(initialTarget)
|
||||
setError(null)
|
||||
}
|
||||
}, [initialTarget, isOpen])
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const parsed = parseSshTarget(target)
|
||||
if (!parsed) {
|
||||
setError('请输入主机,例如 root@192.168.1.10 或 opsbase.cn:5555')
|
||||
return
|
||||
}
|
||||
setIsConnecting(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
addTab({
|
||||
title: `${parsed.username}@${parsed.host}`,
|
||||
type: 'terminal',
|
||||
connectionConfig: {
|
||||
host: parsed.host,
|
||||
port: parsed.port,
|
||||
username: parsed.username,
|
||||
password: password || undefined,
|
||||
},
|
||||
})
|
||||
|
||||
onClose()
|
||||
setTarget('')
|
||||
setPassword('')
|
||||
} catch (err: unknown) {
|
||||
setError((err instanceof Error ? err.message : String(err)) || '连接失败')
|
||||
} finally {
|
||||
setIsConnecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="快速连接"
|
||||
description="粘贴 user@host:port 即可,不必拆成多个框"
|
||||
icon={<Zap className="h-5 w-5" />}
|
||||
size="md"
|
||||
showFooter={false}
|
||||
>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
void handleSubmit()
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<label className="ui-label">目标</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
autoFocus
|
||||
value={target}
|
||||
onChange={(e) => setTarget(e.target.value)}
|
||||
placeholder="root@192.168.1.10:22"
|
||||
className="ui-input font-mono"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<p className="mt-1.5 text-[11px] leading-relaxed text-txt-faint">支持 host、user@host、user@host:port</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="ui-label">
|
||||
密码 <span className="font-normal text-txt-faint">可选 · 用密钥可留空</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="ui-input"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2.5 text-sm text-red-300">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-dark-700/50 flex justify-end gap-2.5 border-t pt-2">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onClose} disabled={isConnecting}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
loading={isConnecting}
|
||||
icon={<ArrowRight className="h-3.5 w-3.5" />}
|
||||
>
|
||||
连接
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Cable, Plus, Search, Server, Terminal } from 'lucide-react'
|
||||
import Modal from '../common/Modal'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import {
|
||||
listLocalShells,
|
||||
type AvailableLocalShell,
|
||||
} from '../../platform/localTerminal'
|
||||
|
||||
interface QuickLauncherProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onNewSession: () => void
|
||||
onQuickConnect: () => void
|
||||
onSerialConnect: () => void
|
||||
}
|
||||
|
||||
export default function QuickLauncher({
|
||||
isOpen,
|
||||
onClose,
|
||||
onNewSession,
|
||||
onQuickConnect,
|
||||
onSerialConnect,
|
||||
}: QuickLauncherProps) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [localShells, setLocalShells] = useState<AvailableLocalShell[]>([])
|
||||
const sessions = useSessionStore((state) => state.sessions)
|
||||
const addTab = useTabStore((state) => state.addTab)
|
||||
const filteredSessions = useMemo(() => {
|
||||
const normalized = query.trim().toLowerCase()
|
||||
if (!normalized) return sessions.slice(0, 8)
|
||||
return sessions
|
||||
.filter((session) =>
|
||||
[session.name, session.host, session.username, session.group, ...(session.tags ?? [])]
|
||||
.filter((value): value is string => typeof value === 'string')
|
||||
.some((value) => value.toLowerCase().includes(normalized))
|
||||
)
|
||||
.slice(0, 8)
|
||||
}, [query, sessions])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setQuery('')
|
||||
return
|
||||
}
|
||||
let disposed = false
|
||||
void listLocalShells().then((shells) => {
|
||||
if (!disposed) setLocalShells(shells)
|
||||
})
|
||||
return () => {
|
||||
disposed = true
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
const run = (action: () => void) => {
|
||||
action()
|
||||
onClose()
|
||||
setQuery('')
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="快速启动"
|
||||
description="会话、本地 Shell 与设备控制台统一入口"
|
||||
showFooter={false}
|
||||
size="lg"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-txt-faint" />
|
||||
<input
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="搜索主机、分组或标签页"
|
||||
className="ui-input pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<button type="button" onClick={() => run(onQuickConnect)} className="launcher-card">
|
||||
<Server className="w-4 h-4 text-accent-blue" />
|
||||
<span><strong>快速连接</strong><small>user@host:port</small></span>
|
||||
</button>
|
||||
<button type="button" onClick={() => run(onNewSession)} className="launcher-card">
|
||||
<Plus className="w-4 h-4 text-accent-green" />
|
||||
<span><strong>保存会话</strong><small>凭据、分组、代理</small></span>
|
||||
</button>
|
||||
<button type="button" onClick={() => run(onSerialConnect)} className="launcher-card">
|
||||
<Cable className="w-4 h-4 text-accent-purple" />
|
||||
<span><strong>串口终端</strong><small>波特率与串口参数</small></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<div className="px-1 mb-1.5 text-[11px] font-semibold uppercase tracking-wider text-txt-faint">本地 Shell</div>
|
||||
<div className="space-y-1">
|
||||
{(localShells.length > 0
|
||||
? localShells.slice(0, 4)
|
||||
: [{ path: '/bin/zsh', name: 'Zsh', kind: 'zsh', isDefault: true }]
|
||||
).map((shell) => (
|
||||
<button
|
||||
key={shell.path}
|
||||
type="button"
|
||||
onClick={() => run(() => addTab({
|
||||
title: shell.name,
|
||||
type: 'terminal',
|
||||
isLocal: true,
|
||||
localShellPath: shell.path,
|
||||
localShellKind: shell.kind,
|
||||
}))}
|
||||
className="launcher-row"
|
||||
>
|
||||
<Terminal className="w-4 h-4 text-emerald-400" />
|
||||
<span className="flex-1 text-left"><strong>{shell.name}</strong><small>{shell.path}</small></span>
|
||||
{shell.isDefault && <span className="launcher-badge">系统默认</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{filteredSessions.length > 0 && (
|
||||
<section>
|
||||
<div className="px-1 mb-1.5 text-[11px] font-semibold uppercase tracking-wider text-txt-faint">保存会话</div>
|
||||
<div className="max-h-56 overflow-y-auto space-y-1">
|
||||
{filteredSessions.map((session) => (
|
||||
<button
|
||||
key={session.id}
|
||||
type="button"
|
||||
onClick={() => run(() => addTab({ title: session.name, type: 'terminal', sessionId: session.id }))}
|
||||
className="launcher-row"
|
||||
>
|
||||
<Server className="w-4 h-4 text-accent-blue" />
|
||||
<span className="flex-1 min-w-0 text-left">
|
||||
<strong className="truncate">{session.name}</strong>
|
||||
<small className="truncate">{session.username}@{session.host}:{session.port || 22}</small>
|
||||
</span>
|
||||
<span className="launcher-badge">{session.group || 'default'}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,615 @@
|
||||
/**
|
||||
* Session editor — right drawer (basic fields first, advanced collapsed)
|
||||
*/
|
||||
|
||||
import { useState, useEffect, type ComponentType } from 'react'
|
||||
import {
|
||||
X, Server, Key, Lock, Tag, Folder, ChevronDown, Check,
|
||||
} from 'lucide-react'
|
||||
import { useSessionStore, Session } from '../../stores'
|
||||
import type { AuthType, ProxyType } from '../../../shared/types/session.types'
|
||||
import { createLogger } from '../../utils/logger'
|
||||
import { parseSshTarget } from './QuickConnectDialog'
|
||||
|
||||
const logger = createLogger('SessionDialog')
|
||||
|
||||
function targetIncludesPort(raw: string): boolean {
|
||||
const cleaned = raw.trim().replace(/^ssh:\/\//i, '')
|
||||
const hostPort = cleaned.slice(cleaned.lastIndexOf('@') + 1)
|
||||
if (/^\[[^\]]+\]:\d+$/.test(hostPort)) return true
|
||||
const firstColon = hostPort.indexOf(':')
|
||||
return firstColon > 0 && firstColon === hostPort.lastIndexOf(':') && /:\d+$/.test(hostPort)
|
||||
}
|
||||
|
||||
interface SessionDialogProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
editSession?: Session | null
|
||||
}
|
||||
|
||||
const emptyForm = {
|
||||
name: '',
|
||||
host: '',
|
||||
port: 22,
|
||||
username: 'root',
|
||||
authType: 'password' as AuthType,
|
||||
password: '',
|
||||
privateKey: '~/.ssh/id_rsa',
|
||||
passphrase: '',
|
||||
proxy: {
|
||||
type: 'none' as ProxyType,
|
||||
host: '',
|
||||
port: 0,
|
||||
username: '',
|
||||
password: '',
|
||||
},
|
||||
group: 'default',
|
||||
tags: '',
|
||||
startupCommand: '',
|
||||
jumpSessionIds: [] as string[],
|
||||
}
|
||||
|
||||
export default function SessionDialog({ isOpen, onClose, editSession }: SessionDialogProps) {
|
||||
const { addSession, updateSession, sessions } = useSessionStore()
|
||||
const [formData, setFormData] = useState(emptyForm)
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const isEditing = !!editSession
|
||||
const jumpCandidates = sessions.filter((session) => session.id !== editSession?.id)
|
||||
/** 一行目标:user@host:port */
|
||||
const [target, setTarget] = useState('root@')
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
if (editSession) {
|
||||
const hasAdvanced =
|
||||
!!(editSession.proxy && editSession.proxy.type !== 'none') ||
|
||||
!!(editSession.jumpSessionId || (editSession.jumpSessionIds && editSession.jumpSessionIds.length)) ||
|
||||
!!editSession.startupCommand ||
|
||||
(!!editSession.group && editSession.group !== 'default') ||
|
||||
!!(editSession.tags && editSession.tags.length)
|
||||
const u = editSession.username || 'root'
|
||||
const h = editSession.host || ''
|
||||
setTarget(`${u}@${h}`)
|
||||
setFormData({
|
||||
name: editSession.name || '',
|
||||
host: editSession.host || '',
|
||||
port: editSession.port || 22,
|
||||
username: editSession.username || '',
|
||||
authType: editSession.authType === 'agent' ? 'key' : editSession.authType || 'password',
|
||||
password: editSession.password || '',
|
||||
privateKey: editSession.privateKey || '~/.ssh/id_rsa',
|
||||
passphrase: editSession.passphrase || '',
|
||||
proxy: editSession.proxy
|
||||
? {
|
||||
type: editSession.proxy.type as ProxyType,
|
||||
host: editSession.proxy.host,
|
||||
port: editSession.proxy.port,
|
||||
username: editSession.proxy.username || '',
|
||||
password: editSession.proxy.password || '',
|
||||
}
|
||||
: { type: 'none', host: '', port: 0, username: '', password: '' },
|
||||
group: editSession.group || 'default',
|
||||
tags: editSession.tags?.join(', ') || '',
|
||||
startupCommand: editSession.startupCommand || '',
|
||||
jumpSessionIds:
|
||||
editSession.jumpSessionIds && editSession.jumpSessionIds.length > 0
|
||||
? [...editSession.jumpSessionIds]
|
||||
: editSession.jumpSessionId
|
||||
? [editSession.jumpSessionId]
|
||||
: [],
|
||||
})
|
||||
setShowAdvanced(hasAdvanced)
|
||||
} else {
|
||||
setFormData({ ...emptyForm, username: 'root', privateKey: '~/.ssh/id_rsa' })
|
||||
setTarget('root@')
|
||||
setShowAdvanced(false)
|
||||
}
|
||||
}, [editSession, isOpen])
|
||||
|
||||
const applyTarget = (raw: string) => {
|
||||
setTarget(raw)
|
||||
const parsed = parseSshTarget(raw)
|
||||
if (parsed) {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
host: parsed.host,
|
||||
port: targetIncludesPort(raw) ? parsed.port : prev.port,
|
||||
username: parsed.username,
|
||||
name:
|
||||
prev.name.trim() && !prev.name.includes('@')
|
||||
? prev.name
|
||||
: `${parsed.username}@${parsed.host}`,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// ESC close
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [isOpen, onClose])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const proxyReady =
|
||||
formData.proxy.type === 'none' ||
|
||||
(formData.proxy.host.trim().length > 0 &&
|
||||
formData.proxy.port > 0 &&
|
||||
formData.proxy.port <= 65535)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const parsed = parseSshTarget(target)
|
||||
if (!parsed?.host) {
|
||||
logger.error('Invalid SSH target')
|
||||
return
|
||||
}
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const proxyConfig =
|
||||
formData.proxy.type !== 'none' && formData.proxy.host
|
||||
? {
|
||||
type: formData.proxy.type,
|
||||
host: formData.proxy.host,
|
||||
port: formData.proxy.port || (formData.proxy.type === 'http' ? 8080 : 1080),
|
||||
username: formData.proxy.username || undefined,
|
||||
password: formData.proxy.password || undefined,
|
||||
}
|
||||
: undefined
|
||||
|
||||
const sessionData = {
|
||||
name: formData.name.trim() || `${parsed.username}@${parsed.host}`,
|
||||
host: parsed.host,
|
||||
port: formData.port || 22,
|
||||
username: parsed.username || 'root',
|
||||
authType: formData.authType,
|
||||
password: formData.authType === 'password' ? formData.password || undefined : undefined,
|
||||
privateKey:
|
||||
formData.authType === 'key'
|
||||
? formData.privateKey.trim() || '~/.ssh/id_rsa'
|
||||
: undefined,
|
||||
passphrase:
|
||||
formData.authType === 'key' ? formData.passphrase || undefined : undefined,
|
||||
proxy: proxyConfig,
|
||||
startupCommand: formData.startupCommand.trim() || undefined,
|
||||
jumpSessionIds:
|
||||
formData.jumpSessionIds.filter(Boolean).length > 0
|
||||
? formData.jumpSessionIds.filter(Boolean)
|
||||
: undefined,
|
||||
jumpSessionId: formData.jumpSessionIds.filter(Boolean)[0] || undefined,
|
||||
group: formData.group || 'default',
|
||||
tags: formData.tags
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean),
|
||||
}
|
||||
|
||||
if (isEditing && editSession) {
|
||||
await updateSession(editSession.id, sessionData)
|
||||
} else {
|
||||
await addSession({
|
||||
...sessionData,
|
||||
id: `session-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||
} as Session)
|
||||
}
|
||||
onClose()
|
||||
} catch (error) {
|
||||
logger.error(isEditing ? 'Failed to update session:' : 'Failed to create session:', error)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const authTypes: {
|
||||
value: AuthType
|
||||
label: string
|
||||
icon: ComponentType<{ className?: string }>
|
||||
}[] = [
|
||||
{ value: 'password', label: '密码', icon: Lock },
|
||||
{ value: 'key', label: '密钥', icon: Key },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex justify-end">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/45 backdrop-blur-[1px] animate-fade-in"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Drawer */}
|
||||
<aside
|
||||
className="relative w-full max-w-[400px] h-full bg-dark-800 border-l border-dark-700/70 shadow-2xl flex flex-col animate-slide-in-right"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="session-drawer-title"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 px-4 py-3.5 border-b border-dark-700/50 flex-shrink-0">
|
||||
<div className="w-9 h-9 rounded-xl bg-accent-blue/15 border border-accent-blue/20 flex items-center justify-center text-accent-blue">
|
||||
<Server className="w-4.5 h-4.5 w-4 h-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 id="session-drawer-title" className="text-sm font-semibold text-content-primary">
|
||||
{isEditing ? '编辑会话' : '新建会话'}
|
||||
</h2>
|
||||
<p className="text-[11px] text-txt-faint mt-0.5">目标一行搞定 · 高级可折叠</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg text-txt-muted hover:text-content-primary hover:bg-dark-700"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col flex-1 min-h-0">
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-4">
|
||||
{/* 基本:名称 + 目标 */}
|
||||
<section className="rounded-xl border border-dark-700/50 bg-dark-900/30 p-3.5 space-y-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint">
|
||||
基本
|
||||
</div>
|
||||
<div>
|
||||
<label className="ui-label">显示名称(可选)</label>
|
||||
<input
|
||||
type="text"
|
||||
autoFocus={!isEditing}
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="自动用 user@host"
|
||||
className="ui-input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_88px] gap-2">
|
||||
<div>
|
||||
<label htmlFor="session-target" className="ui-label">目标</label>
|
||||
<input
|
||||
id="session-target"
|
||||
type="text"
|
||||
required
|
||||
value={target}
|
||||
onChange={(e) => applyTarget(e.target.value)}
|
||||
placeholder="root@192.168.1.10"
|
||||
className="ui-input font-mono"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="session-port" className="ui-label">端口</label>
|
||||
<input
|
||||
id="session-port"
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
required
|
||||
value={formData.port || ''}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
port: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
onBlur={() => {
|
||||
if (formData.port < 1 || formData.port > 65535) {
|
||||
setFormData({ ...formData, port: 22 })
|
||||
}
|
||||
}}
|
||||
className="ui-input font-mono"
|
||||
inputMode="numeric"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-txt-faint">
|
||||
支持 user@host · host;目标中填写端口时会自动同步
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 凭据 */}
|
||||
<section className="rounded-xl border border-dark-700/50 bg-dark-900/30 p-3.5 space-y-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint">
|
||||
登录
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1.5 p-1 rounded-xl bg-dark-900/60 border border-dark-700/40">
|
||||
{authTypes.map((type) => {
|
||||
const Icon = type.icon
|
||||
const active = formData.authType === type.value
|
||||
return (
|
||||
<button
|
||||
key={type.value}
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, authType: type.value })}
|
||||
className={`flex items-center justify-center gap-1 py-2 rounded-lg text-xs font-medium transition-colors ${
|
||||
active
|
||||
? 'bg-dark-700 text-content-primary shadow-sm'
|
||||
: 'text-txt-muted hover:text-content-secondary'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{type.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{formData.authType === 'password' && (
|
||||
<div>
|
||||
<label htmlFor="session-password" className="ui-label">密码</label>
|
||||
<input
|
||||
id="session-password"
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
placeholder="••••••••"
|
||||
className="ui-input"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formData.authType === 'key' && (
|
||||
<div className="space-y-2.5">
|
||||
<div>
|
||||
<label htmlFor="session-private-key" className="ui-label">私钥路径</label>
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
id="session-private-key"
|
||||
type="text"
|
||||
value={formData.privateKey}
|
||||
onChange={(e) => setFormData({ ...formData, privateKey: e.target.value })}
|
||||
placeholder="~/.ssh/id_ed25519"
|
||||
className="ui-input flex-1"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="px-2.5 text-xs rounded-lg bg-dark-700 border border-dark-600 text-content-secondary hover:bg-dark-600"
|
||||
onClick={async () => {
|
||||
const result = await window.opsxApi?.showOpenDialog({
|
||||
title: '选择私钥文件',
|
||||
filters: [{ name: '所有文件', extensions: ['*'] }],
|
||||
})
|
||||
if (!result?.canceled && result?.filePaths?.[0]) {
|
||||
setFormData({ ...formData, privateKey: result.filePaths[0] })
|
||||
}
|
||||
}}
|
||||
>
|
||||
浏览
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="session-passphrase" className="ui-label">密钥口令(可选)</label>
|
||||
<input
|
||||
id="session-passphrase"
|
||||
type="password"
|
||||
value={formData.passphrase}
|
||||
onChange={(e) => setFormData({ ...formData, passphrase: e.target.value })}
|
||||
placeholder="可选"
|
||||
className="ui-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</section>
|
||||
|
||||
{/* 高级 */}
|
||||
<div className="rounded-xl border border-dark-700/50 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced((v) => !v)}
|
||||
className="w-full flex items-center justify-between px-3.5 py-3 text-left bg-dark-900/20 hover:bg-dark-900/40 transition-colors"
|
||||
>
|
||||
<span className="text-xs font-semibold text-content-secondary">高级选项</span>
|
||||
<span className="flex items-center gap-1.5 text-[11px] text-txt-faint">
|
||||
{showAdvanced ? '收起' : '代理 · 跳板 · 分组'}
|
||||
<ChevronDown
|
||||
className={`w-3.5 h-3.5 transition-transform ${showAdvanced ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{showAdvanced && (
|
||||
<div className="px-3.5 pb-3.5 space-y-3 border-t border-dark-700/40 bg-dark-900/20">
|
||||
<div className="grid grid-cols-2 gap-2 pt-3">
|
||||
<div>
|
||||
<label className="ui-label">
|
||||
<Folder className="inline w-3 h-3 mr-0.5" />
|
||||
分组
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.group}
|
||||
onChange={(e) => setFormData({ ...formData, group: e.target.value })}
|
||||
placeholder="default"
|
||||
className="ui-input-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="ui-label">
|
||||
<Tag className="inline w-3 h-3 mr-0.5" />
|
||||
标签
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.tags}
|
||||
onChange={(e) => setFormData({ ...formData, tags: e.target.value })}
|
||||
placeholder="生产, web"
|
||||
className="ui-input-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="ui-label">启动命令(可选)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.startupCommand}
|
||||
onChange={(e) => setFormData({ ...formData, startupCommand: e.target.value })}
|
||||
placeholder="连接后自动执行,如 cd /var/log"
|
||||
className="ui-input-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="ui-label">代理</label>
|
||||
<div className="flex gap-1 mb-2">
|
||||
{(
|
||||
[
|
||||
{ value: 'none', label: '无' },
|
||||
{ value: 'http', label: 'HTTP' },
|
||||
{ value: 'socks5', label: 'SOCKS5' },
|
||||
] as const
|
||||
).map((t) => (
|
||||
<button
|
||||
key={t.value}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setFormData({
|
||||
...formData,
|
||||
proxy: {
|
||||
...formData.proxy,
|
||||
type: t.value,
|
||||
port:
|
||||
formData.proxy.port ||
|
||||
(t.value === 'http' ? 8080 : t.value === 'socks5' ? 1080 : 0),
|
||||
},
|
||||
jumpSessionIds: t.value === 'none' ? formData.jumpSessionIds : [],
|
||||
})
|
||||
}
|
||||
className={`flex-1 py-1.5 rounded-lg text-[11px] border transition-colors ${
|
||||
formData.proxy.type === t.value
|
||||
? 'border-accent-blue bg-accent-blue/15 text-accent-blue'
|
||||
: 'border-dark-600 text-txt-muted hover:border-dark-500'
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{formData.proxy.type !== 'none' && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={formData.proxy.host}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
proxy: { ...formData.proxy, host: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="代理地址"
|
||||
className="ui-input-sm col-span-2"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.proxy.port || ''}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
proxy: {
|
||||
...formData.proxy,
|
||||
port: parseInt(e.target.value) || 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="端口"
|
||||
className="ui-input-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={formData.proxy.username}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
proxy: { ...formData.proxy, username: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="用户名(可选)"
|
||||
autoComplete="off"
|
||||
className="ui-input-sm"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.proxy.password}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
proxy: { ...formData.proxy, password: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="密码(可选)"
|
||||
autoComplete="new-password"
|
||||
className="ui-input-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="session-jump-host" className="ui-label">跳板机(可选)</label>
|
||||
<select
|
||||
id="session-jump-host"
|
||||
value={formData.jumpSessionIds[0] || ''}
|
||||
onChange={(event) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
jumpSessionIds: event.target.value ? [event.target.value] : [],
|
||||
proxy: event.target.value
|
||||
? { type: 'none', host: '', port: 0, username: '', password: '' }
|
||||
: formData.proxy,
|
||||
})
|
||||
}
|
||||
className="ui-input-sm"
|
||||
>
|
||||
<option value="">不使用跳板</option>
|
||||
{jumpCandidates.map((session) => (
|
||||
<option key={session.id} value={session.id}>
|
||||
{session.name} ({session.username}@{session.host})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-1 text-[10px] text-txt-faint">当前支持一个已保存会话作为跳板</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-dark-700/50 flex-shrink-0 bg-dark-800">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !parseSshTarget(target)?.host || !proxyReady}
|
||||
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-xl bg-accent-blue hover:bg-accent-blue-hover disabled:opacity-45 text-white text-sm font-medium transition-colors shadow-sm shadow-accent-blue/25"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
'保存中…'
|
||||
) : (
|
||||
<>
|
||||
<Check className="w-4 h-4" />
|
||||
{isEditing ? '保存' : '创建并保存'}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { Terminal, Plus, Zap, Clock, Server, ChevronRight } from 'lucide-react'
|
||||
import { useSessionStore } from '../../stores'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import EmptyState from '../common/EmptyState'
|
||||
import LocalShellMenu from '../layout/sidebar/LocalShellMenu'
|
||||
|
||||
interface WelcomeViewProps {
|
||||
onNewSession?: () => void
|
||||
onQuickConnect?: () => void
|
||||
}
|
||||
|
||||
export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeViewProps) {
|
||||
const { addTab } = useTabStore()
|
||||
const sessions = useSessionStore((s) => s.sessions)
|
||||
const recentSessions = sessions.slice(-6).reverse()
|
||||
|
||||
const handleSessionClick = (session: (typeof sessions)[number]) => {
|
||||
addTab({
|
||||
title: session.name || session.host,
|
||||
type: 'terminal',
|
||||
sessionId: session.id,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center bg-dark-900 overflow-y-auto">
|
||||
<div className="w-full max-w-3xl px-6 py-10 sm:py-12">
|
||||
{/* Hero — 短文案 */}
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-accent-blue/30 to-accent-purple/20 border border-dark-600/60 flex items-center justify-center shadow-lg shadow-black/20 flex-shrink-0">
|
||||
<Terminal className="w-7 h-7 text-content-primary" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold text-content-primary tracking-tight">OpsXShell</h1>
|
||||
<p className="text-sm text-txt-muted mt-0.5">SSH / SFTP 跨平台运维工作台</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 主路径:快速连接 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onQuickConnect?.()}
|
||||
className="
|
||||
group w-full flex items-center gap-4 p-5 mb-3 rounded-2xl text-left
|
||||
bg-accent-blue hover:bg-blue-500 border border-blue-400/30
|
||||
shadow-lg shadow-accent-blue/10 transition-all duration-150
|
||||
"
|
||||
>
|
||||
<div className="w-12 h-12 rounded-xl bg-white/15 flex items-center justify-center flex-shrink-0">
|
||||
<Zap className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-semibold text-white text-base">快速连接</div>
|
||||
<div className="text-sm text-blue-100/80 mt-0.5">粘贴 user@host:port,一步连上</div>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-white/70 group-hover:text-white group-hover:translate-x-0.5 transition-all flex-shrink-0" />
|
||||
</button>
|
||||
|
||||
{/* 次路径:保存常用会话 */}
|
||||
<div className="mb-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNewSession?.()}
|
||||
className="
|
||||
group flex items-center gap-3 p-4 rounded-2xl text-left
|
||||
bg-dark-800/70 border border-dark-700/50 hover:border-accent-green/40
|
||||
hover:bg-dark-800 transition-all duration-150
|
||||
"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-xl bg-accent-green/15 border border-accent-green/20 flex items-center justify-center flex-shrink-0 group-hover:bg-accent-green/25">
|
||||
<Plus className="w-5 h-5 text-accent-green" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-content-primary text-sm">新建会话</div>
|
||||
<div className="text-xs text-txt-muted mt-0.5">保存后侧栏双击即开终端</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
{/* 本地 Shell — 降级为次要入口 */}
|
||||
<div className="mb-10 opacity-90">
|
||||
<LocalShellMenu />
|
||||
</div>
|
||||
|
||||
{/* 最近会话 */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-3.5 h-3.5 text-txt-faint" />
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-txt-faint">最近会话</h3>
|
||||
</div>
|
||||
{recentSessions.length > 0 && (
|
||||
<span className="text-[11px] text-txt-faint">{recentSessions.length} 个</span>
|
||||
)}
|
||||
</div>
|
||||
{recentSessions.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-dark-600/60 bg-dark-800/30 p-6">
|
||||
<EmptyState icon={Server} title="暂无会话" description="创建会话或快速连接开始运维" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dark-700/50 bg-dark-800/40 overflow-hidden divide-y divide-dark-700/40">
|
||||
{recentSessions.map((session) => (
|
||||
<button
|
||||
key={session.id}
|
||||
type="button"
|
||||
onClick={() => handleSessionClick(session)}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-dark-700/40 transition-colors text-left group"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg bg-dark-700 flex items-center justify-center flex-shrink-0 group-hover:bg-accent-blue/20">
|
||||
<Server className="w-4 h-4 text-accent-blue" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-content-primary truncate">
|
||||
{session.name}
|
||||
</div>
|
||||
<div className="text-xs text-txt-faint font-mono truncate">
|
||||
{session.username}@{session.host}:{session.port || 22}
|
||||
</div>
|
||||
</div>
|
||||
{session.group && (
|
||||
<span className="ui-chip flex-shrink-0">{session.group}</span>
|
||||
)}
|
||||
<ChevronRight className="w-4 h-4 text-txt-faint opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Sessions — 会话管理相关 UI
|
||||
*/
|
||||
export { default as SessionDialog } from './SessionDialog'
|
||||
export { default as QuickConnectDialog } from './QuickConnectDialog'
|
||||
export { default as WelcomeView } from './WelcomeView'
|
||||
export { default as QuickLauncher } from './QuickLauncher'
|
||||
Reference in new issue
Block a user