feat: Initial commit
This commit is contained in:
@@ -0,0 +1,424 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } 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 { Plus, Save, Play, RefreshCw, Upload, FolderUp, Pencil, Eye } from 'lucide-vue-next'
|
||||
import { api, type FileNode } from '@/api'
|
||||
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 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 isEditMode = ref(false)
|
||||
const hasChanges = computed(() => fileContent.value !== originalContent.value)
|
||||
|
||||
async function loadTree() {
|
||||
try {
|
||||
fileTree.value = await api.files.tree()
|
||||
} catch {
|
||||
toast.error('加载文件树失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSelect(node: FileNode) {
|
||||
selectedPath.value = node.path
|
||||
// 更新 URL
|
||||
router.replace({ name: 'editor', params: { path: 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
|
||||
}
|
||||
|
||||
async function runScript() {
|
||||
if (!selectedFile.value) return
|
||||
try {
|
||||
await api.execute.command(`bash ${selectedFile.value}`)
|
||||
toast.success('脚本已执行')
|
||||
} catch {
|
||||
toast.error('执行失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMove(oldPath: string, newPath: string) {
|
||||
try {
|
||||
await api.files.rename(oldPath, newPath)
|
||||
toast.success('移动成功')
|
||||
if (selectedFile.value === oldPath) {
|
||||
selectedFile.value = newPath
|
||||
selectedPath.value = newPath
|
||||
router.replace({ name: 'editor', params: { path: newPath } })
|
||||
} else if (selectedPath.value === oldPath) {
|
||||
selectedPath.value = newPath
|
||||
router.replace({ name: 'editor', params: { path: newPath } })
|
||||
}
|
||||
await loadTree()
|
||||
} catch {
|
||||
toast.error('移动失败')
|
||||
}
|
||||
}
|
||||
|
||||
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.params.path 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
|
||||
} catch {
|
||||
// 可能是文件夹,忽略错误
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(initFromUrl)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-[calc(100vh-100px)] gap-2">
|
||||
<!-- 文件树 -->
|
||||
<div class="w-56 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"
|
||||
@move="handleMove"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑器 -->
|
||||
<div class="flex-1 border rounded-md flex flex-col overflow-hidden">
|
||||
<div class="flex items-center justify-between p-2 border-b">
|
||||
<span class="text-xs font-medium truncate">
|
||||
{{ selectedFile || '选择文件进行编辑' }}
|
||||
<span v-if="hasChanges" class="text-orange-500 ml-1">●</span>
|
||||
</span>
|
||||
<div v-if="selectedFile" class="flex gap-1">
|
||||
<Button v-if="!isEditMode" variant="ghost" size="sm" class="h-6 text-xs gap-1" @click="isEditMode = true">
|
||||
<Pencil class="h-3 w-3" /> 编辑
|
||||
</Button>
|
||||
<template v-else>
|
||||
<Button variant="ghost" size="sm" class="h-6 text-xs gap-1" @click="isEditMode = false; fileContent = originalContent">
|
||||
<Eye class="h-3 w-3" /> 查看
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" class="h-6 text-xs gap-1" :disabled="!hasChanges" @click="saveFile">
|
||||
<Save class="h-3 w-3" /> 保存
|
||||
</Button>
|
||||
</template>
|
||||
<Button variant="ghost" size="sm" class="h-6 text-xs gap-1" @click="runScript">
|
||||
<Play class="h-3 w-3" /> 运行
|
||||
</Button>
|
||||
</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: 13,
|
||||
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
|
||||
}"
|
||||
/>
|
||||
<div v-else class="h-full flex items-center justify-center text-muted-foreground text-sm">
|
||||
从左侧选择文件开始编辑
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<!-- 新建对话框 -->
|
||||
<Dialog v-model:open="showCreateDialog">
|
||||
<DialogContent class="max-w-xs">
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user