Initial commit: SSH Manager (backend + frontend)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal 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
3
frontend/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
5
frontend/README.md
Normal file
5
frontend/README.md
Normal 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
13
frontend/index.html
Normal 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
3384
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
frontend/package.json
Normal file
32
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal 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
6
frontend/src/App.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
20
frontend/src/api/auth.ts
Normal file
20
frontend/src/api/auth.ts
Normal 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')
|
||||
}
|
||||
29
frontend/src/api/client.ts
Normal file
29
frontend/src/api/client.ts
Normal 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
|
||||
45
frontend/src/api/connections.ts
Normal file
45
frontend/src/api/connections.ts
Normal 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
81
frontend/src/api/sftp.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
1
frontend/src/assets/vue.svg
Normal file
1
frontend/src/assets/vue.svg
Normal 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 |
238
frontend/src/components/ConnectionForm.vue
Normal file
238
frontend/src/components/ConnectionForm.vue
Normal 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----- ... -----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>
|
||||
141
frontend/src/components/TerminalWidget.vue
Normal file
141
frontend/src/components/TerminalWidget.vue
Normal 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>
|
||||
74
frontend/src/layouts/MainLayout.vue
Normal file
74
frontend/src/layouts/MainLayout.vue
Normal 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
10
frontend/src/main.ts
Normal 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')
|
||||
63
frontend/src/router/index.ts
Normal file
63
frontend/src/router/index.ts
Normal 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
|
||||
42
frontend/src/stores/auth.ts
Normal file
42
frontend/src/stores/auth.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
45
frontend/src/stores/connections.ts
Normal file
45
frontend/src/stores/connections.ts
Normal 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
19
frontend/src/style.css
Normal 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;
|
||||
}
|
||||
}
|
||||
156
frontend/src/views/ConnectionsView.vue
Normal file
156
frontend/src/views/ConnectionsView.vue
Normal 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>
|
||||
88
frontend/src/views/LoginView.vue
Normal file
88
frontend/src/views/LoginView.vue
Normal 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>
|
||||
436
frontend/src/views/SftpView.vue
Normal file
436
frontend/src/views/SftpView.vue
Normal 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>
|
||||
46
frontend/src/views/TerminalView.vue
Normal file
46
frontend/src/views/TerminalView.vue
Normal 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>
|
||||
18
frontend/tailwind.config.js
Normal file
18
frontend/tailwind.config.js
Normal 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: [],
|
||||
}
|
||||
16
frontend/tsconfig.app.json
Normal file
16
frontend/tsconfig.app.json
Normal 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
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
frontend/tsconfig.node.json
Normal file
26
frontend/tsconfig.node.json
Normal 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
18
frontend/vite.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user