Files
TaskPool/web/src/views/scripts/Scripts.vue
T
2026-02-09 18:28:14 +08:00

386 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, onMounted, computed, 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 { RefreshCw, FolderPlus, FilePlus, Save } from 'lucide-vue-next'
import { api, type FileNode } from '@/api'
import { VueMonacoEditor } from '@guolao/vue-monaco-editor'
import FileTreeNode from '@/components/FileTreeNode.vue'
import { toast } from 'vue-sonner'
const route = useRoute()
const router = useRouter()
const fileTree = ref<FileNode[]>([])
const expandedDirs = ref<Set<string>>(new Set())
const selectedFile = ref<string | null>(null)
const selectedDir = ref<string | null>(null) // 当前选中的文件夹
const fileContent = ref('')
const originalContent = ref('')
const loading = ref(false)
const saving = ref(false)
const showCreateDialog = ref(false)
const createType = ref<'file' | 'folder'>('file')
const createName = ref('')
const showDeleteDialog = ref(false)
const deletePath = ref<string | null>(null)
const showUnsavedDialog = ref(false)
const pendingNode = ref<FileNode | null>(null)
// Monaco Editor 实例引用
const editorRef = shallowRef()
const hasChanges = computed(() => fileContent.value !== originalContent.value)
const editorLanguage = computed(() => {
if (!selectedFile.value) return 'plaintext'
const name = selectedFile.value.toLowerCase()
if (name.endsWith('.ts')) return 'typescript'
if (name.endsWith('.js')) return 'javascript'
if (name.endsWith('.py')) return 'python'
if (name.endsWith('.sh')) return 'shell'
if (name.endsWith('.json')) return 'json'
if (name.endsWith('.yaml') || name.endsWith('.yml')) return 'yaml'
if (name.endsWith('.go')) return 'go'
return 'plaintext'
})
// 编辑器挂载时的回调
function handleEditorMount(editor: any) {
editorRef.value = editor
// 强制设置换行符为 LF
const model = editor.getModel()
if (model) {
model.setEOL(0) // 0 = LF, 1 = CRLF
}
}
// 更新 URL - 每次清空重建
function updateUrl() {
const query: Record<string, string> = {}
if (selectedFile.value) query.file = selectedFile.value
if (selectedDir.value) query.dir = selectedDir.value
if (expandedDirs.value.size > 0) query.dirs = Array.from(expandedDirs.value).join(',')
router.replace({ path: route.path, query })
}
// 展开文件所在的所有父目录
function expandParentDirs(filePath: string) {
const parts = filePath.split('/')
let current: string = ''
for (let i = 0; i < parts.length - 1; i++) {
current = current ? `${current}/${parts[i]}` : parts[i] ?? ''
expandedDirs.value.add(current)
}
}
async function loadTree() {
loading.value = true
try {
fileTree.value = await api.files.tree()
// 仅在首次加载时从 URL 恢复状态
if (expandedDirs.value.size === 0 && selectedFile.value === null && selectedDir.value === null) {
// 从 URL 恢复展开的目录
const dirsParam = route.query.dirs
if (dirsParam && typeof dirsParam === 'string') {
dirsParam.split(',').forEach(dir => expandedDirs.value.add(dir))
}
// 从 URL 恢复选中的文件夹
const dirParam = route.query.dir
if (dirParam && typeof dirParam === 'string') {
selectedDir.value = dirParam
expandedDirs.value.add(dirParam)
}
// 从 URL 加载文件
const fileParam = route.query.file
if (fileParam && typeof fileParam === 'string') {
expandParentDirs(fileParam)
await loadFileContent(fileParam)
}
}
} catch {
fileTree.value = []
} finally {
loading.value = false
}
}
async function loadFileContent(path: string) {
try {
const res = await api.files.getContent(path)
selectedFile.value = path
fileContent.value = res.content
originalContent.value = res.content
} catch {
selectedFile.value = null
fileContent.value = ''
originalContent.value = ''
}
}
function toggleDir(path: string) {
if (expandedDirs.value.has(path)) {
expandedDirs.value.delete(path)
} else {
expandedDirs.value.add(path)
}
// 点击文件夹时不改变文件选择状态,只更新展开状态
updateUrl()
}
async function handleSelect(node: FileNode) {
if (node.isDir) {
selectedDir.value = node.path
selectedFile.value = null
fileContent.value = ''
originalContent.value = ''
toggleDir(node.path)
return
}
if (hasChanges.value) {
pendingNode.value = node
showUnsavedDialog.value = true
return
}
await selectFile(node)
}
async function selectFile(node: FileNode) {
selectedDir.value = null
loading.value = true
try {
await loadFileContent(node.path)
expandParentDirs(node.path)
updateUrl()
} finally {
loading.value = false
}
}
async function confirmSwitchFile() {
showUnsavedDialog.value = false
if (pendingNode.value) {
await selectFile(pendingNode.value)
pendingNode.value = null
}
}
async function saveFile() {
if (!selectedFile.value) return
saving.value = true
try {
await api.files.saveContent(selectedFile.value, fileContent.value)
originalContent.value = fileContent.value
toast.success('文件已保存')
} catch {
toast.error('保存失败')
} finally {
saving.value = false
}
}
function openCreateDialog(type: 'file' | 'folder') {
createType.value = type
createName.value = ''
showCreateDialog.value = true
}
// 计算完整路径(选中文件夹 + 文件名)
const createFullPath = computed(() => {
if (!createName.value) return ''
return selectedDir.value ? `${selectedDir.value}/${createName.value}` : createName.value
})
async function createItem() {
if (!createName.value) return
const fullPath = createFullPath.value
const currentSelectedDir = selectedDir.value
try {
await api.files.create(fullPath, createType.value === 'folder')
showCreateDialog.value = false
toast.success(createType.value === 'file' ? '文件已创建' : '文件夹已创建')
if (currentSelectedDir) {
expandedDirs.value.add(currentSelectedDir)
}
await loadTree()
selectedDir.value = currentSelectedDir
if (createType.value === 'file') {
await loadFileContent(fullPath)
selectedDir.value = null
updateUrl()
}
} catch { toast.error('创建失败') }
}
function confirmDeleteFile(path: string) {
deletePath.value = path
showDeleteDialog.value = true
}
async function handleDelete() {
if (!deletePath.value) return
const path = deletePath.value
const currentSelectedDir = selectedDir.value
try {
await api.files.delete(path)
toast.success('已删除')
if (selectedFile.value === path) {
selectedFile.value = null
fileContent.value = ''
originalContent.value = ''
updateUrl()
}
if (selectedDir.value === path) {
selectedDir.value = null
}
await loadTree()
if (currentSelectedDir && currentSelectedDir !== path) {
selectedDir.value = currentSelectedDir
}
} catch { toast.error('删除失败') }
showDeleteDialog.value = false
deletePath.value = null
}
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 || '未知错误'))
}
}
onMounted(loadTree)
</script>
<template>
<div class="flex flex-col lg:flex-row h-[calc(100vh-2rem)] gap-3">
<!-- File Tree -->
<div class="w-full lg:w-56 flex-shrink-0 border rounded-lg bg-card flex flex-col max-h-[200px] lg:max-h-none">
<div class="p-2 border-b flex items-center justify-between">
<span class="text-xs font-medium">脚本文件</span>
<div class="flex gap-0.5">
<Button variant="ghost" size="icon" class="h-6 w-6" title="新建文件" @click="openCreateDialog('file')">
<FilePlus class="h-3 w-3" />
</Button>
<Button variant="ghost" size="icon" class="h-6 w-6" title="新建文件夹" @click="openCreateDialog('folder')">
<FolderPlus class="h-3 w-3" />
</Button>
<Button variant="ghost" size="icon" class="h-6 w-6" title="刷新" @click="loadTree">
<RefreshCw class="h-3 w-3" :class="{ 'animate-spin': loading }" />
</Button>
</div>
</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="selectedFile || selectedDir" @select="handleSelect" @delete="confirmDeleteFile"
@download-file="handleDownload" />
</div>
</div>
<!-- Editor -->
<div class="flex-1 border rounded-lg bg-card flex flex-col overflow-hidden min-h-[300px]">
<div class="p-2 border-b flex items-center justify-between">
<div class="flex items-center gap-2 min-w-0">
<span class="text-xs font-medium truncate">{{ selectedFile || '未选择文件' }}</span>
<span v-if="hasChanges" class="text-xs text-orange-500 shrink-0"> 未保存</span>
</div>
<Button v-if="selectedFile" size="sm" class="h-6 text-xs gap-1 shrink-0" :disabled="!hasChanges || saving"
@click="saveFile">
<Save class="h-3 w-3" />
<span class="hidden sm:inline">{{ saving ? '保存中...' : '保存' }}</span>
</Button>
</div>
<div class="flex-1">
<VueMonacoEditor v-if="selectedFile" v-model:value="fileContent" :language="editorLanguage" theme="vs-dark"
:options="{
minimap: { enabled: false },
fontSize: 13,
lineNumbers: 'on',
scrollBeyondLastLine: false,
automaticLayout: true,
tabSize: 2,
wordWrap: 'on'
}" style="height: 100%" @mount="handleEditorMount" />
<div v-else class="h-full flex items-center justify-center text-muted-foreground text-sm">
选择一个文件开始编辑
</div>
</div>
</div>
<!-- Create Dialog -->
<Dialog v-model:open="showCreateDialog">
<DialogContent class="max-w-xs">
<DialogHeader>
<DialogTitle class="text-sm">
{{ createType === 'file' ? '新建文件' : '新建文件夹' }}
</DialogTitle>
</DialogHeader>
<div class="py-2 space-y-2">
<div v-if="selectedDir" class="text-xs text-muted-foreground">
位置: {{ selectedDir }}/
</div>
<Input v-model="createName" class="h-8 text-xs"
:placeholder="createType === 'file' ? 'example.js' : 'folder-name'" @keyup.enter="createItem" />
<div v-if="createName" class="text-xs text-muted-foreground">
完整路径: {{ createFullPath }}
</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">确定要删除 {{ deletePath }} 此操作无法撤销</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="handleDelete">删除</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog v-model:open="showUnsavedDialog">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle class="text-sm">未保存的更改</AlertDialogTitle>
<AlertDialogDescription class="text-xs">当前文件有未保存的更改确定要切换文件吗</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel class="h-7 text-xs">取消</AlertDialogCancel>
<AlertDialogAction class="h-7 text-xs" @click="confirmSwitchFile">确定切换</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</template>