feat: Initial commit

This commit is contained in:
engigu
2025-12-20 09:30:16 +08:00
commit 362237241e
189 changed files with 12035 additions and 0 deletions
+84
View File
@@ -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>
+424
View File
@@ -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>
+200
View File
@@ -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>
+216
View File
@@ -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>
+65
View File
@@ -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>
+68
View File
@@ -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>
+376
View File
@@ -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>
+80
View File
@@ -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>
+38
View File
@@ -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>
+73
View File
@@ -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>
+33
View File
@@ -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>
+213
View File
@@ -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>
+208
View File
@@ -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>