feat: fix agent logger display
This commit is contained in:
+11
-1
@@ -68,6 +68,14 @@ func (t *AgentTask) GetTimeout() int {
|
|||||||
return t.Timeout
|
return t.Timeout
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *AgentTask) GetWorkDir() string {
|
||||||
|
return t.WorkDir
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *AgentTask) GetEnvs() string {
|
||||||
|
return t.Envs
|
||||||
|
}
|
||||||
|
|
||||||
func (t *AgentTask) GetSchedule() string {
|
func (t *AgentTask) GetSchedule() string {
|
||||||
if t.Schedule != "" {
|
if t.Schedule != "" {
|
||||||
return t.Schedule
|
return t.Schedule
|
||||||
@@ -628,7 +636,9 @@ func (a *Agent) updateTasks(tasks []AgentTask) {
|
|||||||
// 2. 添加或更新任务
|
// 2. 添加或更新任务
|
||||||
for id, task := range newTasks {
|
for id, task := range newTasks {
|
||||||
oldTask, exists := a.tasks[id]
|
oldTask, exists := a.tasks[id]
|
||||||
if !exists || oldTask.Schedule != task.Schedule || oldTask.Command != task.Command || oldTask.Enabled != task.Enabled {
|
if !exists || oldTask.Schedule != task.Schedule || oldTask.Command != task.Command ||
|
||||||
|
oldTask.Enabled != task.Enabled || oldTask.Timeout != task.Timeout ||
|
||||||
|
oldTask.WorkDir != task.WorkDir || oldTask.Envs != task.Envs {
|
||||||
if task.Enabled {
|
if task.Enabled {
|
||||||
err := a.cronManager.AddTask(task)
|
err := a.cronManager.AddTask(task)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+5
-2
@@ -12,6 +12,7 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
internalLogger "github.com/engigu/baihu-panel/internal/logger"
|
||||||
"github.com/engigu/baihu-panel/internal/utils"
|
"github.com/engigu/baihu-panel/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -155,6 +156,7 @@ func cmdStart() {
|
|||||||
defer unlock()
|
defer unlock()
|
||||||
|
|
||||||
initLogger(logFile, true)
|
initLogger(logFile, true)
|
||||||
|
internalLogger.SetOutput(loggerInstance)
|
||||||
|
|
||||||
config := &Config{Interval: 30}
|
config := &Config{Interval: 30}
|
||||||
if err := loadConfigFile(configFile, config); err != nil {
|
if err := loadConfigFile(configFile, config); err != nil {
|
||||||
@@ -220,8 +222,9 @@ func cmdRun() {
|
|||||||
}
|
}
|
||||||
defer unlock()
|
defer unlock()
|
||||||
|
|
||||||
// 重启模式下只输出到文件(因为是从 daemon 进程 exec 过来的)
|
// 前台模式始终输出到终端+文件
|
||||||
initLogger(logFile, isRestart)
|
initLogger(logFile, false)
|
||||||
|
internalLogger.SetOutput(loggerInstance)
|
||||||
|
|
||||||
config := &Config{Interval: 30}
|
config := &Config{Interval: 30}
|
||||||
if err := loadConfigFile(configFile, config); err != nil {
|
if err := loadConfigFile(configFile, config); err != nil {
|
||||||
|
|||||||
@@ -347,3 +347,29 @@ func (fc *FileController) UploadFiles(c *gin.Context) {
|
|||||||
|
|
||||||
utils.SuccessMsg(c, "上传成功")
|
utils.SuccessMsg(c, "上传成功")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (fc *FileController) DownloadFile(c *gin.Context) {
|
||||||
|
filePath := c.Query("path")
|
||||||
|
if filePath == "" {
|
||||||
|
utils.BadRequest(c, "path参数必填")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fullPath := filepath.Join(fc.workDir, filepath.Clean(filePath))
|
||||||
|
if !strings.HasPrefix(fullPath, fc.workDir) {
|
||||||
|
utils.Forbidden(c, "访问被拒绝")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(fullPath)
|
||||||
|
if err != nil || info.IsDir() {
|
||||||
|
utils.NotFound(c, "文件不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Header("Content-Description", "File Transfer")
|
||||||
|
c.Header("Content-Transfer-Encoding", "binary")
|
||||||
|
c.Header("Content-Disposition", "attachment; filename="+filepath.Base(fullPath))
|
||||||
|
c.Header("Content-Type", "application/octet-stream")
|
||||||
|
c.File(fullPath)
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ import (
|
|||||||
"runtime"
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
"fmt"
|
||||||
"github.com/engigu/baihu-panel/internal/constant"
|
"github.com/engigu/baihu-panel/internal/constant"
|
||||||
"github.com/engigu/baihu-panel/internal/database"
|
"github.com/engigu/baihu-panel/internal/database"
|
||||||
"github.com/engigu/baihu-panel/internal/models"
|
"github.com/engigu/baihu-panel/internal/models"
|
||||||
"github.com/engigu/baihu-panel/internal/services"
|
"github.com/engigu/baihu-panel/internal/services"
|
||||||
"github.com/engigu/baihu-panel/internal/services/tasks"
|
"github.com/engigu/baihu-panel/internal/services/tasks"
|
||||||
"github.com/engigu/baihu-panel/internal/utils"
|
"github.com/engigu/baihu-panel/internal/utils"
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ func (m *CronManager) AddTask(task CronTask) error {
|
|||||||
cmd := task.GetCommand()
|
cmd := task.GetCommand()
|
||||||
name := task.GetName()
|
name := task.GetName()
|
||||||
timeout := task.GetTimeout()
|
timeout := task.GetTimeout()
|
||||||
|
workDir := task.GetWorkDir()
|
||||||
|
envs := task.GetEnvs()
|
||||||
|
|
||||||
entryID, err := m.cron.AddFunc(task.GetSchedule(), func() {
|
entryID, err := m.cron.AddFunc(task.GetSchedule(), func() {
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -90,6 +92,8 @@ func (m *CronManager) AddTask(task CronTask) error {
|
|||||||
Command: cmd,
|
Command: cmd,
|
||||||
Type: TaskTypeCron,
|
Type: TaskTypeCron,
|
||||||
Timeout: timeout,
|
Timeout: timeout,
|
||||||
|
WorkDir: workDir,
|
||||||
|
Envs: ParseEnvVars(envs),
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果有关联的 Scheduler,加入队列执行
|
// 如果有关联的 Scheduler,加入队列执行
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ type Task interface {
|
|||||||
GetName() string
|
GetName() string
|
||||||
GetCommand() string
|
GetCommand() string
|
||||||
GetTimeout() int
|
GetTimeout() int
|
||||||
|
GetWorkDir() string
|
||||||
|
GetEnvs() string
|
||||||
}
|
}
|
||||||
|
|
||||||
// CronTask 计划任务接口
|
// CronTask 计划任务接口
|
||||||
|
|||||||
@@ -104,6 +104,17 @@ func SetupFileOutput(logDir string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetOutput 直接设置 Log 实例
|
||||||
|
func SetOutput(l *zap.Logger) {
|
||||||
|
Log = l
|
||||||
|
Sugar = l.Sugar()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSugar 直接设置 Sugar 实例
|
||||||
|
func SetSugar(s *zap.SugaredLogger) {
|
||||||
|
Sugar = s
|
||||||
|
}
|
||||||
|
|
||||||
// SetLevel 设置日志级别
|
// SetLevel 设置日志级别
|
||||||
func SetLevel(level string) {
|
func SetLevel(level string) {
|
||||||
switch level {
|
switch level {
|
||||||
|
|||||||
@@ -35,4 +35,4 @@ type Script struct {
|
|||||||
|
|
||||||
func (Script) TableName() string {
|
func (Script) TableName() string {
|
||||||
return constant.TablePrefix + "scripts"
|
return constant.TablePrefix + "scripts"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,14 @@ func (t *Task) GetTimeout() int {
|
|||||||
return t.Timeout
|
return t.Timeout
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *Task) GetWorkDir() string {
|
||||||
|
return t.WorkDir
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Task) GetEnvs() string {
|
||||||
|
return t.Envs
|
||||||
|
}
|
||||||
|
|
||||||
func (t *Task) GetSchedule() string {
|
func (t *Task) GetSchedule() string {
|
||||||
return t.Schedule
|
return t.Schedule
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,4 +20,4 @@ type User struct {
|
|||||||
|
|
||||||
func (User) TableName() string {
|
func (User) TableName() string {
|
||||||
return constant.TablePrefix + "users"
|
return constant.TablePrefix + "users"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ func Setup(c *Controllers) *gin.Engine {
|
|||||||
{
|
{
|
||||||
files.GET("/tree", c.File.GetFileTree)
|
files.GET("/tree", c.File.GetFileTree)
|
||||||
files.GET("/content", c.File.GetFileContent)
|
files.GET("/content", c.File.GetFileContent)
|
||||||
|
files.GET("/download", c.File.DownloadFile)
|
||||||
files.POST("/content", c.File.SaveFileContent)
|
files.POST("/content", c.File.SaveFileContent)
|
||||||
files.POST("/create", c.File.CreateFile)
|
files.POST("/create", c.File.CreateFile)
|
||||||
files.POST("/delete", c.File.DeleteFile)
|
files.POST("/delete", c.File.DeleteFile)
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ export const api = {
|
|||||||
files: {
|
files: {
|
||||||
tree: () => request<FileNode[]>('/files/tree'),
|
tree: () => request<FileNode[]>('/files/tree'),
|
||||||
getContent: (path: string) => request<{ path: string; content: string }>(`/files/content?path=${encodeURIComponent(path)}`),
|
getContent: (path: string) => request<{ path: string; content: string }>(`/files/content?path=${encodeURIComponent(path)}`),
|
||||||
|
download: (path: string) => `${API_BASE_URL}/files/download?path=${encodeURIComponent(path)}`,
|
||||||
saveContent: (path: string, content: string) => request('/files/content', { method: 'POST', body: JSON.stringify({ path, content }) }),
|
saveContent: (path: string, content: string) => request('/files/content', { method: 'POST', body: JSON.stringify({ path, content }) }),
|
||||||
create: (path: string, isDir: boolean) => request('/files/create', { method: 'POST', body: JSON.stringify({ path, isDir }) }),
|
create: (path: string, isDir: boolean) => request('/files/create', { method: 'POST', body: JSON.stringify({ path, isDir }) }),
|
||||||
delete: (path: string) => request('/files/delete', { method: 'POST', body: JSON.stringify({ path }) }),
|
delete: (path: string) => request('/files/delete', { method: 'POST', body: JSON.stringify({ path }) }),
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { Folder, File, ChevronRight, ChevronDown, Trash2, Plus } from 'lucide-vue-next'
|
import { Folder, File, ChevronRight, ChevronDown, Trash2, Plus, Download } from 'lucide-vue-next'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import type { FileNode } from '@/api'
|
import type { FileNode } from '@/api'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'FileTreeNode'
|
||||||
|
})
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
node: FileNode
|
node: FileNode
|
||||||
expandedDirs: Set<string>
|
expandedDirs: Set<string>
|
||||||
@@ -14,6 +18,7 @@ const props = defineProps<{
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
select: [node: FileNode]
|
select: [node: FileNode]
|
||||||
delete: [path: string]
|
delete: [path: string]
|
||||||
|
'download-file': [path: string]
|
||||||
create: [parentDir: string]
|
create: [parentDir: string]
|
||||||
move: [oldPath: string, newPath: string]
|
move: [oldPath: string, newPath: string]
|
||||||
}>()
|
}>()
|
||||||
@@ -32,6 +37,11 @@ function handleDelete(e: Event) {
|
|||||||
emit('delete', props.node.path)
|
emit('delete', props.node.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleDownloadClick(e: Event) {
|
||||||
|
e.stopPropagation()
|
||||||
|
emit('download-file', props.node.path)
|
||||||
|
}
|
||||||
|
|
||||||
function handleCreate(e: Event) {
|
function handleCreate(e: Event) {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
emit('create', props.node.path)
|
emit('create', props.node.path)
|
||||||
@@ -57,16 +67,16 @@ function handleDrop(e: DragEvent) {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
isDragOver.value = false
|
isDragOver.value = false
|
||||||
if (!props.node.isDir) return
|
if (!props.node.isDir) return
|
||||||
|
|
||||||
const sourcePath = e.dataTransfer?.getData('text/plain')
|
const sourcePath = e.dataTransfer?.getData('text/plain')
|
||||||
if (!sourcePath || sourcePath === props.node.path) return
|
if (!sourcePath || sourcePath === props.node.path) return
|
||||||
|
|
||||||
// 不能移动到自己的子目录
|
// 不能移动到自己的子目录
|
||||||
if (props.node.path.startsWith(sourcePath + '/')) return
|
if (props.node.path.startsWith(sourcePath + '/')) return
|
||||||
|
|
||||||
const fileName = sourcePath.split('/').pop()
|
const fileName = sourcePath.split('/').pop()
|
||||||
const newPath = props.node.path ? `${props.node.path}/${fileName}` : fileName
|
const newPath = props.node.path ? `${props.node.path}/${fileName}` : fileName
|
||||||
|
|
||||||
if (newPath !== sourcePath) {
|
if (newPath !== sourcePath) {
|
||||||
emit('move', sourcePath, newPath!)
|
emit('move', sourcePath, newPath!)
|
||||||
}
|
}
|
||||||
@@ -75,20 +85,12 @@ function handleDrop(e: DragEvent) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div :class="[
|
||||||
:class="[
|
'flex items-center gap-1 py-0.5 px-1 rounded cursor-pointer text-xs hover:bg-muted group',
|
||||||
'flex items-center gap-1 py-0.5 px-1 rounded cursor-pointer text-xs hover:bg-muted group',
|
isSelected && 'bg-accent',
|
||||||
isSelected && 'bg-accent',
|
isDragOver && 'bg-blue-500/20 ring-1 ring-blue-500'
|
||||||
isDragOver && 'bg-blue-500/20 ring-1 ring-blue-500'
|
]" :style="{ paddingLeft: depth * 12 + 4 + 'px' }" draggable="true" @click="handleSelect"
|
||||||
]"
|
@dragstart="handleDragStart" @dragover="handleDragOver" @dragleave="handleDragLeave" @drop="handleDrop">
|
||||||
:style="{ paddingLeft: depth * 12 + 4 + 'px' }"
|
|
||||||
draggable="true"
|
|
||||||
@click="handleSelect"
|
|
||||||
@dragstart="handleDragStart"
|
|
||||||
@dragover="handleDragOver"
|
|
||||||
@dragleave="handleDragLeave"
|
|
||||||
@drop="handleDrop"
|
|
||||||
>
|
|
||||||
<template v-if="node.isDir">
|
<template v-if="node.isDir">
|
||||||
<ChevronDown v-if="isExpanded" class="h-3 w-3 flex-shrink-0" />
|
<ChevronDown v-if="isExpanded" class="h-3 w-3 flex-shrink-0" />
|
||||||
<ChevronRight v-else class="h-3 w-3 flex-shrink-0" />
|
<ChevronRight v-else class="h-3 w-3 flex-shrink-0" />
|
||||||
@@ -97,26 +99,23 @@ function handleDrop(e: DragEvent) {
|
|||||||
<Folder v-if="node.isDir" class="h-3 w-3 text-yellow-500 flex-shrink-0" />
|
<Folder v-if="node.isDir" class="h-3 w-3 text-yellow-500 flex-shrink-0" />
|
||||||
<File v-else class="h-3 w-3 text-blue-500 flex-shrink-0" />
|
<File v-else class="h-3 w-3 text-blue-500 flex-shrink-0" />
|
||||||
<span class="truncate flex-1">{{ node.name }}</span>
|
<span class="truncate flex-1">{{ node.name }}</span>
|
||||||
<Button v-if="node.isDir" variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100" @click="handleCreate">
|
<Button v-if="node.isDir" variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100"
|
||||||
|
@click="handleCreate">
|
||||||
<Plus class="h-3 w-3" />
|
<Plus class="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button v-if="!node.isDir" variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100"
|
||||||
|
@click="handleDownloadClick">
|
||||||
|
<Download class="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
<Button variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100" @click="handleDelete">
|
<Button variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100" @click="handleDelete">
|
||||||
<Trash2 class="h-3 w-3 text-destructive" />
|
<Trash2 class="h-3 w-3 text-destructive" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<template v-if="node.isDir && isExpanded && node.children">
|
<template v-if="node.isDir && isExpanded && node.children">
|
||||||
<FileTreeNode
|
<FileTreeNode v-for="child in node.children" :key="child.path" :node="child" :expanded-dirs="expandedDirs"
|
||||||
v-for="child in node.children"
|
:selected-path="selectedPath" :depth="depth + 1" @select="$emit('select', $event)"
|
||||||
:key="child.path"
|
@delete="$emit('delete', $event)" @download-file="$emit('download-file', $event)"
|
||||||
:node="child"
|
@create="$emit('create', $event)" @move="(oldPath, newPath) => $emit('move', oldPath, newPath)" />
|
||||||
:expanded-dirs="expandedDirs"
|
|
||||||
:selected-path="selectedPath"
|
|
||||||
:depth="depth + 1"
|
|
||||||
@select="emit('select', $event)"
|
|
||||||
@delete="emit('delete', $event)"
|
|
||||||
@create="emit('create', $event)"
|
|
||||||
@move="(oldPath, newPath) => emit('move', oldPath, newPath)"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ async function handleSelect(node: FileNode) {
|
|||||||
selectedPath.value = node.path
|
selectedPath.value = node.path
|
||||||
// 更新 URL 使用 query 参数
|
// 更新 URL 使用 query 参数
|
||||||
router.replace({ name: 'editor', query: { file: node.path } })
|
router.replace({ name: 'editor', query: { file: node.path } })
|
||||||
|
|
||||||
if (node.isDir) {
|
if (node.isDir) {
|
||||||
if (expandedDirs.value.has(node.path)) {
|
if (expandedDirs.value.has(node.path)) {
|
||||||
expandedDirs.value.delete(node.path)
|
expandedDirs.value.delete(node.path)
|
||||||
@@ -176,24 +176,24 @@ async function deleteItem() {
|
|||||||
|
|
||||||
async function runScript() {
|
async function runScript() {
|
||||||
if (!selectedFile.value) return
|
if (!selectedFile.value) return
|
||||||
|
|
||||||
// 获取文件所在目录和文件名
|
// 获取文件所在目录和文件名
|
||||||
const parts = selectedFile.value.split('/')
|
const parts = selectedFile.value.split('/')
|
||||||
const fileName = parts.pop() || selectedFile.value
|
const fileName = parts.pop() || selectedFile.value
|
||||||
const dirPath = parts.length > 0 ? parts.join('/') : ''
|
const dirPath = parts.length > 0 ? parts.join('/') : ''
|
||||||
|
|
||||||
// 根据文件扩展名确定运行命令
|
// 根据文件扩展名确定运行命令
|
||||||
const ext = fileName.split('.').pop()?.toLowerCase() || ''
|
const ext = fileName.split('.').pop()?.toLowerCase() || ''
|
||||||
const runner = FILE_RUNNERS[ext]
|
const runner = FILE_RUNNERS[ext]
|
||||||
const cmd = runner ? `${runner} ${fileName}` : `./${fileName}`
|
const cmd = runner ? `${runner} ${fileName}` : `./${fileName}`
|
||||||
|
|
||||||
// 构建完整命令
|
// 构建完整命令
|
||||||
if (dirPath) {
|
if (dirPath) {
|
||||||
runCommand.value = `cd ${PATHS.SCRIPTS_DIR}/${dirPath} && ${cmd}`
|
runCommand.value = `cd ${PATHS.SCRIPTS_DIR}/${dirPath} && ${cmd}`
|
||||||
} else {
|
} else {
|
||||||
runCommand.value = `cd ${PATHS.SCRIPTS_DIR} && ${cmd}`
|
runCommand.value = `cd ${PATHS.SCRIPTS_DIR} && ${cmd}`
|
||||||
}
|
}
|
||||||
|
|
||||||
showTerminalDialog.value = true
|
showTerminalDialog.value = true
|
||||||
// 等待 DOM 更新后初始化终端,增加延迟确保 Dialog 完全渲染
|
// 等待 DOM 更新后初始化终端,增加延迟确保 Dialog 完全渲染
|
||||||
await nextTick()
|
await nextTick()
|
||||||
@@ -210,6 +210,21 @@ function closeTerminal() {
|
|||||||
}, 300)
|
}, 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) {
|
async function handleMove(oldPath: string, newPath: string) {
|
||||||
try {
|
try {
|
||||||
await api.files.rename(oldPath, newPath)
|
await api.files.rename(oldPath, newPath)
|
||||||
@@ -362,24 +377,17 @@ onUnmounted(() => {
|
|||||||
<Plus class="h-3 w-3" />
|
<Plus class="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<input ref="archiveInputRef" type="file" accept=".zip,.tar,.gz,.tgz" class="hidden" @change="handleArchiveUpload" />
|
<input ref="archiveInputRef" type="file" accept=".zip,.tar,.gz,.tgz" class="hidden"
|
||||||
|
@change="handleArchiveUpload" />
|
||||||
<input ref="filesInputRef" type="file" multiple class="hidden" @change="handleFilesUpload" />
|
<input ref="filesInputRef" type="file" multiple class="hidden" @change="handleFilesUpload" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 overflow-auto p-1">
|
<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 v-if="fileTree.length === 0" class="text-xs text-muted-foreground text-center py-4">
|
||||||
暂无文件
|
暂无文件
|
||||||
</div>
|
</div>
|
||||||
<FileTreeNode
|
<FileTreeNode v-for="node in fileTree" :key="node.path" :node="node" :expanded-dirs="expandedDirs"
|
||||||
v-for="node in fileTree"
|
:selected-path="selectedPath" @select="handleSelect" @delete="confirmDelete" @create="handleCreate"
|
||||||
:key="node.path"
|
@download-file="handleDownload" @move="handleMove" />
|
||||||
:node="node"
|
|
||||||
:expanded-dirs="expandedDirs"
|
|
||||||
:selected-path="selectedPath"
|
|
||||||
@select="handleSelect"
|
|
||||||
@delete="confirmDelete"
|
|
||||||
@create="handleCreate"
|
|
||||||
@move="handleMove"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -391,11 +399,13 @@ onUnmounted(() => {
|
|||||||
<span v-if="hasChanges" class="text-orange-500 ml-1">●</span>
|
<span v-if="hasChanges" class="text-orange-500 ml-1">●</span>
|
||||||
</span>
|
</span>
|
||||||
<div v-if="selectedFile" class="flex gap-1 shrink-0">
|
<div v-if="selectedFile" class="flex gap-1 shrink-0">
|
||||||
<Button v-if="!isEditMode" variant="ghost" size="sm" class="h-6 text-xs gap-1 px-2" @click="isEditMode = true">
|
<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>
|
<Pencil class="h-3 w-3" /> <span class="hidden sm:inline">编辑</span>
|
||||||
</Button>
|
</Button>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<Button variant="ghost" size="sm" class="h-6 text-xs gap-1 px-2" @click="isEditMode = false; fileContent = originalContent">
|
<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>
|
<Eye class="h-3 w-3" /> <span class="hidden sm:inline">查看</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" class="h-6 text-xs gap-1 px-2" :disabled="!hasChanges" @click="saveFile">
|
<Button variant="ghost" size="sm" class="h-6 text-xs gap-1 px-2" :disabled="!hasChanges" @click="saveFile">
|
||||||
@@ -408,12 +418,8 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<vue-monaco-editor
|
<vue-monaco-editor v-if="selectedFile" v-model:value="fileContent" :language="getLanguage(selectedFile)"
|
||||||
v-if="selectedFile"
|
theme="vs-dark" :options="{
|
||||||
v-model:value="fileContent"
|
|
||||||
:language="getLanguage(selectedFile)"
|
|
||||||
theme="vs-dark"
|
|
||||||
:options="{
|
|
||||||
minimap: { enabled: false },
|
minimap: { enabled: false },
|
||||||
fontSize: editorFontSize,
|
fontSize: editorFontSize,
|
||||||
lineNumbers: 'on',
|
lineNumbers: 'on',
|
||||||
@@ -429,9 +435,7 @@ onUnmounted(() => {
|
|||||||
insertSpaces: true,
|
insertSpaces: true,
|
||||||
readOnly: !isEditMode,
|
readOnly: !isEditMode,
|
||||||
domReadOnly: !isEditMode
|
domReadOnly: !isEditMode
|
||||||
}"
|
}" @mount="handleEditorMount" />
|
||||||
@mount="handleEditorMount"
|
|
||||||
/>
|
|
||||||
<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="lg:hidden">从上方选择文件开始编辑</span>
|
||||||
<span class="hidden lg:inline">从左侧选择文件开始编辑</span>
|
<span class="hidden lg:inline">从左侧选择文件开始编辑</span>
|
||||||
@@ -439,7 +443,7 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- 新建对话框 -->
|
<!-- 新建对话框 -->
|
||||||
<Dialog v-model:open="showCreateDialog">
|
<Dialog v-model:open="showCreateDialog">
|
||||||
@@ -482,28 +486,28 @@ onUnmounted(() => {
|
|||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel class="h-7 text-xs">取消</AlertDialogCancel>
|
<AlertDialogCancel class="h-7 text-xs">取消</AlertDialogCancel>
|
||||||
<AlertDialogAction class="h-7 text-xs bg-destructive text-white hover:bg-destructive/90" @click="deleteItem">删除</AlertDialogAction>
|
<AlertDialogAction class="h-7 text-xs bg-destructive text-white hover:bg-destructive/90" @click="deleteItem">
|
||||||
|
删除
|
||||||
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
<!-- 终端弹窗 -->
|
<!-- 终端弹窗 -->
|
||||||
<Dialog v-model:open="showTerminalDialog">
|
<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">
|
<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">
|
||||||
<div class="flex items-center justify-between px-3 sm:px-4 py-2 border-b border-[#3c3c3c]">
|
<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>
|
<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">
|
<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" />
|
<X class="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 overflow-hidden">
|
<div class="flex-1 overflow-hidden">
|
||||||
<XTerminal
|
<XTerminal v-if="showTerminalDialog" ref="terminalRef" :font-size="isSmallScreen ? 12 : 13"
|
||||||
v-if="showTerminalDialog"
|
:initial-command="runCommand" :auto-connect="false" />
|
||||||
ref="terminalRef"
|
|
||||||
:font-size="isSmallScreen ? 12 : 13"
|
|
||||||
:initial-command="runCommand"
|
|
||||||
:auto-connect="false"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -511,5 +515,4 @@ onUnmounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
||||||
<style scoped>
|
<style scoped></style>
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ async function loadTree() {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
fileTree.value = await api.files.tree()
|
fileTree.value = await api.files.tree()
|
||||||
|
|
||||||
// 仅在首次加载时从 URL 恢复状态
|
// 仅在首次加载时从 URL 恢复状态
|
||||||
if (expandedDirs.value.size === 0 && selectedFile.value === null && selectedDir.value === null) {
|
if (expandedDirs.value.size === 0 && selectedFile.value === null && selectedDir.value === null) {
|
||||||
// 从 URL 恢复展开的目录
|
// 从 URL 恢复展开的目录
|
||||||
@@ -90,14 +90,14 @@ async function loadTree() {
|
|||||||
if (dirsParam && typeof dirsParam === 'string') {
|
if (dirsParam && typeof dirsParam === 'string') {
|
||||||
dirsParam.split(',').forEach(dir => expandedDirs.value.add(dir))
|
dirsParam.split(',').forEach(dir => expandedDirs.value.add(dir))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 从 URL 恢复选中的文件夹
|
// 从 URL 恢复选中的文件夹
|
||||||
const dirParam = route.query.dir
|
const dirParam = route.query.dir
|
||||||
if (dirParam && typeof dirParam === 'string') {
|
if (dirParam && typeof dirParam === 'string') {
|
||||||
selectedDir.value = dirParam
|
selectedDir.value = dirParam
|
||||||
expandedDirs.value.add(dirParam)
|
expandedDirs.value.add(dirParam)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 从 URL 加载文件
|
// 从 URL 加载文件
|
||||||
const fileParam = route.query.file
|
const fileParam = route.query.file
|
||||||
if (fileParam && typeof fileParam === 'string') {
|
if (fileParam && typeof fileParam === 'string') {
|
||||||
@@ -144,13 +144,13 @@ async function handleSelect(node: FileNode) {
|
|||||||
toggleDir(node.path)
|
toggleDir(node.path)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasChanges.value) {
|
if (hasChanges.value) {
|
||||||
pendingNode.value = node
|
pendingNode.value = node
|
||||||
showUnsavedDialog.value = true
|
showUnsavedDialog.value = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await selectFile(node)
|
await selectFile(node)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,6 +251,22 @@ async function handleDelete() {
|
|||||||
deletePath.value = null
|
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)
|
onMounted(loadTree)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -272,20 +288,14 @@ onMounted(loadTree)
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 overflow-auto p-1">
|
<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 v-if="fileTree.length === 0" class="text-xs text-muted-foreground text-center py-4">
|
||||||
暂无文件
|
暂无文件
|
||||||
</div>
|
</div>
|
||||||
<FileTreeNode
|
<FileTreeNode v-for="node in fileTree" :key="node.path" :node="node" :expanded-dirs="expandedDirs"
|
||||||
v-for="node in fileTree"
|
:selected-path="selectedFile || selectedDir" @select="handleSelect" @delete="confirmDeleteFile"
|
||||||
:key="node.path"
|
@download-file="handleDownload" />
|
||||||
:node="node"
|
|
||||||
:expanded-dirs="expandedDirs"
|
|
||||||
:selected-path="selectedFile || selectedDir"
|
|
||||||
@select="handleSelect"
|
|
||||||
@delete="confirmDeleteFile"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -296,24 +306,15 @@ onMounted(loadTree)
|
|||||||
<span class="text-xs font-medium truncate">{{ selectedFile || '未选择文件' }}</span>
|
<span class="text-xs font-medium truncate">{{ selectedFile || '未选择文件' }}</span>
|
||||||
<span v-if="hasChanges" class="text-xs text-orange-500 shrink-0">● 未保存</span>
|
<span v-if="hasChanges" class="text-xs text-orange-500 shrink-0">● 未保存</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button v-if="selectedFile" size="sm" class="h-6 text-xs gap-1 shrink-0" :disabled="!hasChanges || saving"
|
||||||
v-if="selectedFile"
|
@click="saveFile">
|
||||||
size="sm"
|
|
||||||
class="h-6 text-xs gap-1 shrink-0"
|
|
||||||
:disabled="!hasChanges || saving"
|
|
||||||
@click="saveFile"
|
|
||||||
>
|
|
||||||
<Save class="h-3 w-3" />
|
<Save class="h-3 w-3" />
|
||||||
<span class="hidden sm:inline">{{ saving ? '保存中...' : '保存' }}</span>
|
<span class="hidden sm:inline">{{ saving ? '保存中...' : '保存' }}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<VueMonacoEditor
|
<VueMonacoEditor v-if="selectedFile" v-model:value="fileContent" :language="editorLanguage" theme="vs-dark"
|
||||||
v-if="selectedFile"
|
|
||||||
v-model:value="fileContent"
|
|
||||||
:language="editorLanguage"
|
|
||||||
theme="vs-dark"
|
|
||||||
:options="{
|
:options="{
|
||||||
minimap: { enabled: false },
|
minimap: { enabled: false },
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
@@ -322,10 +323,7 @@ onMounted(loadTree)
|
|||||||
automaticLayout: true,
|
automaticLayout: true,
|
||||||
tabSize: 2,
|
tabSize: 2,
|
||||||
wordWrap: 'on'
|
wordWrap: 'on'
|
||||||
}"
|
}" style="height: 100%" @mount="handleEditorMount" />
|
||||||
style="height: 100%"
|
|
||||||
@mount="handleEditorMount"
|
|
||||||
/>
|
|
||||||
<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">
|
||||||
选择一个文件开始编辑
|
选择一个文件开始编辑
|
||||||
</div>
|
</div>
|
||||||
@@ -344,12 +342,8 @@ onMounted(loadTree)
|
|||||||
<div v-if="selectedDir" class="text-xs text-muted-foreground">
|
<div v-if="selectedDir" class="text-xs text-muted-foreground">
|
||||||
位置: {{ selectedDir }}/
|
位置: {{ selectedDir }}/
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<Input v-model="createName" class="h-8 text-xs"
|
||||||
v-model="createName"
|
:placeholder="createType === 'file' ? 'example.js' : 'folder-name'" @keyup.enter="createItem" />
|
||||||
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">
|
<div v-if="createName" class="text-xs text-muted-foreground">
|
||||||
完整路径: {{ createFullPath }}
|
完整路径: {{ createFullPath }}
|
||||||
</div>
|
</div>
|
||||||
@@ -369,7 +363,8 @@ onMounted(loadTree)
|
|||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel class="h-7 text-xs">取消</AlertDialogCancel>
|
<AlertDialogCancel class="h-7 text-xs">取消</AlertDialogCancel>
|
||||||
<AlertDialogAction class="h-7 text-xs bg-destructive text-white hover:bg-destructive/90" @click="handleDelete">删除</AlertDialogAction>
|
<AlertDialogAction class="h-7 text-xs bg-destructive text-white hover:bg-destructive/90"
|
||||||
|
@click="handleDelete">删除</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|||||||
Reference in New Issue
Block a user