639 lines
22 KiB
Vue
639 lines
22 KiB
Vue
<script setup lang="ts">
|
||
import { ref, onMounted, computed, onUnmounted, nextTick, shallowRef } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Input } from '@/components/ui/input'
|
||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'
|
||
import { Label } from '@/components/ui/label'
|
||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||
import FileTreeNode from '@/components/FileTreeNode.vue'
|
||
import XTerminal from '@/components/XTerminal.vue'
|
||
import { Plus, Save, Play, RefreshCw, Upload, FolderUp, Pencil, Eye, X, Download, Trash2, Edit3 } 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()
|
||
|
||
const fileTree = ref<FileNode[]>([])
|
||
const expandedDirs = ref<Set<string>>(new Set())
|
||
const selectedFile = ref<string | null>(null)
|
||
const selectedPath = ref<string | null>(null)
|
||
const fileContent = ref('')
|
||
const originalContent = ref('')
|
||
const isLoading = ref(false)
|
||
|
||
const showCreateDialog = ref(false)
|
||
const newItemName = ref('')
|
||
const newItemType = ref<'file' | 'dir'>('file')
|
||
const createInDir = ref('')
|
||
|
||
const showDeleteDialog = ref(false)
|
||
const deleteTargetPath = ref('')
|
||
|
||
const archiveInputRef = ref<HTMLInputElement | null>(null)
|
||
const filesInputRef = ref<HTMLInputElement | null>(null)
|
||
const uploadTargetDir = ref('')
|
||
|
||
const showRenameDialog = ref(false)
|
||
const renamePath = ref('')
|
||
const newName = 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 scriptsDir = ref('')
|
||
|
||
async function fetchPaths() {
|
||
try {
|
||
const res = await api.settings.getPaths()
|
||
scriptsDir.value = res.scripts_dir
|
||
} catch {
|
||
scriptsDir.value = PATHS.SCRIPTS_DIR
|
||
}
|
||
}
|
||
|
||
// Monaco Editor 实例引用
|
||
const editorRef = shallowRef()
|
||
|
||
// 响应式字体大小
|
||
const isSmallScreen = ref(window.innerWidth < 1024)
|
||
const editorFontSize = computed(() => isSmallScreen.value ? 12 : 13)
|
||
|
||
// 编辑器挂载时的回调
|
||
function handleEditorMount(editor: any) {
|
||
editorRef.value = editor
|
||
// 强制设置换行符为 LF
|
||
const model = editor.getModel()
|
||
if (model) {
|
||
model.setEOL(0) // 0 = LF, 1 = CRLF
|
||
}
|
||
}
|
||
|
||
function handleResize() {
|
||
isSmallScreen.value = window.innerWidth < 1024
|
||
}
|
||
|
||
async function loadTree() {
|
||
try {
|
||
fileTree.value = await api.files.tree()
|
||
} catch {
|
||
toast.error('加载文件树失败')
|
||
}
|
||
}
|
||
|
||
async function handleSelect(node: FileNode) {
|
||
selectedPath.value = node.path
|
||
// 更新 URL 使用 query 参数
|
||
router.replace({ name: 'editor', query: { file: node.path } })
|
||
|
||
if (node.isDir) {
|
||
if (expandedDirs.value.has(node.path)) {
|
||
expandedDirs.value.delete(node.path)
|
||
} else {
|
||
expandedDirs.value.add(node.path)
|
||
}
|
||
expandedDirs.value = new Set(expandedDirs.value)
|
||
} else {
|
||
if (hasChanges.value && !confirm('当前文件有未保存的更改,是否放弃?')) return
|
||
await loadFile(node.path)
|
||
}
|
||
}
|
||
|
||
async function loadFile(path: string) {
|
||
isLoading.value = true
|
||
isEditMode.value = false
|
||
try {
|
||
const res = await api.files.getContent(path)
|
||
selectedFile.value = path
|
||
fileContent.value = res.content
|
||
originalContent.value = res.content
|
||
} catch {
|
||
toast.error('加载文件失败')
|
||
} finally {
|
||
isLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function saveFile() {
|
||
if (!selectedFile.value) return
|
||
try {
|
||
await api.files.saveContent(selectedFile.value, fileContent.value)
|
||
originalContent.value = fileContent.value
|
||
toast.success('保存成功')
|
||
} catch {
|
||
toast.error('保存失败')
|
||
}
|
||
}
|
||
|
||
function openCreateDialog(parentDir = '') {
|
||
newItemName.value = ''
|
||
newItemType.value = 'file'
|
||
createInDir.value = parentDir
|
||
showCreateDialog.value = true
|
||
}
|
||
|
||
function handleCreate(parentDir: string) {
|
||
openCreateDialog(parentDir)
|
||
}
|
||
|
||
async function createItem() {
|
||
if (!newItemName.value.trim()) {
|
||
toast.error('请输入名称')
|
||
return
|
||
}
|
||
try {
|
||
const fullPath = createInDir.value ? `${createInDir.value}/${newItemName.value}` : newItemName.value
|
||
await api.files.create(fullPath, newItemType.value === 'dir')
|
||
toast.success('创建成功')
|
||
showCreateDialog.value = false
|
||
// 展开父目录
|
||
if (createInDir.value) {
|
||
expandedDirs.value.add(createInDir.value)
|
||
expandedDirs.value = new Set(expandedDirs.value)
|
||
}
|
||
await loadTree()
|
||
if (newItemType.value === 'file') {
|
||
await loadFile(fullPath)
|
||
}
|
||
} catch {
|
||
toast.error('创建失败')
|
||
}
|
||
}
|
||
|
||
function confirmDelete(path: string) {
|
||
deleteTargetPath.value = path
|
||
showDeleteDialog.value = true
|
||
}
|
||
|
||
async function deleteItem() {
|
||
try {
|
||
await api.files.delete(deleteTargetPath.value)
|
||
toast.success('删除成功')
|
||
if (selectedFile.value === deleteTargetPath.value) {
|
||
selectedFile.value = null
|
||
fileContent.value = ''
|
||
originalContent.value = ''
|
||
}
|
||
await loadTree()
|
||
} catch {
|
||
toast.error('删除失败')
|
||
}
|
||
showDeleteDialog.value = false
|
||
}
|
||
|
||
function openRenameDialog(path: string) {
|
||
renamePath.value = path
|
||
newName.value = path.split('/').pop() || ''
|
||
showRenameDialog.value = true
|
||
}
|
||
|
||
async function renameItem() {
|
||
if (!newName.value.trim()) {
|
||
toast.error('请输入名称')
|
||
return
|
||
}
|
||
if (newName.value.includes('/')) {
|
||
toast.error('名称不能包含路径分隔符 /')
|
||
return
|
||
}
|
||
const parts = renamePath.value.split('/')
|
||
parts[parts.length - 1] = newName.value
|
||
const newPath = parts.join('/')
|
||
|
||
if (newPath === renamePath.value) {
|
||
showRenameDialog.value = false
|
||
return
|
||
}
|
||
|
||
try {
|
||
await handleMove(renamePath.value, newPath, '重命名成功', true)
|
||
showRenameDialog.value = false
|
||
} catch {
|
||
// Error handled in handleMove
|
||
}
|
||
}
|
||
|
||
async function runScript() {
|
||
if (!selectedFile.value) return
|
||
|
||
// 获取文件所在目录和文件名
|
||
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}`
|
||
|
||
// 构建完整命令
|
||
const baseDir = scriptsDir.value || PATHS.SCRIPTS_DIR
|
||
if (dirPath) {
|
||
runCommand.value = `cd ${baseDir}/${dirPath} && ${cmd}`
|
||
} else {
|
||
runCommand.value = `cd ${baseDir} && ${cmd}`
|
||
}
|
||
|
||
showTerminalDialog.value = true
|
||
// 等待 DOM 更新后初始化终端,增加延迟确保 Dialog 完全渲染
|
||
await nextTick()
|
||
setTimeout(() => {
|
||
terminalRef.value?.initTerminal(true)
|
||
}, 100)
|
||
}
|
||
|
||
function closeTerminal() {
|
||
showTerminalDialog.value = false
|
||
// 延迟清理,确保 Dialog 关闭动画完成
|
||
setTimeout(() => {
|
||
terminalRef.value?.dispose()
|
||
}, 300)
|
||
}
|
||
|
||
async function handleDownload(path: string) {
|
||
try {
|
||
const url = api.files.download(path)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = path.split('/').pop() || 'file'
|
||
document.body.appendChild(a)
|
||
a.click()
|
||
document.body.removeChild(a)
|
||
toast.success('已发起下载')
|
||
} catch (error: any) {
|
||
toast.error('下载出错: ' + (error.message || '未知错误'))
|
||
}
|
||
}
|
||
|
||
async function handleMove(oldPath: string, newPath: string, successMsg = '移动成功', isRename = false) {
|
||
try {
|
||
if (isRename) {
|
||
await api.files.rename(oldPath, newPath)
|
||
} else {
|
||
await api.files.move(oldPath, newPath)
|
||
}
|
||
toast.success(successMsg)
|
||
if (selectedFile.value === oldPath) {
|
||
selectedFile.value = newPath
|
||
selectedPath.value = newPath
|
||
router.replace({ name: 'editor', query: { file: newPath } })
|
||
} else if (selectedPath.value === oldPath) {
|
||
selectedPath.value = newPath
|
||
router.replace({ name: 'editor', query: { file: newPath } })
|
||
}
|
||
await loadTree()
|
||
} catch (err: any) {
|
||
toast.error(err.message || '移动失败')
|
||
}
|
||
}
|
||
|
||
async function handleCopyFile(path: string) {
|
||
try {
|
||
const parts = path.split('/')
|
||
const filename = parts.pop() || ''
|
||
const dir = parts.join('/')
|
||
|
||
const dotIndex = filename.lastIndexOf('.')
|
||
let newFilename = ''
|
||
if (dotIndex !== -1 && dotIndex > 0) {
|
||
newFilename = filename.substring(0, dotIndex) + '-副本' + filename.substring(dotIndex)
|
||
} else {
|
||
newFilename = filename + '-副本'
|
||
}
|
||
|
||
const targetPath = dir ? `${dir}/${newFilename}` : newFilename
|
||
|
||
await api.files.copy(path, targetPath)
|
||
toast.success('已复制为 ' + newFilename)
|
||
await loadTree()
|
||
} catch (error: any) {
|
||
toast.error('复制失败: ' + (error.message || '未知错误'))
|
||
}
|
||
}
|
||
|
||
function triggerArchiveUpload(targetDir = '') {
|
||
uploadTargetDir.value = targetDir
|
||
archiveInputRef.value?.click()
|
||
}
|
||
|
||
function triggerFilesUpload(targetDir = '') {
|
||
uploadTargetDir.value = targetDir
|
||
filesInputRef.value?.click()
|
||
}
|
||
|
||
async function handleArchiveUpload(e: Event) {
|
||
const input = e.target as HTMLInputElement
|
||
const file = input.files?.[0]
|
||
if (!file) return
|
||
|
||
const ext = file.name.split('.').pop()?.toLowerCase()
|
||
if (!['zip', 'tar', 'gz', 'tgz'].includes(ext || '')) {
|
||
toast.error('仅支持 zip、tar、gz、tgz 格式')
|
||
input.value = ''
|
||
return
|
||
}
|
||
|
||
try {
|
||
await api.files.uploadArchive(file, uploadTargetDir.value)
|
||
toast.success('导入成功')
|
||
if (uploadTargetDir.value) {
|
||
expandedDirs.value.add(uploadTargetDir.value)
|
||
expandedDirs.value = new Set(expandedDirs.value)
|
||
}
|
||
await loadTree()
|
||
} catch (err: any) {
|
||
toast.error(err.message || '导入失败')
|
||
}
|
||
input.value = ''
|
||
}
|
||
|
||
async function handleFilesUpload(e: Event) {
|
||
const input = e.target as HTMLInputElement
|
||
const files = input.files
|
||
if (!files || files.length === 0) return
|
||
|
||
try {
|
||
// 获取相对路径(用于保持文件夹结构)
|
||
const paths: string[] = []
|
||
for (let i = 0; i < files.length; i++) {
|
||
const file = files[i] as any
|
||
// webkitRelativePath 用于文件夹上传时保持结构
|
||
paths.push(file.webkitRelativePath || file.name)
|
||
}
|
||
|
||
await api.files.uploadFiles(files, paths, uploadTargetDir.value)
|
||
toast.success('上传成功')
|
||
if (uploadTargetDir.value) {
|
||
expandedDirs.value.add(uploadTargetDir.value)
|
||
expandedDirs.value = new Set(expandedDirs.value)
|
||
}
|
||
await loadTree()
|
||
} catch (err: any) {
|
||
toast.error(err.message || '上传失败')
|
||
}
|
||
input.value = ''
|
||
}
|
||
|
||
function getLanguage(path: string): string {
|
||
const ext = path.split('.').pop()?.toLowerCase()
|
||
const langMap: Record<string, string> = {
|
||
sh: 'shell', bash: 'shell', zsh: 'shell',
|
||
js: 'javascript', ts: 'typescript',
|
||
py: 'python', json: 'json', yaml: 'yaml', yml: 'yaml',
|
||
md: 'markdown', sql: 'sql', xml: 'xml', html: 'html', css: 'css'
|
||
}
|
||
return langMap[ext || ''] || 'plaintext'
|
||
}
|
||
|
||
// 展开路径上的所有父目录
|
||
function expandParentDirs(path: string) {
|
||
const parts = path.split('/')
|
||
for (let i = 1; i < parts.length; i++) {
|
||
expandedDirs.value.add(parts.slice(0, i).join('/'))
|
||
}
|
||
expandedDirs.value = new Set(expandedDirs.value)
|
||
}
|
||
|
||
// 从 URL 初始化选中状态
|
||
async function initFromUrl() {
|
||
await loadTree()
|
||
const urlPath = route.query.file as string
|
||
if (urlPath) {
|
||
selectedPath.value = urlPath
|
||
expandParentDirs(urlPath)
|
||
// 尝试加载文件内容(如果是文件)
|
||
try {
|
||
const res = await api.files.getContent(urlPath)
|
||
selectedFile.value = urlPath
|
||
fileContent.value = res.content
|
||
originalContent.value = res.content
|
||
isEditMode.value = false
|
||
} catch {
|
||
// 可能是文件夹,忽略错误
|
||
}
|
||
}
|
||
}
|
||
|
||
function handleGlobalKeydown(e: KeyboardEvent) {
|
||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
|
||
if (isEditMode.value && selectedFile.value) {
|
||
e.preventDefault()
|
||
saveFile()
|
||
}
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
initFromUrl()
|
||
fetchPaths()
|
||
window.addEventListener('resize', handleResize)
|
||
window.addEventListener('keydown', handleGlobalKeydown)
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
window.removeEventListener('resize', handleResize)
|
||
window.removeEventListener('keydown', handleGlobalKeydown)
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div class="flex flex-col lg:flex-row h-[calc(100vh-100px)] gap-2">
|
||
<!-- 文件树 -->
|
||
<div class="w-full lg:w-56 h-48 lg:h-auto flex-shrink-0 border rounded-md flex flex-col">
|
||
<div class="flex items-center justify-between p-2 border-b">
|
||
<span class="text-xs font-medium">脚本文件</span>
|
||
<div class="flex gap-1">
|
||
<Button variant="ghost" size="icon" class="h-6 w-6" @click="loadTree" title="刷新">
|
||
<RefreshCw class="h-3 w-3" />
|
||
</Button>
|
||
<Button variant="ghost" size="icon" class="h-6 w-6" @click="triggerFilesUpload('')" title="上传文件/文件夹(放在根目录)">
|
||
<FolderUp class="h-3 w-3" />
|
||
</Button>
|
||
<Button variant="ghost" size="icon" class="h-6 w-6" @click="triggerArchiveUpload('')" title="导入压缩包(放在根目录)">
|
||
<Upload class="h-3 w-3" />
|
||
</Button>
|
||
<Button variant="ghost" size="icon" class="h-6 w-6" @click="openCreateDialog('')" title="新建">
|
||
<Plus class="h-3 w-3" />
|
||
</Button>
|
||
</div>
|
||
<input ref="archiveInputRef" type="file" accept=".zip,.tar,.gz,.tgz" class="hidden"
|
||
@change="handleArchiveUpload" />
|
||
<input ref="filesInputRef" type="file" multiple class="hidden" @change="handleFilesUpload" />
|
||
</div>
|
||
<div class="flex-1 overflow-auto p-1">
|
||
<div v-if="fileTree.length === 0" class="text-xs text-muted-foreground text-center py-4">
|
||
暂无文件
|
||
</div>
|
||
<FileTreeNode v-for="node in fileTree" :key="node.path" :node="node" :expanded-dirs="expandedDirs"
|
||
:selected-path="selectedPath" @select="handleSelect" @delete="confirmDelete" @create="handleCreate"
|
||
@download-file="handleDownload" @move="handleMove" @rename="openRenameDialog" @duplicate="handleCopyFile" />
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 编辑器 -->
|
||
<div class="flex-1 min-h-[300px] border rounded-md flex flex-col overflow-hidden">
|
||
<div class="flex items-center justify-between p-2 border-b gap-2">
|
||
<span class="text-xs font-medium truncate flex-1 min-w-0">
|
||
{{ selectedPath || '选择文件或文件夹进行操作' }}
|
||
<span v-if="hasChanges" class="text-orange-500 ml-1">●</span>
|
||
</span>
|
||
<div v-if="selectedPath" class="flex gap-1 shrink-0">
|
||
<Button variant="ghost" size="sm" class="h-6 text-xs gap-1 px-2" @click="openRenameDialog(selectedPath)">
|
||
<Edit3 class="h-3 w-3" /> <span class="hidden sm:inline">重命名</span>
|
||
</Button>
|
||
<Button variant="ghost" size="sm" class="h-6 text-xs gap-1 px-2" @click="confirmDelete(selectedPath)">
|
||
<Trash2 class="h-3 w-3 text-destructive" /> <span class="hidden sm:inline">删除</span>
|
||
</Button>
|
||
|
||
<template v-if="selectedFile">
|
||
<Button variant="ghost" size="sm" class="h-6 text-xs gap-1 px-2" @click="handleDownload(selectedFile)">
|
||
<Download class="h-3 w-3" /> <span class="hidden sm:inline">下载</span>
|
||
</Button>
|
||
<Button v-if="!isEditMode" variant="ghost" size="sm" class="h-6 text-xs gap-1 px-2"
|
||
@click="isEditMode = true">
|
||
<Pencil class="h-3 w-3" /> <span class="hidden sm:inline">编辑</span>
|
||
</Button>
|
||
<template v-else>
|
||
<Button variant="ghost" size="sm" class="h-6 text-xs gap-1 px-2"
|
||
@click="isEditMode = false; fileContent = originalContent">
|
||
<Eye class="h-3 w-3" /> <span class="hidden sm:inline">查看</span>
|
||
</Button>
|
||
<Button variant="ghost" size="sm" class="h-6 text-xs gap-1 px-2" :disabled="!hasChanges"
|
||
@click="saveFile">
|
||
<Save class="h-3 w-3" /> <span class="hidden sm:inline">保存</span>
|
||
</Button>
|
||
</template>
|
||
<Button variant="ghost" size="sm" class="h-6 text-xs gap-1 px-2" @click="runScript">
|
||
<Play class="h-3 w-3" /> <span class="hidden sm:inline">运行</span>
|
||
</Button>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
<div class="flex-1">
|
||
<vue-monaco-editor v-if="selectedFile" v-model:value="fileContent" :language="getLanguage(selectedFile)"
|
||
theme="vs-dark" :options="{
|
||
minimap: { enabled: false },
|
||
fontSize: editorFontSize,
|
||
lineNumbers: 'on',
|
||
scrollBeyondLastLine: false,
|
||
quickSuggestions: isEditMode,
|
||
suggestOnTriggerCharacters: isEditMode,
|
||
wordBasedSuggestions: isEditMode ? 'currentDocument' : 'off',
|
||
parameterHints: { enabled: isEditMode },
|
||
autoClosingBrackets: 'always',
|
||
autoClosingQuotes: 'always',
|
||
formatOnPaste: true,
|
||
tabSize: 4,
|
||
insertSpaces: true,
|
||
readOnly: !isEditMode,
|
||
domReadOnly: !isEditMode
|
||
}" @mount="handleEditorMount" />
|
||
<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>
|
||
|
||
|
||
|
||
<!-- 新建对话框 -->
|
||
<Dialog v-model:open="showCreateDialog">
|
||
<DialogContent class="max-w-xs" @openAutoFocus.prevent>
|
||
<DialogHeader>
|
||
<DialogTitle class="text-sm">新建</DialogTitle>
|
||
</DialogHeader>
|
||
<div class="space-y-3 py-2">
|
||
<div class="text-xs text-muted-foreground">
|
||
位置: {{ createInDir || '根目录' }}
|
||
</div>
|
||
<RadioGroup v-model="newItemType" class="flex gap-4">
|
||
<div class="flex items-center gap-2">
|
||
<RadioGroupItem value="file" id="file" />
|
||
<Label for="file" class="text-xs">文件</Label>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<RadioGroupItem value="dir" id="dir" />
|
||
<Label for="dir" class="text-xs">文件夹</Label>
|
||
</div>
|
||
</RadioGroup>
|
||
<div class="space-y-1">
|
||
<Label class="text-xs">名称</Label>
|
||
<Input v-model="newItemName" class="h-8 text-xs" placeholder="script.sh" @keyup.enter="createItem" />
|
||
</div>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button variant="outline" size="sm" class="h-7 text-xs" @click="showCreateDialog = false">取消</Button>
|
||
<Button size="sm" class="h-7 text-xs" @click="createItem">创建</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
<!-- 删除确认 -->
|
||
<AlertDialog v-model:open="showDeleteDialog">
|
||
<AlertDialogContent>
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle class="text-sm">确认删除</AlertDialogTitle>
|
||
<AlertDialogDescription class="text-xs">确定要删除 {{ deleteTargetPath }} 吗?</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel class="h-7 text-xs">取消</AlertDialogCancel>
|
||
<AlertDialogAction class="h-7 text-xs bg-destructive text-white hover:bg-destructive/90" @click="deleteItem">
|
||
删除
|
||
</AlertDialogAction>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
|
||
<!-- 重命名对话框 -->
|
||
<Dialog v-model:open="showRenameDialog">
|
||
<DialogContent class="max-w-xs" @openAutoFocus.prevent>
|
||
<DialogHeader>
|
||
<DialogTitle class="text-sm">重命名</DialogTitle>
|
||
</DialogHeader>
|
||
<div class="space-y-3 py-2">
|
||
<div class="space-y-1">
|
||
<Label class="text-xs">新名称</Label>
|
||
<Input v-model="newName" class="h-8 text-xs" placeholder="new_name.sh" @keyup.enter="renameItem" />
|
||
<p class="text-[10px] text-muted-foreground">注:仅支持修改名称,不可包含路径分隔符 /</p>
|
||
</div>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button variant="outline" size="sm" class="h-7 text-xs" @click="showRenameDialog = false">取消</Button>
|
||
<Button size="sm" class="h-7 text-xs" @click="renameItem">确定</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
<!-- 终端弹窗 -->
|
||
<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 !bg-[#1e1e1e] border-[#3c3c3c]"
|
||
:show-close-button="false" @openAutoFocus.prevent
|
||
@interact-outside="(e) => e.preventDefault()" @escape-key-down="(e) => e.preventDefault()">
|
||
<div class="flex items-center justify-between px-3 sm:px-4 py-2 border-b border-[#3c3c3c]">
|
||
<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 hover:bg-white/10"
|
||
@click="closeTerminal">
|
||
<X class="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
<div class="flex-1 overflow-hidden">
|
||
<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>
|