Please provide the specific file changes or a description of the modifications you have made so I can generate the commit message for you.

This commit is contained in:
liumangmang
2026-05-07 10:09:40 +08:00
parent 165cc0e35b
commit f24d0f69ed
19 changed files with 1757 additions and 367 deletions
+28 -93
View File
@@ -1,103 +1,38 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="SSH Manager">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="100%" height="100%">
<!-- 背景层:深色圆角矩形与发光效果 -->
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#07152f" />
<stop offset="100%" stop-color="#0a1d3f" />
<linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#0f172a" />
<stop offset="100%" stop-color="#020617" />
</linearGradient>
<linearGradient id="neon" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#3cc8ff" />
<stop offset="55%" stop-color="#6ce7ff" />
<stop offset="100%" stop-color="#7fffd4" />
</linearGradient>
<linearGradient id="panel" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#17345f" />
<stop offset="100%" stop-color="#112748" />
</linearGradient>
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="5" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="15" result="blur" />
<feComposite in="SourceGraphic" in2="blur" operator="over" />
</filter>
<filter id="softGlow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="2.4" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<style>
.trace { fill: none; stroke: #143b6f; stroke-linecap: round; stroke-width: 2.2; opacity: 0.75; }
.trace2 { fill: none; stroke: #1a4f88; stroke-linecap: round; stroke-width: 1.6; opacity: 0.6; }
.dot { fill: #1f78b4; opacity: 0.85; }
.rack { fill: #17345f; stroke: url(#neon); stroke-width: 2.2; }
.lightG { fill: #88ff96; }
.lightY { fill: #ffd96b; }
.lightR { fill: #ff7686; }
</style>
<linearGradient id="primaryGlow" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#3b82f6" stop-opacity="0.6" />
<stop offset="100%" stop-color="#10b981" stop-opacity="0.3" />
</linearGradient>
</defs>
<rect x="18" y="18" width="220" height="220" rx="34" fill="url(#bg)" />
<rect x="18" y="18" width="220" height="220" rx="34" fill="none" stroke="#3cc8ff" stroke-width="4" filter="url(#glow)" />
<!-- 外部圆角底框 -->
<rect x="32" y="32" width="448" height="448" rx="100" ry="100" fill="url(#bgGradient)" stroke="#1e293b" stroke-width="8" />
<path class="trace" d="M36 62h28v-18h18v34h20" />
<path class="trace" d="M36 96h42v18h22v-16h24" />
<path class="trace" d="M36 160h24v22h34v-18h18" />
<path class="trace" d="M36 196h54v-26h20" />
<path class="trace" d="M220 58h-24v-14h-22v36h-14" />
<path class="trace" d="M220 98h-38v14h-20v-18h-18" />
<path class="trace" d="M220 156h-28v24h-36v-20h-16" />
<path class="trace" d="M220 194h-42v-18h-24" />
<circle class="dot" cx="64" cy="44" r="3" />
<circle class="dot" cx="78" cy="114" r="3" />
<circle class="dot" cx="60" cy="182" r="3" />
<circle class="dot" cx="196" cy="44" r="3" />
<circle class="dot" cx="182" cy="112" r="3" />
<circle class="dot" cx="194" cy="180" r="3" />
<!-- 内部光晕点缀 -->
<circle cx="256" cy="256" r="180" fill="url(#primaryGlow)" filter="url(#glow)" />
<rect x="64" y="64" width="384" height="384" rx="80" ry="80" fill="#0f172a" opacity="0.85" />
<g filter="url(#softGlow)">
<rect x="72" y="74" width="114" height="96" rx="12" fill="url(#panel)" stroke="#8af3ff" stroke-width="3" />
<rect x="82" y="86" width="94" height="72" rx="10" fill="#101c35" stroke="#53d9ff" stroke-opacity="0.7" />
<circle cx="90" cy="80" r="3.4" class="lightR" />
<circle cx="98" cy="80" r="3.4" class="lightY" />
<circle cx="106" cy="80" r="3.4" class="lightG" />
<text x="94" y="103" font-size="8.5" fill="#baf7ff" font-family="IBM Plex Mono, monospace">$ ssh -i keys/mgmt.pem</text>
<text x="94" y="114" font-size="7.8" fill="#8bcfe2" font-family="IBM Plex Mono, monospace">admin@prod-1.net</text>
<text x="92" y="132" font-size="34" fill="#b4ffff" font-family="IBM Plex Sans, sans-serif" font-weight="700">$</text>
<rect x="108" y="125" width="24" height="6" rx="3" fill="url(#neon)" />
<!-- 终端符号: >_ -->
<g transform="translate(130, 160)" stroke-linecap="round" stroke-linejoin="round">
<!-- 箭头 > -->
<path d="M 20 20 L 120 90 L 20 160" fill="none" stroke="#3b82f6" stroke-width="40" />
<!-- 下划线 _ -->
<line x1="140" y1="180" x2="240" y2="180" stroke="#10b981" stroke-width="36" />
</g>
<g transform="translate(127 148)" filter="url(#glow)">
<circle r="47" fill="rgba(10,29,63,0.5)" stroke="#5ce8ff" stroke-width="1.8" />
<circle r="36" fill="none" stroke="#2fc7ff" stroke-width="1.4" stroke-opacity="0.5" />
<circle cx="-45" cy="0" r="3.4" fill="#55efff" />
<circle cx="45" cy="0" r="3.4" fill="#55efff" />
<circle cx="0" cy="-45" r="3.4" fill="#55efff" />
<circle cx="0" cy="45" r="3.4" fill="#55efff" />
<rect class="rack" x="-27" y="-24" width="22" height="10" rx="3" />
<rect class="rack" x="-27" y="-10" width="22" height="10" rx="3" />
<rect class="rack" x="-27" y="4" width="22" height="10" rx="3" />
<rect class="rack" x="-27" y="18" width="22" height="10" rx="3" />
<rect class="rack" x="5" y="-24" width="22" height="10" rx="3" />
<rect class="rack" x="5" y="-10" width="22" height="10" rx="3" />
<rect class="rack" x="5" y="4" width="22" height="10" rx="3" />
<rect class="rack" x="5" y="18" width="22" height="10" rx="3" />
<circle cx="-10" cy="-19" r="1.6" class="lightG" />
<circle cx="-14" cy="-5" r="1.6" class="lightY" />
<circle cx="-8" cy="9" r="1.6" class="lightG" />
<circle cx="-12" cy="23" r="1.6" class="lightR" />
<circle cx="22" cy="-19" r="1.6" class="lightY" />
<circle cx="18" cy="-5" r="1.6" class="lightG" />
<circle cx="24" cy="9" r="1.6" class="lightY" />
<circle cx="20" cy="23" r="1.6" class="lightG" />
<path class="trace2" d="M-16 -14v-10c0-7 5-12 12-12h8c7 0 12 5 12 12v10" />
<path class="trace2" d="M-16 18v10c0 7 5 12 12 12h8c7 0 12-5 12-12v-10" />
<path class="trace2" d="M-5 -14v28" />
<path class="trace2" d="M5 -14v28" />
<rect x="-6" y="32" width="12" height="8" rx="2.2" fill="#17345f" stroke="#79f7ff" stroke-width="1.4" />
</g>
<!-- 顶部状态指示灯 (红黄绿) -->
<circle cx="110" cy="110" r="12" fill="#ef4444" />
<circle cx="150" cy="110" r="12" fill="#eab308" />
<circle cx="190" cy="110" r="12" fill="#10b981" />
</svg>

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

+79 -10
View File
@@ -1,16 +1,33 @@
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { CheckCircle2, Clock, Command, Copy, Play, RefreshCw, Server, Terminal, XCircle } from 'lucide-react'
import Modal from './Modal'
import { executeBatchCommand } from '../services/connections'
import type { BatchCommandResult, Connection } from '../types'
import type { BatchCommandResult, Connection, ConnectionReachabilityStatus, ConnectionStatusItem } from '../types'
const reachabilityCopy: Record<ConnectionReachabilityStatus, { dot: string; label: string; text: string }> = {
unknown: { dot: 'bg-slate-500', label: '未检测', text: 'text-slate-500' },
checking: { dot: 'bg-amber-400', label: '检测中', text: 'text-amber-300' },
online: { dot: 'bg-emerald-500 shadow-[0_0_4px_rgba(16,185,129,0.5)]', label: '在线', text: 'text-emerald-500/80' },
offline: { dot: 'bg-slate-500', label: '离线', text: 'text-slate-500' },
}
export default function BatchCommandModal({
open,
connections,
connectionStatuses,
connectionStatusDetails,
onRefreshStatuses,
statusError,
statusLoading,
onClose,
}: {
open: boolean
connections: Connection[]
connectionStatuses: Record<number, ConnectionReachabilityStatus>
connectionStatusDetails: Record<number, ConnectionStatusItem>
onRefreshStatuses: () => Promise<void>
statusError: string | null
statusLoading: boolean
onClose: () => void
}) {
const [selectedIds, setSelectedIds] = useState<number[]>(() => connections.slice(0, 2).map((item) => item.id))
@@ -28,14 +45,35 @@ export default function BatchCommandModal({
[results, selectedIds.length],
)
const onlineIds = useMemo(
() => connections.filter((item) => connectionStatuses[item.id] === 'online').map((item) => item.id),
[connectionStatuses, connections],
)
const offlineCount = useMemo(
() => connections.filter((item) => connectionStatuses[item.id] === 'offline').length,
[connectionStatuses, connections],
)
useEffect(() => {
setSelectedIds((prev) =>
prev.filter(
(id) =>
connections.some((item) => item.id === id) &&
connectionStatuses[id] === 'online',
),
)
}, [connectionStatuses, connections])
if (!open) return null
async function handleRun() {
if (!selectedIds.length || !command.trim()) return
const runnableIds = selectedIds.filter((id) => connectionStatuses[id] === 'online')
if (!runnableIds.length || !command.trim()) return
setRunning(true)
setError(null)
try {
const response = await executeBatchCommand(selectedIds, command)
const response = await executeBatchCommand(runnableIds, command)
setResults(response.data.results)
} catch (err) {
const message =
@@ -53,21 +91,45 @@ export default function BatchCommandModal({
<div className="flex items-center justify-between border-b border-slate-800 px-4 py-3 text-sm text-slate-300">
<span> ({selectedIds.length})</span>
<div className="flex gap-3 text-xs">
<button className="text-blue-400" onClick={() => setSelectedIds(connections.map((item) => item.id))}>
<button className="text-blue-400 disabled:text-slate-600" disabled={statusLoading} onClick={() => setSelectedIds(onlineIds)}>
</button>
<button className="text-slate-400" onClick={() => setSelectedIds([])}>
</button>
<button
className="flex items-center gap-1 text-slate-400 transition hover:text-slate-200 disabled:text-slate-600"
disabled={statusLoading || connections.length === 0}
onClick={() => {
void onRefreshStatuses().catch(() => undefined)
}}
>
<RefreshCw size={12} className={statusLoading ? 'animate-spin' : ''} />
</button>
</div>
</div>
<div className="border-b border-slate-800 px-4 py-2 text-xs text-slate-500">
线 {onlineIds.length} / 线 {offlineCount} / {connections.length}
</div>
<div className="flex-1 space-y-1 overflow-auto p-2">
{connections.map((connection) => {
const checked = selectedIds.includes(connection.id)
const reachability = connectionStatuses[connection.id] ?? 'unknown'
const reachabilityMeta = reachabilityCopy[reachability]
const statusDetail = connectionStatusDetails[connection.id]
const disabled = reachability !== 'online'
return (
<label key={connection.id} className="flex cursor-pointer items-center gap-2 rounded-xl px-3 py-2 transition hover:bg-slate-800">
<label
key={connection.id}
title={statusDetail?.message || reachabilityMeta.label}
className={`flex items-center gap-2 rounded-xl px-3 py-2 transition ${
disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer hover:bg-slate-800'
}`}
>
<input
type="checkbox"
disabled={disabled}
checked={checked}
onChange={() =>
setSelectedIds((prev) =>
@@ -75,8 +137,14 @@ export default function BatchCommandModal({
)
}
/>
<Server size={14} className={checked ? 'text-blue-400' : 'text-slate-500'} />
<span className="truncate text-sm text-slate-300">{connection.name}</span>
<div className="relative shrink-0">
<Server size={14} className={checked && !disabled ? 'text-blue-400' : 'text-slate-500'} />
<span className={`absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full border border-slate-900 ${reachabilityMeta.dot}`} />
</div>
<div className="min-w-0">
<div className="truncate text-sm text-slate-300">{connection.name}</div>
<div className={`text-[10px] ${reachabilityMeta.text}`}>{reachabilityMeta.label}</div>
</div>
</label>
)
})}
@@ -100,7 +168,7 @@ export default function BatchCommandModal({
</div>
<button
className="flex h-[46px] items-center gap-2 rounded-xl bg-blue-600 px-6 text-white transition hover:bg-blue-500 disabled:opacity-60"
disabled={running || selectedIds.length === 0}
disabled={running || statusLoading || selectedIds.filter((id) => connectionStatuses[id] === 'online').length === 0}
onClick={() => void handleRun()}
>
{running ? <RefreshCw size={16} className="animate-spin" /> : <Play size={16} fill="currentColor" />}
@@ -116,11 +184,12 @@ export default function BatchCommandModal({
</div>
<div className="flex-1 space-y-4 overflow-auto p-4">
{statusError ? <div className="rounded-xl border border-amber-500/20 bg-amber-500/10 px-4 py-3 text-sm text-amber-200">{statusError}</div> : null}
{error ? <div className="rounded-xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-300">{error}</div> : null}
{!results.length && !error ? (
<div className="flex h-full flex-col items-center justify-center text-slate-500">
<Command size={48} className="mb-3 text-slate-700" />
<p></p>
<p>{statusLoading ? '正在检测主机状态...' : '请在上方输入命令并点击执行'}</p>
</div>
) : null}
{results.map((result) => (
+74 -22
View File
@@ -1,5 +1,10 @@
import { useEffect, useState } from 'react'
import type { Connection, ConnectionCreateRequest } from '../types'
import type {
Connection,
ConnectionCreateRequest,
ConnectionModalSubmitPayload,
SessionTreeFolderOption,
} from '../types'
import Modal from './Modal'
const emptyForm: ConnectionCreateRequest = {
@@ -18,15 +23,20 @@ const emptyForm: ConnectionCreateRequest = {
export default function ConnectionModal({
open,
connection,
folderOptions,
initialTargetFolderId,
onClose,
onSubmit,
}: {
open: boolean
connection?: Connection | null
folderOptions: SessionTreeFolderOption[]
initialTargetFolderId: string | null
onClose: () => void
onSubmit: (payload: ConnectionCreateRequest) => Promise<void>
onSubmit: (payload: ConnectionModalSubmitPayload) => Promise<void>
}) {
const [form, setForm] = useState<ConnectionCreateRequest>(emptyForm)
const [targetFolderId, setTargetFolderId] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
@@ -48,8 +58,9 @@ export default function ConnectionModal({
} else {
setForm(emptyForm)
}
setTargetFolderId(initialTargetFolderId)
setError(null)
}, [connection, open])
}, [connection, initialTargetFolderId, open])
if (!open) return null
@@ -63,8 +74,8 @@ export default function ConnectionModal({
await onSubmit({
...form,
port: Number(form.port || 22),
targetFolderId,
})
onClose()
} catch (err) {
const message =
(err as { response?: { data?: { message?: string; error?: string } } }).response?.data?.message || '保存连接失败'
@@ -97,38 +108,79 @@ export default function ConnectionModal({
<div className="grid gap-4 md:grid-cols-2">
<label className="space-y-2">
<span className="text-sm text-slate-300"></span>
<input className="w-full rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-white" value={form.name} onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))} />
<input
className="w-full rounded-2xl border border-slate-700 bg-slate-950 px-4 py-3 text-white outline-none focus:border-blue-500"
placeholder="例如:prod-web-01"
value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
/>
</label>
<label className="space-y-2">
<span className="text-sm text-slate-300"> IP</span>
<input className="w-full rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-white" value={form.host} onChange={(e) => setForm((prev) => ({ ...prev, host: e.target.value }))} />
</label>
<label className="space-y-2">
<span className="text-sm text-slate-300"></span>
<input type="number" className="w-full rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-white" value={form.port ?? 22} onChange={(e) => setForm((prev) => ({ ...prev, port: Number(e.target.value) }))} />
</label>
<label className="space-y-2">
<span className="text-sm text-slate-300"></span>
<input className="w-full rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-white" value={form.username} onChange={(e) => setForm((prev) => ({ ...prev, username: e.target.value }))} />
<span className="text-sm text-slate-300"> / </span>
<select
className="w-full rounded-2xl border border-slate-700 bg-slate-950 px-4 py-3 text-white outline-none focus:border-blue-500"
value={targetFolderId ?? '__ROOT__'}
onChange={(event) => setTargetFolderId(event.target.value === '__ROOT__' ? null : event.target.value)}
>
<option value="__ROOT__"></option>
{folderOptions.map((option) => (
<option key={option.id} value={option.id}>
{`${'— '.repeat(option.depth)}${option.name}`}
</option>
))}
</select>
</label>
</div>
<div className="mt-5 space-y-4 border-t border-slate-800 pt-5">
<div className="mt-4 grid gap-4 md:grid-cols-[minmax(0,1.4fr)_120px_minmax(0,1fr)]">
<label className="space-y-2">
<span className="text-sm text-slate-300"> IP</span>
<input
className="w-full rounded-2xl border border-slate-700 bg-slate-950 px-4 py-3 text-white outline-none focus:border-blue-500"
value={form.host}
onChange={(e) => setForm((prev) => ({ ...prev, host: e.target.value }))}
/>
</label>
<label className="space-y-2">
<span className="text-sm text-slate-300"></span>
<input
type="number"
className="w-full rounded-2xl border border-slate-700 bg-slate-950 px-4 py-3 text-white outline-none focus:border-blue-500"
value={form.port ?? 22}
onChange={(e) => setForm((prev) => ({ ...prev, port: Number(e.target.value) }))}
/>
</label>
<label className="space-y-2">
<span className="text-sm text-slate-300"></span>
<input
className="w-full rounded-2xl border border-slate-700 bg-slate-950 px-4 py-3 text-white outline-none focus:border-blue-500"
value={form.username}
onChange={(e) => setForm((prev) => ({ ...prev, username: e.target.value }))}
/>
</label>
</div>
<div className="mt-6 rounded-[28px] border border-slate-800 bg-slate-950/40 p-5">
<div className="mb-4">
<div className="text-sm font-medium text-slate-100"></div>
<div className="mt-1 text-xs text-slate-500"></div>
</div>
<div className="flex flex-wrap gap-3">
<button
className={`rounded-xl border px-4 py-2 text-sm ${isPassword ? 'border-blue-500 bg-blue-500/10 text-blue-300' : 'border-slate-700 bg-slate-900 text-slate-300'}`}
className={`rounded-2xl border px-4 py-2 text-sm ${isPassword ? 'border-blue-500 bg-blue-500/10 text-blue-300' : 'border-slate-700 bg-slate-900 text-slate-300'}`}
onClick={() => setForm((prev) => ({ ...prev, authType: 'PASSWORD', setupMode: prev.setupMode === 'PASSWORD_BOOTSTRAP' ? 'PASSWORD_BOOTSTRAP' : 'NONE' }))}
>
</button>
<button
className={`rounded-xl border px-4 py-2 text-sm ${form.authType === 'PRIVATE_KEY' ? 'border-blue-500 bg-blue-500/10 text-blue-300' : 'border-slate-700 bg-slate-900 text-slate-300'}`}
className={`rounded-2xl border px-4 py-2 text-sm ${form.authType === 'PRIVATE_KEY' ? 'border-blue-500 bg-blue-500/10 text-blue-300' : 'border-slate-700 bg-slate-900 text-slate-300'}`}
onClick={() => setForm((prev) => ({ ...prev, authType: 'PRIVATE_KEY', setupMode: 'NONE' }))}
>
</button>
<button
className={`rounded-xl border px-4 py-2 text-sm ${useBootstrap ? 'border-emerald-500 bg-emerald-500/10 text-emerald-300' : 'border-slate-700 bg-slate-900 text-slate-300'}`}
className={`rounded-2xl border px-4 py-2 text-sm ${useBootstrap ? 'border-emerald-500 bg-emerald-500/10 text-emerald-300' : 'border-slate-700 bg-slate-900 text-slate-300'}`}
onClick={() => setForm((prev) => ({ ...prev, authType: 'PASSWORD', setupMode: prev.setupMode === 'PASSWORD_BOOTSTRAP' ? 'NONE' : 'PASSWORD_BOOTSTRAP' }))}
>
@@ -140,7 +192,7 @@ export default function ConnectionModal({
<span className="text-sm text-slate-300">{useBootstrap ? '引导密码' : '密码'}</span>
<input
type="password"
className="w-full rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-white"
className="w-full rounded-2xl border border-slate-700 bg-slate-950 px-4 py-3 text-white outline-none focus:border-blue-500"
value={useBootstrap ? form.bootstrapPassword ?? '' : form.password ?? ''}
onChange={(e) =>
setForm((prev) =>
@@ -155,7 +207,7 @@ export default function ConnectionModal({
<span className="text-sm text-slate-300"></span>
<textarea
rows={6}
className="w-full rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 font-mono text-sm text-white"
className="w-full rounded-2xl border border-slate-700 bg-slate-950 px-4 py-3 font-mono text-sm text-white outline-none focus:border-blue-500"
value={form.privateKey ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, privateKey: e.target.value }))}
/>
@@ -164,7 +216,7 @@ export default function ConnectionModal({
<span className="text-sm text-slate-300"></span>
<input
type="password"
className="w-full rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-white"
className="w-full rounded-2xl border border-slate-700 bg-slate-950 px-4 py-3 text-white outline-none focus:border-blue-500"
value={form.passphrase ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, passphrase: e.target.value }))}
/>
+105
View File
@@ -0,0 +1,105 @@
import { useEffect, useState } from 'react'
import type { SessionTreeFolderOption } from '../types'
import Modal from './Modal'
export default function FolderModal({
open,
folderOptions,
initialParentId,
onClose,
onSubmit,
}: {
open: boolean
folderOptions: SessionTreeFolderOption[]
initialParentId: string | null
onClose: () => void
onSubmit: (payload: { name: string; parentId: string | null }) => Promise<void>
}) {
const [name, setName] = useState('')
const [parentId, setParentId] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!open) return
setName('')
setParentId(initialParentId)
setSubmitting(false)
setError(null)
}, [initialParentId, open])
if (!open) return null
async function handleSave() {
const trimmedName = name.trim()
if (!trimmedName) {
setError('请输入文件夹名称')
return
}
setSubmitting(true)
setError(null)
try {
await onSubmit({ name: trimmedName, parentId })
onClose()
} catch (err) {
const message =
(err as { response?: { data?: { message?: string; error?: string } } }).response?.data?.message || '创建文件夹失败'
setError(message)
} finally {
setSubmitting(false)
}
}
return (
<Modal
title="新建文件夹"
onClose={onClose}
maxWidth="max-w-lg"
footer={
<>
<button className="rounded-xl bg-slate-700 px-4 py-2 text-sm text-slate-200 transition hover:bg-slate-600" onClick={onClose}>
</button>
<button
className="rounded-xl bg-blue-600 px-4 py-2 text-sm text-white transition hover:bg-blue-500 disabled:opacity-60"
disabled={submitting}
onClick={handleSave}
>
{submitting ? '创建中...' : '创建文件夹'}
</button>
</>
}
>
<div className="space-y-5">
<label className="block space-y-2">
<span className="text-sm text-slate-300"></span>
<input
className="w-full rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-white outline-none focus:border-blue-500"
placeholder="例如:生产环境"
value={name}
onChange={(event) => setName(event.target.value)}
/>
</label>
<label className="block space-y-2">
<span className="text-sm text-slate-300"></span>
<select
className="w-full rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-white outline-none focus:border-blue-500"
value={parentId ?? '__ROOT__'}
onChange={(event) => setParentId(event.target.value === '__ROOT__' ? null : event.target.value)}
>
<option value="__ROOT__"></option>
{folderOptions.map((option) => (
<option key={option.id} value={option.id}>
{`${'— '.repeat(option.depth)}${option.name}`}
</option>
))}
</select>
</label>
{error ? <div className="rounded-xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-300">{error}</div> : null}
</div>
</Modal>
)
}
+93 -11
View File
@@ -1,14 +1,34 @@
import type { MouseEvent } from 'react'
import { ChevronDown, ChevronRight, Folder, Server } from 'lucide-react'
import type { Connection } from '../types'
import type { Connection, ConnectionReachabilityStatus } from '../types'
import type { BuiltTreeNode } from '../lib/utils'
import { cn } from '../lib/utils'
interface SessionTreeContextMenuPayload {
nodeId: string
nodeType: 'folder' | 'connection'
x: number
y: number
}
interface SessionTreeProps {
nodes: BuiltTreeNode[]
activeConnectionId: number | null
connectionStatuses?: Record<number, ConnectionReachabilityStatus>
openConnectionIds: number[]
selectedNodeId: string | null
search: string
onSelectNode: (nodeId: string) => void
onToggleFolder: (nodeId: string) => void
onOpenConnection: (connection: Connection) => void
onOpenConnection: (connection: Connection, nodeId: string) => void
onContextMenu: (payload: SessionTreeContextMenuPayload) => void
}
const statusCopy: Record<ConnectionReachabilityStatus, { dot: string; label: string; text: string }> = {
unknown: { dot: 'bg-slate-500', label: '未检测', text: 'text-slate-500' },
checking: { dot: 'bg-amber-400', label: '检测中', text: 'text-amber-300' },
online: { dot: 'bg-emerald-500', label: '在线', text: 'text-emerald-400' },
offline: { dot: 'bg-red-500', label: '离线', text: 'text-red-400' },
}
function matches(node: BuiltTreeNode, term: string): boolean {
@@ -22,26 +42,55 @@ function TreeNode({
node,
depth,
activeConnectionId,
connectionStatuses,
openConnectionIds,
selectedNodeId,
search,
onSelectNode,
onToggleFolder,
onOpenConnection,
onContextMenu,
}: {
node: BuiltTreeNode
depth: number
activeConnectionId: number | null
connectionStatuses?: Record<number, ConnectionReachabilityStatus>
openConnectionIds: number[]
selectedNodeId: string | null
search: string
onSelectNode: (nodeId: string) => void
onToggleFolder: (nodeId: string) => void
onOpenConnection: (connection: Connection) => void
onOpenConnection: (connection: Connection, nodeId: string) => void
onContextMenu: (payload: SessionTreeContextMenuPayload) => void
}) {
function handleContextMenu(event: MouseEvent<HTMLButtonElement>, nodeType: 'folder' | 'connection') {
event.preventDefault()
event.stopPropagation()
onContextMenu({
nodeId: node.id,
nodeType,
x: event.clientX,
y: event.clientY,
})
}
if (!matches(node, search)) return null
if (node.type === 'folder') {
const expanded = node.expanded ?? true
const selected = selectedNodeId === node.id
return (
<div>
<button
className="flex w-full items-center gap-2 rounded-xl px-2 py-2 text-left text-sm text-slate-300 transition hover:bg-slate-800"
onClick={() => onToggleFolder(node.id)}
className={cn(
'flex w-full items-center gap-2 rounded-xl px-2 py-2 text-left text-sm transition',
selected ? 'bg-slate-800 text-slate-100 ring-1 ring-inset ring-slate-700' : 'text-slate-300 hover:bg-slate-800',
)}
onClick={() => {
onSelectNode(node.id)
onToggleFolder(node.id)
}}
onContextMenu={(event) => handleContextMenu(event, 'folder')}
style={{ paddingLeft: 8 + depth * 16 }}
>
{expanded ? <ChevronDown size={14} className="text-slate-500" /> : <ChevronRight size={14} className="text-slate-500" />}
@@ -56,9 +105,14 @@ function TreeNode({
node={child}
depth={depth + 1}
activeConnectionId={activeConnectionId}
connectionStatuses={connectionStatuses}
openConnectionIds={openConnectionIds}
selectedNodeId={selectedNodeId}
search={search}
onSelectNode={onSelectNode}
onToggleFolder={onToggleFolder}
onOpenConnection={onOpenConnection}
onContextMenu={onContextMenu}
/>
))}
</div>
@@ -69,20 +123,48 @@ function TreeNode({
if (!node.connection) return null
const selected = selectedNodeId === node.id
const active = activeConnectionId === node.connection.id
const opened = openConnectionIds.includes(node.connection.id)
const reachability = connectionStatuses?.[node.connection.id] ?? 'unknown'
const reachabilityMeta = statusCopy[reachability]
return (
<button
className={cn(
'group flex w-full items-center gap-2 rounded-xl px-2 py-2 text-left text-sm transition',
active ? 'bg-blue-600/20 text-blue-300' : 'text-slate-400 hover:bg-slate-800 hover:text-slate-100',
active
? 'bg-blue-600/20 text-blue-200 ring-1 ring-inset ring-blue-500/40'
: selected
? 'bg-slate-800 text-slate-100 ring-1 ring-inset ring-slate-700'
: opened
? 'text-emerald-200 hover:bg-slate-800'
: 'text-slate-400 hover:bg-slate-800 hover:text-slate-100',
)}
onDoubleClick={() => onOpenConnection(node.connection!)}
onClick={() => onOpenConnection(node.connection!)}
onDoubleClick={() => onOpenConnection(node.connection!, node.id)}
onClick={() => {
onSelectNode(node.id)
if (opened && !active) {
onOpenConnection(node.connection!, node.id)
}
}}
onContextMenu={(event) => handleContextMenu(event, 'connection')}
style={{ paddingLeft: 24 + depth * 16 }}
>
<Server size={14} className={active ? 'text-blue-300' : 'text-emerald-400'} />
<span className="flex-1 truncate">{node.connection.name}</span>
<span className="hidden rounded bg-slate-950 px-1.5 py-0.5 text-[10px] text-slate-500 group-hover:inline-block"></span>
<div className="relative shrink-0">
<Server size={14} className={active ? 'text-blue-300' : opened ? 'text-emerald-400' : 'text-slate-500'} />
<span className={cn('absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full border border-slate-900', reachabilityMeta.dot)} />
</div>
<div className="min-w-0 flex-1">
<div className="truncate">{node.connection.name}</div>
<div className={cn('text-[10px]', reachabilityMeta.text)}>{reachabilityMeta.label}</div>
</div>
{active ? (
<span className="rounded-full border border-blue-500/30 bg-blue-500/10 px-2 py-0.5 text-[10px] text-blue-200"></span>
) : opened ? (
<span className="rounded-full border border-emerald-500/20 bg-emerald-500/10 px-2 py-0.5 text-[10px] text-emerald-200"></span>
) : (
<span className="hidden rounded bg-slate-950 px-1.5 py-0.5 text-[10px] text-slate-500 group-hover:inline-block"></span>
)}
</button>
)
}
+60 -87
View File
@@ -1,28 +1,23 @@
import { useEffect, useRef, useState } from 'react'
import { FitAddon } from '@xterm/addon-fit'
import { Terminal } from 'xterm'
import { Activity, Folder, HardDrive, RefreshCw, Search, TerminalSquare } from 'lucide-react'
import 'xterm/css/xterm.css'
import { getMetrics } from '../services/monitor'
import { formatBytes } from '../lib/utils'
import type { Connection, MonitorMetrics, WorkspaceLayout } from '../types'
import type { Connection, TerminalConnectionStatus } from '../types'
const CONTROL_PREFIX = '__SSHMANAGER__:'
export default function TerminalPane({
connection,
active,
layout,
visible,
fontSize,
fontFamily,
onLayoutChange,
onStatusChange,
}: {
connection: Connection
active: boolean
layout: WorkspaceLayout
visible: boolean
fontSize: number
fontFamily: string
onLayoutChange: (layout: WorkspaceLayout) => void
onStatusChange?: (status: TerminalConnectionStatus) => void
}) {
const containerRef = useRef<HTMLDivElement | null>(null)
const termRef = useRef<Terminal | null>(null)
@@ -30,9 +25,28 @@ export default function TerminalPane({
const wsRef = useRef<WebSocket | null>(null)
const resizeObserverRef = useRef<ResizeObserver | null>(null)
const reconnectTimerRef = useRef<number | null>(null)
const monitorTimerRef = useRef<number | null>(null)
const [status, setStatus] = useState<'connecting' | 'connected' | 'reconnecting' | 'error'>('connecting')
const [metrics, setMetrics] = useState<MonitorMetrics>({})
const visibleRef = useRef(visible)
const syncViewportRef = useRef<() => void>(() => {})
const [status, setStatus] = useState<TerminalConnectionStatus>('connecting')
visibleRef.current = visible
syncViewportRef.current = () => {
if (!visibleRef.current) return
const term = termRef.current
if (!term) return
fitAddonRef.current?.fit()
const ws = wsRef.current
if (!ws || ws.readyState !== WebSocket.OPEN) return
ws.send(CONTROL_PREFIX + JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }))
}
useEffect(() => {
onStatusChange?.(status)
}, [onStatusChange, status])
useEffect(() => {
let disposed = false
@@ -52,8 +66,25 @@ export default function TerminalPane({
termRef.current = term
fitAddonRef.current = fit
term.open(containerRef.current!)
fit.fit()
term.focus()
if (visible) {
syncViewportRef.current()
term.focus()
}
const dataDisposable = term.onData((data) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(data)
}
})
const resizeDisposable = term.onResize(() => {
syncViewportRef.current()
})
resizeObserverRef.current = new ResizeObserver(() => {
syncViewportRef.current()
})
resizeObserverRef.current.observe(containerRef.current!)
const connect = () => {
const token = localStorage.getItem('token')
@@ -69,8 +100,7 @@ export default function TerminalPane({
wsRef.current = ws
ws.onopen = () => {
setStatus('connected')
fit.fit()
ws.send(CONTROL_PREFIX + JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }))
syncViewportRef.current()
}
ws.onmessage = (event) => {
term.write(typeof event.data === 'string' ? event.data : '')
@@ -83,95 +113,38 @@ export default function TerminalPane({
ws.onerror = () => {
setStatus('error')
}
const disposable = term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(data)
}
})
const resizeDisposable = term.onResize(({ cols, rows }) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(CONTROL_PREFIX + JSON.stringify({ type: 'resize', cols, rows }))
}
})
resizeObserverRef.current = new ResizeObserver(() => {
fit.fit()
})
resizeObserverRef.current.observe(containerRef.current!)
return () => {
disposable.dispose()
resizeDisposable.dispose()
}
}
const disposeTerminalEvents = connect()
connect()
return () => {
disposed = true
if (monitorTimerRef.current) window.clearInterval(monitorTimerRef.current)
if (reconnectTimerRef.current) window.clearTimeout(reconnectTimerRef.current)
resizeObserverRef.current?.disconnect()
wsRef.current?.close()
disposeTerminalEvents?.()
dataDisposable.dispose()
resizeDisposable.dispose()
term.dispose()
}
}, [connection.id, fontFamily, fontSize])
useEffect(() => {
if (!active) return
const fetchMetrics = async () => {
try {
const response = await getMetrics(connection.id)
setMetrics(response.data)
} catch {
// keep terminal usable even when monitor fails
}
}
void fetchMetrics()
monitorTimerRef.current = window.setInterval(fetchMetrics, 5000)
if (!visible) return
const frame = window.requestAnimationFrame(() => {
syncViewportRef.current()
termRef.current?.focus()
})
return () => {
if (monitorTimerRef.current) window.clearInterval(monitorTimerRef.current)
window.cancelAnimationFrame(frame)
}
}, [active, connection.id])
}, [visible])
return (
<div className="flex h-full flex-col overflow-hidden bg-slate-900">
<div className="flex h-10 items-center justify-between border-b border-slate-800 bg-slate-800/80 px-4 text-xs text-slate-400">
<div className="flex items-center gap-4">
<span className="flex items-center gap-1">
<Activity size={14} className="text-emerald-400" />
CPU: {metrics.cpuUsage ?? '-'}%
</span>
<span className="flex items-center gap-1">
<HardDrive size={14} className="text-blue-400" />
MEM: {formatBytes(metrics.memUsed ?? null)} / {formatBytes(metrics.memTotal ?? null)}
</span>
<span className="flex items-center gap-2 text-emerald-400">
<span className="h-2 w-2 rounded-full bg-emerald-400" />
{status === 'connected' ? 'WebSocket 已连接' : status === 'reconnecting' ? '连接中断,重试中' : '连接建立中'}
</span>
</div>
<div className="flex items-center gap-2">
<button className={`rounded p-1.5 ${layout === 'terminal' ? 'bg-slate-700 text-white' : 'text-slate-400'}`} onClick={() => onLayoutChange('terminal')}>
<TerminalSquare size={16} />
</button>
<button className={`rounded p-1.5 ${layout === 'sftp' ? 'bg-slate-700 text-white' : 'text-slate-400'}`} onClick={() => onLayoutChange('sftp')}>
<Folder size={16} />
</button>
<button className={`rounded p-1.5 ${layout === 'split' ? 'bg-slate-700 text-white' : 'text-slate-400'}`} onClick={() => onLayoutChange('split')}>
<Activity size={16} />
</button>
</div>
</div>
<div className="group relative flex-1 bg-black p-2 font-mono">
<div className="flex-1 bg-black p-2 font-mono">
<div ref={containerRef} className="h-full w-full overflow-hidden rounded-2xl border border-slate-900 bg-black" />
<div className="absolute right-6 top-4 flex rounded-xl border border-slate-700 bg-slate-900/80 opacity-0 transition group-hover:opacity-100">
<button className="p-2 text-slate-400 transition hover:text-white" onClick={() => termRef.current?.clear()}>
<RefreshCw size={14} />
</button>
<button className="p-2 text-slate-400 transition hover:text-white" onClick={() => termRef.current?.focus()}>
<Search size={14} />
</button>
</div>
</div>
</div>
)
+324 -58
View File
@@ -1,4 +1,9 @@
import type { Connection, SessionTreeLayoutPayload, SessionTreeNodePayload } from '../types'
import type {
Connection,
SessionTreeFolderOption,
SessionTreeLayoutPayload,
SessionTreeNodePayload,
} from '../types'
export interface BuiltTreeNode {
id: string
@@ -48,14 +53,63 @@ export function formatSftpPermissions(entry: { directory: boolean }) {
return entry.directory ? 'drwxr-xr-x' : '-rw-r--r--'
}
function sortBuiltNodes(items: BuiltTreeNode[]) {
items.sort((a, b) => a.order - b.order || a.name.localeCompare(b.name))
items.forEach((item) => sortBuiltNodes(item.children))
}
function sortConnections(connections: Connection[]) {
return connections.slice().sort((a, b) => a.name.localeCompare(b.name))
}
function generateNodeId(prefix: 'folder' | 'connection', suffix: string) {
return `${prefix}-${suffix}`
}
function createConnectionNode(
connection: Connection,
parentId: string | null,
order: number,
now: number,
): SessionTreeNodePayload {
return {
id: generateNodeId('connection', String(connection.id)),
type: 'connection',
name: connection.name,
parentId,
order,
connectionId: connection.id,
createdAt: now,
updatedAt: now,
}
}
function isFolderId(layout: SessionTreeLayoutPayload | null, nodeId: string | null) {
if (!nodeId || !layout) return false
return layout.nodes.some((node) => node.id === nodeId && node.type === 'folder')
}
function normalizeParentId(layout: SessionTreeLayoutPayload | null, parentId: string | null) {
return isFolderId(layout, parentId) ? parentId : null
}
function nextSiblingOrder(nodes: SessionTreeNodePayload[], parentId: string | null, excludeNodeId?: string) {
const siblingOrders = nodes
.filter((node) => node.id !== excludeNodeId && (node.parentId ?? null) === parentId)
.map((node) => node.order)
return (siblingOrders.length ? Math.max(...siblingOrders) : -1) + 1
}
function findConnectionNode(layout: SessionTreeLayoutPayload | null, connectionId: number) {
return layout?.nodes.find((node) => node.type === 'connection' && node.connectionId === connectionId) ?? null
}
export function buildSessionTree(layout: SessionTreeLayoutPayload | null, connections: Connection[]): BuiltTreeNode[] {
const connectionMap = new Map(connections.map((item) => [item.id, item]))
const nodes = layout?.nodes ?? []
if (nodes.length === 0) {
return connections
.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.map((connection, index) => ({
return sortConnections(connections).map((connection, index) => ({
id: `connection-${connection.id}`,
type: 'connection',
name: connection.name,
@@ -71,7 +125,7 @@ export function buildSessionTree(layout: SessionTreeLayoutPayload | null, connec
built.set(node.id, {
id: node.id,
type: node.type,
name: node.name,
name: node.type === 'connection' && node.connectionId ? connectionMap.get(node.connectionId)?.name ?? node.name : node.name,
order: node.order,
parentId: node.parentId,
expanded: node.expanded,
@@ -92,24 +146,75 @@ export function buildSessionTree(layout: SessionTreeLayoutPayload | null, connec
}
}
const sortNodes = (items: BuiltTreeNode[]) => {
items.sort((a, b) => a.order - b.order || a.name.localeCompare(b.name))
items.forEach((item) => sortNodes(item.children))
}
sortNodes(roots)
sortBuiltNodes(roots)
return roots
}
export function collectConnectionIds(nodes: BuiltTreeNode[]) {
const ids: number[] = []
const walk = (list: BuiltTreeNode[]) => {
list.forEach((node) => {
if (node.type === 'connection' && node.connection) ids.push(node.connection.id)
if (node.children.length > 0) walk(node.children)
})
export function createInitialSessionTreeLayout(connections: Connection[]): SessionTreeLayoutPayload {
const now = Date.now()
return {
nodes: sortConnections(connections).map((connection, index) => createConnectionNode(connection, null, index, now)),
sortMode: 'manual',
}
}
export function syncSessionTreeLayout(
layout: SessionTreeLayoutPayload | null,
connections: Connection[],
): SessionTreeLayoutPayload {
if (!layout) {
return createInitialSessionTreeLayout(connections)
}
const connectionMap = new Map(connections.map((connection) => [connection.id, connection]))
const seenConnectionIds = new Set<number>()
const retainedNodes: SessionTreeNodePayload[] = []
for (const node of layout.nodes) {
if (node.type === 'folder') {
retainedNodes.push(node)
continue
}
if (!node.connectionId || seenConnectionIds.has(node.connectionId)) {
continue
}
const connection = connectionMap.get(node.connectionId)
if (!connection) {
continue
}
retainedNodes.push({
...node,
name: connection.name,
})
seenConnectionIds.add(node.connectionId)
}
const normalizedLayout: SessionTreeLayoutPayload = {
nodes: retainedNodes.map((node) => ({
...node,
parentId: node.parentId && isFolderId({ nodes: retainedNodes }, node.parentId) ? node.parentId : null,
})),
sortMode: layout.sortMode ?? 'manual',
}
const missingConnections = sortConnections(connections).filter((connection) => !seenConnectionIds.has(connection.id))
if (missingConnections.length === 0) {
return normalizedLayout
}
const now = Date.now()
const nextNodes = normalizedLayout.nodes.slice()
missingConnections.forEach((connection) => {
nextNodes.push(createConnectionNode(connection, null, nextSiblingOrder(nextNodes, null), now))
})
return {
...normalizedLayout,
nodes: nextNodes,
}
walk(nodes)
return ids
}
export function updateExpandedState(layout: SessionTreeLayoutPayload | null, nodeId: string): SessionTreeLayoutPayload | null {
@@ -124,45 +229,206 @@ export function updateExpandedState(layout: SessionTreeLayoutPayload | null, nod
}
}
export function buildDefaultTreeLayout(connections: Connection[]): SessionTreeLayoutPayload {
export function updateAllFoldersExpandedState(
layout: SessionTreeLayoutPayload | null,
expanded: boolean,
): SessionTreeLayoutPayload | null {
if (!layout) return layout
const now = Date.now()
const groups = new Map<string, Connection[]>()
connections.forEach((connection) => {
const key = connection.name.includes('-') ? connection.name.split('-')[0] : '默认分组'
const group = groups.get(key) ?? []
group.push(connection)
groups.set(key, group)
return {
...layout,
nodes: layout.nodes.map((node) =>
node.type === 'folder'
? {
...node,
expanded,
updatedAt: now,
}
: node,
),
}
}
export function listSessionFolderOptions(
layout: SessionTreeLayoutPayload | null,
connections: Connection[],
): SessionTreeFolderOption[] {
const options: SessionTreeFolderOption[] = []
const walk = (nodes: BuiltTreeNode[], depth: number) => {
nodes.forEach((node) => {
if (node.type !== 'folder') return
options.push({ id: node.id, name: node.name, depth })
walk(node.children, depth + 1)
})
}
walk(buildSessionTree(layout, connections), 0)
return options
}
export function resolveSuggestedFolderId(layout: SessionTreeLayoutPayload | null, selectedNodeId: string | null) {
if (!layout || !selectedNodeId) return null
const node = layout.nodes.find((item) => item.id === selectedNodeId)
if (!node) return null
if (node.type === 'folder') return node.id
return normalizeParentId(layout, node.parentId ?? null)
}
export function findConnectionNodeId(layout: SessionTreeLayoutPayload | null, connectionId: number) {
return findConnectionNode(layout, connectionId)?.id ?? null
}
export function findConnectionFolderId(layout: SessionTreeLayoutPayload | null, connectionId: number) {
return normalizeParentId(layout, findConnectionNode(layout, connectionId)?.parentId ?? null)
}
export function insertFolderNode(
layout: SessionTreeLayoutPayload,
folderName: string,
parentId: string | null,
): { layout: SessionTreeLayoutPayload; nodeId: string } {
const now = Date.now()
const nextParentId = normalizeParentId(layout, parentId)
const nodeId = generateNodeId('folder', globalThis.crypto?.randomUUID?.() ?? String(now))
const nextNode: SessionTreeNodePayload = {
id: nodeId,
type: 'folder',
name: folderName,
parentId: nextParentId,
order: nextSiblingOrder(layout.nodes, nextParentId),
expanded: true,
createdAt: now,
updatedAt: now,
}
return {
layout: {
...layout,
nodes: [...layout.nodes, nextNode],
sortMode: layout.sortMode ?? 'manual',
},
nodeId,
}
}
export function upsertConnectionNode(
layout: SessionTreeLayoutPayload,
connection: Connection,
parentId: string | null,
): SessionTreeLayoutPayload {
const now = Date.now()
const nextParentId = normalizeParentId(layout, parentId)
const existingNode = findConnectionNode(layout, connection.id)
if (!existingNode) {
return {
...layout,
nodes: [
...layout.nodes,
createConnectionNode(connection, nextParentId, nextSiblingOrder(layout.nodes, nextParentId), now),
],
sortMode: layout.sortMode ?? 'manual',
}
}
const nextOrder =
existingNode.parentId === nextParentId
? existingNode.order
: nextSiblingOrder(layout.nodes, nextParentId, existingNode.id)
return {
...layout,
nodes: layout.nodes.map((node) =>
node.id === existingNode.id
? {
...node,
name: connection.name,
parentId: nextParentId,
order: nextOrder,
updatedAt: now,
}
: node,
),
sortMode: layout.sortMode ?? 'manual',
}
}
export function renameSessionTreeNode(
layout: SessionTreeLayoutPayload | null,
nodeId: string,
name: string,
): SessionTreeLayoutPayload | null {
if (!layout) return layout
const nextName = name.trim()
if (!nextName) return layout
const now = Date.now()
return {
...layout,
nodes: layout.nodes.map((node) =>
node.id === nodeId
? {
...node,
name: nextName,
updatedAt: now,
}
: node,
),
sortMode: layout.sortMode ?? 'manual',
}
}
export function deleteSessionTreeNodeSubtree(
layout: SessionTreeLayoutPayload | null,
nodeId: string,
): {
layout: SessionTreeLayoutPayload
deletedConnectionIds: number[]
deletedNodeIds: string[]
} | null {
if (!layout) return null
const nodeExists = layout.nodes.some((node) => node.id === nodeId)
if (!nodeExists) {
return {
layout,
deletedConnectionIds: [],
deletedNodeIds: [],
}
}
const childIdsByParent = new Map<string, string[]>()
layout.nodes.forEach((node) => {
if (!node.parentId) return
const childIds = childIdsByParent.get(node.parentId) ?? []
childIds.push(node.id)
childIdsByParent.set(node.parentId, childIds)
})
const nodes: SessionTreeNodePayload[] = []
let order = 0
Array.from(groups.entries()).forEach(([groupName, list]) => {
const folderId = `folder-${groupName}-${order}`
nodes.push({
id: folderId,
type: 'folder',
name: groupName,
parentId: null,
order,
expanded: true,
createdAt: now,
updatedAt: now,
})
list
.sort((a, b) => a.name.localeCompare(b.name))
.forEach((connection, index) => {
nodes.push({
id: `connection-${connection.id}`,
type: 'connection',
name: connection.name,
parentId: folderId,
order: index,
connectionId: connection.id,
createdAt: now,
updatedAt: now,
})
})
order += 1
})
return { nodes, sortMode: 'manual' }
const deletedNodeIdSet = new Set<string>()
const stack = [nodeId]
while (stack.length > 0) {
const currentId = stack.pop()
if (!currentId || deletedNodeIdSet.has(currentId)) continue
deletedNodeIdSet.add(currentId)
const childIds = childIdsByParent.get(currentId) ?? []
childIds.forEach((childId) => stack.push(childId))
}
const deletedConnectionIds = layout.nodes.flatMap((node) =>
deletedNodeIdSet.has(node.id) && node.type === 'connection' && node.connectionId ? [node.connectionId] : [],
)
return {
layout: {
...layout,
nodes: layout.nodes.filter((node) => !deletedNodeIdSet.has(node.id)),
sortMode: layout.sortMode ?? 'manual',
},
deletedConnectionIds,
deletedNodeIds: Array.from(deletedNodeIdSet),
}
}
+732 -70
View File
@@ -1,29 +1,106 @@
import { startTransition, useEffect, useMemo, useState } from 'react'
import {
Activity,
Command,
FileUp,
Folder,
HardDrive,
ListTree,
LogOut,
Monitor,
Plus,
Search,
Settings,
SplitSquareHorizontal,
Terminal,
} from 'lucide-react'
import { useAuth } from '../context/AuthContext'
import { useLocalStorage } from '../hooks/useLocalStorage'
import { buildDefaultTreeLayout, buildSessionTree, collectConnectionIds, updateExpandedState } from '../lib/utils'
import { createConnection, listConnections, updateConnection } from '../services/connections'
import {
buildSessionTree,
cn,
createInitialSessionTreeLayout,
deleteSessionTreeNodeSubtree,
findConnectionFolderId,
findConnectionNodeId,
formatBytes,
insertFolderNode,
listSessionFolderOptions,
renameSessionTreeNode,
resolveSuggestedFolderId,
syncSessionTreeLayout,
updateAllFoldersExpandedState,
updateExpandedState,
upsertConnectionNode,
} from '../lib/utils'
import { checkConnectionStatuses, createConnection, deleteConnection, listConnections, updateConnection } from '../services/connections'
import { getMetrics } from '../services/monitor'
import { getSessionTree, saveSessionTree } from '../services/sessionTree'
import type { Connection, ConnectionCreateRequest, SessionTreeLayoutPayload, TransferTaskGroup, WorkspaceLayout, WorkspaceTab } from '../types'
import type {
Connection,
ConnectionModalSubmitPayload,
ConnectionReachabilityStatus,
ConnectionStatusItem,
MonitorMetrics,
SessionTreeLayoutPayload,
TerminalConnectionStatus,
TransferTaskGroup,
WorkspaceLayout,
WorkspaceTab,
} from '../types'
import BatchCommandModal from '../components/BatchCommandModal'
import ChangePasswordModal from '../components/ChangePasswordModal'
import ConnectionModal from '../components/ConnectionModal'
import FolderModal from '../components/FolderModal'
import SessionTree from '../components/SessionTree'
import SettingsModal from '../components/SettingsModal'
import SftpPane from '../components/SftpPane'
import TerminalPane from '../components/TerminalPane'
import TransferCenterModal from '../components/TransferCenterModal'
const terminalStatusCopy: Record<TerminalConnectionStatus, { label: string; tone: string; dot: string }> = {
idle: { label: '终端未打开', tone: 'text-slate-400', dot: 'bg-slate-500' },
connecting: { label: 'WebSocket 连接中', tone: 'text-amber-300', dot: 'bg-amber-400' },
connected: { label: 'WebSocket 已连接', tone: 'text-emerald-300', dot: 'bg-emerald-400' },
reconnecting: { label: '连接中断,重试中', tone: 'text-amber-300', dot: 'bg-amber-400' },
error: { label: 'WebSocket 连接异常', tone: 'text-red-300', dot: 'bg-red-400' },
}
type TreeContextMenuTargetType = 'folder' | 'connection'
type TreeContextMenuState = {
visible: boolean
x: number
y: number
targetId: string | null
targetType: TreeContextMenuTargetType | null
}
const closedTreeContextMenu: TreeContextMenuState = {
visible: false,
x: 0,
y: 0,
targetId: null,
targetType: null,
}
function getClampedContextMenuPosition(x: number, y: number, itemCount: number) {
const menuWidth = 176
const menuHeight = itemCount * 36 + 8
const viewportPadding = 8
return {
x: Math.max(viewportPadding, Math.min(x, window.innerWidth - menuWidth - viewportPadding)),
y: Math.max(viewportPadding, Math.min(y, window.innerHeight - menuHeight - viewportPadding)),
}
}
function omitConnectionIdsFromRecord<T>(record: Record<number, T>, deletedConnectionIds: Set<number>) {
return Object.fromEntries(
Object.entries(record).filter(([key]) => !deletedConnectionIds.has(Number(key))),
) as Record<number, T>
}
export default function WorkspacePage({
initialTool,
onLogout,
@@ -38,14 +115,24 @@ export default function WorkspacePage({
const [search, setSearch] = useState('')
const [tabs, setTabs] = useState<WorkspaceTab[]>([])
const [currentTabId, setCurrentTabId] = useState<number | null>(null)
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
const [selectedFiles, setSelectedFiles] = useState<string[]>([])
const [showConnectionModal, setShowConnectionModal] = useState(false)
const [showFolderModal, setShowFolderModal] = useState(false)
const [editingConnection, setEditingConnection] = useState<Connection | null>(null)
const [showBatchModal, setShowBatchModal] = useState(false)
const [showTransferModal, setShowTransferModal] = useState(initialTool === 'transfers')
const [showSettingsModal, setShowSettingsModal] = useState(false)
const [showChangePassword, setShowChangePassword] = useState<boolean>(!!user?.passwordChangeRequired)
const [transferTasks, setTransferTasks] = useState<TransferTaskGroup[]>([])
const [workspaceMetrics, setWorkspaceMetrics] = useState<MonitorMetrics>({})
const [connectionStatuses, setConnectionStatuses] = useState<Record<number, ConnectionReachabilityStatus>>({})
const [connectionStatusDetails, setConnectionStatusDetails] = useState<Record<number, ConnectionStatusItem>>({})
const [connectionStatusError, setConnectionStatusError] = useState<string | null>(null)
const [connectionStatusLoading, setConnectionStatusLoading] = useState(false)
const [terminalStatuses, setTerminalStatuses] = useState<Record<number, TerminalConnectionStatus>>({})
const [treeContextMenu, setTreeContextMenu] = useState<TreeContextMenuState>(closedTreeContextMenu)
const [tabContextMenu, setTabContextMenu] = useState({ visible: false, x: 0, y: 0 })
const [terminalFontSize, setTerminalFontSize] = useLocalStorage('ssh-manager.terminal-font-size', 14)
const [terminalFontFamily, setTerminalFontFamily] = useLocalStorage(
'ssh-manager.terminal-font-family',
@@ -57,72 +144,415 @@ export default function WorkspacePage({
}, [user?.passwordChangeRequired])
useEffect(() => {
const connectionIds = new Set(connections.map((connection) => connection.id))
setConnectionStatuses((prev) =>
Object.fromEntries(Object.entries(prev).filter(([key]) => connectionIds.has(Number(key)))) as Record<
number,
ConnectionReachabilityStatus
>,
)
setConnectionStatusDetails((prev) =>
Object.fromEntries(Object.entries(prev).filter(([key]) => connectionIds.has(Number(key)))) as Record<
number,
ConnectionStatusItem
>,
)
}, [connections])
useEffect(() => {
let cancelled = false
;(async () => {
const connectionsResponse = await listConnections()
setConnections(connectionsResponse.data)
if (cancelled) return
const nextConnections = connectionsResponse.data
setConnections(nextConnections)
try {
const treeResponse = await getSessionTree()
if (treeResponse.data.nodes.length) {
setTreeLayout(treeResponse.data)
} else {
const layout = buildDefaultTreeLayout(connectionsResponse.data)
setTreeLayout(layout)
await saveSessionTree(layout)
if (cancelled) return
const baseLayout = treeResponse.data.nodes.length
? treeResponse.data
: createInitialSessionTreeLayout(nextConnections)
const nextLayout = syncSessionTreeLayout(baseLayout, nextConnections)
setTreeLayout(nextLayout)
if (JSON.stringify(treeResponse.data) !== JSON.stringify(nextLayout)) {
await saveSessionTree(nextLayout)
}
} catch {
const layout = buildDefaultTreeLayout(connectionsResponse.data)
setTreeLayout(layout)
if (cancelled) return
setTreeLayout(createInitialSessionTreeLayout(nextConnections))
}
})()
return () => {
cancelled = true
}
}, [])
const treeNodes = useMemo(() => buildSessionTree(treeLayout, connections), [treeLayout, connections])
const folderOptions = useMemo(() => listSessionFolderOptions(treeLayout, connections), [treeLayout, connections])
const openConnectionIds = useMemo(() => tabs.map((tab) => tab.id), [tabs])
const activeConnection = tabs.find((tab) => tab.id === currentTabId)?.connection ?? null
const activeTerminalStatus = activeConnection ? terminalStatuses[activeConnection.id] ?? 'connecting' : 'idle'
const hasFolders = treeLayout?.nodes.some((node) => node.type === 'folder') ?? false
const hasCollapsedFolders = treeLayout?.nodes.some((node) => node.type === 'folder' && node.expanded === false) ?? false
const connectionModalFolderId = useMemo(
() =>
editingConnection
? findConnectionFolderId(treeLayout, editingConnection.id)
: resolveSuggestedFolderId(treeLayout, selectedNodeId),
[editingConnection, selectedNodeId, treeLayout],
)
function openConnection(connection: Connection) {
useEffect(() => {
if (!activeConnection) {
setWorkspaceMetrics({})
return
}
let cancelled = false
const fetchMetrics = async () => {
try {
const response = await getMetrics(activeConnection.id)
if (!cancelled) {
setWorkspaceMetrics(response.data)
}
} catch {
if (!cancelled) {
setWorkspaceMetrics({})
}
}
}
void fetchMetrics()
const timer = window.setInterval(fetchMetrics, 5000)
return () => {
cancelled = true
window.clearInterval(timer)
}
}, [activeConnection?.id])
useEffect(() => {
if (connections.length === 0) {
return
}
const hasCachedReachability = connections.some((connection) => {
const status = connectionStatuses[connection.id]
return status === 'online' || status === 'offline' || status === 'checking'
})
if (hasCachedReachability) {
return
}
void refreshConnectionStatuses({ connectionList: connections })
}, [connections])
function getCurrentTreeLayout(nextConnections = connections) {
return syncSessionTreeLayout(treeLayout ?? createInitialSessionTreeLayout(nextConnections), nextConnections)
}
async function refreshConnectionStatuses(options?: {
connectionList?: Connection[]
throwOnError?: boolean
}) {
const connectionList = options?.connectionList ?? connections
if (connectionList.length === 0) {
setConnectionStatuses({})
setConnectionStatusDetails({})
setConnectionStatusError(null)
return
}
setConnectionStatusLoading(true)
setConnectionStatusError(null)
const connectionIds = connectionList.map((connection) => connection.id)
setConnectionStatuses((prev) => ({
...prev,
...Object.fromEntries(connectionIds.map((id) => [id, 'checking' as ConnectionReachabilityStatus])),
}))
try {
const response = await checkConnectionStatuses(connectionIds)
const nextDetails = Object.fromEntries(
response.data.results.map((item) => [item.connectionId, item]),
) as Record<number, ConnectionStatusItem>
const nextStatuses = Object.fromEntries(
response.data.results.map((item) => [item.connectionId, item.status as ConnectionReachabilityStatus]),
) as Record<number, ConnectionReachabilityStatus>
setConnectionStatusDetails((prev) => ({ ...prev, ...nextDetails }))
setConnectionStatuses((prev) => ({ ...prev, ...nextStatuses }))
} catch (error) {
setConnectionStatusError(
(error as { response?: { data?: { message?: string; error?: string } } }).response?.data?.message ||
'主机状态检测失败',
)
setConnectionStatuses((prev) => ({
...prev,
...Object.fromEntries(connectionIds.map((id) => [id, prev[id] === 'online' || prev[id] === 'offline' ? prev[id] : 'unknown'])),
}))
if (options?.throwOnError) {
throw error
}
} finally {
setConnectionStatusLoading(false)
}
}
async function handleRefreshConnectionStatuses() {
await refreshConnectionStatuses({ throwOnError: true })
}
async function persistTreeLayout(nextLayout: SessionTreeLayoutPayload) {
setTreeLayout(nextLayout)
await saveSessionTree(nextLayout)
}
function findTreeNode(nodeId: string) {
return treeLayout?.nodes.find((node) => node.id === nodeId) ?? null
}
function closeTreeContextMenu() {
setTreeContextMenu(closedTreeContextMenu)
}
function openConnection(connection: Connection, nodeId?: string | null) {
startTransition(() => {
setTerminalStatuses((prev) => (prev[connection.id] ? prev : { ...prev, [connection.id]: 'connecting' }))
setTabs((prev) => {
if (prev.some((tab) => tab.id === connection.id)) return prev
const existing = prev.find((tab) => tab.id === connection.id)
if (existing) {
return prev.map((tab) =>
tab.id === connection.id ? { ...tab, name: connection.name, connection } : tab,
)
}
return [...prev, { id: connection.id, name: connection.name, connection }]
})
setCurrentTabId(connection.id)
setSelectedFiles([])
if (nodeId) {
setSelectedNodeId(nodeId)
}
})
}
async function handleToggleFolder(nodeId: string) {
const nextLayout = updateExpandedState(treeLayout, nodeId)
setTreeLayout(nextLayout)
if (nextLayout) {
await saveSessionTree(nextLayout)
}
if (!nextLayout) return
await persistTreeLayout(nextLayout)
}
async function handleSubmitConnection(payload: ConnectionCreateRequest) {
if (editingConnection) {
await updateConnection(editingConnection.id, payload)
} else {
await createConnection(payload)
async function handleToggleAllFolders() {
const baseLayout = getCurrentTreeLayout()
const hasAnyFolder = baseLayout.nodes.some((node) => node.type === 'folder')
if (!hasAnyFolder) return
const shouldExpand = baseLayout.nodes.some((node) => node.type === 'folder' && node.expanded === false)
const nextLayout = updateAllFoldersExpandedState(baseLayout, shouldExpand)
if (!nextLayout) return
await persistTreeLayout(nextLayout)
}
async function handleCreateFolder(payload: { name: string; parentId: string | null }) {
const baseLayout = getCurrentTreeLayout()
const { layout: nextLayout, nodeId } = insertFolderNode(baseLayout, payload.name, payload.parentId)
setSelectedNodeId(nodeId)
await persistTreeLayout(nextLayout)
}
function handleOpenTreeContextMenu(payload: { nodeId: string; nodeType: TreeContextMenuTargetType; x: number; y: number }) {
const position = getClampedContextMenuPosition(payload.x, payload.y, payload.nodeType === 'folder' ? 4 : 2)
setSelectedNodeId(payload.nodeId)
closeTabContextMenu()
setTreeContextMenu({
visible: true,
x: position.x,
y: position.y,
targetId: payload.nodeId,
targetType: payload.nodeType,
})
}
function isAncestorNode(layout: SessionTreeLayoutPayload, ancestorNodeId: string, nodeId: string) {
const nodesById = new Map(layout.nodes.map((node) => [node.id, node]))
let currentNode = nodesById.get(nodeId) ?? null
while (currentNode?.parentId) {
if (currentNode.parentId === ancestorNodeId) {
return true
}
currentNode = nodesById.get(currentNode.parentId) ?? null
}
const response = await listConnections()
setConnections(response.data)
if (!treeLayout || collectConnectionIds(buildSessionTree(treeLayout, response.data)).length !== response.data.length) {
const nextLayout = buildDefaultTreeLayout(response.data)
setTreeLayout(nextLayout)
await saveSessionTree(nextLayout)
return false
}
function clearSelectedNodeIfNeeded(layout: SessionTreeLayoutPayload, targetId: string, deletedNodeIds: string[]) {
const deletedNodeIdSet = new Set(deletedNodeIds)
setSelectedNodeId((current) => {
if (!current) return current
if (deletedNodeIdSet.has(current)) return null
return isAncestorNode(layout, current, targetId) ? null : current
})
}
function removeConnectionsFromWorkspace(connectionIds: number[]) {
if (connectionIds.length === 0) return
const deletedConnectionIds = new Set(connectionIds)
setConnections((prev) => prev.filter((connection) => !deletedConnectionIds.has(connection.id)))
setTabs((prev) => {
const next = prev.filter((tab) => !deletedConnectionIds.has(tab.id))
setCurrentTabId((current) =>
current != null && deletedConnectionIds.has(current) ? next[next.length - 1]?.id ?? null : current,
)
return next
})
setSelectedFiles([])
setConnectionStatuses((prev) => omitConnectionIdsFromRecord(prev, deletedConnectionIds))
setConnectionStatusDetails((prev) => omitConnectionIdsFromRecord(prev, deletedConnectionIds))
setTerminalStatuses((prev) => omitConnectionIdsFromRecord(prev, deletedConnectionIds))
}
async function handleEditTreeItem() {
const { targetId, targetType } = treeContextMenu
closeTreeContextMenu()
if (!targetId || !targetType) return
if (targetType === 'folder') {
const targetNode = findTreeNode(targetId)
if (!targetNode || targetNode.type !== 'folder') return
const nextName = window.prompt('重命名文件夹', targetNode.name)
const trimmedName = nextName?.trim()
if (!trimmedName || trimmedName === targetNode.name) return
const nextLayout = renameSessionTreeNode(getCurrentTreeLayout(), targetId, trimmedName)
if (!nextLayout) return
await persistTreeLayout(nextLayout)
return
}
const targetNode = findTreeNode(targetId)
if (targetNode?.type !== 'connection' || !targetNode.connectionId) return
const targetConnection = connections.find((connection) => connection.id === targetNode.connectionId) ?? null
if (!targetConnection) return
setEditingConnection(targetConnection)
setShowConnectionModal(true)
}
async function handleDeleteTreeItem() {
const { targetId, targetType } = treeContextMenu
closeTreeContextMenu()
if (!targetId || !targetType) return
const baseLayout = getCurrentTreeLayout()
const targetNode = baseLayout.nodes.find((node) => node.id === targetId)
if (!targetNode) return
const deletedSubtree = deleteSessionTreeNodeSubtree(baseLayout, targetId)
if (!deletedSubtree) return
if (targetType === 'connection') {
if (targetNode.type !== 'connection' || !targetNode.connectionId) return
const confirmed = window.confirm('确认永久删除该 SSH 连接?')
if (!confirmed) return
await deleteConnection(targetNode.connectionId)
await persistTreeLayout(deletedSubtree.layout)
removeConnectionsFromWorkspace([targetNode.connectionId])
clearSelectedNodeIfNeeded(baseLayout, targetId, deletedSubtree.deletedNodeIds)
return
}
const confirmed = window.confirm('确认删除该文件夹?将同时删除其下所有子文件夹与连接。')
if (!confirmed) return
if (targetNode.type !== 'folder') return
if (deletedSubtree.deletedConnectionIds.length > 0) {
await Promise.all(deletedSubtree.deletedConnectionIds.map((connectionId) => deleteConnection(connectionId)))
}
await persistTreeLayout(deletedSubtree.layout)
removeConnectionsFromWorkspace(deletedSubtree.deletedConnectionIds)
clearSelectedNodeIfNeeded(baseLayout, targetId, deletedSubtree.deletedNodeIds)
}
async function handleSubmitConnection(payload: ConnectionModalSubmitPayload) {
const connectionToEdit = editingConnection
const { targetFolderId, ...request } = payload
const connectionResponse = connectionToEdit ? await updateConnection(connectionToEdit.id, request) : await createConnection(request)
const savedConnection = connectionResponse.data
const connectionsResponse = await listConnections()
const nextConnections = connectionsResponse.data
const nextConnection = nextConnections.find((connection) => connection.id === savedConnection.id) ?? savedConnection
setConnections(nextConnections)
const nextLayout = upsertConnectionNode(getCurrentTreeLayout(nextConnections), nextConnection, targetFolderId)
const nodeId = findConnectionNodeId(nextLayout, nextConnection.id)
await persistTreeLayout(nextLayout)
setShowConnectionModal(false)
setEditingConnection(null)
if (connectionToEdit) {
setSelectedNodeId(nodeId)
setTabs((prev) =>
prev.map((tab) => (tab.id === nextConnection.id ? { ...tab, name: nextConnection.name, connection: nextConnection } : tab)),
)
setConnectionStatusDetails((prev) =>
prev[nextConnection.id]
? {
...prev,
[nextConnection.id]: {
...prev[nextConnection.id],
connectionName: nextConnection.name,
},
}
: prev,
)
return
}
openConnection(nextConnection, nodeId)
}
function handleCloseTab(id: number) {
setTabs((prev) => {
const next = prev.filter((tab) => tab.id !== id)
if (currentTabId === id) {
setCurrentTabId(next[next.length - 1]?.id ?? null)
}
setCurrentTabId((current) => (current === id ? next[next.length - 1]?.id ?? null : current))
return next
})
setTerminalStatuses((prev) => {
const next = { ...prev }
delete next[id]
return next
})
}
function closeTabContextMenu() {
setTabContextMenu({ visible: false, x: 0, y: 0 })
}
function closeAllTabs() {
setTabs([])
setCurrentTabId(null)
setTerminalStatuses({})
setSelectedFiles([])
closeTabContextMenu()
}
const statusMeta = terminalStatusCopy[activeTerminalStatus]
return (
<div className="flex h-screen flex-col overflow-hidden bg-slate-950 text-slate-100">
<header className="flex h-14 items-center justify-between border-b border-slate-800 bg-slate-900 px-4 shadow-sm">
@@ -135,15 +565,17 @@ export default function WorkspacePage({
</div>
<div className="h-6 w-px bg-slate-800" />
<nav className="flex items-center gap-1">
<button className="flex items-center gap-2 rounded-md px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white" onClick={() => setShowConnectionModal(true)}>
<Plus size={16} className="text-emerald-500" />
</button>
<button className="flex items-center gap-2 rounded-md px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white" onClick={() => setShowBatchModal(true)}>
<button
className="flex items-center gap-2 rounded-md px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white"
onClick={() => setShowBatchModal(true)}
>
<Command size={16} className="text-purple-400" />
</button>
<button className="flex items-center gap-2 rounded-md px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white" onClick={() => setShowTransferModal(true)}>
<button
className="flex items-center gap-2 rounded-md px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white"
onClick={() => setShowTransferModal(true)}
>
<FileUp size={16} className="text-blue-400" />
</button>
@@ -159,10 +591,15 @@ export default function WorkspacePage({
onChange={(event) => setSearch(event.target.value)}
/>
</div>
<button className="rounded-full p-2 text-slate-400 transition hover:bg-slate-800 hover:text-white" onClick={() => setShowSettingsModal(true)}>
<button
className="rounded-full p-2 text-slate-400 transition hover:bg-slate-800 hover:text-white"
onClick={() => setShowSettingsModal(true)}
>
<Settings size={18} />
</button>
<div className="rounded-full border border-slate-800 bg-slate-950 px-3 py-1.5 text-sm text-slate-400">{user?.displayName || user?.username}</div>
<div className="rounded-full border border-slate-800 bg-slate-950 px-3 py-1.5 text-sm text-slate-400">
{user?.displayName || user?.username}
</div>
<button
className="flex items-center gap-2 rounded-md px-2 py-1 text-sm text-slate-400 transition hover:text-red-400"
onClick={() => {
@@ -178,63 +615,209 @@ export default function WorkspacePage({
<div className="flex flex-1 overflow-hidden">
<aside className="flex w-72 shrink-0 flex-col border-r border-slate-800 bg-slate-900">
<div className="flex items-center justify-between border-b border-slate-800 px-4 py-3 text-sm">
<span className="font-medium text-slate-300"></span>
<button className="rounded-lg p-1 text-slate-400 transition hover:bg-slate-800" onClick={() => setShowConnectionModal(true)}>
<Plus size={14} />
</button>
<div className="border-b border-slate-800 px-4 py-3">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-medium text-slate-300"></div>
<div className="flex items-center gap-1">
<button
type="button"
className={cn(
'rounded-md p-1.5 text-slate-400 transition hover:bg-slate-800 hover:text-white',
hasFolders ? '' : 'cursor-not-allowed text-slate-600 hover:bg-transparent hover:text-slate-600',
)}
disabled={!hasFolders}
onClick={() => void handleToggleAllFolders()}
title={hasFolders ? (hasCollapsedFolders ? '全部展开' : '全部折叠') : '暂无文件夹'}
aria-label={hasFolders ? (hasCollapsedFolders ? '全部展开' : '全部折叠') : '暂无文件夹'}
>
<ListTree size={14} />
</button>
<button
type="button"
className="rounded-md p-1.5 text-slate-400 transition hover:bg-slate-800 hover:text-white"
onClick={() => {
closeTreeContextMenu()
setShowFolderModal(true)
}}
title="新建文件夹"
aria-label="新建文件夹"
>
<Folder size={14} />
</button>
<button
type="button"
className="rounded-md p-1.5 text-slate-400 transition hover:bg-slate-800 hover:text-white"
onClick={() => {
closeTreeContextMenu()
setEditingConnection(null)
setShowConnectionModal(true)
}}
title="新建连接"
aria-label="新建连接"
>
<Plus size={14} />
</button>
</div>
</div>
</div>
<div className="flex-1 overflow-auto p-2">
<SessionTree nodes={treeNodes} activeConnectionId={currentTabId} search={search} onToggleFolder={(nodeId) => void handleToggleFolder(nodeId)} onOpenConnection={openConnection} />
<div
className="flex-1 overflow-auto p-2"
onClick={(event) => {
if (event.target !== event.currentTarget) return
closeTreeContextMenu()
setSelectedNodeId(null)
}}
>
<SessionTree
nodes={treeNodes}
activeConnectionId={currentTabId}
connectionStatuses={connectionStatuses}
openConnectionIds={openConnectionIds}
selectedNodeId={selectedNodeId}
search={search}
onSelectNode={setSelectedNodeId}
onToggleFolder={(nodeId) => void handleToggleFolder(nodeId)}
onOpenConnection={openConnection}
onContextMenu={handleOpenTreeContextMenu}
/>
</div>
</aside>
<main className="flex flex-1 flex-col overflow-hidden bg-[#0d1117]">
<div className="flex border-b border-slate-800 bg-slate-900">
{tabs.length === 0 ? <div className="h-10" /> : null}
<div className="flex h-10 border-b border-slate-800 bg-slate-900">
{tabs.length === 0 ? <div className="h-full flex-1" /> : null}
{tabs.map((tab) => (
<button
key={tab.id}
className={`group flex h-10 min-w-[160px] max-w-[220px] items-center gap-2 border-r border-slate-800 px-4 text-sm ${currentTabId === tab.id ? 'border-t-2 border-t-blue-500 bg-[#0d1117] text-white' : 'text-slate-400 hover:bg-slate-800'}`}
className={cn(
'group flex h-full min-w-[160px] max-w-[220px] items-center gap-2 border-r border-slate-800 px-4 text-sm transition',
currentTabId === tab.id ? 'bg-[#0d1117] text-white' : 'text-slate-400 hover:bg-slate-800 hover:text-slate-200',
)}
onClick={() => setCurrentTabId(tab.id)}
onContextMenu={(event) => {
event.preventDefault()
closeTreeContextMenu()
setTabContextMenu({ visible: true, x: event.clientX, y: event.clientY })
}}
>
<Terminal size={14} className={currentTabId === tab.id ? 'text-emerald-400' : ''} />
<Terminal size={14} className={currentTabId === tab.id ? 'text-emerald-400' : 'text-slate-500'} />
<span className="flex-1 truncate">{tab.name}</span>
<span className="opacity-0 transition group-hover:opacity-100" onClick={(event) => { event.stopPropagation(); handleCloseTab(tab.id) }}>
<span
className="opacity-0 transition group-hover:opacity-100"
onClick={(event) => {
event.stopPropagation()
handleCloseTab(tab.id)
}}
>
×
</span>
</button>
))}
</div>
<div className="flex h-11 items-center justify-between border-b border-slate-800 bg-slate-800/80 px-4">
<div className="flex min-w-0 items-center gap-4 overflow-hidden text-xs">
<div className="flex items-center gap-2 text-slate-300">
<Activity size={14} className="text-emerald-400" />
CPU: {workspaceMetrics.cpuUsage ?? '-'}%
</div>
<div className="flex items-center gap-2 text-slate-300">
<HardDrive size={14} className="text-blue-400" />
MEM: {formatBytes(workspaceMetrics.memUsed ?? null)} / {formatBytes(workspaceMetrics.memTotal ?? null)}
</div>
<div className={cn('flex items-center gap-2 truncate', statusMeta.tone)}>
<span className={cn('h-2 w-2 rounded-full', statusMeta.dot)} />
{statusMeta.label}
</div>
</div>
<div className="ml-3 flex items-center rounded-lg border border-slate-700 bg-slate-900/90 p-1">
<button
className={cn(
'rounded-md p-2 transition',
layout === 'terminal' ? 'bg-slate-700 text-white' : 'text-slate-400 hover:text-white',
)}
onClick={() => setLayout('terminal')}
title="终端"
>
<Terminal size={15} />
</button>
<button
className={cn(
'rounded-md p-2 transition',
layout === 'sftp' ? 'bg-slate-700 text-white' : 'text-slate-400 hover:text-white',
)}
onClick={() => setLayout('sftp')}
title="SFTP"
>
<Folder size={15} />
</button>
<button
className={cn(
'rounded-md p-2 transition',
layout === 'split' ? 'bg-slate-700 text-white' : 'text-slate-400 hover:text-white',
)}
onClick={() => setLayout('split')}
title="分屏"
>
<SplitSquareHorizontal size={15} />
</button>
</div>
</div>
<div className="relative flex-1 overflow-hidden">
{!activeConnection ? (
<div className="absolute inset-0 flex flex-col items-center justify-center text-slate-500">
<Monitor size={64} className="mb-4 text-slate-800" />
<h2 className="mb-2 text-xl font-medium text-slate-300">使 SSH Manager</h2>
<p className="text-sm"></p>
<button className="mt-6 flex items-center gap-2 rounded-xl border border-blue-600/30 bg-blue-600/10 px-6 py-2 text-blue-300 transition hover:bg-blue-600/20" onClick={() => setShowConnectionModal(true)}>
<p className="text-sm"></p>
<button
className="mt-6 flex items-center gap-2 rounded-xl border border-blue-600/30 bg-blue-600/10 px-6 py-2 text-blue-300 transition hover:bg-blue-600/20"
onClick={() => {
setEditingConnection(null)
setShowConnectionModal(true)
}}
>
<Plus size={16} />
</button>
</div>
) : (
<div className={`flex h-full ${layout === 'split' ? 'flex-row' : 'flex-col'}`}>
{(layout === 'split' || layout === 'terminal') && (
<div className={`${layout === 'split' ? 'w-1/2 border-r border-slate-800' : 'w-full'} overflow-hidden`}>
<TerminalPane
connection={activeConnection}
active
layout={layout}
onLayoutChange={setLayout}
fontSize={terminalFontSize}
fontFamily={terminalFontFamily}
/>
</div>
)}
<div className={`flex h-full min-w-0 ${layout === 'split' ? 'flex-row' : 'flex-col'}`}>
<div
className={cn(
'relative min-w-0 flex-1 overflow-hidden',
layout === 'split' && 'w-1/2 border-r border-slate-800',
layout === 'terminal' && 'w-full',
layout === 'sftp' && 'hidden',
)}
>
{tabs.map((tab) => {
const visible = tab.id === currentTabId && layout !== 'sftp'
return (
<div key={tab.id} className={cn('absolute inset-0', !visible && 'hidden')}>
<TerminalPane
connection={tab.connection}
visible={visible}
fontSize={terminalFontSize}
fontFamily={terminalFontFamily}
onStatusChange={(status) => {
setTerminalStatuses((prev) =>
prev[tab.id] === status ? prev : { ...prev, [tab.id]: status },
)
}}
/>
</div>
)
})}
</div>
{(layout === 'split' || layout === 'sftp') && (
<div className={`${layout === 'split' ? 'w-1/2' : 'w-full'} overflow-hidden`}>
<SftpPane connection={activeConnection} selectedFiles={selectedFiles} onSelectedFilesChange={setSelectedFiles} />
<div className={`${layout === 'split' ? 'min-w-0 w-1/2' : 'w-full'} overflow-hidden`}>
<SftpPane
connection={activeConnection}
selectedFiles={selectedFiles}
onSelectedFilesChange={setSelectedFiles}
/>
</div>
)}
</div>
@@ -246,13 +829,31 @@ export default function WorkspacePage({
<ConnectionModal
open={showConnectionModal}
connection={editingConnection}
folderOptions={folderOptions}
initialTargetFolderId={connectionModalFolderId}
onClose={() => {
setShowConnectionModal(false)
setEditingConnection(null)
}}
onSubmit={handleSubmitConnection}
/>
<BatchCommandModal open={showBatchModal} connections={connections} onClose={() => setShowBatchModal(false)} />
<FolderModal
open={showFolderModal}
folderOptions={folderOptions}
initialParentId={resolveSuggestedFolderId(treeLayout, selectedNodeId)}
onClose={() => setShowFolderModal(false)}
onSubmit={handleCreateFolder}
/>
<BatchCommandModal
open={showBatchModal}
connections={connections}
connectionStatuses={connectionStatuses}
connectionStatusDetails={connectionStatusDetails}
statusError={connectionStatusError}
statusLoading={connectionStatusLoading}
onRefreshStatuses={handleRefreshConnectionStatuses}
onClose={() => setShowBatchModal(false)}
/>
<TransferCenterModal
open={showTransferModal}
connections={connections}
@@ -269,6 +870,67 @@ export default function WorkspacePage({
onFontFamilyChange={setTerminalFontFamily}
/>
{showChangePassword ? <ChangePasswordModal force={!!user?.passwordChangeRequired} onClose={() => setShowChangePassword(false)} /> : null}
{treeContextMenu.visible ? (
<div className="fixed inset-0 z-50" onClick={closeTreeContextMenu}>
<div
className="absolute min-w-44 rounded-xl border border-slate-700 bg-slate-900 p-1 shadow-2xl shadow-black/40"
onClick={(event) => event.stopPropagation()}
style={{ left: treeContextMenu.x, top: treeContextMenu.y }}
>
{treeContextMenu.targetType === 'folder' ? (
<>
<button
className="flex w-full items-center rounded-lg px-3 py-2 text-left text-sm text-slate-200 transition hover:bg-slate-800 hover:text-white"
onClick={() => {
closeTreeContextMenu()
setEditingConnection(null)
setShowConnectionModal(true)
}}
>
</button>
<button
className="flex w-full items-center rounded-lg px-3 py-2 text-left text-sm text-slate-200 transition hover:bg-slate-800 hover:text-white"
onClick={() => {
closeTreeContextMenu()
setShowFolderModal(true)
}}
>
</button>
</>
) : null}
<button
className="flex w-full items-center rounded-lg px-3 py-2 text-left text-sm text-slate-200 transition hover:bg-slate-800 hover:text-white"
onClick={() => void handleEditTreeItem()}
>
</button>
<button
className="flex w-full items-center rounded-lg px-3 py-2 text-left text-sm text-red-300 transition hover:bg-red-500/10 hover:text-red-200"
onClick={() => void handleDeleteTreeItem()}
>
</button>
</div>
</div>
) : null}
{tabContextMenu.visible ? (
<div className="fixed inset-0 z-50" onClick={closeTabContextMenu}>
<div
className="absolute min-w-40 rounded-xl border border-slate-700 bg-slate-900 p-1 shadow-2xl shadow-black/40"
onClick={(event) => event.stopPropagation()}
style={{ left: tabContextMenu.x, top: tabContextMenu.y }}
>
<button
className="flex w-full items-center rounded-lg px-3 py-2 text-left text-sm text-slate-200 transition hover:bg-slate-800 hover:text-white"
onClick={closeAllTabs}
>
</button>
</div>
</div>
) : null}
</div>
)
}
+5 -1
View File
@@ -1,5 +1,5 @@
import http from './http'
import type { BatchCommandResponse, Connection, ConnectionCreateRequest } from '../types'
import type { BatchCommandResponse, Connection, ConnectionCreateRequest, ConnectionStatusResponse } from '../types'
export function listConnections() {
return http.get<Connection[]>('/connections')
@@ -20,3 +20,7 @@ export function deleteConnection(id: number) {
export function executeBatchCommand(connectionIds: number[], command: string) {
return http.post<BatchCommandResponse>('/connections/batch-command', { connectionIds, command })
}
export function checkConnectionStatuses(connectionIds: number[]) {
return http.post<ConnectionStatusResponse>('/connections/status', { connectionIds })
}
+27
View File
@@ -3,6 +3,8 @@ export type ConnectionSetupMode = 'NONE' | 'PASSWORD_BOOTSTRAP'
export type SessionTreeNodeType = 'folder' | 'connection'
export type SessionTreeSortMode = 'manual' | 'nameAsc'
export type WorkspaceLayout = 'split' | 'terminal' | 'sftp'
export type TerminalConnectionStatus = 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'error'
export type ConnectionReachabilityStatus = 'unknown' | 'checking' | 'online' | 'offline'
export interface LoginResponse {
token: string
@@ -41,6 +43,10 @@ export interface ConnectionCreateRequest {
bootstrapPassword?: string
}
export interface ConnectionModalSubmitPayload extends ConnectionCreateRequest {
targetFolderId: string | null
}
export interface SessionTreeNodePayload {
id: string
type: SessionTreeNodeType
@@ -58,6 +64,12 @@ export interface SessionTreeLayoutPayload {
sortMode?: SessionTreeSortMode
}
export interface SessionTreeFolderOption {
id: string
name: string
depth: number
}
export interface SftpFileInfo {
name: string
directory: boolean
@@ -115,6 +127,21 @@ export interface BatchCommandResponse {
results: BatchCommandResult[]
}
export interface ConnectionStatusItem {
connectionId: number
connectionName: string
status: 'online' | 'offline'
message: string
durationMs: number
}
export interface ConnectionStatusResponse {
total: number
onlineCount: number
offlineCount: number
results: ConnectionStatusItem[]
}
export interface MonitorMetrics {
cpuUsage?: number | null
memTotal?: number | null