386 lines
13 KiB
Vue
386 lines
13 KiB
Vue
<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>
|