Refactor project structure and update .gitignore; enhance README with setup instructions and environment requirements. Clean up backend code for improved readability and maintainability.

This commit is contained in:
liumangmang
2026-02-04 11:07:42 +08:00
parent 765d05c0a7
commit 7e6ebd18a5
49 changed files with 3381 additions and 3389 deletions

View File

@@ -1,238 +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>
<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

@@ -1,141 +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>
<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>