feat: 主题切换 + 浅色模式适配,SFTP/批量命令/Webhook/仪表盘全面升级

This commit is contained in:
liumangmang
2026-06-10 14:33:47 +08:00
parent 507d59d633
commit 4a17f0106e
69 changed files with 3105 additions and 673 deletions
+7
View File
@@ -10,6 +10,7 @@
"dependencies": {
"@xterm/addon-attach": "^0.12.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-search": "^0.16.0",
"axios": "^1.13.4",
"lucide-react": "^0.563.0",
"react": "^19.2.0",
@@ -1329,6 +1330,12 @@
"@xterm/xterm": "^5.0.0"
}
},
"node_modules/@xterm/addon-search": {
"version": "0.16.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/@xterm/addon-search/-/addon-search-0.16.0.tgz",
"integrity": "sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA==",
"license": "MIT"
},
"node_modules/@xterm/xterm": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
+1
View File
@@ -11,6 +11,7 @@
"dependencies": {
"@xterm/addon-attach": "^0.12.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-search": "^0.16.0",
"axios": "^1.13.4",
"lucide-react": "^0.563.0",
"react": "^19.2.0",
+62 -39
View File
@@ -1,8 +1,19 @@
import { lazy, Suspense } from 'react'
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'
import { AuthProvider, useAuth } from './context/AuthContext'
import LoginPage from './pages/LoginPage'
import WorkspacePage from './pages/WorkspacePage'
import UserManagementPage from './pages/UserManagementPage'
const WorkspacePage = lazy(() => import('./pages/WorkspacePage'))
const UserManagementPage = lazy(() => import('./pages/UserManagementPage'))
const DashboardPage = lazy(() => import('./pages/DashboardPage'))
function PageLoader() {
return (
<div className="flex min-h-screen items-center justify-center bg-surface-app">
<div className="text-xs text-content-dim">...</div>
</div>
)
}
function AppRoutes() {
const { isAuthenticated, user, loading } = useAuth()
@@ -12,48 +23,60 @@ function AppRoutes() {
if (loading) {
return (
<div className="flex min-h-screen items-center justify-center bg-slate-950 text-slate-200">
<div className="rounded-2xl border border-slate-800 bg-slate-900/80 px-6 py-5 shadow-2xl">...</div>
<div className="flex min-h-screen items-center justify-center bg-surface-app text-content-main">
<div className="rounded-2xl border border-border-main bg-surface-panel/80 px-6 py-5 shadow-2xl">...</div>
</div>
)
}
return (
<Routes>
<Route
path="/login"
element={
isAuthenticated ? <Navigate to="/workspace" replace /> : <LoginPage onSuccess={() => navigate('/workspace')} />
}
/>
<Route
path="/workspace"
element={
isAuthenticated ? (
<WorkspacePage
initialTool={new URLSearchParams(location.search).get('tool') ?? undefined}
onLogout={() => navigate('/login', { replace: true })}
/>
) : (
<Navigate to="/login" replace />
)
}
/>
<Route
path="/users"
element={
isAuthenticated && isAdmin ? (
<UserManagementPage onBack={() => navigate('/workspace')} />
) : isAuthenticated ? (
<Navigate to="/workspace" replace />
) : (
<Navigate to="/login" replace />
)
}
/>
<Route path="/" element={<Navigate to={isAuthenticated ? '/workspace' : '/login'} replace />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<Suspense fallback={<PageLoader />}>
<Routes>
<Route
path="/login"
element={
isAuthenticated ? <Navigate to="/workspace" replace /> : <LoginPage onSuccess={() => navigate('/workspace')} />
}
/>
<Route
path="/workspace"
element={
isAuthenticated ? (
<WorkspacePage
initialTool={new URLSearchParams(location.search).get('tool') ?? undefined}
onLogout={() => navigate('/login', { replace: true })}
/>
) : (
<Navigate to="/login" replace />
)
}
/>
<Route
path="/dashboard"
element={
isAuthenticated ? (
<DashboardPage onBack={() => navigate('/workspace')} />
) : (
<Navigate to="/login" replace />
)
}
/>
<Route
path="/users"
element={
isAuthenticated && isAdmin ? (
<UserManagementPage onBack={() => navigate('/workspace')} />
) : isAuthenticated ? (
<Navigate to="/workspace" replace />
) : (
<Navigate to="/login" replace />
)
}
/>
<Route path="/" element={<Navigate to={isAuthenticated ? '/workspace' : '/login'} replace />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
)
}
+118 -41
View File
@@ -1,14 +1,16 @@
import { useEffect, useMemo, useState } from 'react'
import { CheckCircle2, Clock, Command, Copy, Play, RefreshCw, Server, Terminal, XCircle } from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Bookmark, CheckCircle2, Clock, Command, Copy, Play, Plus, RefreshCw, Server, Terminal, Trash2, X, XCircle } from 'lucide-react'
import Modal from './Modal'
import { cn } from '../lib/utils'
import { executeBatchCommand } from '../services/connections'
import { listSnippets, createSnippet, deleteSnippet, type CommandSnippet } from '../services/snippets'
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' },
unknown: { dot: 'bg-content-dim', label: '未检测', text: 'text-content-dim' },
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' },
offline: { dot: 'bg-content-dim', label: '离线', text: 'text-content-dim' },
}
export default function BatchCommandModal({
@@ -35,6 +37,33 @@ export default function BatchCommandModal({
const [running, setRunning] = useState(false)
const [results, setResults] = useState<BatchCommandResult[]>([])
const [error, setError] = useState<string | null>(null)
const [snippets, setSnippets] = useState<CommandSnippet[]>([])
const [showSnippets, setShowSnippets] = useState(false)
const [snippetName, setSnippetName] = useState('')
const [savingSnippet, setSavingSnippet] = useState(false)
useEffect(() => {
if (!open) return
listSnippets().then((res) => setSnippets(res.data)).catch(() => {})
}, [open])
async function handleSaveSnippet() {
if (!command.trim() || !snippetName.trim()) return
setSavingSnippet(true)
try {
const res = await createSnippet(snippetName.trim(), command)
setSnippets((prev) => [res.data, ...prev])
setSnippetName('')
setSavingSnippet(false)
} catch { setSavingSnippet(false) }
}
async function handleDeleteSnippet(id: number) {
try {
await deleteSnippet(id)
setSnippets((prev) => prev.filter((s) => s.id !== id))
} catch {}
}
const summary = useMemo(
() => ({
@@ -86,19 +115,19 @@ export default function BatchCommandModal({
return (
<Modal title="批量执行命令" onClose={onClose} maxWidth="max-w-6xl">
<div className="flex h-[70vh] overflow-hidden rounded-2xl border border-slate-800 bg-[#0d1117]">
<div className="flex w-72 shrink-0 flex-col border-r border-slate-800 bg-slate-900/70">
<div className="flex items-center justify-between border-b border-slate-800 px-4 py-3 text-sm text-slate-300">
<div className="flex h-[70vh] overflow-hidden rounded-2xl border border-border-main bg-surface-app">
<div className="flex w-72 shrink-0 flex-col border-r border-border-main bg-surface-panel/70">
<div className="flex items-center justify-between border-b border-border-main px-4 py-3 text-sm text-content-muted">
<span> ({selectedIds.length})</span>
<div className="flex gap-3 text-xs">
<button className="text-blue-400 disabled:text-slate-600" disabled={statusLoading} onClick={() => setSelectedIds(onlineIds)}>
<button className="text-blue-400 disabled:text-content-dim" disabled={statusLoading} onClick={() => setSelectedIds(onlineIds)}>
</button>
<button className="text-slate-400" onClick={() => setSelectedIds([])}>
<button className="text-content-muted hover:text-content-main transition" onClick={() => setSelectedIds([])}>
</button>
<button
className="flex items-center gap-1 text-slate-400 transition hover:text-slate-200 disabled:text-slate-600"
className="flex items-center gap-1 text-content-muted transition hover:text-content-main disabled:text-content-dim"
disabled={statusLoading || connections.length === 0}
onClick={() => {
void onRefreshStatuses().catch(() => undefined)
@@ -109,7 +138,7 @@ export default function BatchCommandModal({
</button>
</div>
</div>
<div className="border-b border-slate-800 px-4 py-2 text-xs text-slate-500">
<div className="border-b border-border-main px-4 py-2 text-xs text-content-dim">
线 {onlineIds.length} / 线 {offlineCount} / {connections.length}
</div>
<div className="flex-1 space-y-1 overflow-auto p-2">
@@ -124,7 +153,7 @@ export default function BatchCommandModal({
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'
disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer hover:bg-surface-muted'
}`}
>
<input
@@ -138,11 +167,11 @@ export default function BatchCommandModal({
}
/>
<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}`} />
<Server size={14} className={checked && !disabled ? 'text-blue-400' : 'text-content-dim'} />
<span className={`absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full border border-surface-app ${reachabilityMeta.dot}`} />
</div>
<div className="min-w-0">
<div className="truncate text-sm text-slate-300">{connection.name}</div>
<div className="truncate text-sm text-content-muted">{connection.name}</div>
<div className={`text-[10px] ${reachabilityMeta.text}`}>{reachabilityMeta.label}</div>
</div>
</label>
@@ -152,33 +181,81 @@ export default function BatchCommandModal({
</div>
<div className="flex flex-1 flex-col">
<div className="border-b border-slate-800 bg-slate-900 px-4 py-4">
<div className="border-b border-border-main bg-surface-panel px-4 py-4">
<div className="flex items-end gap-4">
<div className="flex-1 space-y-2">
<label className="flex items-center gap-2 text-sm text-slate-300">
<label className="flex items-center gap-2 text-sm text-content-muted">
<Terminal size={14} className="text-emerald-400" />
{snippets.length > 0 ? (
<span className="ml-2 flex items-center gap-1">
<button onClick={() => setShowSnippets(!showSnippets)}
className="inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-[10px] text-content-muted transition hover:text-content-main"
style={{ background: 'rgba(148,163,184,0.06)' }}>
<Bookmark size={10} />
</button>
</span>
) : null}
</label>
<textarea
rows={5}
className="w-full resize-none rounded-2xl border border-slate-700 bg-black px-4 py-3 font-mono text-sm text-emerald-300 outline-none focus:border-blue-500"
value={command}
onChange={(event) => setCommand(event.target.value)}
/>
<div className="relative">
<textarea
rows={5}
className="w-full resize-none rounded-2xl border border-border-main bg-surface-muted px-4 py-3 font-mono text-sm text-emerald-300 outline-none focus:border-blue-500"
value={command}
onChange={(event) => setCommand(event.target.value)}
/>
{/* Snippets dropdown */}
{showSnippets && snippets.length > 0 && (
<div className="absolute bottom-1 left-1 right-1 z-10 max-h-32 overflow-auto rounded-xl border border-border-main bg-surface-panel p-1 shadow-xl"
style={{ top: 'auto', transform: 'translateY(-105%)' }}>
{snippets.map((s) => (
<div key={s.id} className="group flex items-center rounded-lg px-2 py-1.5 text-xs transition hover:bg-surface-muted">
<button className="flex-1 text-left text-content-muted truncate"
onClick={() => { setCommand(s.command); setShowSnippets(false) }}>
<span className="font-medium text-content-main">{s.name}</span>
{s.description ? <span className="ml-2 text-content-dim">{s.description}</span> : null}
</button>
<button onClick={() => void handleDeleteSnippet(s.id)}
className="ml-1 rounded p-0.5 text-content-dim opacity-0 transition hover:text-red-400 group-hover:opacity-100">
<Trash2 size={10} />
</button>
</div>
))}
</div>
)}
</div>
</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 || statusLoading || selectedIds.filter((id) => connectionStatuses[id] === 'online').length === 0}
onClick={() => void handleRun()}
>
{running ? <RefreshCw size={16} className="animate-spin" /> : <Play size={16} fill="currentColor" />}
{running ? '执行中...' : '开始执行'}
</button>
<div className="flex items-center gap-2">
{/* Save as snippet */}
<div className="relative">
<input
placeholder="保存为片段..."
className="w-36 rounded-lg border border-border-main bg-surface-muted px-3 py-2.5 text-xs text-content-main outline-none placeholder:text-content-dim"
value={snippetName}
onChange={(e) => setSnippetName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') void handleSaveSnippet() }}
/>
{snippetName.trim() && command.trim() ? (
<button onClick={() => void handleSaveSnippet()}
className="absolute right-1 top-1/2 -translate-y-1/2 rounded p-1 text-cyan-400 hover:text-cyan-300">
<Plus size={14} />
</button>
) : null}
</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 || statusLoading || selectedIds.filter((id) => connectionStatuses[id] === 'online').length === 0}
onClick={() => void handleRun()}
>
{running ? <RefreshCw size={16} className="animate-spin" /> : <Play size={16} fill="currentColor" />}
{running ? '执行中...' : '开始执行'}
</button>
</div>
</div>
</div>
<div className="flex gap-6 border-b border-slate-800 bg-slate-800/60 px-4 py-2 text-sm">
<span className="text-slate-300">: {summary.total}</span>
<div className="flex gap-6 border-b border-border-main bg-surface-muted/60 px-4 py-2 text-sm">
<span className="text-content-muted">: {summary.total}</span>
<span className="font-medium text-emerald-400">: {summary.success}</span>
<span className="font-medium text-red-400">: {summary.failure}</span>
</div>
@@ -187,25 +264,25 @@ export default function BatchCommandModal({
{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" />
<div className="flex h-full flex-col items-center justify-center text-content-dim">
<Command size={48} className="mb-3 text-border-main" />
<p>{statusLoading ? '正在检测主机状态...' : '请在上方输入命令并点击执行'}</p>
</div>
) : null}
{results.map((result) => (
<div key={result.connectionId} className="overflow-hidden rounded-2xl border border-slate-700 bg-slate-900">
<div className="flex items-center justify-between border-b border-slate-800 bg-slate-800/80 px-4 py-3">
<div key={result.connectionId} className="overflow-hidden rounded-2xl border border-border-main bg-surface-panel">
<div className="flex items-center justify-between border-b border-border-subtle bg-surface-panel/80 px-4 py-3">
<div className="flex items-center gap-3">
{result.success ? <CheckCircle2 size={16} className="text-emerald-500" /> : <XCircle size={16} className="text-red-500" />}
<span className="text-sm font-medium text-slate-100">{result.connectionName}</span>
<span className="text-sm font-medium text-content-main">{result.connectionName}</span>
</div>
<div className="flex items-center gap-4 text-xs text-slate-500">
<div className="flex items-center gap-4 text-xs text-content-dim">
<span className="flex items-center gap-1">
<Clock size={12} />
{result.durationMs}ms
</span>
<button
className="flex items-center gap-1 transition hover:text-slate-300"
className="flex items-center gap-1 transition hover:text-content-muted"
onClick={() => navigator.clipboard.writeText(result.output || result.error || '')}
>
<Copy size={12} />
@@ -213,7 +290,7 @@ export default function BatchCommandModal({
</button>
</div>
</div>
<pre className={`overflow-auto bg-black p-4 text-xs leading-relaxed ${result.success ? 'text-slate-300' : 'text-red-400'}`}>
<pre className={`overflow-auto bg-surface-muted/20 p-4 text-xs leading-relaxed ${result.success ? 'text-content-muted' : 'text-red-400'}`}>
{result.output || result.error || ''}
</pre>
</div>
@@ -43,7 +43,7 @@ export default function ChangePasswordModal({
footer={
<>
{!force ? (
<button className="rounded-xl bg-slate-700 px-4 py-2 text-sm text-slate-200 transition hover:bg-slate-600" onClick={onClose}>
<button className="rounded-xl bg-surface-muted px-4 py-2 text-sm text-content-muted transition hover:bg-surface-panel hover:text-content-main" onClick={onClose}>
</button>
) : null}
@@ -58,32 +58,32 @@ export default function ChangePasswordModal({
}
>
<div className="space-y-4">
<div className="rounded-2xl border border-blue-500/25 bg-blue-500/10 p-4 text-sm text-blue-300">
<div className="rounded-2xl border border-blue-500/25 bg-blue-500/10 p-4 text-sm text-blue-400">
</div>
<label className="block space-y-2">
<span className="text-sm text-slate-300"></span>
<span className="text-sm text-content-muted"></span>
<input
type="password"
className="w-full rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-white outline-none focus:border-blue-500"
className="w-full rounded-xl border border-border-main bg-surface-muted px-4 py-3 text-content-main outline-none focus:border-blue-500"
value={currentPassword}
onChange={(event) => setCurrentPassword(event.target.value)}
/>
</label>
<label className="block space-y-2">
<span className="text-sm text-slate-300"></span>
<span className="text-sm text-content-muted"></span>
<input
type="password"
className="w-full rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-white outline-none focus:border-blue-500"
className="w-full rounded-xl border border-border-main bg-surface-muted px-4 py-3 text-content-main outline-none focus:border-blue-500"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
/>
</label>
<label className="block space-y-2">
<span className="text-sm text-slate-300"></span>
<span className="text-sm text-content-muted"></span>
<input
type="password"
className="w-full rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-white outline-none focus:border-blue-500"
className="w-full rounded-xl border border-border-main bg-surface-muted px-4 py-3 text-content-main outline-none focus:border-blue-500"
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
/>
+27 -27
View File
@@ -92,7 +92,7 @@ export default function ConnectionModal({
maxWidth="max-w-2xl"
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 className="rounded-xl bg-surface-muted px-4 py-2 text-sm text-content-muted transition hover:bg-surface-panel hover:text-content-main" onClick={onClose}>
</button>
<button
@@ -107,18 +107,18 @@ 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>
<span className="text-sm text-content-muted"></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"
className="w-full rounded-2xl border border-border-main bg-surface-muted px-4 py-3 text-content-main 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"> / </span>
<span className="text-sm text-content-muted"> / </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"
className="w-full rounded-2xl border border-border-main bg-surface-muted px-4 py-3 text-content-main outline-none focus:border-blue-500"
value={targetFolderId ?? '__ROOT__'}
onChange={(event) => setTargetFolderId(event.target.value === '__ROOT__' ? null : event.target.value)}
>
@@ -134,53 +134,53 @@ export default function ConnectionModal({
<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>
<span className="text-sm text-content-muted"> 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"
className="w-full rounded-2xl border border-border-main bg-surface-muted px-4 py-3 text-content-main 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>
<span className="text-sm text-content-muted"></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"
className="w-full rounded-2xl border border-border-main bg-surface-muted px-4 py-3 text-content-main 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>
<span className="text-sm text-content-muted"></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"
className="w-full rounded-2xl border border-border-main bg-surface-muted px-4 py-3 text-content-main 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="mt-6 rounded-[28px] border border-border-main bg-surface-muted/30 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 className="text-sm font-medium text-content-main"></div>
<div className="mt-1 text-xs text-content-dim"></div>
</div>
<div className="flex flex-wrap gap-3">
<button
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'}`}
className={`rounded-2xl border px-4 py-2 text-sm ${isPassword ? 'border-blue-500 bg-blue-500/10 text-blue-400' : 'border-border-main bg-surface-panel text-content-muted'}`}
onClick={() => setForm((prev) => ({ ...prev, authType: 'PASSWORD', setupMode: prev.setupMode === 'PASSWORD_BOOTSTRAP' ? 'PASSWORD_BOOTSTRAP' : 'NONE' }))}
>
</button>
<button
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'}`}
className={`rounded-2xl border px-4 py-2 text-sm ${form.authType === 'PRIVATE_KEY' ? 'border-blue-500 bg-blue-500/10 text-blue-400' : 'border-border-main bg-surface-panel text-content-muted'}`}
onClick={() => setForm((prev) => ({ ...prev, authType: 'PRIVATE_KEY', setupMode: 'NONE' }))}
>
</button>
<button
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'}`}
className={`rounded-2xl border px-4 py-2 text-sm ${useBootstrap ? 'border-emerald-500 bg-emerald-500/10 text-emerald-400' : 'border-border-main bg-surface-panel text-content-muted'}`}
onClick={() => setForm((prev) => ({ ...prev, authType: 'PASSWORD', setupMode: prev.setupMode === 'PASSWORD_BOOTSTRAP' ? 'NONE' : 'PASSWORD_BOOTSTRAP' }))}
>
@@ -188,11 +188,11 @@ export default function ConnectionModal({
</div>
{form.authType === 'PASSWORD' ? (
<label className="block space-y-2">
<span className="text-sm text-slate-300">{useBootstrap ? '引导密码' : '密码'}</span>
<label className="block mt-4 space-y-2">
<span className="text-sm text-content-muted">{useBootstrap ? '引导密码' : '密码'}</span>
<input
type="password"
className="w-full rounded-2xl border border-slate-700 bg-slate-950 px-4 py-3 text-white outline-none focus:border-blue-500"
className="w-full rounded-2xl border border-border-main bg-surface-muted px-4 py-3 text-content-main outline-none focus:border-blue-500"
value={useBootstrap ? form.bootstrapPassword ?? '' : form.password ?? ''}
onChange={(e) =>
setForm((prev) =>
@@ -202,30 +202,30 @@ export default function ConnectionModal({
/>
</label>
) : (
<>
<div className="mt-4 space-y-4">
<label className="block space-y-2">
<span className="text-sm text-slate-300"></span>
<span className="text-sm text-content-muted"></span>
<textarea
rows={6}
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"
className="w-full rounded-2xl border border-border-main bg-surface-muted px-4 py-3 font-mono text-sm text-content-main outline-none focus:border-blue-500"
value={form.privateKey ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, privateKey: e.target.value }))}
/>
</label>
<label className="block space-y-2">
<span className="text-sm text-slate-300"></span>
<span className="text-sm text-content-muted"></span>
<input
type="password"
className="w-full rounded-2xl border border-slate-700 bg-slate-950 px-4 py-3 text-white outline-none focus:border-blue-500"
className="w-full rounded-2xl border border-border-main bg-surface-muted px-4 py-3 text-content-main outline-none focus:border-blue-500"
value={form.passphrase ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, passphrase: e.target.value }))}
/>
</label>
</>
</div>
)}
</div>
{error ? <div className="mt-4 rounded-xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-300">{error}</div> : null}
{error ? <div className="mt-4 rounded-xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-400">{error}</div> : null}
</Modal>
)
}
@@ -0,0 +1,85 @@
import { useEffect, useState } from 'react'
import { readFileContent, writeFileContent } from '../services/sftp'
import Modal from './Modal'
function getApiError(err: unknown, fallback: string): string {
const data = (err as any)?.response?.data
return data?.message || data?.error || fallback
}
interface FileEditorModalProps {
open: boolean
connectionId: number
filePath: string
onClose: () => void
}
export default function FileEditorModal({ open, connectionId, filePath, onClose }: FileEditorModalProps) {
const [content, setContent] = useState('')
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false)
const [dirty, setDirty] = useState(false)
useEffect(() => {
if (!open || !filePath) return
setLoading(true)
setError(null)
setSuccess(false)
setDirty(false)
readFileContent(connectionId, filePath)
.then((res) => { setContent(res.data.content); setLoading(false) })
.catch((err) => { setError(getApiError(err, '加载文件失败')); setLoading(false) })
}, [connectionId, filePath, open])
async function handleSave() {
setSaving(true)
setError(null)
setSuccess(false)
try {
await writeFileContent(connectionId, filePath, content)
setSuccess(true)
setDirty(false)
setTimeout(() => setSuccess(false), 2000)
} catch (err) {
setError(getApiError(err, '保存失败'))
} finally {
setSaving(false)
}
}
const filename = filePath.split('/').pop() || filePath
return (
<Modal title={`编辑: ${filename}`} onClose={onClose} maxWidth="max-w-5xl"
footer={
<div className="flex items-center gap-3 w-full">
<div className="flex-1 text-xs" style={{ color: '#475569' }}>{filePath}</div>
{error ? <span className="text-xs text-red-400">{error}</span> : null}
{success ? <span className="text-xs text-emerald-400"> </span> : null}
<button onClick={onClose} className="btn-cyber"></button>
<button onClick={handleSave} disabled={saving || !dirty} className="btn-cyber-primary">
{saving ? '保存中...' : '保存'}
</button>
</div>
}
>
{loading ? (
<div className="flex items-center justify-center py-20 text-sm" style={{ color: '#64748b' }}>...</div>
) : (
<textarea
className="w-full h-[60vh] resize-none rounded-xl border p-4 font-mono text-sm outline-none"
style={{
background: '#0a0e1a',
borderColor: 'rgba(96, 165, 250, 0.12)',
color: '#e2e8f0',
}}
value={content}
onChange={(e) => { setContent(e.target.value); setDirty(true) }}
spellCheck={false}
/>
)}
</Modal>
)
}
+5 -5
View File
@@ -58,7 +58,7 @@ export default function FolderModal({
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 className="rounded-xl bg-surface-muted px-4 py-2 text-sm text-content-muted transition hover:bg-surface-panel hover:text-content-main" onClick={onClose}>
</button>
<button
@@ -73,9 +73,9 @@ export default function FolderModal({
>
<div className="space-y-5">
<label className="block space-y-2">
<span className="text-sm text-slate-300"></span>
<span className="text-sm text-content-muted"></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"
className="w-full rounded-xl border border-border-main bg-surface-muted px-4 py-3 text-content-main outline-none focus:border-blue-500"
placeholder="例如:生产环境"
value={name}
onChange={(event) => setName(event.target.value)}
@@ -83,9 +83,9 @@ export default function FolderModal({
</label>
<label className="block space-y-2">
<span className="text-sm text-slate-300"></span>
<span className="text-sm text-content-muted"></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"
className="w-full rounded-xl border border-border-main bg-surface-muted px-4 py-3 text-content-main outline-none focus:border-blue-500"
value={parentId ?? '__ROOT__'}
onChange={(event) => setParentId(event.target.value === '__ROOT__' ? null : event.target.value)}
>
+5 -5
View File
@@ -19,20 +19,20 @@ export default function Modal({
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm">
<div className={`flex max-h-[92vh] w-full flex-col overflow-hidden rounded-3xl border border-slate-700 bg-slate-900 ${maxWidth}`}>
<div className="flex items-center justify-between border-b border-slate-800 bg-slate-900/90 px-5 py-4">
<h3 className="text-lg font-medium text-slate-100">{title}</h3>
<div className={`flex max-h-[92vh] w-full flex-col overflow-hidden rounded-3xl border border-border-main bg-surface-card ${maxWidth}`}>
<div className="flex items-center justify-between border-b border-border-subtle bg-surface-card/90 px-5 py-4">
<h3 className="text-lg font-medium text-content-main">{title}</h3>
{onClose ? (
<button
onClick={onClose}
className="rounded-xl border border-slate-700 bg-slate-800/80 p-2 text-slate-400 transition hover:text-white"
className="rounded-xl border border-border-main bg-surface-muted p-2 text-content-muted transition hover:text-content-main"
>
<X size={18} />
</button>
) : null}
</div>
<div className="flex-1 overflow-y-auto p-6">{children}</div>
{footer ? <div className="flex justify-end gap-3 border-t border-slate-800 bg-slate-900/90 px-5 py-4">{footer}</div> : null}
{footer ? <div className="flex justify-end gap-3 border-t border-border-subtle bg-surface-card/90 px-5 py-4">{footer}</div> : null}
</div>
</div>
)
@@ -58,7 +58,7 @@ export default function RenameFolderModal({
maxWidth="max-w-md"
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 className="rounded-xl bg-surface-muted px-4 py-2 text-sm text-content-muted transition hover:bg-surface-panel hover:text-content-main" onClick={onClose}>
</button>
<button
@@ -73,9 +73,9 @@ export default function RenameFolderModal({
>
<div className="space-y-5">
<label className="block space-y-2">
<span className="text-sm text-slate-300"></span>
<span className="text-sm text-content-muted"></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"
className="w-full rounded-xl border border-border-main bg-surface-muted px-4 py-3 text-content-main outline-none focus:border-blue-500"
placeholder="例如:生产环境"
value={name}
onChange={(event) => setName(event.target.value)}
+22 -11
View File
@@ -1,5 +1,5 @@
import type { MouseEvent } from 'react'
import { ChevronDown, ChevronRight, Folder, Server } from 'lucide-react'
import { ChevronDown, ChevronRight, Folder, Server, Star } from 'lucide-react'
import type { Connection, ConnectionReachabilityStatus } from '../types'
import type { BuiltTreeNode } from '../lib/utils'
import { cn } from '../lib/utils'
@@ -22,10 +22,11 @@ interface SessionTreeProps {
onToggleFolder: (nodeId: string) => void
onOpenConnection: (connection: Connection, nodeId: string) => void
onContextMenu: (payload: SessionTreeContextMenuPayload) => void
onTogglePin?: (connectionId: number) => void
}
const statusCopy: Record<ConnectionReachabilityStatus, { dot: string; label: string; text: string }> = {
unknown: { dot: 'bg-slate-500', label: '未检测', text: 'text-slate-500' },
unknown: { dot: 'bg-content-dim', label: '未检测', text: 'text-content-dim' },
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' },
@@ -50,6 +51,7 @@ function TreeNode({
onToggleFolder,
onOpenConnection,
onContextMenu,
onTogglePin,
}: {
node: BuiltTreeNode
depth: number
@@ -62,6 +64,7 @@ function TreeNode({
onToggleFolder: (nodeId: string) => void
onOpenConnection: (connection: Connection, nodeId: string) => void
onContextMenu: (payload: SessionTreeContextMenuPayload) => void
onTogglePin?: (connectionId: number) => void
}) {
function handleContextMenu(event: MouseEvent<HTMLButtonElement>, nodeType: 'folder' | 'connection') {
event.preventDefault()
@@ -84,7 +87,7 @@ function TreeNode({
<button
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',
selected ? 'bg-surface-muted text-content-main ring-1 ring-inset ring-border-main' : 'text-content-muted hover:bg-surface-muted',
)}
onClick={() => {
onSelectNode(node.id)
@@ -93,8 +96,8 @@ function TreeNode({
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" />}
<Folder size={15} className={expanded ? 'text-blue-400' : 'text-slate-500'} />
{expanded ? <ChevronDown size={14} className="text-content-dim" /> : <ChevronRight size={14} className="text-content-dim" />}
<Folder size={15} className={expanded ? 'text-blue-400' : 'text-content-dim'} />
<span className="truncate">{node.name}</span>
</button>
{expanded ? (
@@ -113,6 +116,7 @@ function TreeNode({
onToggleFolder={onToggleFolder}
onOpenConnection={onOpenConnection}
onContextMenu={onContextMenu}
onTogglePin={onTogglePin}
/>
))}
</div>
@@ -135,10 +139,10 @@ function TreeNode({
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'
? 'bg-surface-muted text-content-main ring-1 ring-inset ring-border-main'
: opened
? 'text-emerald-200 hover:bg-slate-800'
: 'text-slate-400 hover:bg-slate-800 hover:text-slate-100',
? 'text-emerald-200 hover:bg-surface-muted'
: 'text-content-muted hover:bg-surface-muted hover:text-content-main',
)}
onDoubleClick={() => onOpenConnection(node.connection!, node.id)}
onClick={() => {
@@ -151,19 +155,26 @@ function TreeNode({
style={{ paddingLeft: 24 + depth * 16 }}
>
<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)} />
<Server size={14} className={active ? 'text-blue-300' : opened ? 'text-emerald-400' : 'text-content-dim'} />
<span className={cn('absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full border border-surface-panel', 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>
<button
className="shrink-0 rounded p-0.5 opacity-0 transition group-hover:opacity-100 hover:scale-110"
onClick={(e) => { e.stopPropagation(); onTogglePin?.(node.connection!.id) }}
title={node.connection.pinned ? '取消置顶' : '置顶'}
>
<Star size={11} className={node.connection.pinned ? 'fill-amber-400 text-amber-400' : 'text-content-dim'} />
</button>
{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>
<span className="hidden rounded bg-surface-app px-1.5 py-0.5 text-[10px] text-content-dim group-hover:inline-block"></span>
)}
</button>
)
+139 -33
View File
@@ -1,45 +1,151 @@
import { useCallback, useEffect, useState } from 'react'
import { Sun, Moon, Link, Trash2, Plus, LoaderCircle, CheckCircle2 } from 'lucide-react'
import Modal from './Modal'
import { listWebhooks, createWebhook, deleteWebhook, testWebhook, type WebhookConfig } from '../services/webhooks'
function getApiError(err: unknown, fallback: string): string {
const data = (err as any)?.response?.data
return data?.message || data?.error || fallback
}
const FONT_OPTIONS = [
{ label: 'IBM Plex Mono', value: '"IBM Plex Mono", ui-monospace, monospace' },
{ label: 'JetBrains Mono', value: '"JetBrains Mono", "IBM Plex Mono", ui-monospace, monospace' },
{ label: 'Fira Code', value: '"Fira Code", "IBM Plex Mono", ui-monospace, monospace' },
{ label: 'Source Code Pro', value: '"Source Code Pro", "IBM Plex Mono", ui-monospace, monospace' },
]
export default function SettingsModal({
open,
terminalFontSize,
terminalFontFamily,
onClose,
onFontSizeChange,
onFontFamilyChange,
open, terminalFontSize, terminalFontFamily, darkMode,
onClose, onFontSizeChange, onFontFamilyChange, onDarkModeChange,
}: {
open: boolean
terminalFontSize: number
terminalFontFamily: string
onClose: () => void
onFontSizeChange: (value: number) => void
onFontFamilyChange: (value: string) => void
open: boolean; terminalFontSize: number; terminalFontFamily: string; darkMode?: boolean
onClose: () => void; onFontSizeChange: (v: number) => void; onFontFamilyChange: (v: string) => void
onDarkModeChange?: (v: boolean) => void
}) {
const [tab, setTab] = useState<'appearance' | 'webhooks'>('appearance')
const [webhooks, setWebhooks] = useState<WebhookConfig[]>([])
const [newUrl, setNewUrl] = useState('')
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState<number | null>(null)
const [notice, setNotice] = useState('')
useEffect(() => {
if (!open) return
listWebhooks().then((r) => setWebhooks(r.data)).catch(() => {})
setNotice('')
}, [open])
const handleAdd = useCallback(async () => {
if (!newUrl.trim()) return
setSaving(true)
try {
const r = await createWebhook(newUrl.trim(), '*')
setWebhooks((p) => [...p, r.data])
setNewUrl('')
setNotice('已添加')
} catch (e) { setNotice(getApiError(e, '添加失败')) }
setSaving(false)
}, [newUrl])
const handleDelete = useCallback(async (id: number) => {
try {
await deleteWebhook(id)
setWebhooks((p) => p.filter((w) => w.id !== id))
} catch {}
}, [])
const handleTest = useCallback(async (id: number) => {
setTesting(id)
try {
const w = webhooks.find((h) => h.id === id)
await testWebhook(w?.url || 'test')
setNotice('测试已发送')
} catch { setNotice('测试失败') }
setTesting(null)
}, [webhooks])
if (!open) return null
return (
<Modal title="系统设置" onClose={onClose} maxWidth="max-w-xl">
<div className="space-y-5">
<label className="block space-y-2">
<span className="text-sm text-slate-300"></span>
<input
type="range"
min={12}
max={20}
value={terminalFontSize}
onChange={(event) => onFontSizeChange(Number(event.target.value))}
className="w-full"
/>
<div className="text-sm text-slate-500">{terminalFontSize}px</div>
</label>
<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"
value={terminalFontFamily}
onChange={(event) => onFontFamilyChange(event.target.value)}
/>
</label>
<div className="flex gap-4 mb-4 border-b pb-3 border-border-subtle">
<button onClick={() => setTab('appearance')}
className={`text-xs font-medium pb-1 ${tab === 'appearance' ? 'text-cyan-400 border-b border-cyan-400' : 'text-content-muted'}`}></button>
<button onClick={() => setTab('webhooks')}
className={`text-xs font-medium pb-1 ${tab === 'webhooks' ? 'text-cyan-400 border-b border-cyan-400' : 'text-content-muted'}`}>Webhook</button>
</div>
{tab === 'appearance' ? (
<div className="space-y-6">
{onDarkModeChange ? (
<div>
<div className="mb-3 text-sm font-medium text-content-muted"></div>
<div className="flex gap-3">
{[{ mode: false, label: '浅色', icon: Sun, desc: '亮色界面' }, { mode: true, label: '暗色', icon: Moon, desc: '深色界面' }].map((opt) => (
<button key={opt.label} onClick={() => onDarkModeChange(opt.mode)}
className={`flex flex-1 items-center gap-3 rounded-xl border px-4 py-3 text-sm transition ${
darkMode === opt.mode ? 'border-cyan-500/40 bg-cyan-500/10 text-cyan-400' : 'border-border-main bg-surface-muted text-content-muted hover:border-border-subtle'
}`}>
<opt.icon size={18} />
<div className="text-left"><div className="font-medium text-content-main">{opt.label}</div><div className="text-[11px] text-content-dim">{opt.desc}</div></div>
</button>
))}
</div>
</div>
) : null}
<div className="space-y-2">
<div className="flex justify-between text-sm"><span className="text-content-muted"></span><span className="text-content-dim">{terminalFontSize}px</span></div>
<input type="range" min={11} max={22} value={terminalFontSize}
onChange={(e) => onFontSizeChange(Number(e.target.value))} className="w-full h-1.5 accent-cyan-500 bg-surface-muted rounded-lg appearance-none" />
</div>
<div className="space-y-2">
<div className="text-sm font-medium text-content-muted"></div>
<div className="flex flex-wrap gap-2">
{FONT_OPTIONS.map((opt) => (
<button key={opt.label} onClick={() => onFontFamilyChange(opt.value)}
className={`rounded-lg px-3.5 py-2 text-xs transition ${
terminalFontFamily === opt.value ? 'border border-cyan-400/30 bg-cyan-500/10 text-cyan-400' : 'border-border-main bg-surface-muted text-content-muted hover:border-border-subtle'
}`} style={{ fontFamily: opt.value }}>{opt.label}</button>
))}
</div>
</div>
</div>
) : (
<div className="space-y-4">
<div className="text-xs text-content-dim">Webhook URL /Slack </div>
<div className="flex gap-2">
<input className="input-cyber flex-1 bg-surface-muted border-border-main text-content-main" placeholder="https://hooks.example.com/webhook"
value={newUrl} onChange={(e) => setNewUrl(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') void handleAdd() }} />
<button onClick={() => void handleAdd()} disabled={saving || !newUrl.trim()} className="btn-cyber-primary">
<Plus size={14} />
</button>
</div>
{webhooks.length === 0 ? (
<div className="py-8 text-center text-xs text-content-muted"> URL </div>
) : (
<div className="space-y-2">
{webhooks.map((w) => (
<div key={w.id} className="flex items-center gap-2 rounded-lg px-3 py-2 bg-surface-muted/40 border border-border-subtle">
<Link size={12} className="text-cyan-400" />
<span className="flex-1 truncate text-xs text-content-muted">{w.url}</span>
<button onClick={() => void handleTest(w.id)}
className="rounded p-1 text-content-dim hover:text-content-main transition"
title="发送测试">
{testing === w.id ? <LoaderCircle size={12} className="animate-spin" /> : <CheckCircle2 size={12} />}
</button>
<button onClick={() => void handleDelete(w.id)}
className="rounded p-1 text-content-dim hover:text-red-400 transition">
<Trash2 size={12} />
</button>
</div>
))}
</div>
)}
{notice ? <div className="text-xs text-cyan-400">{notice}</div> : null}
</div>
)}
</Modal>
)
}
@@ -64,7 +64,7 @@ export default function SftpCreateDirectoryModal({
footer={
<>
<button
className="rounded-xl bg-slate-700 px-4 py-2 text-sm text-slate-200 transition hover:bg-slate-600 disabled:cursor-not-allowed disabled:opacity-60"
className="rounded-xl bg-surface-muted px-4 py-2 text-sm text-content-muted transition hover:bg-surface-panel hover:text-content-main disabled:cursor-not-allowed disabled:opacity-60"
disabled={submitting}
onClick={handleClose}
>
@@ -81,16 +81,16 @@ export default function SftpCreateDirectoryModal({
}
>
<div className="space-y-5">
<div className="rounded-2xl border border-slate-800 bg-slate-950/50 px-4 py-3">
<div className="text-xs uppercase tracking-[0.24em] text-slate-500"></div>
<div className="mt-2 break-all font-mono text-sm text-slate-200">{currentPath}</div>
<div className="rounded-2xl border border-border-main bg-surface-muted/50 px-4 py-3">
<div className="text-xs uppercase tracking-[0.24em] text-content-dim"></div>
<div className="mt-2 break-all font-mono text-sm text-content-main">{currentPath}</div>
</div>
<label className="block space-y-2">
<span className="text-sm text-slate-300"></span>
<span className="text-sm text-content-muted"></span>
<input
ref={inputRef}
className="w-full rounded-2xl border border-slate-700 bg-slate-950 px-4 py-3 text-white outline-none transition focus:border-blue-500"
className="w-full rounded-2xl border border-border-main bg-surface-muted px-4 py-3 text-content-main outline-none transition focus:border-blue-500"
placeholder="例如:releases"
value={name}
onChange={(event) => setName(event.target.value)}
@@ -25,18 +25,18 @@ export default function SftpFileSelectorModal({
if (e.target === e.currentTarget) onClose()
}}
>
<div className="flex h-[80vh] w-[80vw] max-w-5xl flex-col overflow-hidden rounded-2xl border border-slate-700 bg-[#0d1117] shadow-2xl shadow-black/60">
<div className="flex h-[80vh] w-[80vw] max-w-5xl flex-col overflow-hidden rounded-2xl border border-border-main bg-surface-app shadow-2xl shadow-black/60">
{/* Header */}
<div className="flex shrink-0 items-center justify-between border-b border-slate-800 bg-slate-900 px-5 py-3.5">
<div className="flex shrink-0 items-center justify-between border-b border-border-main bg-surface-panel px-5 py-3.5">
<div>
<h2 className="text-sm font-semibold text-white"></h2>
<p className="mt-0.5 text-xs text-slate-400">
<h2 className="text-sm font-semibold text-content-main"></h2>
<p className="mt-0.5 text-xs text-content-muted">
<span className="text-purple-400">{connection.name}</span>
 · "确认选择"
</p>
</div>
<button
className="rounded-xl p-2 text-slate-500 transition hover:bg-slate-800 hover:text-white"
className="rounded-xl p-2 text-content-dim transition hover:bg-surface-muted hover:text-content-main"
onClick={onClose}
aria-label="关闭"
>
+199
View File
@@ -0,0 +1,199 @@
import { memo } from 'react'
import { cn, formatBytes, formatSftpDate, formatSftpPermissions } from '../lib/utils'
import type { SftpFileInfo } from '../types'
import type { SftpSortField, SftpSortDirection } from '../hooks/useSftpBrowser'
import { Folder, File, Download, Trash2, Box, ChevronDown } from 'lucide-react'
interface SftpFileTableProps {
loading: boolean
error: string | null
entries: SftpFileInfo[]
visibleEntries: SftpFileInfo[]
filteredEntries: SftpFileInfo[]
sortedEntries: SftpFileInfo[]
selectedFiles: string[]
showHiddenFiles: boolean
searchQuery: string
allSelected: boolean
onSelectAll: () => void
onToggleSelect: (name: string) => void
onDoubleClick: (entry: SftpFileInfo) => void
onDownload: (entry: SftpFileInfo) => void
onDelete: (entry: SftpFileInfo) => void
onEdit?: (entry: SftpFileInfo) => void
onCompress?: (entry: SftpFileInfo, format: string) => void
onDecompress?: (entry: SftpFileInfo) => void
sortField: SftpSortField
sortDirection: SftpSortDirection
onSortChange: (field: SftpSortField) => void
getAriaSort: (field: SftpSortField) => 'ascending' | 'descending' | 'none'
}
const FileRow = memo(function FileRow({
entry, checked, onToggleSelect, onDoubleClick, onDownload, onDelete, onEdit, onCompress, onDecompress,
}: {
entry: SftpFileInfo
checked: boolean
onToggleSelect: (name: string) => void
onDoubleClick: (entry: SftpFileInfo) => void
onDownload: (entry: SftpFileInfo) => void
onDelete: (entry: SftpFileInfo) => void
onEdit?: (entry: SftpFileInfo) => void
onCompress?: (entry: SftpFileInfo, format: string) => void
onDecompress?: (entry: SftpFileInfo) => void
}) {
return (
<tr
className={cn(
'group border-b border-border-subtle transition-all duration-200',
checked ? 'bg-emerald-500/5' : 'hover:bg-surface-muted'
)}
onDoubleClick={() => { if (entry.directory) onDoubleClick(entry) }}
>
<td className="px-4 py-2 w-10">
<div className="flex items-center justify-center">
<input type="checkbox" checked={checked}
onChange={() => onToggleSelect(entry.name)}
className="accent-emerald-500"
/>
</div>
</td>
<td className="relative px-2 py-2">
<div className="flex items-center gap-3">
{entry.directory
? <Folder size={14} className={cn('transition-colors', checked ? 'text-emerald-500' : 'text-content-muted')} />
: <File size={14} className="text-content-dim" />
}
<span className={cn('truncate transition-colors text-[13px]', checked ? 'text-content-main font-medium' : 'text-content-muted')}>
{entry.name}
</span>
</div>
{/* Row actions */}
<div className="absolute right-4 top-1/2 -translate-y-1/2 hidden group-hover:flex gap-1">
{onEdit && !entry.directory ? (
<button className="p-1.5 text-content-dim hover:text-emerald-500 transition-colors"
onClick={(e) => { e.stopPropagation(); onEdit(entry) }} title="编辑">
<File size={13} />
</button>
) : null}
<button className="p-1.5 text-content-dim hover:text-emerald-500 transition-colors"
onClick={(e) => { e.stopPropagation(); onDownload(entry) }} title="下载">
<Download size={13} />
</button>
{onCompress && entry.directory ? (
<button className="p-1.5 text-content-dim hover:text-blue-400 transition-colors"
onClick={(e) => { e.stopPropagation(); onCompress(entry, 'tar.gz') }} title="压缩为 tar.gz">
<Box size={13} />
</button>
) : null}
{onDecompress && (entry.name.endsWith('.tar.gz') || entry.name.endsWith('.zip') || entry.name.endsWith('.tgz')) ? (
<button className="p-1.5 text-content-dim hover:text-amber-400 transition-colors"
onClick={(e) => { e.stopPropagation(); onDecompress(entry) }} title="解压">
<ChevronDown size={13} />
</button>
) : null}
<button className="p-1.5 text-content-dim hover:text-red-500 transition-colors"
onClick={(e) => { e.stopPropagation(); onDelete(entry) }} title="删除">
<Trash2 size={13} />
</button>
</div>
</td>
<td className="px-4 py-2 text-[11px] font-mono text-content-muted opacity-60">
{entry.directory ? '-' : formatBytes(entry.size)}
</td>
<td className="px-4 py-2 font-mono text-[10px] tabular-nums text-content-muted opacity-40">
{formatSftpDate(entry.mtime)}
</td>
<td className="px-4 py-2 font-mono text-[10px] text-content-dim">
{formatSftpPermissions(entry)}
</td>
</tr>
)
})
export default memo(function SftpFileTable({
loading, error, entries, visibleEntries, filteredEntries, sortedEntries,
selectedFiles, allSelected,
onSelectAll, onToggleSelect, onDoubleClick, onDownload, onDelete,
onEdit, onCompress, onDecompress,
sortField, sortDirection, onSortChange,
}: SftpFileTableProps) {
const noResultsMsg = entries.length === 0
? '目录为空'
: visibleEntries.length === 0
? '暂无可见文件'
: '未找到匹配文件'
const noResultsHint = entries.length === 0
? '拖拽文件到此处,或点击上方“上传”开始传输。'
: visibleEntries.length === 0
? '请开启隐藏文件显示以查看内容。'
: '尝试更换搜索词或清空过滤器。'
return (
<div className="h-full overflow-auto scrollbar-thin">
<table className="w-full text-left">
<thead className="sticky top-0 z-20 bg-surface-panel border-b border-border-main">
<tr className="text-[10px] font-display uppercase tracking-widest text-content-dim">
<th className="px-4 py-3 w-10">
<div className="flex items-center justify-center">
<input type="checkbox" checked={allSelected} onChange={onSelectAll} className="accent-emerald-500" />
</div>
</th>
<th className="px-2 py-3 cursor-pointer hover:text-content-main transition-colors" onClick={() => onSortChange('name')}>
<div className="flex items-center gap-2">
{sortField === 'name' && <ChevronDown size={10} className={cn('transition-transform', sortDirection === 'asc' ? 'rotate-180' : '')} />}
</div>
</th>
<th className="px-4 py-3 w-24"></th>
<th className="px-4 py-3 w-44 cursor-pointer hover:text-content-main transition-colors" onClick={() => onSortChange('mtime')}>
<div className="flex items-center gap-2">
{sortField === 'mtime' && <ChevronDown size={10} className={cn('transition-transform', sortDirection === 'asc' ? 'rotate-180' : '')} />}
</div>
</th>
<th className="px-4 py-3 w-28"></th>
</tr>
</thead>
<tbody className="bg-surface-app/20">
{loading && (
<tr><td colSpan={5} className="px-4 py-12 text-center text-[10px] font-mono text-content-dim animate-pulse tracking-widest">...</td></tr>
)}
{error && (
<tr><td colSpan={5} className="px-4 py-12 text-center text-[10px] font-mono text-red-500 tracking-widest uppercase">: {error}</td></tr>
)}
{!loading && !error && filteredEntries.length === 0 && (
<tr>
<td colSpan={5} className="px-4 py-20 text-center">
<div className="inline-flex flex-col items-center gap-4 border border-border-subtle bg-surface-muted/40 p-10 foundry-panel border-t-2 border-t-border-main">
<Box size={32} className="text-content-dim" />
<div>
<div className="text-[11px] font-display uppercase tracking-widest text-content-main mb-1">{noResultsMsg}</div>
<div className="text-[10px] font-mono text-content-dim uppercase tracking-widest">{noResultsHint}</div>
</div>
</div>
</td>
</tr>
)}
{!loading && !error && sortedEntries.map((entry) => (
<FileRow
key={entry.name}
entry={entry}
checked={selectedFiles.includes(entry.name)}
onToggleSelect={onToggleSelect}
onDoubleClick={onDoubleClick}
onDownload={onDownload}
onDelete={onDelete}
onEdit={onEdit}
onCompress={onCompress}
onDecompress={onDecompress}
/>
))}
</tbody>
</table>
</div>
)
})
+156 -87
View File
@@ -18,7 +18,9 @@ import {
X,
} from 'lucide-react'
import {
compressRemote,
createDir,
decompressRemote,
deleteFile,
downloadFile,
getPwd,
@@ -28,7 +30,13 @@ import {
} from '../services/sftp'
import { cn, formatBytes, formatSftpDate, formatSftpPermissions } from '../lib/utils'
import type { Connection, SftpFileInfo, UploadConflictResponse, UploadTask } from '../types'
function getApiError(err: unknown, fallback: string): string {
const data = (err as any)?.response?.data
return data?.message || data?.error || fallback
}
import Modal from './Modal'
import FileEditorModal from './FileEditorModal'
import SftpCreateDirectoryModal from './SftpCreateDirectoryModal'
interface UploadQueueItem {
@@ -100,7 +108,9 @@ export default function SftpPane({
const [conflictDialog, setConflictDialog] = useState<UploadConflictDialogState>(emptyUploadConflictDialog)
const [applyToAll, setApplyToAll] = useState(false)
const [sortField, setSortField] = useState<SftpSortField>('name')
const [sortDirection, setSortDirection] = useState<SftpSortDirection>('asc')
const [sortDirection, setSortDirection] = useState<SftpSortField>('asc')
const [editorPath, setEditorPath] = useState<string | null>(null)
const [compressNotice, setCompressNotice] = useState<string | null>(null)
const normalizedSearchQuery = searchQuery.trim().toLowerCase()
const visibleEntries = useMemo(
() => entries.filter((entry) => showHiddenFiles || !entry.name.startsWith('.')),
@@ -153,10 +163,10 @@ export default function SftpPane({
const uploadMenuId = 'sftp-upload-menu'
const uploadButtonLabel = activeUploads > 0 ? `上传,当前有 ${activeUploads} 个任务进行中` : '上传'
const toolbarIconButtonClass =
'relative inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-slate-700/80 bg-slate-900/80 text-slate-400 transition hover:border-slate-600 hover:bg-slate-800 hover:text-white'
'relative inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-border-main bg-surface-panel/80 text-content-muted transition hover:border-border-subtle hover:bg-surface-muted hover:text-content-main'
const toolbarActiveIconButtonClass = 'border-blue-500/50 bg-blue-500/10 text-blue-200'
const pathToolbarFieldClass = cn(
'group flex h-9 min-w-0 shrink items-center overflow-hidden rounded-2xl border border-slate-700/80 bg-slate-950/90 px-3 text-sm text-slate-200 shadow-[inset_0_1px_0_rgba(148,163,184,0.08)] transition-[flex-grow,flex-basis,border-color,box-shadow] duration-200 focus-within:border-blue-500/60 focus-within:shadow-[inset_0_1px_0_rgba(96,165,250,0.18)]',
'group flex h-9 min-w-0 shrink items-center overflow-hidden rounded-2xl border border-border-main bg-surface-control px-3 text-sm text-content-main shadow-[inset_0_1px_0_rgba(148,163,184,0.08)] transition-[flex-grow,flex-basis,border-color,box-shadow] duration-200 focus-within:border-blue-500/60 focus-within:shadow-[inset_0_1px_0_rgba(96,165,250,0.18)]',
activeToolbarField === 'path'
? 'basis-[70%] grow-[7]'
: activeToolbarField === 'search'
@@ -164,7 +174,7 @@ export default function SftpPane({
: 'basis-[60%] grow-[6]',
)
const searchToolbarFieldClass = cn(
'group relative h-9 min-w-0 shrink overflow-hidden rounded-2xl border border-slate-700/80 bg-slate-950/90 text-slate-200 shadow-[inset_0_1px_0_rgba(148,163,184,0.08)] transition-[flex-grow,flex-basis,border-color,box-shadow] duration-200 focus-within:border-blue-500/60 focus-within:shadow-[inset_0_1px_0_rgba(96,165,250,0.18)]',
'group relative h-9 min-w-0 shrink overflow-hidden rounded-2xl border border-border-main bg-surface-control text-content-main shadow-[inset_0_1px_0_rgba(148,163,184,0.08)] transition-[flex-grow,flex-basis,border-color,box-shadow] duration-200 focus-within:border-blue-500/60 focus-within:shadow-[inset_0_1px_0_rgba(96,165,250,0.18)]',
activeToolbarField === 'search'
? 'basis-[58%] grow-[29]'
: activeToolbarField === 'path'
@@ -315,6 +325,36 @@ export default function SftpPane({
})
}
async function handleEditFile(entry: SftpFileInfo) {
setEditorPath(joinPath(currentPath, entry.name))
}
async function handleCompress(entry: SftpFileInfo, format: string) {
setCompressNotice(null)
try {
const path = joinPath(currentPath, entry.name)
const res = await compressRemote(connection.id, path, format)
setCompressNotice(res.data.message)
setTimeout(() => setCompressNotice(null), 4000)
await refresh()
} catch (err) {
setCompressNotice(getApiError(err, '压缩失败'))
}
}
async function handleDecompress(entry: SftpFileInfo) {
setCompressNotice(null)
try {
const path = joinPath(currentPath, entry.name)
const res = await decompressRemote(connection.id, path)
setCompressNotice(res.data.message)
setTimeout(() => setCompressNotice(null), 4000)
await refresh()
} catch (err) {
setCompressNotice(getApiError(err, '解压失败'))
}
}
async function handleDeleteMany() {
const targets = entries.filter((entry) => selectedFiles.includes(entry.name))
await Promise.all(targets.map((entry) => deleteFile(connection.id, joinPath(currentPath, entry.name), entry.directory)))
@@ -495,7 +535,7 @@ export default function SftpPane({
}
return (
<div className="relative flex h-full flex-col bg-slate-900">
<div className="relative flex h-full flex-col bg-surface-app">
<input
ref={fileInputRef}
type="file"
@@ -508,10 +548,10 @@ export default function SftpPane({
}}
/>
<div className="flex min-h-14 flex-nowrap items-center gap-2 border-b border-slate-800 bg-slate-800/80 px-3 py-2">
<div className="flex min-h-14 flex-nowrap items-center gap-2 border-b border-border-main bg-surface-panel px-3 py-2">
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
<button
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-slate-700/80 bg-slate-900/80 text-slate-400 transition hover:border-slate-600 hover:bg-slate-800 hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-border-subtle bg-surface-panel text-content-muted transition hover:border-border-main hover:bg-surface-muted hover:text-content-main disabled:cursor-not-allowed disabled:opacity-40"
onClick={() => {
if (currentPath === '/') return
const parent = currentPath.split('/').slice(0, -1).join('/') || '/'
@@ -533,7 +573,7 @@ export default function SftpPane({
className="mr-2 shrink-0 text-blue-400 transition-colors group-focus-within:text-blue-300"
/>
<input
className="min-w-0 flex-1 bg-transparent outline-none placeholder:text-slate-500"
className="min-w-0 flex-1 bg-transparent outline-none placeholder:text-content-dim"
value={currentPath}
onChange={(event) => setCurrentPath(event.target.value)}
onKeyDown={(event) => {
@@ -548,19 +588,19 @@ export default function SftpPane({
>
<Search
size={14}
className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500 transition-colors group-focus-within:text-blue-400"
className="absolute left-3 top-1/2 -translate-y-1/2 text-content-dim transition-colors group-focus-within:text-blue-400"
/>
<input
type="text"
placeholder="搜索文件..."
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
className="h-full w-full min-w-0 bg-transparent py-2 pl-9 pr-8 text-xs text-slate-200 outline-none placeholder:text-slate-500"
className="h-full w-full min-w-0 bg-transparent py-2 pl-9 pr-8 text-xs text-content-main outline-none placeholder:text-content-dim"
/>
{searchQuery ? (
<button
onClick={() => setSearchQuery('')}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-full p-1 text-slate-500 transition hover:bg-slate-800 hover:text-slate-300"
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-full p-1 text-content-dim transition hover:bg-surface-muted hover:text-content-muted"
title="清空搜索"
aria-label="清空搜索"
>
@@ -569,13 +609,13 @@ export default function SftpPane({
) : null}
</div>
</div>
<div className="ml-auto flex shrink-0 items-center gap-1.5 rounded-2xl border border-slate-700/80 bg-slate-950/70 p-1">
<div className="ml-auto flex shrink-0 items-center gap-1.5 rounded-2xl border border-border-subtle bg-surface-control p-1">
<div className="relative" ref={uploadMenuRef}>
<button
className={cn(
toolbarIconButtonClass,
'relative inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-border-subtle bg-surface-panel text-content-muted transition hover:border-border-main hover:bg-surface-muted hover:text-content-main',
uploadMenuOpen || activeUploads > 0
? toolbarActiveIconButtonClass
? 'border-blue-500/50 bg-blue-500/10 text-blue-200'
: undefined,
)}
onClick={() => setUploadMenuOpen((prev) => !prev)}
@@ -590,8 +630,8 @@ export default function SftpPane({
size={11}
aria-hidden="true"
className={cn(
'absolute bottom-1 right-1 text-slate-500 transition',
uploadMenuOpen || activeUploads > 0 ? 'text-blue-200' : 'text-slate-500',
'absolute bottom-1 right-1 text-content-dim transition',
uploadMenuOpen || activeUploads > 0 ? 'text-blue-200' : 'text-content-dim',
uploadMenuOpen && 'rotate-180',
)}
/>
@@ -608,11 +648,11 @@ export default function SftpPane({
<div
id={uploadMenuId}
role="menu"
className="absolute right-0 top-[calc(100%+8px)] z-20 w-44 overflow-hidden rounded-2xl border border-slate-700 bg-slate-900 shadow-2xl shadow-black/40"
className="absolute right-0 top-[calc(100%+8px)] z-20 w-44 overflow-hidden rounded-2xl border border-border-main bg-surface-card shadow-2xl shadow-black/40"
>
<button
role="menuitem"
className="flex w-full items-center gap-2 px-4 py-3 text-left text-sm text-slate-200 transition hover:bg-slate-800"
className="flex w-full items-center gap-2 px-4 py-3 text-left text-sm text-content-main transition hover:bg-surface-muted"
onClick={() => {
setUploadMenuOpen(false)
fileInputRef.current?.click()
@@ -623,7 +663,7 @@ export default function SftpPane({
</button>
<button
role="menuitem"
className="flex w-full items-center gap-2 border-t border-slate-800 px-4 py-3 text-left text-sm text-slate-200 transition hover:bg-slate-800"
className="flex w-full items-center gap-2 border-t border-border-subtle px-4 py-3 text-left text-sm text-content-main transition hover:bg-surface-muted"
onClick={handleUploadFolderPlaceholder}
>
<Folder size={14} className="text-amber-400" />
@@ -633,7 +673,7 @@ export default function SftpPane({
) : null}
</div>
<button
className={toolbarIconButtonClass}
className="relative inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-border-subtle bg-surface-panel text-content-muted transition hover:border-border-main hover:bg-surface-muted hover:text-content-main"
onClick={() => void refresh()}
title="刷新目录"
aria-label="刷新目录"
@@ -642,8 +682,8 @@ export default function SftpPane({
</button>
<button
className={cn(
toolbarIconButtonClass,
showHiddenFiles ? toolbarActiveIconButtonClass : undefined,
'relative inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-border-subtle bg-surface-panel text-content-muted transition hover:border-border-main hover:bg-surface-muted hover:text-content-main',
showHiddenFiles ? 'border-blue-500/50 bg-blue-500/10 text-blue-200' : undefined,
)}
onClick={() => setShowHiddenFiles((prev) => !prev)}
title={showHiddenFiles ? '关闭隐藏文件显示' : '显示隐藏文件'}
@@ -653,7 +693,7 @@ export default function SftpPane({
{showHiddenFiles ? <Eye size={16} /> : <EyeOff size={16} />}
</button>
<button
className={toolbarIconButtonClass}
className="relative inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-border-subtle bg-surface-panel text-content-muted transition hover:border-border-main hover:bg-surface-muted hover:text-content-main"
onClick={() => setCreateDirectoryModalOpen(true)}
title="新建目录"
aria-label="新建目录"
@@ -672,14 +712,14 @@ export default function SftpPane({
{selectedFiles.length > 0 ? (
<div className="flex h-10 items-center justify-between border-b border-blue-900/50 bg-blue-900/15 px-4 text-sm">
<span className="text-blue-300"> {selectedFiles.length} </span>
<div className="flex items-center gap-3 text-slate-300">
<div className="flex items-center gap-3 text-content-muted">
<button
onClick={() =>
entries
.filter((entry) => selectedFiles.includes(entry.name))
.forEach((entry) => void downloadFile(connection.id, joinPath(currentPath, entry.name), entry.name))
}
className="flex items-center gap-1 hover:text-white"
className="flex items-center gap-1 hover:text-content-main"
>
<Download size={14} />
@@ -693,10 +733,10 @@ export default function SftpPane({
) : null}
<div className="flex-1 overflow-auto isolate">
<table className="min-w-[48rem] w-full text-left text-sm text-slate-300">
<thead className="text-xs text-slate-400">
<table className="min-w-[48rem] w-full text-left text-sm text-content-muted">
<thead className="text-xs text-content-dim">
<tr>
<th className="sticky top-0 z-20 w-10 border-b border-slate-700 bg-slate-800 px-4 py-3">
<th className="sticky top-0 z-20 w-10 border-b border-border-main bg-surface-panel px-4 py-3">
<input
type="checkbox"
checked={allSelected}
@@ -709,12 +749,12 @@ export default function SftpPane({
}
/>
</th>
<th className="sticky top-0 z-20 border-b border-slate-700 bg-slate-800 px-2 py-3" aria-sort={getAriaSort('name')}>
<th className="sticky top-0 z-20 border-b border-border-main bg-surface-panel px-2 py-3" aria-sort={getAriaSort('name')}>
<button
type="button"
className={cn(
'inline-flex items-center gap-1.5 rounded-md transition hover:text-white',
sortField === 'name' ? 'text-blue-300' : undefined,
'inline-flex items-center gap-1.5 rounded-md transition hover:text-content-main',
sortField === 'name' ? 'text-blue-400' : undefined,
)}
onClick={() => handleSortChange('name')}
title={sortField === 'name' && sortDirection === 'asc' ? '按文件名降序排序' : '按文件名升序排序'}
@@ -727,19 +767,19 @@ export default function SftpPane({
className={cn('transition', sortDirection === 'asc' ? 'rotate-180' : undefined)}
/>
) : (
<span aria-hidden="true" className="text-[10px] text-slate-600">
<span aria-hidden="true" className="text-[10px] text-content-dim">
</span>
)}
</button>
</th>
<th className="sticky top-0 z-20 w-24 border-b border-slate-700 bg-slate-800 px-4 py-3"></th>
<th className="sticky top-0 z-20 w-44 border-b border-slate-700 bg-slate-800 px-4 py-3" aria-sort={getAriaSort('mtime')}>
<th className="sticky top-0 z-20 w-24 border-b border-border-main bg-surface-panel px-4 py-3"></th>
<th className="sticky top-0 z-20 w-44 border-b border-border-main bg-surface-panel px-4 py-3" aria-sort={getAriaSort('mtime')}>
<button
type="button"
className={cn(
'inline-flex items-center gap-1.5 rounded-md whitespace-nowrap transition hover:text-white',
sortField === 'mtime' ? 'text-blue-300' : undefined,
'inline-flex items-center gap-1.5 rounded-md whitespace-nowrap transition hover:text-content-main',
sortField === 'mtime' ? 'text-blue-400' : undefined,
)}
onClick={() => handleSortChange('mtime')}
title={sortField === 'mtime' && sortDirection === 'desc' ? '按修改时间从旧到新排序' : '按修改时间从新到旧排序'}
@@ -752,19 +792,19 @@ export default function SftpPane({
className={cn('transition', sortDirection === 'asc' ? 'rotate-180' : undefined)}
/>
) : (
<span aria-hidden="true" className="text-[10px] text-slate-600">
<span aria-hidden="true" className="text-[10px] text-content-dim">
</span>
)}
</button>
</th>
<th className="sticky top-0 z-20 w-28 border-b border-slate-700 bg-slate-800 px-4 py-3"></th>
<th className="sticky top-0 z-20 w-28 border-b border-border-main bg-surface-panel px-4 py-3"></th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-slate-500">
<td colSpan={5} className="px-4 py-8 text-center text-content-dim">
...
</td>
</tr>
@@ -778,17 +818,17 @@ export default function SftpPane({
) : null}
{!loading && !error && filteredEntries.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-16 text-center text-slate-500">
<div className="mx-auto flex max-w-sm flex-col items-center gap-3 rounded-[28px] border border-dashed border-slate-700 bg-slate-950/40 px-6 py-8">
<td colSpan={5} className="px-4 py-16 text-center text-content-dim">
<div className="mx-auto flex max-w-sm flex-col items-center gap-3 rounded-[28px] border border-dashed border-border-subtle bg-surface-muted/40 px-6 py-8">
<Upload size={22} className="text-blue-400" />
<div className="text-sm text-slate-300">
<div className="text-sm text-content-muted">
{entries.length === 0
? '当前目录为空'
: visibleEntries.length === 0 && !normalizedSearchQuery
? '当前目录暂无可见文件'
: '未找到匹配文件'}
</div>
<div className="text-xs text-slate-500">
<div className="text-xs text-content-dim">
{entries.length === 0
? '拖拽文件到这里,或使用上方“上传文件...”入口开始传输。'
: visibleEntries.length === 0 && !normalizedSearchQuery
@@ -805,7 +845,7 @@ export default function SftpPane({
return (
<tr
key={entry.name}
className={`group border-b border-slate-800 ${checked ? 'bg-blue-900/10' : 'hover:bg-slate-800/70'}`}
className={`group border-b border-border-subtle ${checked ? 'bg-blue-500/10' : 'hover:bg-surface-muted'}`}
onDoubleClick={() => {
if (entry.directory) void refresh(joinPath(currentPath, entry.name))
}}
@@ -823,33 +863,47 @@ export default function SftpPane({
</td>
<td className="relative px-2 py-3">
<div className="flex items-center gap-2">
{entry.directory ? <Folder size={16} className="text-blue-400" /> : <FileText size={16} className="text-slate-400" />}
<span className="truncate">{entry.name}</span>
{entry.directory ? <Folder size={16} className="text-blue-400" /> : <FileText size={16} className="text-content-dim" />}
<span className="truncate text-content-main">{entry.name}</span>
</div>
<div className="absolute right-2 top-1/2 hidden -translate-y-1/2 gap-1 rounded-lg border border-slate-700 bg-slate-900 p-1 group-hover:flex">
<button
className="p-1 text-slate-400 hover:text-blue-300"
<div className="absolute right-2 top-1/2 hidden -translate-y-1/2 gap-1 rounded-lg border border-border-subtle bg-surface-card p-1 group-hover:flex">
{!entry.directory ? (
<button className="p-1 text-content-dim hover:text-cyan-400"
onClick={() => setEditorPath(joinPath(currentPath, entry.name))}
title={`编辑 ${entry.name}`}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
) : (
<button className="p-1 text-content-dim hover:text-blue-400"
onClick={() => void handleCompress(entry, 'tar.gz')}
title={`压缩 ${entry.name}`}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>
</button>
)}
{(entry.name.endsWith('.tar.gz') || entry.name.endsWith('.zip') || entry.name.endsWith('.tgz')) ? (
<button className="p-1 text-content-dim hover:text-amber-400"
onClick={() => void handleDecompress(entry)}
title={`解压 ${entry.name}`}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/></svg>
</button>
) : null}
<button className="p-1 text-content-dim hover:text-blue-300"
onClick={() => void downloadFile(connection.id, joinPath(currentPath, entry.name), entry.name)}
title={`下载 ${entry.name}`}
aria-label={`下载 ${entry.name}`}
>
title={`下载 ${entry.name}`}>
<Download size={13} />
</button>
<button
className="p-1 text-slate-400 hover:text-red-400"
<button className="p-1 text-content-dim hover:text-red-400"
onClick={() => void deleteFile(connection.id, joinPath(currentPath, entry.name), entry.directory).then(() => refresh())}
title={`删除 ${entry.name}`}
aria-label={`删除 ${entry.name}`}
>
title={`删除 ${entry.name}`}>
<Trash2 size={13} />
</button>
</div>
</td>
<td className="px-4 py-3 text-slate-400">{entry.directory ? '-' : formatBytes(entry.size)}</td>
<td className="whitespace-nowrap px-4 py-3 font-mono text-[11px] tabular-nums text-slate-400">
<td className="px-4 py-3 text-content-dim">{entry.directory ? '-' : formatBytes(entry.size)}</td>
<td className="whitespace-nowrap px-4 py-3 font-mono text-[11px] tabular-nums text-content-dim">
{formatSftpDate(entry.mtime)}
</td>
<td className="px-4 py-3 font-mono text-xs text-slate-500">{formatSftpPermissions(entry)}</td>
<td className="px-4 py-3 font-mono text-xs text-content-dim">{formatSftpPermissions(entry)}</td>
</tr>
)
})
@@ -860,45 +914,45 @@ export default function SftpPane({
{uploadQueue.length > 0 ? (
<div className="pointer-events-none absolute bottom-4 right-4 z-10 w-[min(360px,calc(100%-2rem))]">
<div className="pointer-events-auto overflow-hidden rounded-[28px] border border-slate-700 bg-slate-950/95 shadow-2xl shadow-black/30 backdrop-blur">
<div className="flex items-center justify-between border-b border-slate-800 px-4 py-3">
<div className="pointer-events-auto overflow-hidden rounded-[28px] border border-border-main bg-surface-card shadow-2xl shadow-black/30 backdrop-blur">
<div className="flex items-center justify-between border-b border-border-subtle px-4 py-3">
<div>
<div className="text-sm font-medium text-slate-100"></div>
<div className="text-xs text-slate-500"></div>
<div className="text-sm font-medium text-content-main"></div>
<div className="text-xs text-content-dim"></div>
</div>
<button className="text-xs text-slate-400 transition hover:text-white" onClick={clearFinishedUploads}>
<button className="text-xs text-content-dim transition hover:text-content-main" onClick={clearFinishedUploads}>
</button>
</div>
<div className="max-h-72 space-y-3 overflow-auto p-3">
{uploadQueue.map((item) => (
<div key={item.id} className="rounded-2xl border border-slate-800 bg-slate-900/80 p-3">
<div key={item.id} className="rounded-2xl border border-border-subtle bg-surface-panel p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm text-slate-100">{item.filename}</div>
<div className="mt-1 flex items-center gap-2 text-xs text-slate-500">
<div className="truncate text-sm text-content-main">{item.filename}</div>
<div className="mt-1 flex items-center gap-2 text-xs text-content-dim">
{item.status === 'running' || item.status === 'queued' ? (
<LoaderCircle size={12} className="animate-spin text-blue-400" />
) : item.status === 'success' ? (
<CheckCircle2 size={12} className="text-emerald-400" />
) : item.status === 'skipped' || item.status === 'cancelled' ? (
<X size={12} className="text-slate-400" />
<X size={12} className="text-content-dim" />
) : (
<AlertCircle size={12} className="text-red-400" />
)}
<span>{item.message}</span>
</div>
</div>
<span className="text-xs text-slate-400">{item.progress}%</span>
<span className="text-xs text-content-dim">{item.progress}%</span>
</div>
<div className="mt-3 h-2 rounded-full bg-slate-800">
<div className="mt-3 h-2 rounded-full bg-surface-muted">
<div
className={cn(
'h-2 rounded-full transition-all',
item.status === 'success'
? 'bg-emerald-500'
: item.status === 'skipped' || item.status === 'cancelled'
? 'bg-slate-600'
? 'bg-content-dim'
: item.status === 'error'
? 'bg-red-500'
: 'bg-blue-500',
@@ -906,7 +960,7 @@ export default function SftpPane({
style={{ width: `${item.progress}%` }}
/>
</div>
<div className="mt-2 text-[11px] text-slate-500">
<div className="mt-2 text-[11px] text-content-dim">
{formatBytes(item.transferredBytes)} / {formatBytes(item.totalBytes)}
</div>
</div>
@@ -925,13 +979,13 @@ export default function SftpPane({
<>
<button
onClick={() => resolveConflictDecision('cancel', false)}
className="rounded bg-slate-700 px-4 py-2 text-sm text-slate-300 transition-colors hover:bg-slate-600 hover:text-white"
className="rounded bg-surface-muted px-4 py-2 text-sm text-content-muted transition-colors hover:bg-surface-panel hover:text-content-main"
>
</button>
<button
onClick={() => resolveConflictDecision('skip')}
className="rounded bg-slate-700 px-4 py-2 text-sm text-slate-300 transition-colors hover:bg-slate-600 hover:text-white"
className="rounded bg-surface-muted px-4 py-2 text-sm text-content-muted transition-colors hover:bg-surface-panel hover:text-content-main"
>
</button>
@@ -939,10 +993,10 @@ export default function SftpPane({
onClick={() => resolveConflictDecision('overwrite')}
disabled={!conflictDialog.canOverwrite}
className={cn(
'rounded px-4 py-2 text-sm text-white transition-colors shadow-lg shadow-blue-500/20',
'rounded px-4 py-2 text-sm transition-colors',
conflictDialog.canOverwrite
? 'bg-blue-600 hover:bg-blue-500'
: 'cursor-not-allowed bg-slate-700 text-slate-500 shadow-none',
? 'bg-blue-600 text-white hover:bg-blue-500 shadow-lg shadow-blue-500/20'
: 'cursor-not-allowed bg-surface-muted text-content-dim shadow-none',
)}
>
@@ -950,27 +1004,27 @@ export default function SftpPane({
</>
}
>
<div className="flex items-start gap-3 text-slate-300">
<div className="flex items-start gap-3 text-content-muted">
<AlertCircle className="mt-0.5 shrink-0 text-yellow-500" size={24} />
<div>
<p className="mb-2">
<strong className="text-white">{conflictDialog.fileName}</strong>
<strong className="text-content-main">{conflictDialog.fileName}</strong>
{conflictDialog.fileType === 'dir' ? '文件夹' : '文件'}
</p>
<p className="text-sm text-slate-400">{conflictDialog.message}</p>
<p className="text-sm text-content-dim">{conflictDialog.message}</p>
{conflictDialog.canOverwrite ? (
<p className="mt-2 text-sm text-slate-400"></p>
<p className="mt-2 text-sm text-content-dim"></p>
) : (
<p className="mt-2 text-sm text-slate-400"></p>
<p className="mt-2 text-sm text-content-dim"></p>
)}
<label className="group mt-5 flex w-max cursor-pointer items-center gap-2">
<input
type="checkbox"
checked={applyToAll}
onChange={(event) => setApplyToAll(event.target.checked)}
className="cursor-pointer rounded border-slate-600 bg-slate-900 text-blue-500 transition-colors focus:ring-blue-500 focus:ring-offset-slate-800"
className="cursor-pointer rounded border-border-main bg-surface-control text-blue-500 transition-colors focus:ring-blue-500 focus:ring-offset-surface-app"
/>
<span className="text-sm text-slate-300 transition-colors group-hover:text-white"></span>
<span className="text-sm text-content-muted transition-colors group-hover:text-content-main"></span>
</label>
</div>
</div>
@@ -984,6 +1038,21 @@ export default function SftpPane({
onSubmit={handleCreateDir}
/>
{compressNotice ? (
<div className="absolute bottom-4 left-4 z-10 rounded-xl border border-blue-800/40 bg-blue-950/80 px-4 py-2 text-xs text-blue-200 shadow-xl backdrop-blur">
{compressNotice}
</div>
) : null}
{editorPath && (
<FileEditorModal
open={!!editorPath}
connectionId={connection.id}
filePath={editorPath}
onClose={() => setEditorPath(null)}
/>
)}
{selectionMode ? (() => {
const singleSelected = selectedFiles.length === 1 ? selectedFiles[0] : null
const confirmPath = singleSelected
@@ -993,8 +1062,8 @@ export default function SftpPane({
<div className="shrink-0 border-t border-purple-900/50 bg-purple-950/30 px-4 py-3">
<div className="flex items-center gap-3">
<div className="min-w-0 flex-1">
<p className="text-xs text-slate-400">{singleSelected ? '已选择文件/文件夹' : '将选择当前目录'}</p>
<p className="mt-0.5 truncate font-mono text-xs text-purple-300">{confirmPath}</p>
<p className="text-xs text-content-dim">{singleSelected ? '已选择文件/文件夹' : '将选择当前目录'}</p>
<p className="mt-0.5 truncate font-mono text-xs text-purple-400">{confirmPath}</p>
</div>
<button
className="shrink-0 rounded-xl bg-purple-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-purple-500"
+145
View File
@@ -0,0 +1,145 @@
import { cn } from '../lib/utils'
import type { FocusEvent } from 'react'
import { ArrowUp, Search, X, Upload, RefreshCw, Eye, EyeOff, FolderPlus, ChevronDown, FileUp, FolderUp } from 'lucide-react'
export const toolbarIconButtonClass =
'relative inline-flex h-9 w-9 shrink-0 items-center justify-center border border-border-main bg-surface-panel/50 text-content-muted transition-all duration-300 hover:border-border-subtle hover:bg-surface-muted hover:text-content-main disabled:opacity-20 disabled:cursor-not-allowed'
export const toolbarActiveIconButtonClass = 'border-emerald-500/30 bg-emerald-500/10 text-emerald-500'
interface SftpToolbarProps {
currentPath: string
onPathChange: (path: string) => void
onPathEnter: () => void
onGoUp: () => void
searchQuery: string
onSearchChange: (query: string) => void
onSearchClear: () => void
showHiddenFiles: boolean
onToggleHiddenFiles: () => void
activeToolbarField: 'path' | 'search' | null
onFieldFocus: (field: 'path' | 'search') => void
onFieldBlur: (field: 'path' | 'search', event: FocusEvent<HTMLDivElement>) => void
uploadMenuOpen: boolean
activeUploads: number
onUploadMenuToggle: () => void
onUploadMenuClose: () => void
onUploadFileClick: () => void
onUploadFolderClick: () => void
onRefresh: () => void
onCreateDirectory: () => void
}
export default function SftpToolbar({
currentPath, onPathChange, onPathEnter, onGoUp,
searchQuery, onSearchChange, onSearchClear,
showHiddenFiles, onToggleHiddenFiles,
activeToolbarField, onFieldFocus, onFieldBlur,
uploadMenuOpen, activeUploads, onUploadMenuToggle, onUploadMenuClose,
onUploadFileClick, onUploadFolderClick,
onRefresh, onCreateDirectory,
}: SftpToolbarProps) {
const pathFieldClass = cn(
'group flex h-9 min-w-0 shrink items-center overflow-hidden border border-border-main bg-surface-muted/50 px-3 text-[11px] font-mono text-content-main transition-all duration-300 focus-within:border-emerald-500/40 focus-within:bg-surface-muted focus-within:shadow-inner-glow',
activeToolbarField === 'path'
? 'basis-[70%] grow-[7]'
: activeToolbarField === 'search'
? 'basis-[42%] grow-[21]'
: 'basis-[60%] grow-[6]',
)
const searchFieldClass = cn(
'group relative h-9 min-w-0 shrink overflow-hidden border border-border-main bg-surface-muted/50 text-[11px] font-mono text-content-main transition-all duration-300 focus-within:border-emerald-500/40 focus-within:bg-surface-muted focus-within:shadow-inner-glow',
activeToolbarField === 'search'
? 'basis-[58%] grow-[29]'
: activeToolbarField === 'path'
? 'basis-[30%] grow-[3]'
: 'basis-[40%] grow-[4]',
)
const buttonLabel = activeUploads > 0 ? `当前有 ${activeUploads} 个上传任务` : '上传'
return (
<div className="flex min-h-14 flex-nowrap items-center gap-2 border-b border-border-main bg-surface-panel/40 px-3 py-2">
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
<button
className={toolbarIconButtonClass}
onClick={onGoUp} disabled={currentPath === '/'}
title="返回上级"
>
<ArrowUp size={14} />
</button>
<div className={pathFieldClass}
onFocusCapture={() => onFieldFocus('path')}
onBlurCapture={(e) => onFieldBlur('path', e)}
>
<span className="mr-2 text-content-dim font-bold uppercase tracking-tighter"></span>
<input className="min-w-0 flex-1 bg-transparent outline-none placeholder:text-content-dim"
value={currentPath} onChange={(e) => onPathChange(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') onPathEnter() }}
/>
</div>
<div className={searchFieldClass}
onFocusCapture={() => onFieldFocus('search')}
onBlurCapture={(e) => onFieldBlur('search', e)}
>
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-content-dim group-focus-within:text-emerald-500 transition-colors" />
<input type="text" placeholder="搜索文件..." value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="h-full w-full min-w-0 bg-transparent py-2 pl-9 pr-8 text-[11px] outline-none placeholder:text-content-dim"
/>
{searchQuery && (
<button onClick={onSearchClear}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-content-dim hover:text-red-500 transition-colors"
>
<X size={12} />
</button>
)}
</div>
</div>
<div className="ml-auto flex shrink-0 items-center gap-1.5 p-1">
<div className="relative">
<button
className={cn(toolbarIconButtonClass, (uploadMenuOpen || activeUploads > 0) ? toolbarActiveIconButtonClass : undefined)}
onClick={onUploadMenuToggle}
title="上传"
>
<Upload size={14} />
<ChevronDown size={10} className={cn('absolute bottom-0 right-0 transition-transform duration-300', uploadMenuOpen && 'rotate-180')} />
{activeUploads > 0 && (
<span className="absolute -right-1 -top-1 h-2 w-2 rounded-full bg-emerald-500 animate-pulse" />
)}
</button>
{uploadMenuOpen && (
<div className="absolute right-0 top-[calc(100%+8px)] z-50 w-48 border border-border-main bg-surface-card shadow-xl animate-staggered-fade-in">
<button
className="flex w-full items-center gap-3 px-4 py-3 text-left text-[11px] font-display uppercase tracking-widest text-content-muted hover:bg-surface-muted hover:text-content-main transition-colors"
onClick={onUploadFileClick}
>
<FileUp size={14} className="text-emerald-500" />
</button>
<button
className="flex w-full items-center gap-3 border-t border-border-subtle px-4 py-3 text-left text-[11px] font-display uppercase tracking-widest text-content-muted hover:bg-surface-muted hover:text-content-main transition-colors"
onClick={onUploadFolderClick}
>
<FolderUp size={14} className="text-amber-500" />
</button>
</div>
)}
</div>
<button className={toolbarIconButtonClass} onClick={onRefresh} title="刷新目录">
<RefreshCw size={14} />
</button>
<button className={cn(toolbarIconButtonClass, showHiddenFiles ? toolbarActiveIconButtonClass : undefined)}
onClick={onToggleHiddenFiles}
title={showHiddenFiles ? '隐藏点文件' : '显示隐藏文件'}
>
{showHiddenFiles ? <Eye size={14} /> : <EyeOff size={14} />}
</button>
<button className={toolbarIconButtonClass} onClick={onCreateDirectory} title="新建目录">
<FolderPlus size={14} />
</button>
</div>
</div>
)
}
+135 -14
View File
@@ -1,44 +1,60 @@
import { useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { FitAddon } from '@xterm/addon-fit'
import { SearchAddon } from '@xterm/addon-search'
import { Terminal } from 'xterm'
import 'xterm/css/xterm.css'
import type { Connection, TerminalConnectionStatus } from '../types'
const CONTROL_PREFIX = '__SSHMANAGER__:'
// ── Quick SSH detection ──
const SSH_USER_RE = /^ssh\s+(\S+)@(\S+)\s*$/i
const SSH_PORT_RE = /^ssh\s+-p\s+(\d+)\s+(\S+)@(\S+)\s*$/i
const SSH_HOST_RE = /^ssh\s+(\S+)\s*$/i
export default function TerminalPane({
connection,
visible,
fontSize,
fontFamily,
onStatusChange,
onQuickConnect,
credentialToken,
}: {
connection: Connection
visible: boolean
fontSize: number
fontFamily: string
onStatusChange?: (status: TerminalConnectionStatus) => void
onQuickConnect?: (host: string, username: string, port: number) => void
credentialToken?: string
}) {
const containerRef = useRef<HTMLDivElement | null>(null)
const termRef = useRef<Terminal | null>(null)
const fitAddonRef = useRef<FitAddon | null>(null)
const searchAddonRef = useRef<SearchAddon | null>(null)
const wsRef = useRef<WebSocket | null>(null)
const resizeObserverRef = useRef<ResizeObserver | null>(null)
const reconnectTimerRef = useRef<number | null>(null)
const visibleRef = useRef(visible)
const syncViewportRef = useRef<() => void>(() => {})
const [status, setStatus] = useState<TerminalConnectionStatus>('connecting')
const [showSearch, setShowSearch] = useState(false)
const searchInputRef = useRef<HTMLInputElement | null>(null)
const searchTermRef = useRef('')
const sshBufferRef = useRef('')
const onQuickConnectRef = useRef(onQuickConnect)
onQuickConnectRef.current = onQuickConnect
const credentialTokenRef = useRef(credentialToken)
credentialTokenRef.current = credentialToken
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 }))
@@ -48,6 +64,34 @@ export default function TerminalPane({
onStatusChange?.(status)
}, [onStatusChange, status])
useEffect(() => {
const term = termRef.current
if (!term) return
term.options.fontSize = fontSize
term.options.fontFamily = fontFamily
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 }))
}, [fontSize, fontFamily])
const handleSearch = useCallback((direction: 'next' | 'prev') => {
const sa = searchAddonRef.current
const term = searchTermRef.current
if (!sa || !term) return
if (direction === 'next') sa.findNext(term, { caseSensitive: false })
else sa.findPrevious(term, { caseSensitive: false })
}, [])
const doSearch = useCallback((term: string) => {
searchTermRef.current = term
if (!term) {
searchAddonRef.current?.clearActiveDecoration()
return
}
searchAddonRef.current?.findNext(term, { caseSensitive: false, highlightAll: true })
}, [])
useEffect(() => {
let disposed = false
const term = new Terminal({
@@ -62,9 +106,12 @@ export default function TerminalPane({
},
})
const fit = new FitAddon()
const searchAddon = new SearchAddon()
term.loadAddon(fit)
term.loadAddon(searchAddon)
termRef.current = term
fitAddonRef.current = fit
searchAddonRef.current = searchAddon
term.open(containerRef.current!)
if (visible) {
syncViewportRef.current()
@@ -72,6 +119,35 @@ export default function TerminalPane({
}
const dataDisposable = term.onData((data) => {
// Quick SSH: detect `ssh user@host` or `ssh host` patterns
const buffer = sshBufferRef.current
if (data === '\r' || data === '\n') {
// Enter pressed - check buffer
const line = buffer.trim()
let match: RegExpMatchArray | null
if ((match = line.match(SSH_PORT_RE))) {
const port = parseInt(match[1]), user = match[2], host = match[3]
sshBufferRef.current = ''
onQuickConnectRef.current?.(host, user, port)
return // Don't send the ssh command
} else if ((match = line.match(SSH_USER_RE))) {
const user = match[1], host = match[2]
sshBufferRef.current = ''
onQuickConnectRef.current?.(host, user, 22)
return
} else if ((match = line.match(SSH_HOST_RE))) {
const host = match[1]
sshBufferRef.current = ''
onQuickConnectRef.current?.(host, 'root', 22)
return
}
sshBufferRef.current = ''
} else if (data === '\x7f') {
// Backspace
sshBufferRef.current = buffer.slice(0, -1)
} else if (data.length === 1 && data >= ' ') {
sshBufferRef.current = buffer + data
}
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(data)
}
@@ -86,6 +162,20 @@ export default function TerminalPane({
})
resizeObserverRef.current.observe(containerRef.current!)
// Ctrl+F opens search, Escape closes it
const keyDisposable = term.onKey((e) => {
if (e.domEvent.ctrlKey && e.domEvent.key === 'f') {
e.domEvent.preventDefault()
setShowSearch(true)
setTimeout(() => searchInputRef.current?.focus(), 50)
}
if (e.domEvent.key === 'Escape' && showSearch) {
setShowSearch(false)
searchAddon.clearActiveDecoration()
term.focus()
}
})
const connect = () => {
const token = localStorage.getItem('token')
if (!token) {
@@ -94,9 +184,12 @@ export default function TerminalPane({
}
setStatus('connecting')
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const ws = new WebSocket(
`${protocol}//${window.location.host}/ws/terminal?connectionId=${connection.id}&token=${encodeURIComponent(token)}`,
)
const credToken = credentialTokenRef.current
let wsUrl = `${protocol}//${window.location.host}/ws/terminal?connectionId=${connection.id}&token=${encodeURIComponent(token)}`
if (credToken) {
wsUrl += `&credentialToken=${encodeURIComponent(credToken)}`
}
const ws = new WebSocket(wsUrl)
wsRef.current = ws
ws.onopen = () => {
setStatus('connected')
@@ -124,25 +217,53 @@ export default function TerminalPane({
wsRef.current?.close()
dataDisposable.dispose()
resizeDisposable.dispose()
keyDisposable.dispose()
term.dispose()
}
}, [connection.id, fontFamily, fontSize])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [connection.id])
useEffect(() => {
if (!visible) return
const frame = window.requestAnimationFrame(() => {
syncViewportRef.current()
termRef.current?.focus()
})
return () => {
window.cancelAnimationFrame(frame)
}
return () => window.cancelAnimationFrame(frame)
}, [visible])
return (
<div className="flex h-full flex-col overflow-hidden bg-black">
<div className="relative flex h-full flex-col overflow-hidden bg-black">
{showSearch ? (
<div className="absolute top-2 right-2 z-10 flex items-center gap-1.5 rounded-lg border border-border-main bg-surface-card px-2.5 py-1.5 shadow-xl animate-in">
<input
ref={searchInputRef}
type="text"
placeholder="搜索..."
className="w-40 bg-transparent px-1.5 py-0.5 text-xs text-content-main outline-none placeholder:text-content-dim"
onChange={(e) => doSearch(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSearch('next')
if (e.key === 'Escape') { setShowSearch(false); searchAddonRef.current?.clearActiveDecoration(); termRef.current?.focus() }
}}
/>
<div className="flex items-center gap-0.5">
<button className="rounded p-1 text-content-dim hover:text-content-main transition" onClick={() => handleSearch('prev')} title="上一个">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="m18 15-6-6-6 6"/></svg>
</button>
<button className="rounded p-1 text-content-dim hover:text-content-main transition" onClick={() => handleSearch('next')} title="下一个">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="m6 9 6 6 6-6"/></svg>
</button>
<button
className="ml-1 rounded p-1 text-content-dim hover:text-content-main transition"
onClick={() => { setShowSearch(false); searchAddonRef.current?.clearActiveDecoration(); termRef.current?.focus() }}
title="关闭"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
</div>
</div>
) : null}
<div ref={containerRef} className="h-full w-full overflow-hidden" />
</div>
)
+57 -57
View File
@@ -34,12 +34,12 @@ function aggregate(items: TransferTaskItem[]) {
function StatusDot({ status }: { status: ConnectionReachabilityStatus | undefined }) {
if (status === 'online')
return <span className="h-2 w-2 shrink-0 rounded-full bg-emerald-400" title="在线" />
return <span className="h-2 w-2 shrink-0 rounded-full bg-emerald-500" title="在线" />
if (status === 'offline')
return <span className="h-2 w-2 shrink-0 rounded-full bg-red-400" title="离线" />
return <span className="h-2 w-2 shrink-0 rounded-full bg-red-500" title="离线" />
if (status === 'checking')
return <span className="h-2 w-2 shrink-0 animate-pulse rounded-full bg-yellow-400" title="检测中" />
return <span className="h-2 w-2 shrink-0 rounded-full bg-slate-600" title="未知" />
return <span className="h-2 w-2 shrink-0 rounded-full bg-content-dim" title="未知" />
}
@@ -216,11 +216,11 @@ export default function TransferCenterModal({
return (
<>
<Modal title="文件传输中心" onClose={onClose} maxWidth="max-w-7xl">
<div className="flex h-[74vh] flex-col overflow-hidden rounded-2xl border border-slate-800 bg-[#0d1117]">
<div className="flex h-[74vh] flex-col overflow-hidden rounded-2xl border border-border-main bg-surface-app">
{/* Tabs */}
<div className="flex gap-8 border-b border-slate-800 bg-slate-900 px-6 pt-4">
<div className="flex gap-8 border-b border-border-main bg-surface-panel px-6 pt-4">
<button
className={`border-b-2 pb-3 text-sm font-medium ${tab === 'local' ? 'border-blue-500 text-blue-400' : 'border-transparent text-slate-400'}`}
className={`border-b-2 pb-3 text-sm font-medium ${tab === 'local' ? 'border-blue-500 text-blue-400' : 'border-transparent text-content-muted'}`}
onClick={() => setTab('local')}
>
<span className="flex items-center gap-2">
@@ -229,7 +229,7 @@ export default function TransferCenterModal({
</span>
</button>
<button
className={`border-b-2 pb-3 text-sm font-medium ${tab === 'remote' ? 'border-purple-500 text-purple-400' : 'border-transparent text-slate-400'}`}
className={`border-b-2 pb-3 text-sm font-medium ${tab === 'remote' ? 'border-purple-500 text-purple-400' : 'border-transparent text-content-muted'}`}
onClick={() => { setTab('remote'); setShowBrowser(false) }}
>
<span className="flex items-center gap-2">
@@ -241,26 +241,26 @@ export default function TransferCenterModal({
<div className="flex flex-1 overflow-hidden">
{/* Left panel */}
<div className="flex flex-1 overflow-hidden border-r border-slate-800 bg-slate-900/70 p-6">
<div className="flex flex-1 overflow-hidden border-r border-border-main bg-surface-panel/70 p-6">
{tab === 'local' ? (
<div className="flex h-full flex-1 gap-6 overflow-hidden">
{/* Source config */}
<div className="flex w-1/2 shrink-0 flex-col gap-4 overflow-y-auto border-r border-slate-800 pr-6">
<div className="flex w-1/2 shrink-0 flex-col gap-4 overflow-y-auto border-r border-border-main pr-6">
<h3 className="flex items-center gap-2 text-sm font-bold text-blue-400">
<Monitor size={16} />
</h3>
<div className="space-y-2">
<span className="text-sm text-slate-300"></span>
<label className="group flex cursor-pointer flex-col items-center justify-center rounded-2xl border-2 border-dashed border-slate-700 bg-slate-800/30 px-6 py-8 transition-all hover:border-blue-500 hover:bg-blue-500/5">
<div className="mb-3 rounded-full bg-slate-800 p-3 shadow-sm transition-colors group-hover:bg-blue-600">
<FileUp size={24} className="text-slate-400 transition-colors group-hover:text-white" />
<span className="text-sm text-content-muted"></span>
<label className="group flex cursor-pointer flex-col items-center justify-center rounded-2xl border-2 border-dashed border-border-main bg-surface-muted/30 px-6 py-8 transition-all hover:border-blue-500 hover:bg-blue-500/5">
<div className="mb-3 rounded-full bg-surface-muted p-3 shadow-sm transition-colors group-hover:bg-blue-600">
<FileUp size={24} className="text-content-muted transition-colors group-hover:text-content-main" />
</div>
<span className="text-sm font-medium text-slate-200 transition-colors group-hover:text-blue-400">
<span className="text-sm font-medium text-content-main transition-colors group-hover:text-blue-400">
{localFiles && localFiles.length > 0 ? '重新选择文件' : '点击选择本地文件'}
</span>
<span className="mt-1 max-w-full truncate text-xs text-slate-500">
<span className="mt-1 max-w-full truncate text-xs text-content-dim">
{localFiles && localFiles.length > 0 ? (
<span className="text-emerald-400">{localFiles.length === 1 ? localFiles[0].name : `已选择 ${localFiles.length} 个文件`}</span>
) : (
@@ -277,12 +277,12 @@ export default function TransferCenterModal({
</div>
<label className="space-y-2">
<span className="text-sm text-slate-300"></span>
<input className="w-full rounded-xl border border-slate-700 bg-black px-4 py-3 text-sm text-white" value={localTargetPath} onChange={(event) => setLocalTargetPath(event.target.value)} />
<span className="text-sm text-content-muted"></span>
<input className="w-full rounded-xl border border-border-main bg-surface-muted px-4 py-3 text-sm text-content-main outline-none focus:border-blue-500" value={localTargetPath} onChange={(event) => setLocalTargetPath(event.target.value)} />
</label>
<div className="mt-auto rounded-xl border border-slate-800 bg-slate-950/50 px-4 py-3 text-xs text-slate-500">
<p className="font-medium text-slate-400"> </p>
<div className="mt-auto rounded-xl border border-border-main bg-surface-muted/50 px-4 py-3 text-xs text-content-dim">
<p className="font-medium text-content-muted"> </p>
<p className="mt-1"></p>
</div>
</div>
@@ -296,7 +296,7 @@ export default function TransferCenterModal({
<div className="flex min-h-0 flex-1 flex-col space-y-2">
<div className="flex shrink-0 items-center justify-between">
<span className="text-sm text-slate-300"></span>
<span className="text-sm text-content-muted"></span>
<button
className="text-xs text-blue-400"
onClick={() => setTargetIds(connections.filter((c) => connectionStatuses[c.id] === 'online').map((c) => c.id))}
@@ -304,7 +304,7 @@ export default function TransferCenterModal({
线
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto rounded-2xl border border-slate-700 bg-black p-2">
<div className="min-h-0 flex-1 overflow-y-auto rounded-2xl border border-border-main bg-surface-muted p-2">
{connections.map((server) => {
const st = connectionStatuses[server.id]
const isOnline = st === 'online'
@@ -312,7 +312,7 @@ export default function TransferCenterModal({
<label
key={server.id}
className={`flex items-center gap-2 rounded-xl px-3 py-2 ${
isOnline ? 'cursor-pointer hover:bg-slate-800' : 'cursor-not-allowed opacity-40'
isOnline ? 'cursor-pointer hover:bg-surface-panel' : 'cursor-not-allowed opacity-40'
}`}
>
<input
@@ -326,7 +326,7 @@ export default function TransferCenterModal({
}
/>
<StatusDot status={st} />
<span className="text-sm text-slate-300">{server.name}</span>
<span className="text-sm text-content-muted">{server.name}</span>
</label>
)
})}
@@ -336,7 +336,7 @@ export default function TransferCenterModal({
<button
className={`flex w-full items-center justify-center gap-2 rounded-xl py-3 font-medium transition ${
!localFiles?.length || targetIds.length === 0
? 'cursor-not-allowed bg-slate-800 text-slate-500'
? 'cursor-not-allowed bg-surface-muted text-content-dim'
: 'bg-blue-600 text-white hover:bg-blue-500'
}`}
disabled={!localFiles?.length || targetIds.length === 0}
@@ -350,7 +350,7 @@ export default function TransferCenterModal({
) : (
<div className="flex h-full flex-1 gap-6 overflow-hidden">
{/* Source config */}
<div className="flex w-1/2 shrink-0 flex-col gap-4 overflow-y-auto border-r border-slate-800 pr-6">
<div className="flex w-1/2 shrink-0 flex-col gap-4 overflow-y-auto border-r border-border-main pr-6">
<h3 className="flex items-center gap-2 text-sm font-bold text-purple-400">
<Zap size={16} />
@@ -358,22 +358,22 @@ export default function TransferCenterModal({
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-sm text-slate-300"></span>
<span className="text-sm text-content-muted"></span>
{(() => {
const st = connectionStatuses[remoteSourceId]
if (st === 'online') return <span className="flex items-center gap-1 rounded-full bg-emerald-500/15 px-2 py-0.5 text-xs font-medium text-emerald-400"><span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />线</span>
if (st === 'offline') return <span className="flex items-center gap-1 rounded-full bg-red-500/15 px-2 py-0.5 text-xs font-medium text-red-400"><span className="h-1.5 w-1.5 rounded-full bg-red-400" />线</span>
if (st === 'checking') return <span className="flex items-center gap-1 rounded-full bg-yellow-500/15 px-2 py-0.5 text-xs font-medium text-yellow-400"><span className="h-1.5 w-1.5 animate-pulse rounded-full bg-yellow-400" /></span>
return <span className="flex items-center gap-1 rounded-full bg-slate-700/60 px-2 py-0.5 text-xs font-medium text-slate-500"><span className="h-1.5 w-1.5 rounded-full bg-slate-500" /></span>
return <span className="flex items-center gap-1 rounded-full bg-surface-muted/60 px-2 py-0.5 text-xs font-medium text-content-dim"><span className="h-1.5 w-1.5 rounded-full bg-content-dim" /></span>
})()}
</div>
<select
className={`w-full rounded-xl border bg-black px-4 py-3 text-sm text-white ${
className={`w-full rounded-xl border bg-surface-muted px-4 py-3 text-sm text-content-main outline-none ${
isRemoteSourceOnline
? 'border-emerald-700'
: isRemoteSourceValid && connectionStatuses[remoteSourceId] === 'offline'
? 'border-red-800'
: 'border-slate-700'
: 'border-border-main'
}`}
value={remoteSourceId}
onChange={(event) => {
@@ -401,12 +401,12 @@ export default function TransferCenterModal({
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-slate-300"> / </span>
<span className="text-sm text-content-muted"> / </span>
<button
className={`flex items-center gap-1 rounded-lg px-2 py-1 text-xs transition ${
showBrowser
? 'bg-purple-600 text-white'
: 'border border-slate-700 text-slate-400 hover:border-purple-500 hover:text-purple-400'
: 'border border-border-main text-content-muted hover:border-purple-500 hover:text-purple-400'
}`}
onClick={() => setShowBrowser((v) => !v)}
>
@@ -415,15 +415,15 @@ export default function TransferCenterModal({
</button>
</div>
<input
className="w-full rounded-xl border border-slate-700 bg-black px-4 py-3 font-mono text-sm text-white"
className="w-full rounded-xl border border-border-main bg-surface-muted px-4 py-3 font-mono text-sm text-content-main outline-none focus:border-blue-500"
value={remoteSourcePath}
onChange={(event) => setRemoteSourcePath(event.target.value)}
placeholder="/opt/app"
/>
</div>
<div className="mt-auto rounded-xl border border-slate-800 bg-slate-950/50 px-4 py-3 text-xs text-slate-500">
<p className="font-medium text-slate-400">📁 </p>
<div className="mt-auto rounded-xl border border-border-main bg-surface-muted/50 px-4 py-3 text-xs text-content-dim">
<p className="font-medium text-content-muted">📁 </p>
<p className="mt-1"></p>
</div>
</div>
@@ -436,9 +436,9 @@ export default function TransferCenterModal({
</h3>
<label className="space-y-2">
<span className="text-sm text-slate-300"></span>
<span className="text-sm text-content-muted"></span>
<input
className="w-full rounded-xl border border-slate-700 bg-black px-4 py-3 text-sm text-white"
className="w-full rounded-xl border border-border-main bg-surface-muted px-4 py-3 text-sm text-content-main outline-none focus:border-blue-500"
value={remoteTargetPath}
onChange={(event) => setRemoteTargetPath(event.target.value)}
/>
@@ -446,7 +446,7 @@ export default function TransferCenterModal({
<div className="flex min-h-0 flex-1 flex-col space-y-2">
<div className="flex shrink-0 items-center justify-between">
<span className="text-sm text-slate-300"></span>
<span className="text-sm text-content-muted"></span>
<button
className="text-xs text-blue-400"
onClick={() =>
@@ -462,7 +462,7 @@ export default function TransferCenterModal({
线
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto rounded-2xl border border-slate-700 bg-black p-2">
<div className="min-h-0 flex-1 overflow-y-auto rounded-2xl border border-border-main bg-surface-muted p-2">
{(tab === 'remote'
? connections.filter((c) => c.id !== remoteSourceId)
: connections
@@ -473,7 +473,7 @@ export default function TransferCenterModal({
<label
key={server.id}
className={`flex items-center gap-2 rounded-xl px-3 py-2 ${
isOnline ? 'cursor-pointer hover:bg-slate-800' : 'cursor-not-allowed opacity-40'
isOnline ? 'cursor-pointer hover:bg-surface-panel' : 'cursor-not-allowed opacity-40'
}`}
>
<input
@@ -487,7 +487,7 @@ export default function TransferCenterModal({
}
/>
<StatusDot status={st} />
<span className="text-sm text-slate-300">{server.name}</span>
<span className="text-sm text-content-muted">{server.name}</span>
</label>
)
})}
@@ -497,7 +497,7 @@ export default function TransferCenterModal({
<button
className={`flex w-full items-center justify-center gap-2 rounded-xl py-3 font-medium transition ${
remoteStartDisabled
? 'cursor-not-allowed bg-slate-800 text-slate-500'
? 'cursor-not-allowed bg-surface-muted text-content-dim'
: 'bg-purple-600 text-white hover:bg-purple-500'
}`}
disabled={remoteStartDisabled}
@@ -520,31 +520,31 @@ export default function TransferCenterModal({
</div>
{/* Task status panel */}
<div className="flex w-[360px] shrink-0 flex-col bg-[#0d1117]">
<div className="flex items-center justify-between border-b border-slate-800 bg-slate-900 px-4 py-4">
<h4 className="flex items-center gap-2 text-sm font-medium text-slate-200">
<ListTree size={16} className="text-slate-400" />
<div className="flex w-[360px] shrink-0 flex-col bg-surface-app border-l border-border-main">
<div className="flex items-center justify-between border-b border-border-main bg-surface-panel px-4 py-4">
<h4 className="flex items-center gap-2 text-sm font-medium text-content-main">
<ListTree size={16} className="text-content-muted" />
</h4>
<button className="text-xs text-slate-400" onClick={() => onTasksChange([])}>
<button className="text-xs text-content-dim hover:text-content-muted" onClick={() => onTasksChange([])}>
</button>
</div>
<div className="flex-1 space-y-4 overflow-auto p-3">
{tasks.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center text-slate-500">
<div className="flex h-full flex-col items-center justify-center text-content-dim">
<FileUp size={32} className="mb-2 opacity-50" />
<p className="text-sm"></p>
</div>
) : (
tasks.map((task) => (
<div key={task.id} className="overflow-hidden rounded-2xl border border-slate-700 bg-slate-800">
<div className="border-b border-slate-700 bg-slate-800/90 p-3">
<div key={task.id} className="overflow-hidden rounded-2xl border border-border-main bg-surface-panel">
<div className="border-b border-border-subtle bg-surface-panel/90 p-3">
<div className="mb-2 flex items-start justify-between">
<span className="pr-2 text-sm font-medium text-slate-100">{task.title}</span>
<span className="pr-2 text-sm font-medium text-content-main">{task.title}</span>
{task.status === 'running' ? (
<button
className="shrink-0 text-xs text-red-400"
className="shrink-0 text-xs text-red-400 hover:text-red-300"
onClick={() => {
task.items.forEach((item) => {
if (item.taskId) void cancelRemoteTransferTask(item.taskId)
@@ -555,22 +555,22 @@ export default function TransferCenterModal({
</button>
) : null}
</div>
<div className="text-xs text-slate-500">
<div className="text-xs text-content-dim">
{task.createdAt}
</div>
</div>
<div className="max-h-48 space-y-1 overflow-auto bg-slate-900/60 p-2">
<div className="max-h-48 space-y-1 overflow-auto bg-surface-app/60 p-2">
{task.items.map((item) => (
<div key={item.id} className="rounded-xl p-2 hover:bg-slate-800">
<div key={item.id} className="rounded-xl p-2 hover:bg-surface-muted">
<div className="flex justify-between text-xs">
<span className="truncate text-slate-300">{item.label}</span>
<span className="truncate text-content-muted">{item.label}</span>
<div className="flex items-center gap-2">
<span className="text-slate-500">{item.message}</span>
<span className="text-content-dim">{item.message}</span>
<span className={item.status === 'success' ? 'text-emerald-400' : 'text-blue-400'}>{item.progress}%</span>
</div>
</div>
<div className="mt-2 flex items-center gap-2">
<div className="h-1 flex-1 rounded-full bg-black">
<div className="h-1 flex-1 rounded-full bg-surface-muted">
<div
className={`h-1 rounded-full ${item.status === 'success' ? 'bg-emerald-500' : item.status === 'error' ? 'bg-red-500' : 'bg-blue-400'}`}
style={{ width: `${item.progress}%` }}
+10 -2
View File
@@ -54,12 +54,20 @@ export function formatSftpPermissions(entry: { directory: boolean }) {
}
function sortBuiltNodes(items: BuiltTreeNode[]) {
items.sort((a, b) => a.order - b.order || a.name.localeCompare(b.name))
items.sort((a, b) => {
const aPinned = a.connection?.pinned ?? false
const bPinned = b.connection?.pinned ?? false
if (aPinned !== bPinned) return aPinned ? -1 : 1
return 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))
return connections.slice().sort((a, b) => {
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1
return a.name.localeCompare(b.name)
})
}
function generateNodeId(prefix: 'folder' | 'connection', suffix: string) {
+264
View File
@@ -0,0 +1,264 @@
import { useEffect, useMemo, useState } from 'react'
import { Activity, Cpu, HardDrive, Monitor, Server, Terminal, Wifi } from 'lucide-react'
import type { Connection, ConnectionReachabilityStatus, MonitorMetrics } from '../types'
import { listConnections } from '../services/connections'
import { checkConnectionStatuses } from '../services/connections'
import { getMetrics } from '../services/monitor'
type StatusCount = { online: number; offline: number; unknown: number }
function MiniSparkline({ data, color }: { data: number[]; color: string }) {
if (data.length < 2) return null
const max = Math.max(...data, 1)
const min = Math.min(...data, 0)
const range = max - min || 1
const w = 120; const h = 32
const pts = data.map((v, i) => `${(i / (data.length - 1)) * w},${h - ((v - min) / range) * (h - 4) - 2}`).join(' ')
return (
<svg width={w} height={h} className="shrink-0">
<polyline fill="none" stroke={color} strokeWidth="1.5" points={pts} />
</svg>
)
}
function Gauge({ value, label, color }: { value: number; label: string; color: string }) {
const r = 28; const cx = 32; const cy = 32; const circ = 2 * Math.PI * r
const offset = circ - (value / 100) * circ
return (
<div className="flex flex-col items-center">
<svg width="64" height="64" viewBox="0 0 64 64">
<circle cx={cx} cy={cy} r={r} fill="none" stroke="rgba(148,163,184,0.1)" strokeWidth="5" />
<circle cx={cx} cy={cy} r={r} fill="none" stroke={color} strokeWidth="5"
strokeDasharray={circ} strokeDashoffset={offset}
strokeLinecap="round" transform={`rotate(-90 ${cx} ${cy})`} />
<text x={cx} y={cy} textAnchor="middle" dominantBaseline="central" fill="#e2e8f0" fontSize="11" fontWeight="600">{Math.round(value)}%</text>
</svg>
<span className="mt-1 text-[10px] text-content-dim">{label}</span>
</div>
)
}
export default function DashboardPage({ onBack }: { onBack: () => void }) {
const [connections, setConnections] = useState<Connection[]>([])
const [statuses, setStatuses] = useState<Record<number, ConnectionReachabilityStatus>>({})
const [metricsMap, setMetricsMap] = useState<Record<number, MonitorMetrics>>({})
const [history, setHistory] = useState<Record<number, number[]>>({})
const [loading, setLoading] = useState(true)
const [metricsLoading, setMetricsLoading] = useState(false)
const [selectedConn, setSelectedConn] = useState<number | null>(null)
useEffect(() => {
let cancelled = false
;(async () => {
try {
const connRes = await listConnections()
if (cancelled) return
const conns = connRes.data
setConnections(conns)
if (conns.length > 0 && !selectedConn) setSelectedConn(conns[0].id)
} catch {} finally { if (!cancelled) setLoading(false) }
})()
return () => { cancelled = true }
}, []) // eslint-disable-line react-hooks/exhaustive-deps
// Fetch status and metrics only for the selected server. Avoid loading every host on dashboard open.
useEffect(() => {
if (!selectedConn) return
let cancelled = false
const refreshSelected = async () => {
setMetricsLoading(true)
setStatuses((prev) => ({ ...prev, [selectedConn]: 'checking' }))
try {
const statusRes = await checkConnectionStatuses([selectedConn])
if (cancelled) return
const status = statusRes.data.results[0]?.status ?? 'unknown'
setStatuses((prev) => ({ ...prev, [selectedConn]: status }))
if (status !== 'online') {
setMetricsMap((prev) => {
const next = { ...prev }
delete next[selectedConn]
return next
})
return
}
const m = await getMetrics(selectedConn)
if (cancelled) return
setMetricsMap((prev) => ({ ...prev, [selectedConn]: m.data }))
const cpu = m.data.cpuUsage
if (cpu != null) {
setHistory((prev) => ({ ...prev, [selectedConn]: [...(prev[selectedConn] || []).slice(-29), cpu] }))
}
} catch {
if (!cancelled) {
setStatuses((prev) => ({ ...prev, [selectedConn]: 'unknown' }))
setMetricsMap((prev) => {
const next = { ...prev }
delete next[selectedConn]
return next
})
}
} finally {
if (!cancelled) setMetricsLoading(false)
}
}
void refreshSelected()
const timer = window.setInterval(refreshSelected, 5000)
return () => {
cancelled = true
window.clearInterval(timer)
}
}, [selectedConn])
const counts: StatusCount = useMemo(() => {
const c = { online: 0, offline: 0, unknown: 0 }
connections.forEach((conn) => {
const s = statuses[conn.id]
if (s === 'online') c.online++
else if (s === 'offline') c.offline++
else c.unknown++
})
return c
}, [connections, statuses])
const activeConn = connections.find((c) => c.id === selectedConn)
const activeMetrics = selectedConn ? metricsMap[selectedConn] : null
return (
<div className="flex h-screen flex-col overflow-hidden bg-surface-app">
{/* Header */}
<header className="flex h-12 items-center justify-between shrink-0 px-4 border-b border-border-main bg-surface-panel/60">
<div className="flex items-center gap-3">
<button onClick={onBack} className="flex items-center gap-1.5 text-xs font-medium transition hover:brightness-150 text-cyan-500">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="m15 18-6-6 6-6"/></svg>
</button>
<div className="h-4 w-px bg-border-main" />
<Monitor size={16} className="text-cyan-500" />
<h1 className="text-sm font-semibold text-content-main"></h1>
</div>
<div className="flex items-center gap-3">
<span className="flex items-center gap-1.5 text-xs text-content-main">
<span className="h-2 w-2 rounded-full bg-emerald-500" />线 {counts.online}
</span>
<span className="flex items-center gap-1.5 text-xs text-content-main">
<span className="h-2 w-2 rounded-full bg-red-500" />线 {counts.offline}
</span>
<span className="flex items-center gap-1.5 text-xs text-content-dim"> {connections.length} </span>
</div>
</header>
{loading ? (
<div className="flex flex-1 items-center justify-center text-xs text-content-dim">...</div>
) : connections.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center">
<Monitor size={48} className="text-cyan-500/15" />
<p className="mt-3 text-sm text-content-dim"> SSH </p>
</div>
) : (
<div className="flex flex-1 overflow-hidden">
{/* Server list sidebar */}
<aside className="w-56 shrink-0 overflow-auto p-2 border-r border-border-main">
{connections.map((conn) => {
const s = statuses[conn.id]
const isOnline = s === 'online'
const m = metricsMap[conn.id]
const sel = selectedConn === conn.id
return (
<button key={conn.id} onClick={() => setSelectedConn(conn.id)}
className={`w-full rounded-lg px-3 py-2 text-left text-xs transition-all mb-0.5 ${sel ? 'bg-cyan-500/5 border border-cyan-500/20' : 'border border-transparent hover:bg-surface-muted'}`}>
<div className="flex items-center gap-2">
<div className={`h-1.5 w-1.5 shrink-0 rounded-full ${isOnline ? 'bg-emerald-500' : 'bg-content-dim'}`} />
<span className={`truncate font-medium ${sel ? 'text-content-main' : 'text-content-muted'}`}>{conn.name}</span>
</div>
{isOnline && m ? (
<div className="mt-1 flex gap-2 text-[10px] text-content-dim">
<span>CPU {m.cpuUsage ?? '-'}%</span>
<span>MEM {m.memUsage ?? '-'}%</span>
</div>
) : null}
</button>
)
})}
</aside>
{/* Main panel */}
<main className="flex-1 overflow-auto p-5">
{activeConn && activeMetrics ? (
<div className="space-y-5">
{/* Server header */}
<div>
<h2 className="text-base font-semibold text-content-main">{activeConn.name}</h2>
<p className="text-xs mt-0.5 text-content-dim">{activeConn.host}:{activeConn.port}</p>
</div>
{/* Gauges row */}
<div className="flex gap-6 flex-wrap">
<Gauge value={activeMetrics.cpuUsage ?? 0} label="CPU" color="#22d3ee" />
<Gauge value={activeMetrics.memUsage ?? 0} label="内存" color="#818cf8" />
<Gauge value={activeMetrics.diskUsage ?? 0} label="磁盘" color="#34d399" />
</div>
{/* CPU history sparkline */}
<div className="rounded-xl p-4 bg-surface-panel/40 border border-border-main">
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-medium text-content-muted">CPU (30s)</span>
<div className="flex gap-4 text-[10px] text-content-dim">
<span className="flex items-center gap-1"><Cpu size={10} /> : {activeMetrics.load1 ?? '-'}</span>
<span className="flex items-center gap-1"><Server size={10} /> : {activeMetrics.cpuCores ?? '-'}</span>
<span className="flex items-center gap-1"><Activity size={10} /> : {activeMetrics.uptime ?? '-'}</span>
</div>
</div>
{(history[selectedConn!]?.length ?? 0) >= 2 ? (
<MiniSparkline data={history[selectedConn!]} color="#22d3ee" />
) : (
<div className="h-8 flex items-center text-[10px] text-content-dim">...</div>
)}
</div>
{/* Server stats */}
<div className="grid grid-cols-2 gap-4">
<div className="rounded-xl p-4 bg-surface-panel/40 border border-border-main">
<div className="text-xs font-medium mb-2 text-content-muted"></div>
<div className="text-lg font-semibold text-content-main">
{formatSize(activeMetrics.memUsed ?? 0)} / {formatSize(activeMetrics.memTotal ?? 0)}
</div>
<div className="text-[10px] mt-0.5 text-content-dim"> / </div>
</div>
<div className="rounded-xl p-4 bg-surface-panel/40 border border-border-main">
<div className="text-xs font-medium mb-2 text-content-muted">使</div>
<div className="text-lg font-semibold text-content-main">
{activeMetrics.diskUsage != null ? `${activeMetrics.diskUsage}%` : '-'}
</div>
<div className="text-[10px] mt-0.5 text-content-dim"></div>
</div>
</div>
</div>
) : activeConn && metricsLoading ? (
<div className="flex flex-1 items-center justify-center h-full text-xs text-content-dim">
<Wifi size={32} className="mr-3 animate-pulse text-cyan-500/30" />
...
</div>
) : activeConn && !activeMetrics ? (
<div className="flex flex-1 items-center justify-center h-full text-xs text-content-dim">
<Wifi size={32} className="mr-3 text-red-500/30" />
线
</div>
) : null}
</main>
</div>
)}
</div>
)
}
function formatSize(bytes?: number | null) {
if (bytes == null || Number.isNaN(bytes)) return '-'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(0)} MB`
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
}
+33 -33
View File
@@ -73,30 +73,30 @@ export default function LoginPage({ onSuccess }: { onSuccess: () => void }) {
}
return (
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-slate-950 p-4">
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-surface-app p-4">
<div className="absolute left-[12%] top-[18%] h-80 w-80 rounded-full bg-cyan-500/10 blur-3xl" />
<div className="absolute bottom-[14%] right-[10%] h-96 w-96 rounded-full bg-blue-600/20 blur-3xl" />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.09),transparent_35%),linear-gradient(135deg,rgba(15,23,42,0.8),rgba(2,6,23,1))]" />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.09),transparent_35%)] dark:bg-[linear-gradient(135deg,rgba(15,23,42,0.8),rgba(2,6,23,1))]" />
<div className="relative z-10 w-full max-w-md rounded-[28px] border border-slate-800 bg-slate-900/75 p-8 shadow-[0_30px_80px_rgba(2,6,23,0.72)] backdrop-blur-xl">
<div className="relative z-10 w-full max-w-md rounded-[28px] border border-border-main bg-surface-card/75 p-8 shadow-[0_30px_80px_rgba(2,6,23,0.3)] backdrop-blur-xl">
<div className="mb-8 flex items-center justify-center gap-4">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-cyan-400 to-blue-600 shadow-lg shadow-blue-900/40">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-cyan-400 to-blue-600 shadow-lg shadow-blue-900/20">
<Terminal size={28} className="text-white" />
</div>
<div>
<div className="text-xs uppercase tracking-[0.4em] text-cyan-300/70">Secure Control</div>
<h1 className="text-3xl font-semibold tracking-tight text-white">SSH Manager</h1>
<div className="text-xs uppercase tracking-[0.4em] text-cyan-500/70">Secure Control</div>
<h1 className="text-3xl font-semibold tracking-tight text-content-main">SSH Manager</h1>
</div>
</div>
{mode === 'login' ? (
<form className="space-y-5" onSubmit={handleLogin}>
<label className="block space-y-2">
<span className="text-sm text-slate-300"></span>
<span className="text-sm text-content-muted"></span>
<div className="relative">
<User className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" size={18} />
<User className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-content-dim" size={18} />
<input
className="w-full rounded-xl border border-slate-800 bg-slate-950 px-10 py-3 text-white outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
className="w-full rounded-xl border border-border-main bg-surface-control px-10 py-3 text-content-main outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
value={username}
onChange={(event) => setUsername(event.target.value)}
placeholder="请输入账号"
@@ -105,12 +105,12 @@ export default function LoginPage({ onSuccess }: { onSuccess: () => void }) {
</label>
<label className="block space-y-2">
<span className="text-sm text-slate-300"></span>
<span className="text-sm text-content-muted"></span>
<div className="relative">
<Lock className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" size={18} />
<Lock className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-content-dim" size={18} />
<input
type="password"
className="w-full rounded-xl border border-slate-800 bg-slate-950 px-10 py-3 text-white outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
className="w-full rounded-xl border border-border-main bg-surface-control px-10 py-3 text-content-main outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="请输入密码"
@@ -118,12 +118,12 @@ export default function LoginPage({ onSuccess }: { onSuccess: () => void }) {
</div>
</label>
{error ? <div className="rounded-xl border border-red-500/25 bg-red-500/10 px-4 py-3 text-sm text-red-300">{error}</div> : null}
{error ? <div className="rounded-xl border border-red-500/25 bg-red-500/10 px-4 py-3 text-sm text-red-500">{error}</div> : null}
<button
type="submit"
disabled={submitting}
className="w-full rounded-xl bg-gradient-to-r from-cyan-500 to-blue-600 px-4 py-3 font-medium text-white shadow-lg shadow-cyan-900/30 transition hover:from-cyan-400 hover:to-blue-500 disabled:cursor-not-allowed disabled:opacity-60"
className="w-full rounded-xl bg-gradient-to-r from-cyan-500 to-blue-600 px-4 py-3 font-medium text-white shadow-lg shadow-cyan-900/20 transition hover:from-cyan-400 hover:to-blue-500 disabled:cursor-not-allowed disabled:opacity-60"
>
{submitting ? '登录中...' : '登 录'}
</button>
@@ -131,21 +131,21 @@ export default function LoginPage({ onSuccess }: { onSuccess: () => void }) {
<div className="text-center">
<button
type="button"
className="text-sm text-slate-500 transition hover:text-cyan-400"
className="text-sm text-content-dim transition hover:text-cyan-500"
onClick={switchMode}
>
<span className="font-medium"></span>
<span className="font-medium text-cyan-600 dark:text-cyan-400"></span>
</button>
</div>
</form>
) : (
<form className="space-y-5" onSubmit={handleRegister}>
<label className="block space-y-2">
<span className="text-sm text-slate-300"> *</span>
<span className="text-sm text-content-muted"> *</span>
<div className="relative">
<User className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" size={18} />
<User className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-content-dim" size={18} />
<input
className="w-full rounded-xl border border-slate-800 bg-slate-950 px-10 py-3 text-white outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
className="w-full rounded-xl border border-border-main bg-surface-control px-10 py-3 text-content-main outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
value={regUsername}
onChange={(event) => setRegUsername(event.target.value)}
placeholder="登录使用的用户名"
@@ -154,11 +154,11 @@ export default function LoginPage({ onSuccess }: { onSuccess: () => void }) {
</label>
<label className="block space-y-2">
<span className="text-sm text-slate-300"></span>
<span className="text-sm text-content-muted"></span>
<div className="relative">
<UserPlus className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" size={18} />
<UserPlus className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-content-dim" size={18} />
<input
className="w-full rounded-xl border border-slate-800 bg-slate-950 px-10 py-3 text-white outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
className="w-full rounded-xl border border-border-main bg-surface-control px-10 py-3 text-content-main outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
value={regDisplayName}
onChange={(event) => setRegDisplayName(event.target.value)}
placeholder="你希望别人看到的名称"
@@ -167,12 +167,12 @@ export default function LoginPage({ onSuccess }: { onSuccess: () => void }) {
</label>
<label className="block space-y-2">
<span className="text-sm text-slate-300"> *</span>
<span className="text-sm text-content-muted"> *</span>
<div className="relative">
<Lock className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" size={18} />
<Lock className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-content-dim" size={18} />
<input
type="password"
className="w-full rounded-xl border border-slate-800 bg-slate-950 px-10 py-3 text-white outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
className="w-full rounded-xl border border-border-main bg-surface-control px-10 py-3 text-content-main outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
value={regPassword}
onChange={(event) => setRegPassword(event.target.value)}
placeholder="至少 8 个字符"
@@ -181,12 +181,12 @@ export default function LoginPage({ onSuccess }: { onSuccess: () => void }) {
</label>
<label className="block space-y-2">
<span className="text-sm text-slate-300"> *</span>
<span className="text-sm text-content-muted"> *</span>
<div className="relative">
<Lock className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" size={18} />
<Lock className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-content-dim" size={18} />
<input
type="password"
className="w-full rounded-xl border border-slate-800 bg-slate-950 px-10 py-3 text-white outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
className="w-full rounded-xl border border-border-main bg-surface-control px-10 py-3 text-content-main outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
value={regConfirmPassword}
onChange={(event) => setRegConfirmPassword(event.target.value)}
placeholder="再次输入密码"
@@ -194,12 +194,12 @@ export default function LoginPage({ onSuccess }: { onSuccess: () => void }) {
</div>
</label>
{error ? <div className="rounded-xl border border-red-500/25 bg-red-500/10 px-4 py-3 text-sm text-red-300">{error}</div> : null}
{error ? <div className="rounded-xl border border-red-500/25 bg-red-500/10 px-4 py-3 text-sm text-red-500">{error}</div> : null}
<button
type="submit"
disabled={submitting || !regUsername.trim()}
className="w-full rounded-xl bg-gradient-to-r from-emerald-500 to-teal-600 px-4 py-3 font-medium text-white shadow-lg shadow-emerald-900/30 transition hover:from-emerald-400 hover:to-teal-500 disabled:cursor-not-allowed disabled:opacity-60"
className="w-full rounded-xl bg-gradient-to-r from-emerald-500 to-teal-600 px-4 py-3 font-medium text-white shadow-lg shadow-emerald-900/20 transition hover:from-emerald-400 hover:to-teal-500 disabled:cursor-not-allowed disabled:opacity-60"
>
{submitting ? '注册中...' : '注 册'}
</button>
@@ -207,17 +207,17 @@ export default function LoginPage({ onSuccess }: { onSuccess: () => void }) {
<div className="text-center">
<button
type="button"
className="text-sm text-slate-500 transition hover:text-cyan-400"
className="text-sm text-content-dim transition hover:text-cyan-500"
onClick={switchMode}
>
<span className="font-medium"></span>
<span className="font-medium text-cyan-600 dark:text-cyan-400"></span>
</button>
</div>
</form>
)}
{mode === 'login' && (
<div className="mt-6 text-center text-sm text-slate-500">使</div>
<div className="mt-6 text-center text-sm text-content-dim">使</div>
)}
</div>
</div>
+47 -47
View File
@@ -165,18 +165,18 @@ export default function UserManagementPage({ onBack }: { onBack: () => void }) {
}
return (
<div className="flex h-screen flex-col bg-slate-950 text-slate-100">
<div className="flex h-screen flex-col bg-surface-app text-content-main">
{/* Header */}
<header className="flex h-14 items-center justify-between border-b border-slate-800 bg-slate-900 px-4">
<header className="flex h-14 items-center justify-between border-b border-border-main bg-surface-panel px-4">
<div className="flex items-center gap-3">
<button
className="flex items-center gap-1 rounded-md px-2 py-1 text-sm text-slate-400 transition hover:text-white"
className="flex items-center gap-1 rounded-md px-2 py-1 text-sm text-content-muted transition hover:text-content-main"
onClick={onBack}
>
<ChevronLeft size={18} />
</button>
<div className="h-6 w-px bg-slate-800" />
<div className="h-6 w-px bg-border-main" />
<h1 className="text-lg font-semibold tracking-wide text-blue-400">
</h1>
@@ -193,7 +193,7 @@ export default function UserManagementPage({ onBack }: { onBack: () => void }) {
{/* Content */}
<div className="flex-1 overflow-auto p-6">
{loading ? (
<div className="flex items-center justify-center py-20 text-slate-500">...</div>
<div className="flex items-center justify-center py-20 text-content-dim">...</div>
) : error ? (
<div className="flex items-center justify-center gap-2 py-20 text-red-400">
<AlertCircle size={18} />
@@ -203,23 +203,23 @@ export default function UserManagementPage({ onBack }: { onBack: () => void }) {
</button>
</div>
) : (
<div className="overflow-hidden rounded-2xl border border-slate-800 bg-slate-900/60">
<div className="overflow-hidden rounded-2xl border border-border-main bg-surface-panel/60">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-slate-800 bg-slate-900/80">
<th className="px-5 py-3.5 font-medium text-slate-400">ID</th>
<th className="px-5 py-3.5 font-medium text-slate-400"></th>
<th className="px-5 py-3.5 font-medium text-slate-400"></th>
<th className="px-5 py-3.5 font-medium text-slate-400"></th>
<th className="px-5 py-3.5 font-medium text-slate-400"></th>
<th className="px-5 py-3.5 font-medium text-slate-400"></th>
<th className="px-5 py-3.5 font-medium text-slate-400"></th>
<tr className="border-b border-border-main bg-surface-panel/80">
<th className="px-5 py-3.5 font-medium text-content-muted">ID</th>
<th className="px-5 py-3.5 font-medium text-content-muted"></th>
<th className="px-5 py-3.5 font-medium text-content-muted"></th>
<th className="px-5 py-3.5 font-medium text-content-muted"></th>
<th className="px-5 py-3.5 font-medium text-content-muted"></th>
<th className="px-5 py-3.5 font-medium text-content-muted"></th>
<th className="px-5 py-3.5 font-medium text-content-muted"></th>
</tr>
</thead>
<tbody>
{users.length === 0 ? (
<tr>
<td colSpan={7} className="px-5 py-16 text-center text-slate-500">
<td colSpan={7} className="px-5 py-16 text-center text-content-dim">
</td>
</tr>
@@ -227,19 +227,19 @@ export default function UserManagementPage({ onBack }: { onBack: () => void }) {
users.map((u) => {
const isSelf = currentUser?.username === u.username
return (
<tr key={u.id} className="border-b border-slate-800/50 transition hover:bg-slate-800/40">
<td className="px-5 py-3.5 text-slate-500">{u.id}</td>
<td className="px-5 py-3.5 font-medium text-slate-200">
<tr key={u.id} className="border-b border-border-subtle transition hover:bg-surface-muted">
<td className="px-5 py-3.5 text-content-dim">{u.id}</td>
<td className="px-5 py-3.5 font-medium text-content-main">
{u.username}
{isSelf && <span className="ml-2 rounded bg-blue-500/15 px-1.5 py-0.5 text-xs text-blue-400"></span>}
</td>
<td className="px-5 py-3.5 text-slate-300">{u.displayName || '-'}</td>
<td className="px-5 py-3.5 text-content-muted">{u.displayName || '-'}</td>
<td className="px-5 py-3.5">
<span
className={`inline-block rounded-full px-2.5 py-0.5 text-xs font-medium ${
u.role === 'ROLE_ADMIN'
? 'bg-amber-500/15 text-amber-400'
: 'bg-slate-700/50 text-slate-400'
: 'bg-surface-muted text-content-muted'
}`}
>
{u.role === 'ROLE_ADMIN' ? '管理员' : '普通用户'}
@@ -258,18 +258,18 @@ export default function UserManagementPage({ onBack }: { onBack: () => void }) {
</span>
)}
</td>
<td className="px-5 py-3.5 text-slate-500">{formatDateTime(u.createdAt)}</td>
<td className="px-5 py-3.5 text-content-dim">{formatDateTime(u.createdAt)}</td>
<td className="px-5 py-3.5">
<div className="flex items-center gap-1">
<button
className="rounded-lg p-1.5 text-slate-400 transition hover:bg-slate-700 hover:text-white"
className="rounded-lg p-1.5 text-content-muted transition hover:bg-surface-muted hover:text-content-main"
title="编辑"
onClick={() => openEditModal(u)}
>
<Pencil size={15} />
</button>
<button
className="rounded-lg p-1.5 text-slate-400 transition hover:bg-slate-700 hover:text-amber-400"
className="rounded-lg p-1.5 text-content-muted transition hover:bg-surface-muted hover:text-amber-400"
title="重置密码"
onClick={() => openResetModal(u)}
>
@@ -277,7 +277,7 @@ export default function UserManagementPage({ onBack }: { onBack: () => void }) {
</button>
{!isSelf && (
<button
className="rounded-lg p-1.5 text-slate-400 transition hover:bg-slate-700 hover:text-red-400"
className="rounded-lg p-1.5 text-content-muted transition hover:bg-surface-muted hover:text-red-400"
title="删除"
onClick={() => {
setFormError(null)
@@ -303,37 +303,37 @@ export default function UserManagementPage({ onBack }: { onBack: () => void }) {
<Modal title="新建用户" open={showCreate} onClose={() => setShowCreate(false)} maxWidth="max-w-md">
<div className="space-y-4">
<label className="block space-y-1.5">
<span className="text-sm text-slate-300"> *</span>
<span className="text-sm text-content-muted"> *</span>
<input
className="w-full rounded-xl border border-slate-800 bg-slate-950 px-4 py-2.5 text-sm text-white outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
className="w-full rounded-xl border border-border-main bg-surface-control px-4 py-2.5 text-sm text-content-main outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
value={formUsername}
onChange={(e) => setFormUsername(e.target.value)}
placeholder="登录用户名"
/>
</label>
<label className="block space-y-1.5">
<span className="text-sm text-slate-300"> *</span>
<span className="text-sm text-content-muted"> *</span>
<input
type="password"
className="w-full rounded-xl border border-slate-800 bg-slate-950 px-4 py-2.5 text-sm text-white outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
className="w-full rounded-xl border border-border-main bg-surface-control px-4 py-2.5 text-sm text-content-main outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
value={formPassword}
onChange={(e) => setFormPassword(e.target.value)}
placeholder="至少 8 个字符"
/>
</label>
<label className="block space-y-1.5">
<span className="text-sm text-slate-300"></span>
<span className="text-sm text-content-muted"></span>
<input
className="w-full rounded-xl border border-slate-800 bg-slate-950 px-4 py-2.5 text-sm text-white outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
className="w-full rounded-xl border border-border-main bg-surface-control px-4 py-2.5 text-sm text-content-main outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
value={formDisplayName}
onChange={(e) => setFormDisplayName(e.target.value)}
placeholder="用户显示名称(选填)"
/>
</label>
<label className="block space-y-1.5">
<span className="text-sm text-slate-300"></span>
<span className="text-sm text-content-muted"></span>
<select
className="w-full rounded-xl border border-slate-800 bg-slate-950 px-4 py-2.5 text-sm text-white outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
className="w-full rounded-xl border border-border-main bg-surface-control px-4 py-2.5 text-sm text-content-main outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
value={formRole}
onChange={(e) => setFormRole(e.target.value)}
>
@@ -357,21 +357,21 @@ export default function UserManagementPage({ onBack }: { onBack: () => void }) {
{/* Edit Modal */}
<Modal title="编辑用户" open={!!editingUser} onClose={() => setEditingUser(null)} maxWidth="max-w-md">
<div className="space-y-4">
<div className="rounded-xl border border-slate-800 bg-slate-950/50 px-4 py-2.5 text-sm text-slate-400">
: <span className="text-slate-200">{editingUser?.username}</span>
<div className="rounded-xl border border-border-main bg-surface-muted px-4 py-2.5 text-sm text-content-muted">
: <span className="text-content-main">{editingUser?.username}</span>
</div>
<label className="block space-y-1.5">
<span className="text-sm text-slate-300"></span>
<span className="text-sm text-content-muted"></span>
<input
className="w-full rounded-xl border border-slate-800 bg-slate-950 px-4 py-2.5 text-sm text-white outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
className="w-full rounded-xl border border-border-main bg-surface-control px-4 py-2.5 text-sm text-content-main outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
value={formDisplayName}
onChange={(e) => setFormDisplayName(e.target.value)}
/>
</label>
<label className="block space-y-1.5">
<span className="text-sm text-slate-300"></span>
<span className="text-sm text-content-muted"></span>
<select
className="w-full rounded-xl border border-slate-800 bg-slate-950 px-4 py-2.5 text-sm text-white outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
className="w-full rounded-xl border border-border-main bg-surface-control px-4 py-2.5 text-sm text-content-main outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
value={formRole}
onChange={(e) => setFormRole(e.target.value)}
>
@@ -382,11 +382,11 @@ export default function UserManagementPage({ onBack }: { onBack: () => void }) {
<label className="flex items-center gap-3">
<input
type="checkbox"
className="h-4 w-4 rounded border-slate-700 bg-slate-800 text-cyan-500 focus:ring-cyan-500/30"
className="h-4 w-4 rounded border-border-main bg-surface-control text-cyan-500 focus:ring-cyan-500/30"
checked={formEnabled}
onChange={(e) => setFormEnabled(e.target.checked)}
/>
<span className="text-sm text-slate-300"></span>
<span className="text-sm text-content-muted"></span>
</label>
{formError && (
<div className="rounded-xl border border-red-500/25 bg-red-500/10 px-4 py-3 text-sm text-red-300">{formError}</div>
@@ -404,15 +404,15 @@ export default function UserManagementPage({ onBack }: { onBack: () => void }) {
{/* Reset Password Modal */}
<Modal title="重置密码" open={!!resettingUser} onClose={() => setResettingUser(null)} maxWidth="max-w-md">
<div className="space-y-4">
<div className="rounded-xl border border-slate-800 bg-slate-950/50 px-4 py-2.5 text-sm text-slate-400">
: <span className="text-slate-200">{resettingUser?.username}</span>
<div className="rounded-xl border border-border-main bg-surface-muted px-4 py-2.5 text-sm text-content-muted">
: <span className="text-content-main">{resettingUser?.username}</span>
</div>
<p className="text-xs text-amber-400"></p>
<label className="block space-y-1.5">
<span className="text-sm text-slate-300"> *</span>
<span className="text-sm text-content-muted"> *</span>
<input
type="password"
className="w-full rounded-xl border border-slate-800 bg-slate-950 px-4 py-2.5 text-sm text-white outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
className="w-full rounded-xl border border-border-main bg-surface-control px-4 py-2.5 text-sm text-content-main outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
value={formNewPassword}
onChange={(e) => setFormNewPassword(e.target.value)}
placeholder="至少 8 个字符"
@@ -434,15 +434,15 @@ export default function UserManagementPage({ onBack }: { onBack: () => void }) {
{/* Delete Confirm */}
<Modal title="确认删除" open={!!deletingUser} onClose={() => setDeletingUser(null)} maxWidth="max-w-sm">
<div className="space-y-4">
<p className="text-sm text-slate-300">
<span className="font-medium text-white">{deletingUser?.username}</span>
<p className="text-sm text-content-muted">
<span className="font-medium text-content-main">{deletingUser?.username}</span>
</p>
{formError && (
<div className="rounded-xl border border-red-500/25 bg-red-500/10 px-4 py-3 text-sm text-red-300">{formError}</div>
)}
<div className="flex justify-end gap-3">
<button
className="rounded-xl border border-slate-700 px-4 py-2 text-sm text-slate-300 transition hover:bg-slate-800"
className="rounded-xl border border-border-main px-4 py-2 text-sm text-content-muted transition hover:bg-surface-muted"
onClick={() => setDeletingUser(null)}
>
+169 -72
View File
@@ -35,7 +35,8 @@ import {
updateExpandedState,
upsertConnectionNode,
} from '../lib/utils'
import { checkConnectionStatuses, createConnection, deleteConnection, listConnections, updateConnection } from '../services/connections'
import { checkConnectionStatuses, createConnection, deleteConnection, listConnections, quickConnect, togglePin, updateConnection } from '../services/connections'
import { exportSshConfig } from '../services/sftp'
import { getMetrics } from '../services/monitor'
import { getSessionTree, saveSessionTree } from '../services/sessionTree'
import type {
@@ -62,7 +63,7 @@ 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' },
idle: { label: '终端未打开', tone: 'text-content-muted', dot: 'bg-content-muted' },
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' },
@@ -253,6 +254,7 @@ export default function WorkspacePage({
const [showBatchModal, setShowBatchModal] = useState(false)
const [showTransferModal, setShowTransferModal] = useState(initialTool === 'transfers')
const [showSettingsModal, setShowSettingsModal] = useState(false)
const [quickTokens, setQuickTokens] = useState<Record<string, string>>({})
const [showChangePassword, setShowChangePassword] = useState<boolean>(!!user?.passwordChangeRequired)
const [transferTasks, setTransferTasks] = useState<TransferTaskGroup[]>([])
const [workspaceMetrics, setWorkspaceMetrics] = useState<MonitorMetrics>({})
@@ -272,6 +274,14 @@ export default function WorkspacePage({
'ssh-manager.terminal-font-family',
'"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace',
)
const [darkMode, setDarkMode] = useLocalStorage('ssh-manager.dark-mode', true)
const [renamingTabId, setRenamingTabId] = useState<string | null>(null)
const [renamingTabName, setRenamingTabName] = useState('')
// Sync dark mode class to html element
useEffect(() => {
document.documentElement.classList.toggle('dark', darkMode)
}, [darkMode])
useEffect(() => {
setShowChangePassword(!!user?.passwordChangeRequired)
@@ -669,37 +679,88 @@ export default function WorkspacePage({
closeTabContextMenu()
}
function startRenameTab(tabId: string) {
const tab = tabs.find((t) => t.tabId === tabId)
if (!tab) return
setRenamingTabId(tabId)
setRenamingTabName(tab.name)
closeTabContextMenu()
}
function commitRenameTab() {
if (!renamingTabId || !renamingTabName.trim()) {
setRenamingTabId(null)
return
}
const tab = tabs.find((t) => t.tabId === renamingTabId)
if (!tab) { setRenamingTabId(null); return }
dispatchTab({ type: 'UPDATE_CONNECTION', connectionId: tab.connection.id, name: renamingTabName.trim(), connection: tab.connection })
setRenamingTabId(null)
}
async function handleTogglePin(connectionId: number) {
try {
const response = await togglePin(connectionId)
setConnections((prev) => prev.map((c) => c.id === connectionId ? { ...c, pinned: response.data.pinned } : c))
} catch { /* ignore */ }
}
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">
<div className="flex h-screen flex-col overflow-hidden bg-surface-app text-content-main">
<header className="flex h-14 items-center justify-between border-b border-border-main bg-surface-panel px-4 shadow-sm">
<div className="flex items-center gap-6">
<div className="flex items-center gap-2 text-lg font-semibold tracking-wide text-blue-400">
<Terminal size={22} />
<span>
SSH<span className="text-slate-100">Manager</span>
SSH<span className="text-content-main">Manager</span>
</span>
</div>
<div className="h-6 w-px bg-slate-800" />
<div className="h-6 w-px bg-border-main" />
<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"
className="flex items-center gap-2 rounded-md px-3 py-1.5 text-sm text-content-muted transition hover:bg-surface-muted hover:text-content-main"
onClick={() => navigate('/dashboard')}
>
<Monitor size={16} className="text-cyan-400" />
</button>
<button
className="flex items-center gap-2 rounded-md px-3 py-1.5 text-sm text-content-muted transition hover:bg-surface-muted hover:text-content-main"
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"
className="flex items-center gap-2 rounded-md px-3 py-1.5 text-sm text-content-muted transition hover:bg-surface-muted hover:text-content-main"
onClick={() => setShowTransferModal(true)}
>
<FileUp size={16} className="text-blue-400" />
</button>
<button
className="flex items-center gap-2 rounded-md px-3 py-1.5 text-sm text-content-muted transition hover:bg-surface-muted hover:text-content-main"
onClick={async () => {
try {
const res = await exportSshConfig()
const blob = new Blob([res.data], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a'); a.href = url; a.download = 'ssh-config.txt'
document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url)
} catch {}
}}
title="导出 SSH 配置"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-amber-400">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
</svg>
</button>
{user?.role === 'ROLE_ADMIN' && (
<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"
className="flex items-center gap-2 rounded-md px-3 py-1.5 text-sm text-content-muted transition hover:bg-surface-muted hover:text-content-main"
onClick={() => navigate('/users')}
>
<Users size={16} className="text-emerald-400" />
@@ -710,16 +771,16 @@ export default function WorkspacePage({
</div>
<div className="flex items-center gap-3">
<button
className="rounded-full p-2 text-slate-400 transition hover:bg-slate-800 hover:text-white"
className="rounded-full p-2 text-content-muted transition hover:bg-surface-muted hover:text-content-main"
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">
<div className="rounded-full border border-border-main bg-surface-app px-3 py-1.5 text-sm text-content-muted">
{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"
className="flex items-center gap-2 rounded-md px-2 py-1 text-sm text-content-muted transition hover:text-red-400"
onClick={() => {
logout()
onLogout()
@@ -732,16 +793,16 @@ export default function WorkspacePage({
</header>
<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 flex-col gap-3 border-b border-slate-800 px-4 py-3">
<aside className="flex w-72 shrink-0 flex-col border-r border-border-main bg-surface-panel">
<div className="flex flex-col gap-3 border-b border-border-main 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="text-sm font-medium text-content-muted"></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',
'rounded-md p-1.5 text-content-muted transition hover:bg-surface-muted hover:text-content-main',
hasFolders ? '' : 'cursor-not-allowed text-content-dim hover:bg-transparent hover:text-content-dim',
)}
disabled={!hasFolders}
onClick={() => void handleToggleAllFolders()}
@@ -752,7 +813,7 @@ export default function WorkspacePage({
</button>
<button
type="button"
className="rounded-md p-1.5 text-slate-400 transition hover:bg-slate-800 hover:text-white"
className="rounded-md p-1.5 text-content-muted transition hover:bg-surface-muted hover:text-content-main"
onClick={() => {
closeTreeContextMenu()
setShowFolderModal(true)
@@ -764,7 +825,7 @@ export default function WorkspacePage({
</button>
<button
type="button"
className="rounded-md p-1.5 text-slate-400 transition hover:bg-slate-800 hover:text-white"
className="rounded-md p-1.5 text-content-muted transition hover:bg-surface-muted hover:text-content-main"
onClick={() => {
closeTreeContextMenu()
setEditingConnection(null)
@@ -778,9 +839,9 @@ export default function WorkspacePage({
</div>
</div>
<div className="relative">
<Search size={14} className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-500" />
<Search size={14} className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-content-dim" />
<input
className="w-full rounded-md border border-slate-800 bg-slate-950/50 py-1.5 pl-8 pr-3 text-xs text-slate-200 outline-none transition-colors placeholder:text-slate-600 focus:border-slate-700 focus:bg-slate-900"
className="w-full rounded-md border border-border-main bg-surface-control py-1.5 pl-8 pr-3 text-xs text-content-main outline-none transition-colors placeholder:text-content-dim focus:border-border-subtle focus:bg-surface-panel"
placeholder="搜索主机..."
value={search}
onChange={(event) => setSearch(event.target.value)}
@@ -806,50 +867,70 @@ export default function WorkspacePage({
onToggleFolder={(nodeId) => void handleToggleFolder(nodeId)}
onOpenConnection={openConnection}
onContextMenu={handleOpenTreeContextMenu}
onTogglePin={handleTogglePin}
/>
</div>
</aside>
<main className="flex flex-1 flex-col overflow-hidden bg-[#0d1117]">
<div className="flex h-10 border-b border-slate-800 bg-slate-900">
<main className="flex flex-1 flex-col overflow-hidden bg-surface-app">
<div className="flex h-10 border-b border-border-main bg-surface-panel">
{tabs.length === 0 ? <div className="h-full flex-1" /> : null}
{tabs.map((tab) => (
<button
key={tab.tabId}
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',
currentTabKey === tab.tabId ? 'bg-[#0d1117] text-white' : 'text-slate-400 hover:bg-slate-800 hover:text-slate-200',
)}
onClick={() => dispatchTab({ type: 'ACTIVATE_TAB', tabId: tab.tabId })}
onContextMenu={(event) => {
event.preventDefault()
closeTreeContextMenu()
const position = getClampedContextMenuPosition(event.clientX, event.clientY, 2)
setTabContextMenu({ visible: true, x: position.x, y: position.y, targetTabId: tab.tabId })
}}
>
<Terminal size={14} className={currentTabKey === tab.tabId ? '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.tabId)
{tabs.map((tab) => {
const isRenaming = renamingTabId === tab.tabId
return (
<button
key={tab.tabId}
className={cn(
'group flex h-full min-w-[160px] max-w-[220px] items-center gap-2 border-r border-border-main px-4 text-sm transition',
currentTabKey === tab.tabId ? 'bg-surface-app text-content-main' : 'text-content-muted hover:bg-surface-muted hover:text-content-main',
)}
onClick={() => {
if (isRenaming) return
dispatchTab({ type: 'ACTIVATE_TAB', tabId: tab.tabId })
}}
onContextMenu={(event) => {
event.preventDefault()
if (isRenaming) return
closeTreeContextMenu()
const position = getClampedContextMenuPosition(event.clientX, event.clientY, 3)
setTabContextMenu({ visible: true, x: position.x, y: position.y, targetTabId: tab.tabId })
}}
>
×
</span>
</button>
))}
<Terminal size={14} className={currentTabKey === tab.tabId ? 'text-emerald-400' : 'text-content-dim'} />
{isRenaming ? (
<input
className="flex-1 min-w-0 bg-transparent border-b border-cyan-400 text-sm text-content-main outline-none"
value={renamingTabName}
onChange={(e) => setRenamingTabName(e.target.value)}
onBlur={commitRenameTab}
onKeyDown={(e) => { if (e.key === 'Enter') commitRenameTab(); if (e.key === 'Escape') setRenamingTabId(null) }}
onClick={(e) => e.stopPropagation()}
autoFocus
/>
) : (
<span className="flex-1 truncate">{tab.name}</span>
)}
<span
className="opacity-0 transition group-hover:opacity-100"
onClick={(event) => {
event.stopPropagation()
handleCloseTab(tab.tabId)
}}
>
×
</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 h-11 items-center justify-between border-b border-border-main bg-surface-panel 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">
<div className="flex items-center gap-2 text-content-muted">
<Activity size={14} className="text-emerald-400" />
CPU: {workspaceMetrics.cpuUsage ?? '-'}%
</div>
<div className="flex items-center gap-2 text-slate-300">
<div className="flex items-center gap-2 text-content-muted">
<HardDrive size={14} className="text-blue-400" />
MEM: {formatBytes(workspaceMetrics.memUsed ?? null)} / {formatBytes(workspaceMetrics.memTotal ?? null)}
</div>
@@ -859,11 +940,11 @@ export default function WorkspacePage({
</div>
</div>
<div className="ml-3 flex items-center rounded-lg border border-slate-700 bg-slate-900/90 p-1">
<div className="ml-3 flex items-center rounded-lg border border-border-subtle bg-surface-panel p-1">
<button
className={cn(
'rounded-md p-2 transition',
layout === 'terminal' ? 'bg-slate-700 text-white' : 'text-slate-400 hover:text-white',
layout === 'terminal' ? 'bg-surface-muted text-content-main' : 'text-content-muted hover:text-content-main',
)}
onClick={() => setLayout('terminal')}
title="终端"
@@ -873,7 +954,7 @@ export default function WorkspacePage({
<button
className={cn(
'rounded-md p-2 transition',
layout === 'sftp' ? 'bg-slate-700 text-white' : 'text-slate-400 hover:text-white',
layout === 'sftp' ? 'bg-surface-muted text-content-main' : 'text-content-muted hover:text-content-main',
)}
onClick={() => setLayout('sftp')}
title="SFTP"
@@ -883,7 +964,7 @@ export default function WorkspacePage({
<button
className={cn(
'rounded-md p-2 transition',
layout === 'split' ? 'bg-slate-700 text-white' : 'text-slate-400 hover:text-white',
layout === 'split' ? 'bg-surface-muted text-content-main' : 'text-content-muted hover:text-content-main',
)}
onClick={() => setLayout(layout === 'split' ? 'terminal' : 'split')}
title={layout === 'split' ? '切换到终端' : '分屏'}
@@ -895,9 +976,9 @@ export default function WorkspacePage({
<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>
<div className="absolute inset-0 flex flex-col items-center justify-center text-content-dim">
<Monitor size={64} className="mb-4 text-surface-muted" />
<h2 className="mb-2 text-xl font-medium text-content-muted">使 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"
@@ -915,7 +996,7 @@ export default function WorkspacePage({
<div
className={cn(
'relative min-w-0 flex-1 overflow-hidden',
layout === 'split' && 'w-1/2 border-r border-slate-800',
layout === 'split' && 'w-1/2 border-r border-border-main',
layout === 'terminal' && 'w-full',
layout === 'sftp' && 'hidden',
)}
@@ -929,9 +1010,21 @@ export default function WorkspacePage({
visible={visible}
fontSize={terminalFontSize}
fontFamily={terminalFontFamily}
credentialToken={quickTokens[tab.connection.id]}
onStatusChange={(status) => {
dispatchTab({ type: 'SET_TERMINAL_STATUS', tabId: tab.tabId, status })
}}
onQuickConnect={async (host, user, port) => {
try {
const password = window.prompt(`输入 ${user}@${host} 的密码:`)
if (!password) return
const res = await quickConnect(host, user, port, password)
const { connection, credentialToken } = res.data
const newTabId = `${connection.id}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
setQuickTokens((prev) => ({ ...prev, [connection.id]: credentialToken }))
dispatchTab({ type: 'OPEN_CONNECTION', connection, tabId: newTabId })
} catch (e) { console.error('Quick connect failed', e) }
}}
/>
</div>
)
@@ -1001,19 +1094,21 @@ export default function WorkspacePage({
terminalFontFamily={terminalFontFamily}
onFontSizeChange={setTerminalFontSize}
onFontFamilyChange={setTerminalFontFamily}
darkMode={darkMode}
onDarkModeChange={setDarkMode}
/>
{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"
className="absolute min-w-44 rounded-xl border border-border-subtle bg-surface-panel 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"
className="flex w-full items-center rounded-lg px-3 py-2 text-left text-sm text-content-main transition hover:bg-surface-muted hover:text-content-main"
onClick={() => {
closeTreeContextMenu()
setEditingConnection(null)
@@ -1023,7 +1118,7 @@ export default function WorkspacePage({
</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"
className="flex w-full items-center rounded-lg px-3 py-2 text-left text-sm text-content-main transition hover:bg-surface-muted hover:text-content-main"
onClick={() => {
closeTreeContextMenu()
setShowFolderModal(true)
@@ -1034,7 +1129,7 @@ export default function WorkspacePage({
</>
) : 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"
className="flex w-full items-center rounded-lg px-3 py-2 text-left text-sm text-content-main transition hover:bg-surface-muted hover:text-content-main"
onClick={() => void handleEditTreeItem()}
>
@@ -1051,22 +1146,24 @@ export default function WorkspacePage({
{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"
className="absolute min-w-40 rounded-xl border border-border-subtle bg-surface-panel 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={() => {
if (tabContextMenu.targetTabId) {
duplicateTab(tabContextMenu.targetTabId)
}
}}
className="flex w-full items-center rounded-lg px-3 py-2 text-left text-sm text-content-main transition hover:bg-surface-muted hover:text-content-main"
onClick={() => { if (tabContextMenu.targetTabId) startRenameTab(tabContextMenu.targetTabId) }}
>
</button>
<button
className="flex w-full items-center rounded-lg px-3 py-2 text-left text-sm text-content-main transition hover:bg-surface-muted hover:text-content-main"
onClick={() => { if (tabContextMenu.targetTabId) duplicateTab(tabContextMenu.targetTabId) }}
>
</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"
className="flex w-full items-center rounded-lg px-3 py-2 text-left text-sm text-content-main transition hover:bg-surface-muted hover:text-content-main"
onClick={closeAllTabs}
>
+8
View File
@@ -24,3 +24,11 @@ export function executeBatchCommand(connectionIds: number[], command: string) {
export function checkConnectionStatuses(connectionIds: number[]) {
return http.post<ConnectionStatusResponse>('/connections/status', { connectionIds })
}
export function togglePin(id: number) {
return http.put<Connection>(`/connections/${id}/pin`)
}
export function quickConnect(host: string, username: string, port: number, password: string) {
return http.post<{ connection: Connection; credentialToken: string }>('/connections/quick-connect', { host, username, port, password })
}
+20
View File
@@ -89,3 +89,23 @@ export function subscribeRemoteTransferProgress(taskId: string, onProgress: (tas
export function cancelRemoteTransferTask(taskId: string) {
return http.delete(`/sftp/transfer-remote/tasks/${encodeURIComponent(taskId)}`)
}
export function readFileContent(connectionId: number, path: string) {
return http.get<{ content: string; path: string }>('/sftp/read', { params: { connectionId, path } })
}
export function writeFileContent(connectionId: number, path: string, content: string) {
return http.post('/sftp/write', { content }, { params: { connectionId, path } })
}
export function compressRemote(connectionId: number, path: string, format?: string) {
return http.post<{ message: string }>('/sftp/compress', null, { params: { connectionId, path, format: format || 'tar.gz' } })
}
export function decompressRemote(connectionId: number, path: string, targetDir?: string) {
return http.post<{ message: string }>('/sftp/decompress', null, { params: { connectionId, path, targetDir: targetDir || '/tmp' } })
}
export function exportSshConfig() {
return http.get<string>('/connections/export/config', { responseType: 'text' })
}
+26
View File
@@ -0,0 +1,26 @@
import http from './http'
export interface CommandSnippet {
id: number
name: string
command: string
description?: string
createdAt: string
updatedAt: string
}
export function listSnippets() {
return http.get<CommandSnippet[]>('/snippets')
}
export function createSnippet(name: string, command: string, description?: string) {
return http.post<CommandSnippet>('/snippets', { name, command, description })
}
export function updateSnippet(id: number, name: string, command: string, description?: string) {
return http.put<CommandSnippet>(`/snippets/${id}`, { name, command, description })
}
export function deleteSnippet(id: number) {
return http.delete<{ message: string }>(`/snippets/${id}`)
}
+25
View File
@@ -0,0 +1,25 @@
import http from './http'
export interface WebhookConfig {
id: number
url: string
eventType: string
enabled: boolean
createdAt: string
}
export function listWebhooks() {
return http.get<WebhookConfig[]>('/webhooks')
}
export function createWebhook(url: string, eventType: string) {
return http.post<WebhookConfig>('/webhooks', { url, eventType })
}
export function deleteWebhook(id: number) {
return http.delete<{ message: string }>(`/webhooks/${id}`)
}
export function testWebhook(message: string) {
return http.post<{ message: string }>('/webhooks/test', { message })
}
+62 -1
View File
@@ -8,6 +8,18 @@
--app-bg-1: #0a1626;
--app-card: rgba(15, 23, 42, 0.72);
--app-border: rgba(148, 163, 184, 0.16);
/* Semantic tokens - Dark (Default) */
--surface-app: #020617;
--surface-panel: #0a1626;
--surface-card: #0f172a;
--surface-muted: rgba(30, 41, 59, 0.5);
--surface-control: #020617;
--border-main: rgba(148, 163, 184, 0.16);
--border-subtle: rgba(148, 163, 184, 0.08);
--text-main: #f1f5f9;
--text-muted: #94a3b8;
--text-dim: #64748b;
}
html {
@@ -21,7 +33,8 @@
min-width: 320px;
min-height: 100vh;
font-family: "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif;
@apply text-slate-100 antialiased;
color: var(--text-main);
@apply antialiased;
background:
radial-gradient(1200px 600px at 10% -10%, rgba(34, 211, 238, 0.16), transparent 60%),
radial-gradient(900px 500px at 90% 0%, rgba(59, 130, 246, 0.1), transparent 55%),
@@ -64,6 +77,54 @@
}
}
/* ── Light theme overrides ── */
html:not(.dark) {
color-scheme: light;
--app-bg-0: #f1f5f9;
--app-bg-1: #e2e8f0;
--app-card: rgba(255, 255, 255, 0.85);
--app-border: rgba(148, 163, 184, 0.25);
/* Semantic tokens - Light */
--surface-app: #f8fafc;
--surface-panel: #f1f5f9;
--surface-card: #ffffff;
--surface-muted: #e2e8f0;
--surface-control: #ffffff;
--border-main: rgba(148, 163, 184, 0.3);
--border-subtle: rgba(148, 163, 184, 0.15);
--text-main: #1e293b;
--text-muted: #475569;
--text-dim: #64748b;
}
html:not(.dark) body {
color: var(--text-main);
background:
radial-gradient(1400px 700px at 5% -15%, rgba(34, 211, 238, 0.05), transparent 65%),
radial-gradient(1000px 600px at 95% 5%, rgba(59, 130, 246, 0.04), transparent 55%),
linear-gradient(185deg, #f8fafc 0%, #f1f5f9 100%);
}
html:not(.dark) * {
scrollbar-color: rgba(148, 163, 184, 0.4) rgba(226, 232, 240, 0.6);
}
html:not(.dark) *::-webkit-scrollbar-track {
background: rgba(226, 232, 240, 0.5);
}
html:not(.dark) *::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, rgba(148, 163, 184, 0.4), rgba(203, 213, 225, 0.3));
}
/* Light mode glass panel override */
html:not(.dark) .glass-panel,
html:not(.dark) .glass-panel-hover {
background: rgba(255, 255, 255, 0.7);
border-color: rgba(148, 163, 184, 0.2);
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
+2
View File
@@ -58,6 +58,7 @@ export interface Connection {
port: number
username: string
authType: AuthType
pinned?: boolean
createdAt: string
updatedAt: string
}
@@ -73,6 +74,7 @@ export interface ConnectionCreateRequest {
passphrase?: string
setupMode?: ConnectionSetupMode
bootstrapPassword?: string
pinned?: boolean
}
export interface ConnectionModalSubmitPayload extends ConnectionCreateRequest {
+16
View File
@@ -10,6 +10,22 @@ export default {
colors: {
slate: {
850: '#172033',
},
surface: {
app: 'var(--surface-app)',
panel: 'var(--surface-panel)',
card: 'var(--surface-card)',
muted: 'var(--surface-muted)',
control: 'var(--surface-control)',
},
border: {
main: 'var(--border-main)',
subtle: 'var(--border-subtle)',
},
content: {
main: 'var(--text-main)',
muted: 'var(--text-muted)',
dim: 'var(--text-dim)',
}
}
},