feat: Initial commit
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ListTodo, FileCode, Variable, Clock, Play, ScrollText } from 'lucide-vue-next'
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||
import { api, type Task, type Stats } from '@/api'
|
||||
|
||||
const router = useRouter()
|
||||
const stats = ref<Stats>({ tasks: 0, scripts: 0, envs: 0, logs: 0, scheduled: 0, running: 0 })
|
||||
const recentTasks = ref<Task[]>([])
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [statsData, tasksRes] = await Promise.all([
|
||||
api.dashboard.stats(),
|
||||
api.tasks.list({ page: 1, page_size: 5 })
|
||||
])
|
||||
stats.value = statsData
|
||||
recentTasks.value = tasksRes.data
|
||||
} catch {}
|
||||
})
|
||||
|
||||
const statItems = [
|
||||
{ key: 'tasks', label: '任务总数', icon: ListTodo, route: '/tasks' },
|
||||
{ key: 'scripts', label: '脚本数量', icon: FileCode, route: '/editor' },
|
||||
{ key: 'envs', label: '环境变量', icon: Variable, route: '/environments' },
|
||||
{ key: 'logs', label: '日志总数', icon: ScrollText, route: '/history' },
|
||||
{ key: 'scheduled', label: '调度注册', icon: Clock, route: '/tasks' },
|
||||
{ key: 'running', label: '正在运行', icon: Play, route: '/tasks' },
|
||||
]
|
||||
|
||||
function navigateTo(route?: string) {
|
||||
if (route) router.push(route)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold tracking-tight">数据仪表</h2>
|
||||
<p class="text-muted-foreground">查看系统运行状态和统计数据</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-3 lg:grid-cols-6">
|
||||
<Card v-for="item in statItems" :key="item.key" class="cursor-pointer hover:bg-accent/50 transition-colors" @click="navigateTo(item.route)">
|
||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle class="text-sm font-medium">{{ item.label }}</CardTitle>
|
||||
<component :is="item.icon" class="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="text-2xl font-bold">{{ stats[item.key as keyof Stats] }}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>最近任务</CardTitle>
|
||||
<CardDescription>最近创建的定时任务</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="space-y-2">
|
||||
<div v-if="recentTasks.length === 0" class="text-sm text-muted-foreground py-8 text-center">
|
||||
暂无任务
|
||||
</div>
|
||||
<div
|
||||
v-for="task in recentTasks"
|
||||
:key="task.id"
|
||||
class="flex items-center justify-between py-2 border-b last:border-0"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-muted-foreground text-sm">#{{ task.id }}</span>
|
||||
<span class="font-medium">{{ task.name }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 text-muted-foreground">
|
||||
<code class="text-xs bg-muted px-2 py-0.5 rounded">{{ task.schedule }}</code>
|
||||
<span class="w-2 h-2 rounded-full" :class="task.enabled ? 'bg-green-500' : 'bg-gray-400'" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,200 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
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 { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import Pagination from '@/components/Pagination.vue'
|
||||
import { Plus, Pencil, Trash2, Eye, EyeOff, Search } from 'lucide-vue-next'
|
||||
import { api, type EnvVar } from '@/api'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
const envVars = ref<EnvVar[]>([])
|
||||
const showDialog = ref(false)
|
||||
const editingEnv = ref<Partial<EnvVar>>({})
|
||||
const isEdit = ref(false)
|
||||
const showValues = ref<Record<number, boolean>>({})
|
||||
const showDeleteDialog = ref(false)
|
||||
const deleteEnvId = ref<number | null>(null)
|
||||
|
||||
const filterName = ref('')
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
async function loadEnvVars() {
|
||||
try {
|
||||
const res = await api.env.list({ page: currentPage.value, page_size: pageSize.value, name: filterName.value || undefined })
|
||||
envVars.value = res.data
|
||||
total.value = res.total
|
||||
} catch { toast.error('加载环境变量失败') }
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(() => {
|
||||
currentPage.value = 1
|
||||
loadEnvVars()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
currentPage.value = page
|
||||
loadEnvVars()
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingEnv.value = { name: '', value: '', remark: '' }
|
||||
isEdit.value = false
|
||||
showDialog.value = true
|
||||
}
|
||||
|
||||
function openEdit(env: EnvVar) {
|
||||
editingEnv.value = { ...env }
|
||||
isEdit.value = true
|
||||
showDialog.value = true
|
||||
}
|
||||
|
||||
async function saveEnv() {
|
||||
try {
|
||||
if (isEdit.value && editingEnv.value.id) {
|
||||
await api.env.update(editingEnv.value.id, editingEnv.value)
|
||||
toast.success('变量已更新')
|
||||
} else {
|
||||
await api.env.create(editingEnv.value)
|
||||
toast.success('变量已创建')
|
||||
}
|
||||
showDialog.value = false
|
||||
loadEnvVars()
|
||||
} catch { toast.error('保存失败') }
|
||||
}
|
||||
|
||||
function confirmDelete(id: number) {
|
||||
deleteEnvId.value = id
|
||||
showDeleteDialog.value = true
|
||||
}
|
||||
|
||||
async function deleteEnv() {
|
||||
if (!deleteEnvId.value) return
|
||||
try {
|
||||
await api.env.delete(deleteEnvId.value)
|
||||
toast.success('变量已删除')
|
||||
loadEnvVars()
|
||||
} catch { toast.error('删除失败') }
|
||||
showDeleteDialog.value = false
|
||||
deleteEnvId.value = null
|
||||
}
|
||||
|
||||
function toggleShow(id: number) {
|
||||
showValues.value[id] = !showValues.value[id]
|
||||
}
|
||||
|
||||
function maskValue(value: string) {
|
||||
return '•'.repeat(Math.min(value.length, 20))
|
||||
}
|
||||
|
||||
onMounted(loadEnvVars)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold tracking-tight">环境变量</h2>
|
||||
<p class="text-muted-foreground">管理脚本执行时的环境变量</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="relative">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input v-model="filterName" placeholder="搜索变量..." class="h-9 pl-9 w-56 text-sm" @input="handleSearch" />
|
||||
</div>
|
||||
<Button @click="openCreate">
|
||||
<Plus class="h-4 w-4 mr-2" /> 新建变量
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border bg-card">
|
||||
<!-- 表头 -->
|
||||
<div class="flex items-center gap-4 px-4 py-2 border-b bg-muted/50 text-sm text-muted-foreground font-medium">
|
||||
<span class="w-48 shrink-0">变量名</span>
|
||||
<span class="flex-1">值</span>
|
||||
<span class="w-48 shrink-0">备注</span>
|
||||
<span class="w-24 shrink-0 text-center">操作</span>
|
||||
</div>
|
||||
<!-- 列表 -->
|
||||
<div class="divide-y">
|
||||
<div v-if="envVars.length === 0" class="text-sm text-muted-foreground text-center py-8">
|
||||
暂无环境变量
|
||||
</div>
|
||||
<div
|
||||
v-for="env in envVars"
|
||||
:key="env.id"
|
||||
class="flex items-center gap-4 px-4 py-2 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<code class="w-48 font-medium truncate shrink-0 text-xs bg-muted px-2 py-1 rounded">{{ env.name }}</code>
|
||||
<span class="flex-1 font-mono text-muted-foreground truncate text-xs">
|
||||
{{ showValues[env.id] ? env.value : maskValue(env.value) }}
|
||||
</span>
|
||||
<span class="w-48 shrink-0 text-muted-foreground truncate text-sm">{{ env.remark || '-' }}</span>
|
||||
<span class="w-24 shrink-0 flex justify-center gap-1">
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" @click="toggleShow(env.id)" :title="showValues[env.id] ? '隐藏' : '显示'">
|
||||
<Eye v-if="!showValues[env.id]" class="h-3.5 w-3.5" />
|
||||
<EyeOff v-else class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" @click="openEdit(env)" title="编辑">
|
||||
<Pencil class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7 text-destructive" @click="confirmDelete(env.id)" title="删除">
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 分页 -->
|
||||
<Pagination :total="total" :page="currentPage" :page-size="pageSize" @update:page="handlePageChange" />
|
||||
</div>
|
||||
|
||||
<Dialog v-model:open="showDialog">
|
||||
<DialogContent class="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ isEdit ? '编辑变量' : '新建变量' }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="space-y-4 py-2">
|
||||
<div class="space-y-2">
|
||||
<Label>变量名</Label>
|
||||
<Input v-model="editingEnv.name" class="font-mono" placeholder="MY_VAR" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>变量值</Label>
|
||||
<Input v-model="editingEnv.value" class="font-mono" placeholder="value" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>备注</Label>
|
||||
<Textarea v-model="editingEnv.remark" class="resize-none" rows="3" placeholder="变量说明..." />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="showDialog = false">取消</Button>
|
||||
<Button @click="saveEnv">保存</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog v-model:open="showDeleteDialog">
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||
<AlertDialogDescription>确定要删除此环境变量吗?此操作无法撤销。</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction class="bg-destructive text-white hover:bg-destructive/90" @click="deleteEnv">删除</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,216 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import Pagination from '@/components/Pagination.vue'
|
||||
import LogViewer from './LogViewer.vue'
|
||||
import { RefreshCw, X, Search, Maximize2 } from 'lucide-vue-next'
|
||||
import { api, type TaskLog, type LogDetail } from '@/api'
|
||||
import { toast } from 'vue-sonner'
|
||||
import pako from 'pako'
|
||||
|
||||
const logs = ref<TaskLog[]>([])
|
||||
const selectedLog = ref<TaskLog | null>(null)
|
||||
const logDetail = ref<LogDetail | null>(null)
|
||||
const filterKeyword = ref('')
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// 全屏查看
|
||||
const showFullscreen = ref(false)
|
||||
|
||||
function decompressOutput(compressed: string): string {
|
||||
if (!compressed) return '无输出'
|
||||
try {
|
||||
const binaryString = atob(compressed)
|
||||
const bytes = new Uint8Array(binaryString.length)
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i)
|
||||
}
|
||||
const decompressed = pako.inflate(bytes)
|
||||
return new TextDecoder().decode(decompressed)
|
||||
} catch {
|
||||
return compressed
|
||||
}
|
||||
}
|
||||
|
||||
const decompressedOutput = computed(() => {
|
||||
if (!logDetail.value?.output) return '无输出'
|
||||
return decompressOutput(logDetail.value.output)
|
||||
})
|
||||
|
||||
async function loadLogs() {
|
||||
try {
|
||||
const params: { page: number; page_size: number; task_name?: string } = {
|
||||
page: currentPage.value,
|
||||
page_size: pageSize.value
|
||||
}
|
||||
if (filterKeyword.value.trim()) {
|
||||
params.task_name = filterKeyword.value.trim()
|
||||
}
|
||||
const response = await api.logs.list(params)
|
||||
logs.value = response.data
|
||||
total.value = response.total
|
||||
} catch {
|
||||
toast.error('加载日志失败')
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(() => {
|
||||
currentPage.value = 1
|
||||
loadLogs()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
currentPage.value = page
|
||||
loadLogs()
|
||||
}
|
||||
|
||||
async function selectLog(log: TaskLog) {
|
||||
selectedLog.value = log
|
||||
logDetail.value = null
|
||||
try {
|
||||
logDetail.value = await api.logs.detail(log.id)
|
||||
} catch {
|
||||
toast.error('加载日志详情失败')
|
||||
}
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
selectedLog.value = null
|
||||
logDetail.value = null
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
|
||||
return `${(ms / 60000).toFixed(1)}m`
|
||||
}
|
||||
|
||||
onMounted(loadLogs)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold tracking-tight">执行历史</h2>
|
||||
<p class="text-muted-foreground">查看任务执行记录和日志</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="relative">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input v-model="filterKeyword" placeholder="搜索任务..." class="h-9 pl-9 w-56 text-sm" @input="handleSearch" />
|
||||
</div>
|
||||
<Button variant="outline" size="icon" class="h-9 w-9" @click="loadLogs">
|
||||
<RefreshCw class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4">
|
||||
<!-- 日志列表 -->
|
||||
<div class="flex-1 rounded-lg border bg-card">
|
||||
<!-- 表头 -->
|
||||
<div class="flex items-center gap-4 px-4 py-2 border-b bg-muted/50 text-sm text-muted-foreground font-medium">
|
||||
<span class="w-12 shrink-0">ID</span>
|
||||
<span class="w-32 shrink-0">任务名称</span>
|
||||
<span class="flex-1">命令</span>
|
||||
<span class="w-12 shrink-0 text-center">状态</span>
|
||||
<span class="w-20 text-right shrink-0">耗时</span>
|
||||
<span class="w-40 text-right shrink-0">执行时间</span>
|
||||
</div>
|
||||
<!-- 列表 -->
|
||||
<div class="divide-y">
|
||||
<div v-if="logs.length === 0" class="text-sm text-muted-foreground text-center py-8">
|
||||
暂无日志
|
||||
</div>
|
||||
<div
|
||||
v-for="log in logs"
|
||||
:key="log.id"
|
||||
:class="[
|
||||
'flex items-center gap-4 px-4 py-2 min-h-[44px] cursor-pointer hover:bg-muted/50 transition-colors',
|
||||
selectedLog?.id === log.id && 'bg-accent'
|
||||
]"
|
||||
@click="selectLog(log)"
|
||||
>
|
||||
<span class="w-12 shrink-0 text-muted-foreground text-sm">#{{ log.id }}</span>
|
||||
<span class="w-32 font-medium truncate shrink-0 text-sm">{{ log.task_name }}</span>
|
||||
<code class="flex-1 text-muted-foreground truncate text-xs bg-muted px-2 py-1 rounded">{{ log.command }}</code>
|
||||
<span class="w-12 flex justify-center shrink-0">
|
||||
<span :class="['w-2 h-2 rounded-full', log.status === 'success' ? 'bg-green-500' : log.status === 'failed' ? 'bg-red-500' : 'bg-yellow-500']" />
|
||||
</span>
|
||||
<span class="w-20 text-right shrink-0 text-muted-foreground text-xs">{{ formatDuration(log.duration) }}</span>
|
||||
<span class="w-40 text-right shrink-0 text-muted-foreground text-xs">{{ log.created_at }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 分页 -->
|
||||
<Pagination :total="total" :page="currentPage" :page-size="pageSize" @update:page="handlePageChange" />
|
||||
</div>
|
||||
|
||||
<!-- 日志详情侧边栏 -->
|
||||
<div
|
||||
v-if="selectedLog"
|
||||
class="w-[480px] rounded-lg border bg-card flex flex-col overflow-hidden shrink-0 max-h-[calc(100vh-180px)]"
|
||||
>
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b">
|
||||
<span class="text-sm font-medium">日志详情</span>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" @click="closeDetail">
|
||||
<X class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="px-4 py-3 border-b space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">任务名称</span>
|
||||
<span class="font-medium">{{ selectedLog.task_name }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-muted-foreground">状态</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span :class="['w-2 h-2 rounded-full', selectedLog.status === 'success' ? 'bg-green-500' : selectedLog.status === 'failed' ? 'bg-red-500' : 'bg-yellow-500']" />
|
||||
{{ selectedLog.status }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">耗时</span>
|
||||
<span>{{ formatDuration(selectedLog.duration) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">执行时间</span>
|
||||
<span>{{ selectedLog.created_at }}</span>
|
||||
</div>
|
||||
<div class="pt-1">
|
||||
<span class="text-muted-foreground">命令</span>
|
||||
<code class="mt-1 block font-mono bg-muted px-2 py-1 rounded text-xs break-all">
|
||||
{{ selectedLog.command }}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col overflow-hidden">
|
||||
<div class="px-4 py-2 text-sm text-muted-foreground border-b bg-muted/50 flex items-center justify-between">
|
||||
<span>输出</span>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6" @click="showFullscreen = true" title="全屏查看">
|
||||
<Maximize2 class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-auto">
|
||||
<pre v-if="logDetail" class="p-4 text-xs font-mono whitespace-pre-wrap break-all">{{ decompressedOutput }}</pre>
|
||||
<div v-else class="p-4 text-sm text-muted-foreground">加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 全屏查看日志 -->
|
||||
<LogViewer
|
||||
v-model:open="showFullscreen"
|
||||
:title="`日志输出 - ${selectedLog?.task_name || ''}`"
|
||||
:content="decompressedOutput"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { X, Search } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
title: string
|
||||
content: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
}>()
|
||||
|
||||
const searchKeyword = ref('')
|
||||
|
||||
// 高亮搜索结果
|
||||
const highlightedContent = computed(() => {
|
||||
if (!searchKeyword.value.trim()) return props.content
|
||||
|
||||
const keyword = searchKeyword.value.trim()
|
||||
const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const regex = new RegExp(`(${escaped})`, 'gi')
|
||||
return props.content.replace(regex, '<mark class="bg-yellow-300 text-black">$1</mark>')
|
||||
})
|
||||
|
||||
function close() {
|
||||
emit('update:open', false)
|
||||
}
|
||||
|
||||
// 打开时重置搜索
|
||||
watch(() => props.open, (val) => {
|
||||
if (val) searchKeyword.value = ''
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="open"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||
@click.self="close"
|
||||
>
|
||||
<div class="bg-background rounded-lg shadow-lg flex flex-col w-[80vw] max-w-5xl h-[85vh]">
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b shrink-0">
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-sm font-medium">{{ title }}</span>
|
||||
<div class="relative">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input v-model="searchKeyword" placeholder="搜索内容..." class="h-8 pl-9 w-64 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" @click="close">
|
||||
<X class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-auto">
|
||||
<pre class="p-4 text-xs font-mono whitespace-pre-wrap break-all" v-html="highlightedContent"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import ThemeToggle from '@/components/ThemeToggle.vue'
|
||||
import { api } from '@/api'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
const router = useRouter()
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
async function handleLogin() {
|
||||
loading.value = true
|
||||
try {
|
||||
await api.auth.login({ username: username.value, password: password.value })
|
||||
toast.success('登录成功')
|
||||
router.push('/')
|
||||
} catch {
|
||||
toast.error('登录失败,请检查用户名和密码')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center bg-muted/30 p-4 relative">
|
||||
<!-- 右上角主题切换 -->
|
||||
<div class="absolute top-4 right-4">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
<div class="border rounded-lg bg-background shadow-sm overflow-hidden">
|
||||
<div class="flex">
|
||||
<!-- 左侧登录表单 -->
|
||||
<div class="w-96 p-10">
|
||||
<div class="space-y-8">
|
||||
<div class="space-y-2">
|
||||
<h1 class="text-2xl font-bold tracking-tight">白虎面板</h1>
|
||||
<p class="text-muted-foreground">轻量级定时任务管理系统</p>
|
||||
</div>
|
||||
<form @submit.prevent="handleLogin" class="space-y-5">
|
||||
<div class="space-y-2">
|
||||
<Label>用户名</Label>
|
||||
<Input v-model="username" placeholder="请输入用户名" class="h-10 text-base" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>密码</Label>
|
||||
<Input v-model="password" type="password" placeholder="请输入密码" class="h-10 text-base" />
|
||||
</div>
|
||||
<Button type="submit" class="w-full h-10" :disabled="loading">
|
||||
{{ loading ? '登录中...' : '登录' }}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 右侧 Logo 展示(大屏显示) -->
|
||||
<div class="hidden lg:flex w-64 bg-muted/50 dark:bg-muted/30 items-center justify-center">
|
||||
<img src="/logo.svg" alt="Logo" class="w-44 h-44" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,376 @@
|
||||
<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 { 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)
|
||||
|
||||
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'
|
||||
})
|
||||
|
||||
// 更新 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
|
||||
}
|
||||
|
||||
onMounted(loadTree)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-[calc(100vh-2rem)] gap-3">
|
||||
<!-- File Tree -->
|
||||
<div class="w-56 flex-shrink-0 border rounded-lg bg-card flex flex-col">
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Editor -->
|
||||
<div class="flex-1 border rounded-lg bg-card flex flex-col overflow-hidden">
|
||||
<div class="p-2 border-b flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-medium">{{ selectedFile || '未选择文件' }}</span>
|
||||
<span v-if="hasChanges" class="text-xs text-orange-500">● 未保存</span>
|
||||
</div>
|
||||
<Button
|
||||
v-if="selectedFile"
|
||||
size="sm"
|
||||
class="h-6 text-xs gap-1"
|
||||
:disabled="!hasChanges || saving"
|
||||
@click="saveFile"
|
||||
>
|
||||
<Save class="h-3 w-3" />
|
||||
{{ saving ? '保存中...' : '保存' }}
|
||||
</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%"
|
||||
/>
|
||||
<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>
|
||||
@@ -0,0 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { ExternalLink } from 'lucide-vue-next'
|
||||
import { api, type AboutInfo } from '@/api'
|
||||
|
||||
const aboutInfo = ref<AboutInfo | null>(null)
|
||||
|
||||
const techStack = ['Golang', 'Vue 3', 'TypeScript', 'Vite', 'Tailwind CSS', 'Shadcn/ui']
|
||||
const features = ['脚本管理', '定时任务', '在线终端', '执行日志', '环境变量', 'Docker部署']
|
||||
|
||||
async function loadAbout() {
|
||||
try {
|
||||
aboutInfo.value = await api.settings.getAbout()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
onMounted(loadAbout)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- 站点关于 -->
|
||||
<div class="mb-8">
|
||||
<h3 class="text-xl font-semibold mb-2">白虎面板</h3>
|
||||
<p class="text-muted-foreground">一个轻量级的定时任务管理系统,支持脚本管理、定时执行和日志追踪。</p>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-x-16 gap-y-8">
|
||||
<!-- 左侧:技术栈和功能特性 -->
|
||||
<div class="space-y-8">
|
||||
<div>
|
||||
<h4 class="text-sm font-medium mb-4">技术栈</h4>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Badge v-for="tech in techStack" :key="tech" variant="secondary">{{ tech }}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="text-sm font-medium mb-4">功能特性</h4>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Badge v-for="feature in features" :key="feature" variant="outline">{{ feature }}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:系统信息 -->
|
||||
<div>
|
||||
<h4 class="text-sm font-medium mb-4">系统信息</h4>
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-muted-foreground text-sm">系统版本:</span>
|
||||
<Badge variant="outline" class="font-mono">{{ aboutInfo?.version || 'dev' }}</Badge>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-muted-foreground text-sm">构建时间:</span>
|
||||
<span class="text-sm">{{ aboutInfo?.build_time || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-muted-foreground text-sm">内存使用:</span>
|
||||
<span class="text-sm">{{ aboutInfo?.mem_usage || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-muted-foreground text-sm">运行时间:</span>
|
||||
<span class="text-sm">{{ aboutInfo?.uptime || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部:版权和链接 -->
|
||||
<div class="mt-10 pt-6 border-t flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<span>© 2025 保留所有权利。</span>
|
||||
<a href="https://github.com" target="_blank" class="inline-flex items-center gap-1 text-primary hover:underline">
|
||||
<ExternalLink class="h-3.5 w-3.5" />
|
||||
GitHub 仓库
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { api } from '@/api'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
const cleanDays = ref(30)
|
||||
const cleanResult = ref<number | null>(null)
|
||||
|
||||
async function cleanLogs() {
|
||||
if (cleanDays.value < 1) {
|
||||
toast.error('天数必须大于0')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await api.settings.cleanLogs(cleanDays.value)
|
||||
cleanResult.value = res.deleted
|
||||
toast.success(`已清理 ${res.deleted} 条日志`)
|
||||
} catch {
|
||||
toast.error('清理失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label>清理多少天前的日志</Label>
|
||||
<Input v-model.number="cleanDays" type="number" class="w-32" min="1" />
|
||||
</div>
|
||||
<Button variant="destructive" @click="cleanLogs">清理日志</Button>
|
||||
<p v-if="cleanResult !== null" class="text-sm text-muted-foreground">
|
||||
上次清理了 {{ cleanResult }} 条日志
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { api } from '@/api'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
const oldPassword = ref('')
|
||||
const newPassword = ref('')
|
||||
const confirmPassword = ref('')
|
||||
|
||||
async function changePassword() {
|
||||
if (!oldPassword.value || !newPassword.value) {
|
||||
toast.error('请填写完整')
|
||||
return
|
||||
}
|
||||
if (newPassword.value.length < 6) {
|
||||
toast.error('新密码至少6位')
|
||||
return
|
||||
}
|
||||
if (newPassword.value !== confirmPassword.value) {
|
||||
toast.error('两次密码不一致')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await api.settings.changePassword({ old_password: oldPassword.value, new_password: newPassword.value })
|
||||
toast.success('密码修改成功')
|
||||
oldPassword.value = ''
|
||||
newPassword.value = ''
|
||||
confirmPassword.value = ''
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || '修改失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label>原密码</Label>
|
||||
<Input v-model="oldPassword" type="password" placeholder="请输入原密码" class="max-w-sm" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>新密码</Label>
|
||||
<Input v-model="newPassword" type="password" placeholder="请输入新密码(至少6位)" class="max-w-sm" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>确认密码</Label>
|
||||
<Input v-model="confirmPassword" type="password" placeholder="请再次输入新密码" class="max-w-sm" />
|
||||
</div>
|
||||
<Button @click="changePassword">修改密码</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import PasswordSettings from './PasswordSettings.vue'
|
||||
import LogsSettings from './LogsSettings.vue'
|
||||
import SiteSettings from './SiteSettings.vue'
|
||||
import AboutSettings from './AboutSettings.vue'
|
||||
|
||||
const activeTab = ref('password')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold tracking-tight">系统设置</h2>
|
||||
<p class="text-muted-foreground">管理系统配置和账户安全</p>
|
||||
</div>
|
||||
|
||||
<Tabs v-model="activeTab" class="w-full">
|
||||
<TabsList>
|
||||
<TabsTrigger value="password">密码修改</TabsTrigger>
|
||||
<TabsTrigger value="logs">日志清理</TabsTrigger>
|
||||
<TabsTrigger value="site">站点设置</TabsTrigger>
|
||||
<TabsTrigger value="about">关于</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="password" class="mt-6">
|
||||
<Card class="max-w-lg">
|
||||
<CardHeader>
|
||||
<CardTitle>修改密码</CardTitle>
|
||||
<CardDescription>更新您的账户密码</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PasswordSettings />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="logs" class="mt-6">
|
||||
<Card class="max-w-lg">
|
||||
<CardHeader>
|
||||
<CardTitle>日志清理</CardTitle>
|
||||
<CardDescription>清理历史执行日志以释放存储空间</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<LogsSettings />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="site" class="mt-6">
|
||||
<Card class="max-w-lg">
|
||||
<CardHeader>
|
||||
<CardTitle>站点设置</CardTitle>
|
||||
<CardDescription>查看当前站点配置信息</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SiteSettings />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="about" class="mt-6">
|
||||
<Card class="max-w-3xl">
|
||||
<CardContent class="pt-6">
|
||||
<AboutSettings />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { api } from '@/api'
|
||||
|
||||
const siteName = ref('')
|
||||
const sitePort = ref(0)
|
||||
|
||||
async function loadSiteSettings() {
|
||||
try {
|
||||
const res = await api.settings.getSite()
|
||||
siteName.value = res.site_name || '白虎面板'
|
||||
sitePort.value = res.port
|
||||
} catch {}
|
||||
}
|
||||
|
||||
onMounted(loadSiteSettings)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label>站点名称</Label>
|
||||
<Input v-model="siteName" disabled class="max-w-sm" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>端口</Label>
|
||||
<Input v-model.number="sitePort" type="number" class="w-32" disabled />
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">站点设置需要修改配置文件后重启服务</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,213 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
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 { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import Pagination from '@/components/Pagination.vue'
|
||||
import { Plus, Play, Pencil, Trash2, Search } from 'lucide-vue-next'
|
||||
import { api, type Task } from '@/api'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
const tasks = ref<Task[]>([])
|
||||
const showDialog = ref(false)
|
||||
const editingTask = ref<Partial<Task>>({})
|
||||
const isEdit = ref(false)
|
||||
const showDeleteDialog = ref(false)
|
||||
const deleteTaskId = ref<number | null>(null)
|
||||
|
||||
const filterName = ref('')
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
async function loadTasks() {
|
||||
try {
|
||||
const res = await api.tasks.list({ page: currentPage.value, page_size: pageSize.value, name: filterName.value || undefined })
|
||||
tasks.value = res.data
|
||||
total.value = res.total
|
||||
} catch { toast.error('加载任务失败') }
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(() => {
|
||||
currentPage.value = 1
|
||||
loadTasks()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
currentPage.value = page
|
||||
loadTasks()
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingTask.value = { name: '', command: '', schedule: '0 * * * *', timeout: 30, enabled: true }
|
||||
isEdit.value = false
|
||||
showDialog.value = true
|
||||
}
|
||||
|
||||
function openEdit(task: Task) {
|
||||
editingTask.value = { ...task }
|
||||
isEdit.value = true
|
||||
showDialog.value = true
|
||||
}
|
||||
|
||||
async function saveTask() {
|
||||
try {
|
||||
if (isEdit.value && editingTask.value.id) {
|
||||
await api.tasks.update(editingTask.value.id, editingTask.value)
|
||||
toast.success('任务已更新')
|
||||
} else {
|
||||
await api.tasks.create(editingTask.value)
|
||||
toast.success('任务已创建')
|
||||
}
|
||||
showDialog.value = false
|
||||
loadTasks()
|
||||
} catch { toast.error('保存失败') }
|
||||
}
|
||||
|
||||
function confirmDelete(id: number) {
|
||||
deleteTaskId.value = id
|
||||
showDeleteDialog.value = true
|
||||
}
|
||||
|
||||
async function deleteTask() {
|
||||
if (!deleteTaskId.value) return
|
||||
try {
|
||||
await api.tasks.delete(deleteTaskId.value)
|
||||
toast.success('任务已删除')
|
||||
loadTasks()
|
||||
} catch { toast.error('删除失败') }
|
||||
showDeleteDialog.value = false
|
||||
deleteTaskId.value = null
|
||||
}
|
||||
|
||||
async function runTask(id: number) {
|
||||
try { await api.tasks.execute(id); toast.success('任务已执行') } catch { toast.error('执行失败') }
|
||||
}
|
||||
|
||||
async function toggleTask(task: Task, enabled: boolean) {
|
||||
try {
|
||||
await api.tasks.update(task.id, { name: task.name, command: task.command, schedule: task.schedule, timeout: task.timeout, enabled })
|
||||
toast.success(enabled ? '任务已启用' : '任务已禁用')
|
||||
loadTasks()
|
||||
} catch { toast.error('操作失败') }
|
||||
}
|
||||
|
||||
onMounted(loadTasks)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold tracking-tight">定时任务</h2>
|
||||
<p class="text-muted-foreground">管理和调度自动化任务</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="relative">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input v-model="filterName" placeholder="搜索任务..." class="h-9 pl-9 w-56 text-sm" @input="handleSearch" />
|
||||
</div>
|
||||
<Button @click="openCreate">
|
||||
<Plus class="h-4 w-4 mr-2" /> 新建任务
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border bg-card">
|
||||
<!-- 表头 -->
|
||||
<div class="flex items-center gap-4 px-4 py-2 border-b bg-muted/50 text-sm text-muted-foreground font-medium">
|
||||
<span class="w-12 shrink-0">ID</span>
|
||||
<span class="w-40 shrink-0">名称</span>
|
||||
<span class="flex-1">命令</span>
|
||||
<span class="w-28 shrink-0">定时规则</span>
|
||||
<span class="w-40 shrink-0">上次执行</span>
|
||||
<span class="w-40 shrink-0">下次执行</span>
|
||||
<span class="w-12 shrink-0 text-center">状态</span>
|
||||
<span class="w-28 shrink-0 text-center">操作</span>
|
||||
</div>
|
||||
<!-- 列表 -->
|
||||
<div class="divide-y">
|
||||
<div v-if="tasks.length === 0" class="text-sm text-muted-foreground text-center py-8">
|
||||
暂无任务
|
||||
</div>
|
||||
<div
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
class="flex items-center gap-4 px-4 py-2 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<span class="w-12 shrink-0 text-muted-foreground text-sm">#{{ task.id }}</span>
|
||||
<span class="w-40 font-medium truncate shrink-0 text-sm">{{ task.name }}</span>
|
||||
<code class="flex-1 text-muted-foreground truncate text-xs bg-muted px-2 py-1 rounded">{{ task.command }}</code>
|
||||
<code class="w-28 shrink-0 text-muted-foreground text-xs bg-muted px-2 py-1 rounded">{{ task.schedule }}</code>
|
||||
<span class="w-40 shrink-0 text-muted-foreground text-xs">{{ task.last_run || '-' }}</span>
|
||||
<span class="w-40 shrink-0 text-muted-foreground text-xs">{{ task.next_run || '-' }}</span>
|
||||
<span class="w-12 flex justify-center shrink-0 cursor-pointer" @click="toggleTask(task, !task.enabled)" :title="task.enabled ? '点击禁用' : '点击启用'">
|
||||
<span :class="['w-2 h-2 rounded-full', task.enabled ? 'bg-green-500' : 'bg-gray-400']" />
|
||||
</span>
|
||||
<span class="w-28 shrink-0 flex justify-center gap-1">
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" @click="runTask(task.id)" title="执行">
|
||||
<Play class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" @click="openEdit(task)" title="编辑">
|
||||
<Pencil class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7 text-destructive" @click="confirmDelete(task.id)" title="删除">
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 分页 -->
|
||||
<Pagination :total="total" :page="currentPage" :page-size="pageSize" @update:page="handlePageChange" />
|
||||
</div>
|
||||
|
||||
<Dialog v-model:open="showDialog">
|
||||
<DialogContent class="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ isEdit ? '编辑任务' : '新建任务' }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="space-y-4 py-2">
|
||||
<div class="space-y-2">
|
||||
<Label>任务名称</Label>
|
||||
<Input v-model="editingTask.name" placeholder="任务名称" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>执行命令</Label>
|
||||
<Input v-model="editingTask.command" class="font-mono" placeholder="node script.js" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>定时规则 (Cron)</Label>
|
||||
<Input v-model="editingTask.schedule" class="font-mono" placeholder="0 * * * *" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>超时时间 (分钟)</Label>
|
||||
<Input v-model.number="editingTask.timeout" type="number" placeholder="30" />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="showDialog = false">取消</Button>
|
||||
<Button @click="saveTask">保存</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog v-model:open="showDeleteDialog">
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||
<AlertDialogDescription>确定要删除此任务吗?此操作无法撤销。</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction class="bg-destructive text-white hover:bg-destructive/90" @click="deleteTask">删除</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,208 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { RefreshCw } from 'lucide-vue-next'
|
||||
|
||||
const terminalRef = ref<HTMLDivElement | null>(null)
|
||||
let terminal: Terminal | null = null
|
||||
let fitAddon: FitAddon | null = null
|
||||
let ws: WebSocket | null = null
|
||||
let isPtyMode = false // 是否是 PTY 模式(Unix)
|
||||
let inputBuffer = ''
|
||||
let commandHistory: string[] = []
|
||||
let historyIndex = -1
|
||||
|
||||
function initTerminal() {
|
||||
if (!terminalRef.value || terminal) return
|
||||
|
||||
terminal = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 13,
|
||||
fontFamily: 'Consolas, Monaco, monospace',
|
||||
theme: {
|
||||
background: '#1e1e1e',
|
||||
foreground: '#d4d4d4',
|
||||
cursor: '#d4d4d4',
|
||||
}
|
||||
})
|
||||
|
||||
fitAddon = new FitAddon()
|
||||
terminal.loadAddon(fitAddon)
|
||||
terminal.open(terminalRef.value)
|
||||
fitAddon.fit()
|
||||
terminal.focus()
|
||||
|
||||
connectWebSocket()
|
||||
|
||||
// 清除当前输入行(Windows 模式用)
|
||||
function clearLine() {
|
||||
for (let i = 0; i < inputBuffer.length; i++) {
|
||||
terminal?.write('\b \b')
|
||||
}
|
||||
}
|
||||
|
||||
// 处理用户输入
|
||||
terminal.onData((data) => {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
||||
|
||||
// PTY 模式:直接透传所有输入
|
||||
if (isPtyMode) {
|
||||
ws.send(data)
|
||||
return
|
||||
}
|
||||
|
||||
// Windows 模式:本地处理输入和历史记录
|
||||
// 回车键
|
||||
if (data === '\r') {
|
||||
terminal?.write('\r\n')
|
||||
if (inputBuffer.trim()) {
|
||||
commandHistory.push(inputBuffer)
|
||||
historyIndex = commandHistory.length
|
||||
ws.send(inputBuffer + '\r\n')
|
||||
}
|
||||
inputBuffer = ''
|
||||
}
|
||||
// 上箭头 - 上一条历史
|
||||
else if (data === '\x1b[A') {
|
||||
if (commandHistory.length > 0 && historyIndex > 0) {
|
||||
clearLine()
|
||||
historyIndex--
|
||||
inputBuffer = commandHistory[historyIndex] ?? ''
|
||||
terminal?.write(inputBuffer)
|
||||
}
|
||||
}
|
||||
// 下箭头 - 下一条历史
|
||||
else if (data === '\x1b[B') {
|
||||
clearLine()
|
||||
if (historyIndex < commandHistory.length - 1) {
|
||||
historyIndex++
|
||||
inputBuffer = commandHistory[historyIndex] ?? ''
|
||||
terminal?.write(inputBuffer)
|
||||
} else {
|
||||
historyIndex = commandHistory.length
|
||||
inputBuffer = ''
|
||||
}
|
||||
}
|
||||
// 退格键
|
||||
else if (data === '\x7f' || data === '\b') {
|
||||
if (inputBuffer.length > 0) {
|
||||
inputBuffer = inputBuffer.slice(0, -1)
|
||||
terminal?.write('\b \b')
|
||||
}
|
||||
}
|
||||
// Ctrl+C
|
||||
else if (data === '\x03') {
|
||||
ws.send('\x03')
|
||||
inputBuffer = ''
|
||||
historyIndex = commandHistory.length
|
||||
terminal?.write('^C\r\n')
|
||||
}
|
||||
// 普通字符
|
||||
else if (data >= ' ' || data === '\t') {
|
||||
inputBuffer += data
|
||||
terminal?.write(data)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function connectWebSocket() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const wsUrl = `${protocol}//${window.location.host}/api/terminal/ws`
|
||||
|
||||
ws = new WebSocket(wsUrl)
|
||||
|
||||
ws.onopen = () => {
|
||||
terminal?.writeln('\x1b[32m已连接到终端\x1b[0m')
|
||||
terminal?.writeln('')
|
||||
terminal?.focus()
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
// 检查是否是 PTY 模式标识
|
||||
if (event.data === '__PTY_MODE__') {
|
||||
isPtyMode = true
|
||||
return
|
||||
}
|
||||
if (event.data === '__PIPE_MODE__') {
|
||||
isPtyMode = false
|
||||
return
|
||||
}
|
||||
terminal?.write(event.data)
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
terminal?.writeln('')
|
||||
terminal?.writeln('\x1b[31m连接已断开\x1b[0m')
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
terminal?.writeln('\x1b[31m连接错误\x1b[0m')
|
||||
}
|
||||
}
|
||||
|
||||
function reconnect() {
|
||||
if (ws) {
|
||||
ws.close()
|
||||
}
|
||||
inputBuffer = ''
|
||||
isPtyMode = false
|
||||
terminal?.clear()
|
||||
connectWebSocket()
|
||||
}
|
||||
|
||||
function handleResize() {
|
||||
fitAddon?.fit()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', handleResize)
|
||||
setTimeout(initTerminal, 100)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
ws?.close()
|
||||
terminal?.dispose()
|
||||
terminal = null
|
||||
ws = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-[calc(100vh-100px)]">
|
||||
<div class="flex items-center justify-between p-2 border rounded-t-md bg-[#252526]">
|
||||
<span class="text-xs font-medium text-gray-300">终端</span>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6 text-gray-400 hover:text-white" @click="reconnect" title="重新连接">
|
||||
<RefreshCw class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div ref="terminalRef" class="terminal-container flex-1 border border-t-0 rounded-b-md bg-[#1e1e1e] p-1" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.terminal-container :deep(.xterm-viewport) {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #4a4a4a #1e1e1e;
|
||||
}
|
||||
|
||||
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar) {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar-track) {
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar-thumb) {
|
||||
background: #4a4a4a;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar-thumb:hover) {
|
||||
background: #5a5a5a;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user