feat: opt page style

This commit is contained in:
engigu
2025-12-25 09:36:44 +08:00
parent 26d6407961
commit 49f8a4db6c
5 changed files with 440 additions and 212 deletions
+243
View File
@@ -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>
+19
View File
@@ -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
+81 -9
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <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 { useRoute, useRouter } from 'vue-router'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
@@ -8,9 +8,11 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import FileTreeNode from '@/components/FileTreeNode.vue' 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 { api, type FileNode } from '@/api'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
import { PATHS, FILE_RUNNERS } from '@/constants'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -38,6 +40,19 @@ const uploadTargetDir = ref('')
const isEditMode = ref(false) const isEditMode = ref(false)
const hasChanges = computed(() => fileContent.value !== originalContent.value) 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() { async function loadTree() {
try { try {
fileTree.value = await api.files.tree() fileTree.value = await api.files.tree()
@@ -148,12 +163,35 @@ async function deleteItem() {
async function runScript() { async function runScript() {
if (!selectedFile.value) return if (!selectedFile.value) return
try {
await api.execute.command(`bash ${selectedFile.value}`) // 获取文件所在目录和文件名
toast.success('脚本已执行') const parts = selectedFile.value.split('/')
} catch { const fileName = parts.pop() || selectedFile.value
toast.error('执行失败') 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) { async function handleMove(oldPath: string, newPath: string) {
@@ -277,6 +315,14 @@ async function initFromUrl() {
} }
onMounted(initFromUrl) onMounted(initFromUrl)
onMounted(() => {
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
</script> </script>
<template> <template>
@@ -352,7 +398,7 @@ onMounted(initFromUrl)
theme="vs-dark" theme="vs-dark"
:options="{ :options="{
minimap: { enabled: false }, minimap: { enabled: false },
fontSize: 13, fontSize: editorFontSize,
lineNumbers: 'on', lineNumbers: 'on',
scrollBeyondLastLine: false, scrollBeyondLastLine: false,
quickSuggestions: isEditMode, quickSuggestions: isEditMode,
@@ -368,7 +414,8 @@ onMounted(initFromUrl)
}" }"
/> />
<div v-else class="h-full flex items-center justify-center text-muted-foreground text-sm"> <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> </div>
</div> </div>
@@ -420,5 +467,30 @@ onMounted(initFromUrl)
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </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> </div>
</template> </template>
<style scoped>
</style>
+90 -14
View File
@@ -3,11 +3,17 @@ import { ref, onMounted } from 'vue'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import Pagination from '@/components/Pagination.vue' 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 TextOverflow from '@/components/TextOverflow.vue'
import { api } from '@/api' import { api } from '@/api'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
import { useSiteSettings } from '@/composables/useSiteSettings' import { useSiteSettings } from '@/composables/useSiteSettings'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
const { pageSize } = useSiteSettings() const { pageSize } = useSiteSettings()
@@ -21,6 +27,21 @@ interface LoginLog {
created_at: string 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 logs = ref<LoginLog[]>([])
const filterUsername = ref('') const filterUsername = ref('')
const currentPage = ref(1) const currentPage = ref(1)
@@ -28,6 +49,29 @@ const total = ref(0)
const loading = ref(false) const loading = ref(false)
let searchTimer: ReturnType<typeof setTimeout> | null = null 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() { async function loadLogs() {
loading.value = true loading.value = true
try { try {
@@ -86,36 +130,68 @@ onMounted(loadLogs)
<div class="rounded-lg border bg-card overflow-x-auto"> <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]"> <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-20 sm:w-24 shrink-0">用户名</span> <span class="w-16 sm:w-24 shrink-0">用户名</span>
<span class="w-24 sm:w-32 shrink-0">IP 地址</span> <span class="w-20 sm:w-32 shrink-0">IP 地址</span>
<span class="w-12 sm:w-16 shrink-0 text-center">状态</span> <span class="w-10 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="hidden sm:flex sm:flex-1">User Agent</span>
<span class="w-32 sm:w-40 shrink-0 text-right">时间</span> <span class="shrink-0 sm:w-40 sm:text-right">时间</span>
</div> </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 v-if="logs.length === 0" class="text-sm text-muted-foreground text-center py-8">
暂无登录日志 暂无登录日志
</div> </div>
<div <div
v-for="log in logs" v-for="log in logs"
:key="log.id" :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> <span class="w-16 sm:w-24 shrink-0 font-medium text-xs sm: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> <code
<span class="w-12 sm:w-16 shrink-0 flex justify-center"> 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 :class="['h-2 w-2 rounded-full', log.status === 'success' ? 'bg-green-500' : 'bg-red-500']"></span>
</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" /> <TextOverflow :text="log.user_agent || '-'" title="User Agent" />
</span> </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>
</div> </div>
<!-- 分页 --> <!-- 分页 -->
<Pagination :total="total" :page="currentPage" @update:page="handlePageChange" /> <Pagination :total="total" :page="currentPage" @update:page="handlePageChange" />
</div> </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> </div>
</template> </template>
+7 -189
View File
@@ -1,174 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue' import { ref } from 'vue'
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import '@xterm/xterm/css/xterm.css'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { RefreshCw } from 'lucide-vue-next' import { RefreshCw } from 'lucide-vue-next'
import XTerminal from '@/components/XTerminal.vue'
const terminalRef = ref<HTMLDivElement | null>(null) const terminalRef = ref<InstanceType<typeof XTerminal> | 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')
}
}
function reconnect() { function reconnect() {
if (ws) { terminalRef.value?.reconnect()
ws.close()
} }
inputBuffer = ''
isPtyMode = false
terminal?.clear()
connectWebSocket()
}
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> </script>
<template> <template>
@@ -179,30 +19,8 @@ onUnmounted(() => {
<RefreshCw class="h-3 w-3" /> <RefreshCw class="h-3 w-3" />
</Button> </Button>
</div> </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> </div>
</template> </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>