Initial commit: SSH Manager (backend + frontend)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
liu
2026-02-03 09:10:06 +08:00
commit 1c5a44ff71
63 changed files with 6946 additions and 0 deletions

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
frontend/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

5
frontend/README.md Normal file
View File

@@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
本模板用于在 Vite 中基于 Vue 3 与 TypeScript 进行开发。模板使用 Vue 3 的 `<script setup>` 单文件组件,可参阅 [script setup 文档](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) 了解更多。
推荐的项目配置与 IDE 支持请参考 [Vue 文档 TypeScript 指南](https://vuejs.org/guide/typescript/overview.html#project-setup)。

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SSH 管理器</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

3384
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
frontend/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@xterm/addon-attach": "^0.12.0",
"@xterm/addon-fit": "^0.10.0",
"axios": "^1.13.4",
"lucide-vue-next": "^0.563.0",
"pinia": "^3.0.4",
"vue": "^3.5.24",
"vue-router": "^5.0.2",
"xterm": "^5.3.0"
},
"devDependencies": {
"@types/node": "^24.10.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.14",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/tsconfig": "^0.8.1",
"typescript": "~5.9.3",
"vite": "^7.2.4",
"vue-tsc": "^3.1.4"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

1
frontend/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

6
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,6 @@
<script setup lang="ts">
</script>
<template>
<RouterView />
</template>

20
frontend/src/api/auth.ts Normal file
View File

@@ -0,0 +1,20 @@
import client from './client'
export interface LoginRequest {
username: string
password: string
}
export interface LoginResponse {
token: string
username: string
displayName: string
}
export function login(data: LoginRequest) {
return client.post<LoginResponse>('/auth/login', data)
}
export function getMe() {
return client.get<{ username: string; displayName: string }>('/auth/me')
}

View File

@@ -0,0 +1,29 @@
import axios from 'axios'
const client = axios.create({
baseURL: '/api',
headers: {
'Content-Type': 'application/json',
},
})
client.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
client.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('token')
window.location.href = '/login'
}
return Promise.reject(error)
}
)
export default client

View File

@@ -0,0 +1,45 @@
import client from './client'
export type AuthType = 'PASSWORD' | 'PRIVATE_KEY'
export interface Connection {
id: number
name: string
host: string
port: number
username: string
authType: AuthType
createdAt: string
updatedAt: string
}
export interface ConnectionCreateRequest {
name: string
host: string
port?: number
username: string
authType?: AuthType
password?: string
privateKey?: string
passphrase?: string
}
export function listConnections() {
return client.get<Connection[]>('/connections')
}
export function getConnection(id: number) {
return client.get<Connection>(`/connections/${id}`)
}
export function createConnection(data: ConnectionCreateRequest) {
return client.post<Connection>('/connections', data)
}
export function updateConnection(id: number, data: ConnectionCreateRequest) {
return client.put<Connection>(`/connections/${id}`, data)
}
export function deleteConnection(id: number) {
return client.delete(`/connections/${id}`)
}

81
frontend/src/api/sftp.ts Normal file
View File

@@ -0,0 +1,81 @@
import client from './client'
export interface SftpFileInfo {
name: string
directory: boolean
size: number
mtime: number
}
export function listFiles(connectionId: number, path: string) {
return client.get<SftpFileInfo[]>('/sftp/list', {
params: { connectionId, path: path || '.' },
})
}
export function getPwd(connectionId: number) {
return client.get<{ path: string }>('/sftp/pwd', {
params: { connectionId },
})
}
export async function downloadFile(connectionId: number, path: string) {
const token = localStorage.getItem('token')
const params = new URLSearchParams({ connectionId: String(connectionId), path })
const res = await fetch(`/api/sftp/download?${params}`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Download failed')
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = path.split('/').pop() || 'download'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
export function uploadFile(connectionId: number, path: string, file: File) {
const form = new FormData()
form.append('file', file)
return client.post('/sftp/upload', form, {
params: { connectionId, path },
headers: { 'Content-Type': 'multipart/form-data' },
})
}
export function deleteFile(connectionId: number, path: string, directory: boolean) {
return client.delete('/sftp/delete', {
params: { connectionId, path, directory },
})
}
export function createDir(connectionId: number, path: string) {
return client.post('/sftp/mkdir', null, {
params: { connectionId, path },
})
}
export function renameFile(connectionId: number, oldPath: string, newPath: string) {
return client.post('/sftp/rename', null, {
params: { connectionId, oldPath, newPath },
})
}
export function transferRemote(
sourceConnectionId: number,
sourcePath: string,
targetConnectionId: number,
targetPath: string
) {
return client.post<{ message: string }>('/sftp/transfer-remote', null, {
params: {
sourceConnectionId,
sourcePath,
targetConnectionId,
targetPath,
},
})
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@@ -0,0 +1,238 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import type { Connection, ConnectionCreateRequest, AuthType } from '../api/connections'
import { X } from 'lucide-vue-next'
const props = defineProps<{
connection: Connection | null
onSave?: (data: ConnectionCreateRequest) => Promise<void>
}>()
const emit = defineEmits<{
save: [data: ConnectionCreateRequest]
close: []
}>()
const name = ref('')
const host = ref('')
const port = ref(22)
const username = ref('')
const authType = ref<AuthType>('PASSWORD')
const password = ref('')
const privateKey = ref('')
const passphrase = ref('')
const isEdit = computed(() => !!props.connection)
watch(
() => props.connection,
(c) => {
if (c) {
name.value = c.name
host.value = c.host
port.value = c.port
username.value = c.username
authType.value = c.authType
password.value = ''
privateKey.value = ''
passphrase.value = ''
} else {
name.value = ''
host.value = ''
port.value = 22
username.value = ''
authType.value = 'PASSWORD'
password.value = ''
privateKey.value = ''
passphrase.value = ''
}
},
{ immediate: true }
)
const error = ref('')
const loading = ref(false)
async function handleSubmit() {
error.value = ''
if (!name.value.trim()) {
error.value = '请填写名称'
return
}
if (!host.value.trim()) {
error.value = '请填写主机'
return
}
if (!username.value.trim()) {
error.value = '请填写用户名'
return
}
if (authType.value === 'PASSWORD' && !isEdit.value && !password.value) {
error.value = '请填写密码'
return
}
if (authType.value === 'PRIVATE_KEY' && !isEdit.value && !privateKey.value.trim()) {
error.value = '请填写私钥'
return
}
loading.value = true
error.value = ''
const data: ConnectionCreateRequest = {
name: name.value.trim(),
host: host.value.trim(),
port: port.value,
username: username.value.trim(),
authType: authType.value,
}
if (authType.value === 'PASSWORD' && password.value) {
data.password = password.value
}
if (authType.value === 'PRIVATE_KEY') {
if (privateKey.value.trim()) data.privateKey = privateKey.value.trim()
if (passphrase.value) data.passphrase = passphrase.value
}
try {
if (props.onSave) {
await props.onSave(data)
} else {
emit('save', data)
}
} catch (e: unknown) {
const err = e as { response?: { data?: { message?: string } } }
error.value = err.response?.data?.message || '保存失败'
} finally {
loading.value = false
}
}
</script>
<template>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" @click.self="emit('close')">
<div
class="w-full max-w-lg bg-slate-800 rounded-xl border border-slate-700 shadow-xl"
role="dialog"
aria-modal="true"
aria-labelledby="form-title"
>
<div class="flex items-center justify-between p-4 border-b border-slate-700">
<h2 id="form-title" class="text-lg font-semibold text-slate-100">
{{ isEdit ? '编辑连接' : '新建连接' }}
</h2>
<button
@click="emit('close')"
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer"
aria-label="关闭"
>
<X class="w-5 h-5" aria-hidden="true" />
</button>
</div>
<form @submit.prevent="handleSubmit" class="p-4 space-y-4">
<div>
<label for="name" class="block text-sm font-medium text-slate-300 mb-1">名称</label>
<input
id="name"
v-model="name"
type="text"
class="w-full px-4 py-2.5 rounded-lg bg-slate-700 border border-slate-600 text-slate-100 focus:outline-none focus:ring-2 focus:ring-cyan-500"
placeholder="我的服务器"
/>
</div>
<div class="grid grid-cols-3 gap-4">
<div class="col-span-2">
<label for="host" class="block text-sm font-medium text-slate-300 mb-1">主机</label>
<input
id="host"
v-model="host"
type="text"
class="w-full px-4 py-2.5 rounded-lg bg-slate-700 border border-slate-600 text-slate-100 focus:outline-none focus:ring-2 focus:ring-cyan-500"
placeholder="192.168.1.1"
/>
</div>
<div>
<label for="port" class="block text-sm font-medium text-slate-300 mb-1">端口</label>
<input
id="port"
v-model.number="port"
type="number"
min="1"
max="65535"
class="w-full px-4 py-2.5 rounded-lg bg-slate-700 border border-slate-600 text-slate-100 focus:outline-none focus:ring-2 focus:ring-cyan-500"
/>
</div>
</div>
<div>
<label for="username" class="block text-sm font-medium text-slate-300 mb-1">用户名</label>
<input
id="username"
v-model="username"
type="text"
class="w-full px-4 py-2.5 rounded-lg bg-slate-700 border border-slate-600 text-slate-100 focus:outline-none focus:ring-2 focus:ring-cyan-500"
placeholder="root"
/>
</div>
<div>
<label class="block text-sm font-medium text-slate-300 mb-2">认证方式</label>
<div class="flex gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<input v-model="authType" type="radio" value="PASSWORD" class="rounded" />
<span class="text-slate-300">密码</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input v-model="authType" type="radio" value="PRIVATE_KEY" class="rounded" />
<span class="text-slate-300">私钥</span>
</label>
</div>
</div>
<div v-if="authType === 'PASSWORD'">
<label for="password" class="block text-sm font-medium text-slate-300 mb-1">
密码 {{ isEdit ? '(留空则不修改)' : '' }}
</label>
<input
id="password"
v-model="password"
type="password"
class="w-full px-4 py-2.5 rounded-lg bg-slate-700 border border-slate-600 text-slate-100 focus:outline-none focus:ring-2 focus:ring-cyan-500"
:placeholder="isEdit ? '••••••••' : ''"
/>
</div>
<div v-if="authType === 'PRIVATE_KEY'" class="space-y-2">
<label for="privateKey" class="block text-sm font-medium text-slate-300 mb-1">
私钥 {{ isEdit ? '(留空则不修改)' : '' }}
</label>
<textarea
id="privateKey"
v-model="privateKey"
rows="6"
class="w-full px-4 py-2.5 rounded-lg bg-slate-700 border border-slate-600 text-slate-100 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-cyan-500"
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----&#10;...&#10;-----END OPENSSH PRIVATE KEY-----"
></textarea>
<label for="passphrase" class="block text-sm font-medium text-slate-300 mb-1">私钥口令可选</label>
<input
id="passphrase"
v-model="passphrase"
type="password"
class="w-full px-4 py-2.5 rounded-lg bg-slate-700 border border-slate-600 text-slate-100 focus:outline-none focus:ring-2 focus:ring-cyan-500"
/>
</div>
<p v-if="error" class="text-sm text-red-400">{{ error }}</p>
<div class="flex justify-end gap-2 pt-2">
<button
type="button"
@click="emit('close')"
class="px-4 py-2 rounded-lg text-slate-300 hover:bg-slate-700 transition-colors duration-200 cursor-pointer"
>
取消
</button>
<button
type="submit"
:disabled="loading"
class="px-4 py-2 rounded-lg bg-cyan-600 hover:bg-cyan-500 text-white font-medium transition-colors duration-200 disabled:opacity-50 cursor-pointer"
>
{{ isEdit ? '更新' : '创建' }}
</button>
</div>
</form>
</div>
</div>
</template>

View File

@@ -0,0 +1,141 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { Terminal } from 'xterm'
import { AttachAddon } from '@xterm/addon-attach'
import { FitAddon } from '@xterm/addon-fit'
import { useAuthStore } from '../stores/auth'
import 'xterm/css/xterm.css'
const props = defineProps<{
connectionId: number
}>()
const containerRef = ref<HTMLElement | null>(null)
const status = ref<'connecting' | 'connected' | 'error'>('connecting')
const errorMessage = ref('')
let term: Terminal | null = null
let fitAddon: FitAddon | null = null
let ws: WebSocket | null = null
let resizeObserver: ResizeObserver | null = null
function getWsUrl(): string {
const authStore = useAuthStore()
const token = authStore.token
if (!token) {
throw new Error('Not authenticated')
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const host = window.location.host
return `${protocol}//${host}/ws/terminal?connectionId=${props.connectionId}&token=${encodeURIComponent(token)}`
}
function cleanup() {
if (resizeObserver && containerRef.value) {
resizeObserver.unobserve(containerRef.value)
}
resizeObserver = null
if (ws) {
ws.close()
ws = null
}
if (term) {
term.dispose()
term = null
}
fitAddon = null
}
onMounted(() => {
const authStore = useAuthStore()
if (!authStore.token) {
status.value = 'error'
errorMessage.value = '未登录'
return
}
if (!containerRef.value) return
term = new Terminal({
cursorBlink: true,
theme: {
background: '#0f172a',
foreground: '#f1f5f9',
cursor: '#22d3ee',
cursorAccent: '#0f172a',
selectionBackground: 'rgba(34, 211, 238, 0.3)',
},
fontSize: 14,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
})
fitAddon = new FitAddon()
term.loadAddon(fitAddon)
term.open(containerRef.value)
fitAddon.fit()
try {
ws = new WebSocket(getWsUrl())
} catch (e) {
status.value = 'error'
errorMessage.value = e instanceof Error ? e.message : '连接失败'
return
}
ws.onopen = () => {
status.value = 'connected'
const attachAddon = new AttachAddon(ws!)
term!.loadAddon(attachAddon)
fitAddon?.fit()
}
ws.onerror = () => {
if (status.value === 'connecting') {
status.value = 'error'
errorMessage.value = 'WebSocket 连接失败'
}
}
ws.onclose = () => {
if (status.value === 'connected') {
term?.writeln('\r\n[连接已关闭]')
}
}
resizeObserver = new ResizeObserver(() => {
fitAddon?.fit()
})
resizeObserver.observe(containerRef.value)
})
onUnmounted(() => {
cleanup()
})
</script>
<template>
<div class="h-full flex flex-col bg-slate-950 rounded-lg border border-slate-700 overflow-hidden">
<div
ref="containerRef"
class="flex-1 min-h-0 p-4 xterm-container"
:class="{ 'flex items-center justify-center': status !== 'connected' }"
style="min-height: 300px"
>
<div v-if="status === 'connecting'" class="text-slate-400">
正在连接 SSH...
</div>
<div v-else-if="status === 'error'" class="text-red-400">
{{ errorMessage }}
</div>
</div>
</div>
</template>
<style>
.xterm-container .xterm {
padding: 0.5rem;
}
.xterm-container .xterm-viewport {
overflow-y: auto !important;
}
</style>

View File

@@ -0,0 +1,74 @@
<script setup lang="ts">
import { ref } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { useConnectionsStore } from '../stores/connections'
import { Server, LogOut, Menu, X } from 'lucide-vue-next'
const route = useRoute()
const authStore = useAuthStore()
const connectionsStore = useConnectionsStore()
const sidebarOpen = ref(false)
connectionsStore.fetchConnections().catch(() => {})
function closeSidebar() {
sidebarOpen.value = false
}
</script>
<template>
<div class="flex h-screen bg-slate-900">
<button
@click="sidebarOpen = !sidebarOpen"
class="lg:hidden fixed top-4 left-4 z-30 p-2 rounded-lg bg-slate-800 border border-slate-700 text-slate-300 hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-cyan-500 cursor-pointer"
aria-label="切换侧边栏"
>
<Menu v-if="!sidebarOpen" class="w-6 h-6" aria-hidden="true" />
<X v-else class="w-6 h-6" aria-hidden="true" />
</button>
<aside
:class="[
'w-64 bg-slate-800 border-r border-slate-700 flex flex-col transition-transform duration-200 z-20',
'fixed lg:static inset-y-0 left-0',
sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
]"
>
<div class="p-4 border-b border-slate-700">
<h1 class="text-lg font-semibold text-slate-100">SSH 管理器</h1>
<p class="text-sm text-slate-400">{{ authStore.displayName || authStore.username }}</p>
</div>
<nav class="flex-1 p-4 space-y-1 pt-16 lg:pt-4">
<RouterLink
to="/connections"
@click="closeSidebar"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-300 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer min-h-[44px] focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-inset"
:class="{ 'bg-slate-700 text-cyan-400': route.path === '/connections' }"
aria-label="连接列表"
>
<Server class="w-5 h-5 flex-shrink-0" aria-hidden="true" />
<span>连接列表</span>
</RouterLink>
</nav>
<div class="p-4 border-t border-slate-700">
<button
@click="authStore.logout(); $router.push('/login')"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer w-full min-h-[44px] focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-inset"
aria-label="退出登录"
>
<LogOut class="w-5 h-5" aria-hidden="true" />
<span>退出登录</span>
</button>
</div>
</aside>
<div
v-if="sidebarOpen"
class="lg:hidden fixed inset-0 bg-black/50 z-10"
aria-hidden="true"
@click="sidebarOpen = false"
/>
<main class="flex-1 overflow-auto min-w-0">
<RouterView />
</main>
</div>
</template>

10
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,10 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import './style.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

View File

@@ -0,0 +1,63 @@
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
import { useAuthStore } from '../stores/auth'
const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'Login',
component: () => import('../views/LoginView.vue'),
meta: { public: true },
},
{
path: '/',
component: () => import('../layouts/MainLayout.vue'),
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'Home',
redirect: '/connections',
},
{
path: 'connections',
name: 'Connections',
component: () => import('../views/ConnectionsView.vue'),
},
{
path: 'terminal/:id',
name: 'Terminal',
component: () => import('../views/TerminalView.vue'),
},
{
path: 'sftp/:id',
name: 'Sftp',
component: () => import('../views/SftpView.vue'),
},
],
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
router.beforeEach(async (to, _from, next) => {
const authStore = useAuthStore()
if (to.meta.public) {
if (authStore.isAuthenticated && to.path === '/login') {
next('/connections')
} else {
next()
}
return
}
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
next('/login')
return
}
next()
})
export default router

View File

@@ -0,0 +1,42 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import * as authApi from '../api/auth'
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(localStorage.getItem('token'))
const username = ref<string | null>(null)
const displayName = ref<string | null>(null)
const isAuthenticated = computed(() => !!token.value)
function setAuth(t: string, u: string, d: string) {
token.value = t
username.value = u
displayName.value = d || u
localStorage.setItem('token', t)
}
function logout() {
token.value = null
username.value = null
displayName.value = null
localStorage.removeItem('token')
}
async function fetchMe() {
const res = await authApi.getMe()
username.value = res.data.username
displayName.value = res.data.displayName || res.data.username
return res.data
}
return {
token,
username,
displayName,
isAuthenticated,
setAuth,
logout,
fetchMe,
}
})

View File

@@ -0,0 +1,45 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { Connection, ConnectionCreateRequest } from '../api/connections'
import * as connectionsApi from '../api/connections'
export const useConnectionsStore = defineStore('connections', () => {
const connections = ref<Connection[]>([])
async function fetchConnections() {
const res = await connectionsApi.listConnections()
connections.value = res.data
return res.data
}
async function createConnection(data: ConnectionCreateRequest) {
const res = await connectionsApi.createConnection(data)
connections.value.unshift(res.data)
return res.data
}
async function updateConnection(id: number, data: ConnectionCreateRequest) {
const res = await connectionsApi.updateConnection(id, data)
const idx = connections.value.findIndex((c) => c.id === id)
if (idx >= 0) connections.value[idx] = res.data
return res.data
}
async function deleteConnection(id: number) {
await connectionsApi.deleteConnection(id)
connections.value = connections.value.filter((c) => c.id !== id)
}
function getConnection(id: number): Connection | undefined {
return connections.value.find((c) => c.id === id)
}
return {
connections,
fetchConnections,
createConnection,
updateConnection,
deleteConnection,
getConnection,
}
})

19
frontend/src/style.css Normal file
View File

@@ -0,0 +1,19 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-slate-900 text-slate-100 antialiased;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}

View File

@@ -0,0 +1,156 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useConnectionsStore } from '../stores/connections'
import type { Connection, ConnectionCreateRequest } from '../api/connections'
import ConnectionForm from '../components/ConnectionForm.vue'
import {
Server,
Plus,
Terminal,
FolderOpen,
Pencil,
Trash2,
Key,
Lock,
} from 'lucide-vue-next'
const router = useRouter()
const store = useConnectionsStore()
const showForm = ref(false)
const editingConn = ref<Connection | null>(null)
onMounted(() => store.fetchConnections())
function openCreate() {
editingConn.value = null
showForm.value = true
}
function openEdit(conn: Connection) {
editingConn.value = conn
showForm.value = true
}
function closeForm() {
showForm.value = false
editingConn.value = null
}
async function handleSave(data: ConnectionCreateRequest) {
if (editingConn.value) {
await store.updateConnection(editingConn.value.id, data)
} else {
await store.createConnection(data)
}
closeForm()
}
async function handleDelete(conn: Connection) {
if (!confirm(`确定删除连接「${conn.name}」?`)) return
await store.deleteConnection(conn.id)
}
function openTerminal(conn: Connection) {
router.push(`/terminal/${conn.id}`)
}
function openSftp(conn: Connection) {
router.push(`/sftp/${conn.id}`)
}
</script>
<template>
<div class="p-6">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold text-slate-100">连接列表</h2>
<button
@click="openCreate"
class="flex items-center gap-2 px-4 py-2 rounded-lg bg-cyan-600 hover:bg-cyan-500 text-white font-medium transition-colors duration-200 cursor-pointer"
aria-label="添加连接"
>
<Plus class="w-5 h-5" aria-hidden="true" />
添加连接
</button>
</div>
<div v-if="store.connections.length === 0" class="text-center py-16 bg-slate-800/50 rounded-xl border border-slate-700">
<Server class="w-16 h-16 text-slate-500 mx-auto mb-4" aria-hidden="true" />
<p class="text-slate-400 mb-4">暂无连接</p>
<button
@click="openCreate"
class="px-4 py-2 rounded-lg bg-cyan-600 hover:bg-cyan-500 text-white transition-colors duration-200 cursor-pointer"
>
添加第一个连接
</button>
</div>
<div v-else class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div
v-for="conn in store.connections"
:key="conn.id"
class="bg-slate-800 rounded-xl border border-slate-700 p-4 hover:border-slate-600 transition-colors duration-200"
>
<div class="flex items-start justify-between mb-3">
<div class="flex items-center gap-2">
<div class="w-10 h-10 rounded-lg bg-slate-700 flex items-center justify-center">
<Server class="w-5 h-5 text-cyan-400" aria-hidden="true" />
</div>
<div>
<h3 class="font-medium text-slate-100">{{ conn.name }}</h3>
<p class="text-sm text-slate-400">{{ conn.username }}@{{ conn.host }}:{{ conn.port }}</p>
</div>
</div>
<div class="flex items-center gap-1">
<button
@click="openEdit(conn)"
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer"
aria-label="编辑"
>
<Pencil class="w-4 h-4" aria-hidden="true" />
</button>
<button
@click="handleDelete(conn)"
class="p-2 rounded-lg text-slate-400 hover:bg-red-900/50 hover:text-red-400 transition-colors duration-200 cursor-pointer"
aria-label="删除"
>
<Trash2 class="w-4 h-4" aria-hidden="true" />
</button>
</div>
</div>
<div class="flex items-center gap-2 mb-3">
<component
:is="conn.authType === 'PRIVATE_KEY' ? Key : Lock"
class="w-4 h-4 text-slate-500"
aria-hidden="true"
/>
<span class="text-sm text-slate-500">{{ conn.authType === 'PRIVATE_KEY' ? '私钥' : '密码' }}</span>
</div>
<div class="flex gap-2">
<button
@click="openTerminal(conn)"
class="flex-1 flex items-center justify-center gap-2 py-2 rounded-lg bg-slate-700 hover:bg-slate-600 text-slate-300 hover:text-slate-100 transition-colors duration-200 cursor-pointer text-sm"
>
<Terminal class="w-4 h-4" aria-hidden="true" />
终端
</button>
<button
@click="openSftp(conn)"
class="flex-1 flex items-center justify-center gap-2 py-2 rounded-lg bg-slate-700 hover:bg-slate-600 text-slate-300 hover:text-slate-100 transition-colors duration-200 cursor-pointer text-sm"
>
<FolderOpen class="w-4 h-4" aria-hidden="true" />
文件
</button>
</div>
</div>
</div>
<ConnectionForm
v-if="showForm"
:connection="editingConn"
:on-save="handleSave"
@close="closeForm"
/>
</div>
</template>

View File

@@ -0,0 +1,88 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import * as authApi from '../api/auth'
import { LogIn } from 'lucide-vue-next'
const router = useRouter()
const authStore = useAuthStore()
const username = ref('')
const password = ref('')
const error = ref('')
const loading = ref(false)
async function handleSubmit() {
error.value = ''
loading.value = true
try {
const res = await authApi.login({ username: username.value, password: password.value })
authStore.setAuth(res.data.token, res.data.username, res.data.displayName)
router.push('/connections')
} catch (e: unknown) {
const err = e as { response?: { data?: { message?: string }; status?: number } }
error.value = err.response?.data?.message || (err.response?.status === 401 ? '用户名或密码错误' : '登录失败')
} finally {
loading.value = false
}
}
</script>
<template>
<div class="min-h-screen flex items-center justify-center bg-slate-900 px-4">
<div class="w-full max-w-md">
<div class="bg-slate-800 rounded-xl shadow-xl border border-slate-700 p-8">
<div class="flex items-center gap-2 mb-6">
<div class="w-10 h-10 rounded-lg bg-cyan-600 flex items-center justify-center">
<LogIn class="w-6 h-6 text-white" aria-hidden="true" />
</div>
<h1 class="text-xl font-semibold text-slate-100">SSH 管理器</h1>
</div>
<h2 class="text-lg text-slate-300 mb-6">登录您的账户</h2>
<form @submit.prevent="handleSubmit" class="space-y-4">
<div>
<label for="username" class="block text-sm font-medium text-slate-300 mb-1">
用户名
</label>
<input
id="username"
v-model="username"
type="text"
required
autocomplete="username"
class="w-full px-4 py-2.5 rounded-lg bg-slate-700 border border-slate-600 text-slate-100 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-transparent transition-colors duration-200"
placeholder="admin"
/>
</div>
<div>
<label for="password" class="block text-sm font-medium text-slate-300 mb-1">
密码
</label>
<input
id="password"
v-model="password"
type="password"
required
autocomplete="current-password"
class="w-full px-4 py-2.5 rounded-lg bg-slate-700 border border-slate-600 text-slate-100 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-transparent transition-colors duration-200"
placeholder="••••••••"
/>
</div>
<p v-if="error" class="text-sm text-red-400">{{ error }}</p>
<button
type="submit"
:disabled="loading"
class="w-full py-2.5 px-4 rounded-lg bg-cyan-600 hover:bg-cyan-500 text-white font-medium transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-offset-2 focus:ring-offset-slate-800"
>
{{ loading ? '登录中...' : '登录' }}
</button>
</form>
<p class="mt-4 text-sm text-slate-500">
默认admin / admin123
</p>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,436 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useConnectionsStore } from '../stores/connections'
import * as sftpApi from '../api/sftp'
import type { SftpFileInfo } from '../api/sftp'
import {
ArrowLeft,
FolderOpen,
File,
Upload,
FolderPlus,
RefreshCw,
Download,
Trash2,
ChevronRight,
Copy,
} from 'lucide-vue-next'
const route = useRoute()
const router = useRouter()
const store = useConnectionsStore()
const connectionId = computed(() => Number(route.params.id))
const conn = ref(store.getConnection(connectionId.value))
const currentPath = ref('.')
const pathParts = ref<string[]>([])
const files = ref<SftpFileInfo[]>([])
const loading = ref(false)
const error = ref('')
const uploading = ref(false)
const selectedFile = ref<string | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
const showTransferModal = ref(false)
const transferFile = ref<SftpFileInfo | null>(null)
const transferTargetConnectionId = ref<number | null>(null)
const transferTargetPath = ref('')
const transferring = ref(false)
const transferError = ref('')
onMounted(() => {
conn.value = store.getConnection(connectionId.value)
if (!conn.value) {
store.fetchConnections().then(() => {
conn.value = store.getConnection(connectionId.value)
initPath()
})
} else {
initPath()
}
})
function initPath() {
sftpApi.getPwd(connectionId.value).then((res) => {
const p = res.data.path || '/'
currentPath.value = p || '.'
pathParts.value = p === '/' ? [''] : p.split('/').filter(Boolean)
loadPath()
}).catch(() => {
currentPath.value = '.'
pathParts.value = []
loadPath()
})
}
function loadPath() {
loading.value = true
error.value = ''
sftpApi
.listFiles(connectionId.value, currentPath.value)
.then((res) => {
files.value = res.data.sort((a, b) => {
if (a.directory !== b.directory) return a.directory ? -1 : 1
return a.name.localeCompare(b.name)
})
})
.catch(() => {
error.value = '获取文件列表失败'
})
.finally(() => {
loading.value = false
})
}
function navigateToDir(name: string) {
const base = currentPath.value === '.' || currentPath.value === '' ? '' : currentPath.value
currentPath.value = base ? (base.endsWith('/') ? base + name : base + '/' + name) : name
pathParts.value = currentPath.value === '/' ? [''] : currentPath.value.split('/').filter(Boolean)
loadPath()
}
function navigateToIndex(i: number) {
if (i < 0) {
currentPath.value = '.'
} else {
currentPath.value = pathParts.value.length ? '/' + pathParts.value.slice(0, i + 1).join('/') : '/'
}
pathParts.value = currentPath.value === '/' ? [''] : currentPath.value.split('/').filter(Boolean)
loadPath()
}
function goUp() {
if (currentPath.value === '.' || currentPath.value === '' || currentPath.value === '/') {
return
}
const parts = currentPath.value.split('/').filter(Boolean)
if (parts.length <= 1) {
currentPath.value = '/'
pathParts.value = ['']
} else {
parts.pop()
currentPath.value = '/' + parts.join('/')
pathParts.value = parts
}
loadPath()
}
function handleFileClick(file: SftpFileInfo) {
if (file.directory) {
navigateToDir(file.name)
} else {
selectedFile.value = file.name
}
}
function handleDownload(file: SftpFileInfo) {
if (file.directory) return
const base = currentPath.value === '.' || !currentPath.value ? '' : currentPath.value
const path = base ? base + '/' + file.name : file.name
sftpApi.downloadFile(connectionId.value, path).catch(() => {
error.value = '下载失败'
})
}
function triggerUpload() {
fileInputRef.value?.click()
}
async function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement
const selected = input.files
if (!selected?.length) return
uploading.value = true
error.value = ''
const path = currentPath.value === '.' ? '' : currentPath.value
try {
for (let i = 0; i < selected.length; i++) {
await sftpApi.uploadFile(connectionId.value, path, selected[i])
}
loadPath()
} catch {
error.value = '上传失败'
} finally {
uploading.value = false
input.value = ''
}
}
function handleMkdir() {
const name = prompt('文件夹名称:')
if (!name?.trim()) return
const base = currentPath.value === '.' || !currentPath.value ? '' : currentPath.value
const path = base ? base + '/' + name : name
sftpApi.createDir(connectionId.value, path).then(() => loadPath()).catch(() => {
error.value = '创建文件夹失败'
})
}
function handleDelete(file: SftpFileInfo) {
if (!confirm(`确定删除「${file.name}」?`)) return
const base = currentPath.value === '.' || !currentPath.value ? '' : currentPath.value
const path = base ? base + '/' + file.name : file.name
sftpApi.deleteFile(connectionId.value, path, file.directory).then(() => loadPath()).catch(() => {
error.value = '删除失败'
})
}
const targetConnectionOptions = computed(() => {
const list = store.connections.filter((c) => c.id !== connectionId.value)
return list
})
async function openTransferModal(file: SftpFileInfo) {
if (file.directory) return
if (store.connections.length === 0) await store.fetchConnections()
transferFile.value = file
transferTargetConnectionId.value = targetConnectionOptions.value[0]?.id ?? null
transferTargetPath.value = currentPath.value === '.' || !currentPath.value ? '/' : currentPath.value
if (!transferTargetPath.value.endsWith('/')) transferTargetPath.value += '/'
transferError.value = ''
showTransferModal.value = true
}
function closeTransferModal() {
showTransferModal.value = false
transferFile.value = null
transferTargetConnectionId.value = null
transferTargetPath.value = ''
transferError.value = ''
}
async function submitTransfer() {
const file = transferFile.value
const targetId = transferTargetConnectionId.value
if (!file || targetId == null) return
const base = currentPath.value === '.' || !currentPath.value ? '' : currentPath.value
const sourcePath = base ? base + '/' + file.name : file.name
let targetPath = transferTargetPath.value.trim()
if (targetPath.endsWith('/') || !targetPath) targetPath = targetPath + file.name
transferring.value = true
transferError.value = ''
try {
await sftpApi.transferRemote(connectionId.value, sourcePath, targetId, targetPath)
loadPath()
closeTransferModal()
} catch (err: unknown) {
const res = err as { response?: { data?: { error?: string } } }
transferError.value = res?.response?.data?.error ?? '传输失败'
} finally {
transferring.value = false
}
}
function formatSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
function formatDate(ts: number): string {
return new Date(ts).toLocaleString()
}
</script>
<template>
<div class="h-full flex flex-col">
<div class="flex items-center gap-4 p-4 border-b border-slate-700 bg-slate-800/50">
<button
@click="router.push('/connections')"
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer"
aria-label="返回"
>
<ArrowLeft class="w-5 h-5" aria-hidden="true" />
</button>
<h2 class="text-lg font-semibold text-slate-100">
{{ conn?.name || '文件' }} - {{ conn?.username }}@{{ conn?.host }}
</h2>
</div>
<div class="flex-1 overflow-auto p-4">
<div class="bg-slate-800 rounded-xl border border-slate-700 overflow-hidden">
<div class="flex items-center gap-2 p-3 border-b border-slate-700 bg-slate-800/80">
<nav class="flex items-center gap-1 text-sm text-slate-400 min-w-0 flex-1">
<button
@click="navigateToIndex(-1)"
class="px-2 py-1 rounded hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer truncate"
>
/
</button>
<template v-for="(part, i) in pathParts" :key="i">
<ChevronRight class="w-4 h-4 flex-shrink-0 text-slate-600" aria-hidden="true" />
<button
@click="navigateToIndex(i)"
class="px-2 py-1 rounded hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer truncate max-w-[120px]"
>
{{ part || '/' }}
</button>
</template>
</nav>
<div class="flex items-center gap-1 flex-shrink-0">
<button
@click="triggerUpload"
:disabled="uploading"
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer disabled:opacity-50"
aria-label="上传"
>
<Upload class="w-4 h-4" aria-hidden="true" />
</button>
<button
@click="handleMkdir"
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer"
aria-label="新建文件夹"
>
<FolderPlus class="w-4 h-4" aria-hidden="true" />
</button>
<button
@click="loadPath"
:disabled="loading"
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer disabled:opacity-50"
aria-label="刷新"
>
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': loading }" aria-hidden="true" />
</button>
</div>
<input
ref="fileInputRef"
type="file"
multiple
class="hidden"
@change="handleFileSelect"
/>
</div>
<p v-if="error" class="px-4 py-2 text-sm text-red-400">{{ error }}</p>
<div v-if="loading" class="p-8 text-center text-slate-400">
加载中...
</div>
<div v-else class="divide-y divide-slate-700">
<button
v-if="currentPath !== '.' && pathParts.length > 1"
@click="goUp"
class="w-full flex items-center gap-3 px-4 py-3 hover:bg-slate-700/50 transition-colors duration-200 cursor-pointer text-left"
>
<FolderOpen class="w-5 h-5 text-slate-500" aria-hidden="true" />
<span class="text-slate-400">..</span>
</button>
<button
v-for="file in files"
:key="file.name"
@click="handleFileClick(file)"
@dblclick="file.directory ? navigateToDir(file.name) : handleDownload(file)"
class="w-full flex items-center gap-3 px-4 py-3 hover:bg-slate-700/50 transition-colors duration-200 cursor-pointer text-left group"
>
<component
:is="file.directory ? FolderOpen : File"
class="w-5 h-5 flex-shrink-0 text-slate-400"
aria-hidden="true"
/>
<span class="flex-1 truncate text-slate-200">{{ file.name }}</span>
<span v-if="!file.directory" class="text-sm text-slate-500 flex-shrink-0">
{{ formatSize(file.size) }}
</span>
<span class="text-sm text-slate-500 flex-shrink-0">{{ formatDate(file.mtime) }}</span>
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
v-if="!file.directory"
@click.stop="handleDownload(file)"
class="p-1.5 rounded text-slate-400 hover:bg-slate-600 hover:text-cyan-400 transition-colors duration-200 cursor-pointer"
aria-label="下载"
>
<Download class="w-4 h-4" aria-hidden="true" />
</button>
<button
v-if="!file.directory"
@click.stop="openTransferModal(file)"
class="p-1.5 rounded text-slate-400 hover:bg-slate-600 hover:text-cyan-400 transition-colors duration-200 cursor-pointer"
aria-label="复制到远程"
>
<Copy class="w-4 h-4" aria-hidden="true" />
</button>
<button
@click.stop="handleDelete(file)"
class="p-1.5 rounded text-slate-400 hover:bg-red-900/50 hover:text-red-400 transition-colors duration-200 cursor-pointer"
aria-label="删除"
>
<Trash2 class="w-4 h-4" aria-hidden="true" />
</button>
</div>
</button>
<div v-if="files.length === 0 && !loading" class="p-8 text-center text-slate-500">
空目录
</div>
</div>
</div>
</div>
<Teleport to="body">
<div
v-if="showTransferModal"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="transfer-modal-title"
>
<div class="w-full max-w-md rounded-xl border border-slate-700 bg-slate-800 p-5 shadow-xl">
<h3 id="transfer-modal-title" class="text-lg font-semibold text-slate-100 mb-4">
复制到远程
</h3>
<p v-if="transferFile" class="text-sm text-slate-400 mb-3">
文件{{ transferFile.name }}
</p>
<div class="space-y-3">
<div>
<label for="transfer-target-conn" class="block text-sm text-slate-400 mb-1">目标连接</label>
<select
id="transfer-target-conn"
v-model.number="transferTargetConnectionId"
class="w-full rounded-lg border border-slate-600 bg-slate-700 px-3 py-2 text-slate-100 focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500"
>
<option v-for="c in targetConnectionOptions" :key="c.id" :value="c.id">
{{ c.name }} ({{ c.username }}@{{ c.host }})
</option>
</select>
<p v-if="targetConnectionOptions.length === 0" class="mt-1 text-xs text-amber-400">
暂无其他连接请先在连接管理中添加
</p>
</div>
<div>
<label for="transfer-target-path" class="block text-sm text-slate-400 mb-1">目标路径目录以 / 结尾</label>
<input
id="transfer-target-path"
v-model="transferTargetPath"
type="text"
class="w-full rounded-lg border border-slate-600 bg-slate-700 px-3 py-2 text-slate-100 placeholder-slate-500 focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500"
placeholder="/"
/>
</div>
</div>
<p v-if="transferError" class="mt-3 text-sm text-red-400">{{ transferError }}</p>
<div class="mt-5 flex justify-end gap-2">
<button
type="button"
@click="closeTransferModal"
:disabled="transferring"
class="rounded-lg border border-slate-600 px-4 py-2 text-slate-300 hover:bg-slate-700 disabled:opacity-50 cursor-pointer"
>
取消
</button>
<button
type="button"
@click="submitTransfer"
:disabled="transferring || transferTargetConnectionId == null"
class="rounded-lg bg-cyan-600 px-4 py-2 text-white hover:bg-cyan-500 disabled:opacity-50 cursor-pointer"
>
{{ transferring ? '传输中...' : '确定' }}
</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>

View File

@@ -0,0 +1,46 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useConnectionsStore } from '../stores/connections'
import TerminalWidget from '../components/TerminalWidget.vue'
import { ArrowLeft } from 'lucide-vue-next'
const route = useRoute()
const router = useRouter()
const store = useConnectionsStore()
const connectionId = computed(() => Number(route.params.id))
const conn = ref(store.getConnection(connectionId.value))
onMounted(() => {
conn.value = store.getConnection(connectionId.value)
if (!conn.value) {
store.fetchConnections().then(() => {
conn.value = store.getConnection(connectionId.value)
})
}
})
</script>
<template>
<div class="h-full flex flex-col">
<div class="flex items-center gap-4 p-4 border-b border-slate-700 bg-slate-800/50">
<button
@click="router.push('/connections')"
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer"
aria-label="返回"
>
<ArrowLeft class="w-5 h-5" aria-hidden="true" />
</button>
<h2 class="text-lg font-semibold text-slate-100">
{{ conn?.name || '终端' }} - {{ conn?.username }}@{{ conn?.host }}
</h2>
</div>
<div class="flex-1 min-h-0 p-4">
<TerminalWidget v-if="conn" :connection-id="conn.id" />
<div v-else class="flex items-center justify-center h-64 text-slate-400">
加载中...
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,18 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
darkMode: 'class',
theme: {
extend: {
colors: {
slate: {
850: '#172033',
}
}
},
},
plugins: [],
}

View File

@@ -0,0 +1,16 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

7
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

18
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/ws': {
target: 'ws://localhost:8080',
ws: true,
},
},
},
})