feat: opt page style
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
fontSize?: number
|
||||
autoConnect?: boolean
|
||||
initialCommand?: string
|
||||
}>(),
|
||||
{
|
||||
fontSize: 13,
|
||||
autoConnect: true,
|
||||
initialCommand: ''
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
connected: []
|
||||
disconnected: []
|
||||
}>()
|
||||
|
||||
const terminalRef = ref<HTMLDivElement | null>(null)
|
||||
let terminal: Terminal | null = null
|
||||
let fitAddon: FitAddon | null = null
|
||||
let ws: WebSocket | null = null
|
||||
let isPtyMode = false
|
||||
let inputBuffer = ''
|
||||
let commandHistory: string[] = []
|
||||
let historyIndex = -1
|
||||
|
||||
function initTerminal(forceConnect = false) {
|
||||
if (!terminalRef.value) return
|
||||
|
||||
// 清理旧终端
|
||||
if (terminal) {
|
||||
terminal.dispose()
|
||||
terminal = null
|
||||
}
|
||||
if (ws) {
|
||||
ws.close()
|
||||
ws = null
|
||||
}
|
||||
inputBuffer = ''
|
||||
isPtyMode = false
|
||||
|
||||
terminal = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: props.fontSize,
|
||||
fontFamily: 'Consolas, Monaco, monospace',
|
||||
theme: {
|
||||
background: '#1e1e1e',
|
||||
foreground: '#d4d4d4',
|
||||
cursor: '#d4d4d4',
|
||||
}
|
||||
})
|
||||
|
||||
fitAddon = new FitAddon()
|
||||
terminal.loadAddon(fitAddon)
|
||||
terminal.open(terminalRef.value)
|
||||
fitAddon.fit()
|
||||
terminal.focus()
|
||||
|
||||
// autoConnect 或者强制连接时才连接
|
||||
if (props.autoConnect || forceConnect) {
|
||||
connectWebSocket()
|
||||
}
|
||||
|
||||
// 清除当前输入行(Windows 模式用)
|
||||
function clearLine() {
|
||||
for (let i = 0; i < inputBuffer.length; i++) {
|
||||
terminal?.write('\b \b')
|
||||
}
|
||||
}
|
||||
|
||||
// 处理用户输入
|
||||
terminal.onData((data) => {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
||||
|
||||
if (isPtyMode) {
|
||||
ws.send(data)
|
||||
return
|
||||
}
|
||||
|
||||
if (data === '\r') {
|
||||
terminal?.write('\r\n')
|
||||
if (inputBuffer.trim()) {
|
||||
commandHistory.push(inputBuffer)
|
||||
historyIndex = commandHistory.length
|
||||
ws.send(inputBuffer + '\r\n')
|
||||
}
|
||||
inputBuffer = ''
|
||||
} else if (data === '\x1b[A') {
|
||||
if (commandHistory.length > 0 && historyIndex > 0) {
|
||||
clearLine()
|
||||
historyIndex--
|
||||
inputBuffer = commandHistory[historyIndex] ?? ''
|
||||
terminal?.write(inputBuffer)
|
||||
}
|
||||
} else if (data === '\x1b[B') {
|
||||
clearLine()
|
||||
if (historyIndex < commandHistory.length - 1) {
|
||||
historyIndex++
|
||||
inputBuffer = commandHistory[historyIndex] ?? ''
|
||||
terminal?.write(inputBuffer)
|
||||
} else {
|
||||
historyIndex = commandHistory.length
|
||||
inputBuffer = ''
|
||||
}
|
||||
} else if (data === '\x7f' || data === '\b') {
|
||||
if (inputBuffer.length > 0) {
|
||||
inputBuffer = inputBuffer.slice(0, -1)
|
||||
terminal?.write('\b \b')
|
||||
}
|
||||
} else if (data === '\x03') {
|
||||
ws.send('\x03')
|
||||
inputBuffer = ''
|
||||
historyIndex = commandHistory.length
|
||||
terminal?.write('^C\r\n')
|
||||
} else if (data >= ' ' || data === '\t') {
|
||||
inputBuffer += data
|
||||
terminal?.write(data)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function connectWebSocket() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const wsUrl = `${protocol}//${window.location.host}/api/terminal/ws`
|
||||
|
||||
ws = new WebSocket(wsUrl)
|
||||
|
||||
ws.onopen = () => {
|
||||
terminal?.writeln('\x1b[32m已连接到终端\x1b[0m')
|
||||
terminal?.writeln('')
|
||||
terminal?.focus()
|
||||
emit('connected')
|
||||
|
||||
// 如果有初始命令,延迟发送
|
||||
if (props.initialCommand) {
|
||||
setTimeout(() => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(props.initialCommand + '\r\n')
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (event.data === '__PTY_MODE__') {
|
||||
isPtyMode = true
|
||||
return
|
||||
}
|
||||
if (event.data === '__PIPE_MODE__') {
|
||||
isPtyMode = false
|
||||
return
|
||||
}
|
||||
terminal?.write(event.data)
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
terminal?.writeln('')
|
||||
terminal?.writeln('\x1b[31m连接已断开\x1b[0m')
|
||||
emit('disconnected')
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
terminal?.writeln('\x1b[31m连接错误\x1b[0m')
|
||||
}
|
||||
}
|
||||
|
||||
function reconnect() {
|
||||
if (ws) {
|
||||
ws.close()
|
||||
}
|
||||
inputBuffer = ''
|
||||
isPtyMode = false
|
||||
terminal?.clear()
|
||||
connectWebSocket()
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
if (ws) {
|
||||
ws.close()
|
||||
ws = null
|
||||
}
|
||||
if (terminal) {
|
||||
terminal.dispose()
|
||||
terminal = null
|
||||
}
|
||||
}
|
||||
|
||||
function handleResize() {
|
||||
fitAddon?.fit()
|
||||
}
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
reconnect,
|
||||
dispose,
|
||||
initTerminal
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', handleResize)
|
||||
setTimeout(initTerminal, 100)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
dispose()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="terminalRef" class="terminal-container w-full h-full bg-[#1e1e1e]" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.terminal-container :deep(.xterm-viewport) {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #4a4a4a #1e1e1e;
|
||||
}
|
||||
|
||||
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar) {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar-track) {
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar-thumb) {
|
||||
background: #4a4a4a;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar-thumb:hover) {
|
||||
background: #5a5a5a;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,19 @@
|
||||
// 应用路径常量
|
||||
export const PATHS = {
|
||||
// 脚本文件目录
|
||||
SCRIPTS_DIR: '/app/data/scripts',
|
||||
// 数据目录
|
||||
DATA_DIR: '/app/data',
|
||||
// 配置目录
|
||||
CONFIGS_DIR: '/app/configs',
|
||||
// 环境目录
|
||||
ENVS_DIR: '/app/envs',
|
||||
} as const
|
||||
|
||||
// 文件扩展名对应的运行命令
|
||||
export const FILE_RUNNERS: Record<string, string> = {
|
||||
py: 'python',
|
||||
js: 'node',
|
||||
sh: 'bash',
|
||||
bash: 'bash',
|
||||
} as const
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ref, onMounted, computed, onUnmounted, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -8,9 +8,11 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||
import FileTreeNode from '@/components/FileTreeNode.vue'
|
||||
import { Plus, Save, Play, RefreshCw, Upload, FolderUp, Pencil, Eye } from 'lucide-vue-next'
|
||||
import XTerminal from '@/components/XTerminal.vue'
|
||||
import { Plus, Save, Play, RefreshCw, Upload, FolderUp, Pencil, Eye, X } from 'lucide-vue-next'
|
||||
import { api, type FileNode } from '@/api'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { PATHS, FILE_RUNNERS } from '@/constants'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -38,6 +40,19 @@ const uploadTargetDir = ref('')
|
||||
const isEditMode = ref(false)
|
||||
const hasChanges = computed(() => fileContent.value !== originalContent.value)
|
||||
|
||||
// 终端弹窗相关
|
||||
const showTerminalDialog = ref(false)
|
||||
const terminalRef = ref<InstanceType<typeof XTerminal> | null>(null)
|
||||
const runCommand = ref('')
|
||||
|
||||
// 响应式字体大小
|
||||
const isSmallScreen = ref(window.innerWidth < 1024)
|
||||
const editorFontSize = computed(() => isSmallScreen.value ? 12 : 13)
|
||||
|
||||
function handleResize() {
|
||||
isSmallScreen.value = window.innerWidth < 1024
|
||||
}
|
||||
|
||||
async function loadTree() {
|
||||
try {
|
||||
fileTree.value = await api.files.tree()
|
||||
@@ -148,12 +163,35 @@ async function deleteItem() {
|
||||
|
||||
async function runScript() {
|
||||
if (!selectedFile.value) return
|
||||
try {
|
||||
await api.execute.command(`bash ${selectedFile.value}`)
|
||||
toast.success('脚本已执行')
|
||||
} catch {
|
||||
toast.error('执行失败')
|
||||
|
||||
// 获取文件所在目录和文件名
|
||||
const parts = selectedFile.value.split('/')
|
||||
const fileName = parts.pop() || selectedFile.value
|
||||
const dirPath = parts.length > 0 ? parts.join('/') : ''
|
||||
|
||||
// 根据文件扩展名确定运行命令
|
||||
const ext = fileName.split('.').pop()?.toLowerCase() || ''
|
||||
const runner = FILE_RUNNERS[ext]
|
||||
const cmd = runner ? `${runner} ${fileName}` : `./${fileName}`
|
||||
|
||||
// 构建完整命令
|
||||
if (dirPath) {
|
||||
runCommand.value = `cd ${PATHS.SCRIPTS_DIR}/${dirPath} && ${cmd}`
|
||||
} else {
|
||||
runCommand.value = `cd ${PATHS.SCRIPTS_DIR} && ${cmd}`
|
||||
}
|
||||
|
||||
showTerminalDialog.value = true
|
||||
// 等待 DOM 更新后初始化终端
|
||||
await nextTick()
|
||||
setTimeout(() => {
|
||||
terminalRef.value?.initTerminal(true)
|
||||
}, 100)
|
||||
}
|
||||
|
||||
function closeTerminal() {
|
||||
showTerminalDialog.value = false
|
||||
terminalRef.value?.dispose()
|
||||
}
|
||||
|
||||
async function handleMove(oldPath: string, newPath: string) {
|
||||
@@ -277,6 +315,14 @@ async function initFromUrl() {
|
||||
}
|
||||
|
||||
onMounted(initFromUrl)
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -352,7 +398,7 @@ onMounted(initFromUrl)
|
||||
theme="vs-dark"
|
||||
:options="{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 13,
|
||||
fontSize: editorFontSize,
|
||||
lineNumbers: 'on',
|
||||
scrollBeyondLastLine: false,
|
||||
quickSuggestions: isEditMode,
|
||||
@@ -368,7 +414,8 @@ onMounted(initFromUrl)
|
||||
}"
|
||||
/>
|
||||
<div v-else class="h-full flex items-center justify-center text-muted-foreground text-sm">
|
||||
从左侧选择文件开始编辑
|
||||
<span class="lg:hidden">从上方选择文件开始编辑</span>
|
||||
<span class="hidden lg:inline">从左侧选择文件开始编辑</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -420,5 +467,30 @@ onMounted(initFromUrl)
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<!-- 终端弹窗 -->
|
||||
<Dialog v-model:open="showTerminalDialog">
|
||||
<DialogContent class="w-[calc(100%-2rem)] sm:max-w-3xl h-[60vh] sm:h-[70vh] flex flex-col p-0 overflow-hidden" :show-close-button="false">
|
||||
<div class="flex items-center justify-between px-3 sm:px-4 py-2 border-b bg-[#252526] rounded-t-lg">
|
||||
<span class="text-xs sm:text-sm font-medium text-gray-300">运行脚本</span>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6 text-gray-400 hover:text-white" @click="closeTerminal">
|
||||
<X class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-hidden p-1 rounded-b-lg">
|
||||
<XTerminal
|
||||
v-if="showTerminalDialog"
|
||||
ref="terminalRef"
|
||||
:font-size="isSmallScreen ? 12 : 13"
|
||||
:initial-command="runCommand"
|
||||
:auto-connect="false"
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
|
||||
@@ -3,11 +3,17 @@ import { ref, onMounted } from 'vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import Pagination from '@/components/Pagination.vue'
|
||||
import { RefreshCw, Search } from 'lucide-vue-next'
|
||||
import { RefreshCw, Search, Loader2 } from 'lucide-vue-next'
|
||||
import TextOverflow from '@/components/TextOverflow.vue'
|
||||
import { api } from '@/api'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { useSiteSettings } from '@/composables/useSiteSettings'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
|
||||
const { pageSize } = useSiteSettings()
|
||||
|
||||
@@ -21,6 +27,21 @@ interface LoginLog {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface IpGeoInfo {
|
||||
ip: string
|
||||
country: string
|
||||
country_code: string
|
||||
organization: string
|
||||
isp: string
|
||||
asn: number
|
||||
asn_organization: string
|
||||
timezone: string
|
||||
latitude: number
|
||||
longitude: number
|
||||
continent_code: string
|
||||
offset: number
|
||||
}
|
||||
|
||||
const logs = ref<LoginLog[]>([])
|
||||
const filterUsername = ref('')
|
||||
const currentPage = ref(1)
|
||||
@@ -28,6 +49,29 @@ const total = ref(0)
|
||||
const loading = ref(false)
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// IP 地理位置弹窗
|
||||
const ipDialogOpen = ref(false)
|
||||
const ipGeoInfo = ref<IpGeoInfo | null>(null)
|
||||
const ipGeoLoading = ref(false)
|
||||
const selectedIp = ref('')
|
||||
|
||||
async function showIpInfo(ip: string) {
|
||||
selectedIp.value = ip
|
||||
ipDialogOpen.value = true
|
||||
ipGeoLoading.value = true
|
||||
ipGeoInfo.value = null
|
||||
|
||||
try {
|
||||
const res = await fetch(`https://api.ip.sb/geoip/${ip}`)
|
||||
if (!res.ok) throw new Error('请求失败')
|
||||
ipGeoInfo.value = await res.json()
|
||||
} catch {
|
||||
toast.error('获取 IP 信息失败')
|
||||
} finally {
|
||||
ipGeoLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -86,36 +130,68 @@ onMounted(loadLogs)
|
||||
|
||||
<div class="rounded-lg border bg-card overflow-x-auto">
|
||||
<!-- 表头 -->
|
||||
<div class="flex items-center gap-4 px-4 py-2 border-b bg-muted/50 text-sm text-muted-foreground font-medium min-w-[500px]">
|
||||
<span class="w-20 sm:w-24 shrink-0">用户名</span>
|
||||
<span class="w-24 sm:w-32 shrink-0">IP 地址</span>
|
||||
<span class="w-12 sm:w-16 shrink-0 text-center">状态</span>
|
||||
<span class="w-32 sm:flex-1 shrink-0 sm:shrink hidden md:block">User Agent</span>
|
||||
<span class="w-32 sm:w-40 shrink-0 text-right">时间</span>
|
||||
<div class="flex items-center gap-2 sm:gap-4 px-3 sm:px-4 py-2 border-b bg-muted/50 text-xs sm:text-sm text-muted-foreground font-medium sm:min-w-[500px]">
|
||||
<span class="w-16 sm:w-24 shrink-0">用户名</span>
|
||||
<span class="w-20 sm:w-32 shrink-0">IP 地址</span>
|
||||
<span class="w-10 sm:w-16 shrink-0 text-center">状态</span>
|
||||
<span class="hidden sm:flex sm:flex-1">User Agent</span>
|
||||
<span class="shrink-0 sm:w-40 sm:text-right">时间</span>
|
||||
</div>
|
||||
<!-- 列表 -->
|
||||
<div class="divide-y min-w-[500px]">
|
||||
<div class="divide-y sm:min-w-[500px]">
|
||||
<div v-if="logs.length === 0" class="text-sm text-muted-foreground text-center py-8">
|
||||
暂无登录日志
|
||||
</div>
|
||||
<div
|
||||
v-for="log in logs"
|
||||
:key="log.id"
|
||||
class="flex items-center gap-4 px-4 py-2 hover:bg-muted/50 transition-colors"
|
||||
class="flex items-center gap-2 sm:gap-4 px-3 sm:px-4 py-2 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<span class="w-20 sm:w-24 shrink-0 font-medium text-sm truncate">{{ log.username }}</span>
|
||||
<code class="w-24 sm:w-32 shrink-0 text-xs text-muted-foreground bg-muted px-2 py-1 rounded truncate">{{ log.ip }}</code>
|
||||
<span class="w-12 sm:w-16 shrink-0 flex justify-center">
|
||||
<span class="w-16 sm:w-24 shrink-0 font-medium text-xs sm:text-sm truncate">{{ log.username }}</span>
|
||||
<code
|
||||
class="w-20 sm:w-32 shrink-0 text-xs text-muted-foreground bg-muted px-1 sm:px-2 py-0.5 sm:py-1 rounded truncate cursor-pointer hover:bg-muted/80 transition-colors"
|
||||
@click="showIpInfo(log.ip)"
|
||||
>{{ log.ip }}</code>
|
||||
<span class="w-10 sm:w-16 shrink-0 flex justify-center">
|
||||
<span :class="['h-2 w-2 rounded-full', log.status === 'success' ? 'bg-green-500' : 'bg-red-500']"></span>
|
||||
</span>
|
||||
<span class="w-32 sm:flex-1 shrink-0 sm:shrink text-xs text-muted-foreground truncate hidden md:block">
|
||||
<span class="hidden sm:flex sm:flex-1 text-xs text-muted-foreground truncate">
|
||||
<TextOverflow :text="log.user_agent || '-'" title="User Agent" />
|
||||
</span>
|
||||
<span class="w-32 sm:w-40 shrink-0 text-right text-xs text-muted-foreground">{{ log.created_at }}</span>
|
||||
<span class="shrink-0 sm:w-40 sm:text-right text-xs text-muted-foreground">{{ log.created_at }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 分页 -->
|
||||
<Pagination :total="total" :page="currentPage" @update:page="handlePageChange" />
|
||||
</div>
|
||||
|
||||
<!-- IP 地理位置弹窗 -->
|
||||
<Dialog v-model:open="ipDialogOpen">
|
||||
<DialogContent class="max-w-[90vw] sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle class="text-base sm:text-lg">IP 信息 - {{ selectedIp }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div v-if="ipGeoLoading" class="flex items-center justify-center py-8">
|
||||
<Loader2 class="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
<div v-else-if="ipGeoInfo" class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-xs sm:text-sm">
|
||||
<div class="text-muted-foreground">国家</div>
|
||||
<div class="font-medium">{{ ipGeoInfo.country }} ({{ ipGeoInfo.country_code }})</div>
|
||||
<div class="text-muted-foreground">运营商</div>
|
||||
<div class="font-medium truncate">{{ ipGeoInfo.isp || '-' }}</div>
|
||||
<div class="text-muted-foreground">组织</div>
|
||||
<div class="font-medium truncate">{{ ipGeoInfo.organization || '-' }}</div>
|
||||
<div class="text-muted-foreground">ASN</div>
|
||||
<div class="font-medium truncate">{{ ipGeoInfo.asn }} - {{ ipGeoInfo.asn_organization || '-' }}</div>
|
||||
<div class="text-muted-foreground">时区</div>
|
||||
<div class="font-medium">{{ ipGeoInfo.timezone || '-' }}</div>
|
||||
<div class="text-muted-foreground">坐标</div>
|
||||
<div class="font-medium">{{ ipGeoInfo.latitude }}, {{ ipGeoInfo.longitude }}</div>
|
||||
</div>
|
||||
<div v-else class="text-center text-muted-foreground py-4">
|
||||
无法获取 IP 信息
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,174 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import { ref } from 'vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { RefreshCw } from 'lucide-vue-next'
|
||||
import XTerminal from '@/components/XTerminal.vue'
|
||||
|
||||
const terminalRef = ref<HTMLDivElement | null>(null)
|
||||
let terminal: Terminal | null = null
|
||||
let fitAddon: FitAddon | null = null
|
||||
let ws: WebSocket | null = null
|
||||
let isPtyMode = false // 是否是 PTY 模式(Unix)
|
||||
let inputBuffer = ''
|
||||
let commandHistory: string[] = []
|
||||
let historyIndex = -1
|
||||
|
||||
function initTerminal() {
|
||||
if (!terminalRef.value || terminal) return
|
||||
|
||||
terminal = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 13,
|
||||
fontFamily: 'Consolas, Monaco, monospace',
|
||||
theme: {
|
||||
background: '#1e1e1e',
|
||||
foreground: '#d4d4d4',
|
||||
cursor: '#d4d4d4',
|
||||
}
|
||||
})
|
||||
|
||||
fitAddon = new FitAddon()
|
||||
terminal.loadAddon(fitAddon)
|
||||
terminal.open(terminalRef.value)
|
||||
fitAddon.fit()
|
||||
terminal.focus()
|
||||
|
||||
connectWebSocket()
|
||||
|
||||
// 清除当前输入行(Windows 模式用)
|
||||
function clearLine() {
|
||||
for (let i = 0; i < inputBuffer.length; i++) {
|
||||
terminal?.write('\b \b')
|
||||
}
|
||||
}
|
||||
|
||||
// 处理用户输入
|
||||
terminal.onData((data) => {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
||||
|
||||
// PTY 模式:直接透传所有输入
|
||||
if (isPtyMode) {
|
||||
ws.send(data)
|
||||
return
|
||||
}
|
||||
|
||||
// Windows 模式:本地处理输入和历史记录
|
||||
// 回车键
|
||||
if (data === '\r') {
|
||||
terminal?.write('\r\n')
|
||||
if (inputBuffer.trim()) {
|
||||
commandHistory.push(inputBuffer)
|
||||
historyIndex = commandHistory.length
|
||||
ws.send(inputBuffer + '\r\n')
|
||||
}
|
||||
inputBuffer = ''
|
||||
}
|
||||
// 上箭头 - 上一条历史
|
||||
else if (data === '\x1b[A') {
|
||||
if (commandHistory.length > 0 && historyIndex > 0) {
|
||||
clearLine()
|
||||
historyIndex--
|
||||
inputBuffer = commandHistory[historyIndex] ?? ''
|
||||
terminal?.write(inputBuffer)
|
||||
}
|
||||
}
|
||||
// 下箭头 - 下一条历史
|
||||
else if (data === '\x1b[B') {
|
||||
clearLine()
|
||||
if (historyIndex < commandHistory.length - 1) {
|
||||
historyIndex++
|
||||
inputBuffer = commandHistory[historyIndex] ?? ''
|
||||
terminal?.write(inputBuffer)
|
||||
} else {
|
||||
historyIndex = commandHistory.length
|
||||
inputBuffer = ''
|
||||
}
|
||||
}
|
||||
// 退格键
|
||||
else if (data === '\x7f' || data === '\b') {
|
||||
if (inputBuffer.length > 0) {
|
||||
inputBuffer = inputBuffer.slice(0, -1)
|
||||
terminal?.write('\b \b')
|
||||
}
|
||||
}
|
||||
// Ctrl+C
|
||||
else if (data === '\x03') {
|
||||
ws.send('\x03')
|
||||
inputBuffer = ''
|
||||
historyIndex = commandHistory.length
|
||||
terminal?.write('^C\r\n')
|
||||
}
|
||||
// 普通字符
|
||||
else if (data >= ' ' || data === '\t') {
|
||||
inputBuffer += data
|
||||
terminal?.write(data)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function connectWebSocket() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const wsUrl = `${protocol}//${window.location.host}/api/terminal/ws`
|
||||
|
||||
ws = new WebSocket(wsUrl)
|
||||
|
||||
ws.onopen = () => {
|
||||
terminal?.writeln('\x1b[32m已连接到终端\x1b[0m')
|
||||
terminal?.writeln('')
|
||||
terminal?.focus()
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
// 检查是否是 PTY 模式标识
|
||||
if (event.data === '__PTY_MODE__') {
|
||||
isPtyMode = true
|
||||
return
|
||||
}
|
||||
if (event.data === '__PIPE_MODE__') {
|
||||
isPtyMode = false
|
||||
return
|
||||
}
|
||||
terminal?.write(event.data)
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
terminal?.writeln('')
|
||||
terminal?.writeln('\x1b[31m连接已断开\x1b[0m')
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
terminal?.writeln('\x1b[31m连接错误\x1b[0m')
|
||||
}
|
||||
}
|
||||
const terminalRef = ref<InstanceType<typeof XTerminal> | null>(null)
|
||||
|
||||
function reconnect() {
|
||||
if (ws) {
|
||||
ws.close()
|
||||
}
|
||||
inputBuffer = ''
|
||||
isPtyMode = false
|
||||
terminal?.clear()
|
||||
connectWebSocket()
|
||||
terminalRef.value?.reconnect()
|
||||
}
|
||||
|
||||
function handleResize() {
|
||||
fitAddon?.fit()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', handleResize)
|
||||
setTimeout(initTerminal, 100)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
ws?.close()
|
||||
terminal?.dispose()
|
||||
terminal = null
|
||||
ws = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -179,30 +19,8 @@ onUnmounted(() => {
|
||||
<RefreshCw class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div ref="terminalRef" class="terminal-container flex-1 border border-t-0 rounded-b-md bg-[#1e1e1e] p-1" />
|
||||
<div class="flex-1 border border-t-0 rounded-b-md overflow-hidden">
|
||||
<XTerminal ref="terminalRef" :font-size="13" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.terminal-container :deep(.xterm-viewport) {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #4a4a4a #1e1e1e;
|
||||
}
|
||||
|
||||
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar) {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar-track) {
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar-thumb) {
|
||||
background: #4a4a4a;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar-thumb:hover) {
|
||||
background: #5a5a5a;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user