feat: 主题切换 + 浅色模式适配,SFTP/批量命令/Webhook/仪表盘全面升级
This commit is contained in:
Generated
+7
@@ -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",
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)}
|
||||
>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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="关闭"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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}%` }}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
取消
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
关闭所有标签
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)',
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user