feat: add delete log

This commit is contained in:
engigu
2026-02-27 09:02:00 +08:00
parent 789a538557
commit d4f1defdeb
7 changed files with 187 additions and 10 deletions
+38
View File
@@ -97,3 +97,41 @@ func (lc *LogController) GetLogDetail(c *gin.Context) {
utils.Success(c, vo.ToTaskLogVO(&log))
}
func (lc *LogController) ClearLogs(c *gin.Context) {
var req struct {
TaskID *int `json:"task_id"`
}
if err := c.ShouldBindJSON(&req); err != nil {
utils.BadRequest(c, err.Error())
return
}
query := database.DB.Model(&models.TaskLog{})
if req.TaskID != nil && *req.TaskID > 0 {
query = query.Where("task_id = ?", *req.TaskID)
}
if err := query.Delete(&models.TaskLog{}).Error; err != nil {
utils.ServerError(c, "清空日志失败")
return
}
utils.SuccessMsg(c, "日志清空成功")
}
func (lc *LogController) DeleteLog(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
utils.BadRequest(c, "无效的日志ID")
return
}
if err := database.DB.Delete(&models.TaskLog{}, id).Error; err != nil {
utils.ServerError(c, "删除日志失败")
return
}
utils.SuccessMsg(c, "日志已删除")
}
+2 -1
View File
@@ -109,6 +109,7 @@ func (tc *TaskController) GetTasks(c *gin.Context) {
agentIDStr := c.DefaultQuery("agent_id", "")
tags := c.DefaultQuery("tags", "")
taskType := c.DefaultQuery("type", "")
var agentID *uint
if agentIDStr != "" {
@@ -118,7 +119,7 @@ func (tc *TaskController) GetTasks(c *gin.Context) {
}
}
tasks, total := tc.taskService.GetTasksWithPagination(p.Page, p.PageSize, name, agentID, tags)
tasks, total := tc.taskService.GetTasksWithPagination(p.Page, p.PageSize, name, agentID, tags, taskType)
utils.PaginatedResponse(c, vo.ToTaskVOListFromModels(tasks), total, p)
}
+2
View File
@@ -172,8 +172,10 @@ func Setup(c *Controllers) *gin.Engine {
logs := authorized.Group("/logs")
{
logs.GET("", c.Log.GetLogs)
logs.POST("/clear", c.Log.ClearLogs)
logs.GET("/ws", c.LogWS.StreamLog)
logs.GET("/:id", c.Log.GetLogDetail)
logs.DELETE("/:id", c.Log.DeleteLog)
}
// 终端模块
+4 -1
View File
@@ -49,7 +49,7 @@ func (ts *TaskService) GetTasks() []models.Task {
}
// GetTasksWithPagination 分页获取任务列表
func (ts *TaskService) GetTasksWithPagination(page, pageSize int, name string, agentID *uint, tags string) ([]models.Task, int64) {
func (ts *TaskService) GetTasksWithPagination(page, pageSize int, name string, agentID *uint, tags string, taskType string) ([]models.Task, int64) {
var tasks []models.Task
var total int64
@@ -60,6 +60,9 @@ func (ts *TaskService) GetTasksWithPagination(page, pageSize int, name string, a
if tags != "" {
query = query.Where("tags LIKE ?", "%"+tags+"%")
}
if taskType != "" {
query = query.Where("type = ?", taskType)
}
if agentID != nil {
query = query.Where("agent_id = ?", *agentID)
}
+5 -2
View File
@@ -58,13 +58,14 @@ export const api = {
request('/auth/register', { method: 'POST', body: JSON.stringify(data) })
},
tasks: {
list: (params?: { page?: number; page_size?: number; name?: string; agent_id?: number; tags?: string }) => {
list: (params?: { page?: number; page_size?: number; name?: string; agent_id?: number; tags?: string; type?: string }) => {
const query = new URLSearchParams()
if (params?.page) query.set('page', String(params.page))
if (params?.page_size) query.set('page_size', String(params.page_size))
if (params?.name) query.set('name', params.name)
if (params?.tags) query.set('tags', params.tags)
if (params?.agent_id) query.set('agent_id', String(params.agent_id))
if (params?.type) query.set('type', params.type)
return request<TaskListResponse>(`/tasks?${query}`)
},
create: (data: Partial<Task>) => request<Task>('/tasks', { method: 'POST', body: JSON.stringify(data) }),
@@ -106,7 +107,9 @@ export const api = {
return request<LogListResponse>(`/logs?${query}`)
},
get: (id: number) => request<LogDetail>(`/logs/${id}`),
detail: (id: number) => request<LogDetail>(`/logs/${id}`)
detail: (id: number) => request<LogDetail>(`/logs/${id}`),
delete: (id: number) => request(`/logs/${id}`, { method: 'DELETE' }),
clear: (taskId?: number) => request('/logs/clear', { method: 'POST', body: JSON.stringify({ task_id: taskId }) })
},
dashboard: {
stats: () => request<Stats>('/stats'),
+116 -6
View File
@@ -8,10 +8,20 @@ import Pagination from '@/components/Pagination.vue'
import LogViewer from './LogViewer.vue'
import {
RefreshCw, X, Search, Maximize2, GitBranch, Terminal,
CheckCircle2, XCircle, AlertCircle, Ban, Clock, Zap as ZapIcon, Check
CheckCircle2, XCircle, AlertCircle, Ban, Clock, Zap as ZapIcon, Check, Trash2
} from 'lucide-vue-next'
import { api, type TaskLog } from '@/api'
import { Badge } from '@/components/ui/badge'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { toast } from 'vue-sonner'
import { useSiteSettings } from '@/composables/useSiteSettings'
import TextOverflow from '@/components/TextOverflow.vue'
@@ -32,6 +42,13 @@ let durationTimer: ReturnType<typeof setInterval> | null = null
// 全屏查看
const showFullscreen = ref(false)
// 清除所有日志弹窗
const showClearDialog = ref(false)
// 删除单条日志弹窗
const showDeleteDialog = ref(false)
const deleteLogId = ref<number | null>(null)
const wsContent = ref('')
const isWsLoading = ref(false)
let logSocket: WebSocket | null = null
@@ -203,6 +220,40 @@ function formatDuration(ms: number): string {
return `${(ms / 60000).toFixed(1)}分钟`
}
async function handleClearLogs() {
try {
await api.logs.clear(filterTaskId.value)
toast.success('相关日志已清空')
showClearDialog.value = false
loadLogs()
} catch (error: any) {
toast.error(error.message || '清空失败')
}
}
function confirmDeleteLog(id: number) {
deleteLogId.value = id
showDeleteDialog.value = true
}
async function handleDeleteLog() {
if (!deleteLogId.value) return
try {
await api.logs.delete(deleteLogId.value)
toast.success('该日志已删除')
// 如果当前选中的是这条日志,关闭详情页
if (selectedLog.value?.id === deleteLogId.value) {
closeDetail()
}
showDeleteDialog.value = false
loadLogs()
} catch (err: any) {
toast.error(err.message || '删除失败')
}
}
function getStatusBadgeClass(status: string) {
switch (status) {
case TASK_STATUS.SUCCESS:
@@ -256,9 +307,12 @@ watch(() => route.query.task_id, (newTaskId) => {
<Input v-model="filterKeyword" placeholder="搜索任务..." class="h-9 pl-9 w-full sm:w-56 text-sm"
@input="handleSearch" />
</div>
<Button variant="outline" size="icon" class="h-9 w-9 shrink-0" @click="loadLogs">
<Button variant="outline" size="icon" class="h-9 w-9 shrink-0" @click="loadLogs" title="刷新">
<RefreshCw class="h-4 w-4" />
</Button>
<Button variant="outline" class="h-9 px-4 shrink-0 text-sm text-destructive hover:bg-destructive/10 hover:text-destructive border-destructive/20" @click="showClearDialog = true">
<Trash2 class="h-4 w-4 sm:mr-2" /> <span class="hidden sm:inline" style="padding-left: 2px;">清空日志</span>
</Button>
</div>
</div>
@@ -273,6 +327,7 @@ watch(() => route.query.task_id, (newTaskId) => {
<span class="flex-1 min-w-0">任务名称</span>
<span class="w-8 shrink-0 text-center">状态</span>
<span class="w-12 text-right shrink-0">耗时</span>
<span class="w-8 text-center shrink-0"></span>
</div>
<!-- 大屏表头 -->
<div
@@ -284,6 +339,7 @@ watch(() => route.query.task_id, (newTaskId) => {
<span class="w-12 shrink-0 text-center">状态</span>
<span class="w-16 text-right shrink-0">耗时</span>
<span v-if="!selectedLog" class="w-40 text-right shrink-0 hidden md:block">执行时间</span>
<span class="w-10 shrink-0 text-center"></span>
</div>
<!-- 列表 -->
<div class="divide-y flex-1">
@@ -291,7 +347,7 @@ watch(() => route.query.task_id, (newTaskId) => {
暂无日志
</div>
<div v-for="log in logs" :key="log.id" :class="[
'cursor-pointer hover:bg-muted/30 transition-colors',
'cursor-pointer hover:bg-muted/30 transition-colors group',
selectedLog?.id === log.id && 'bg-accent/50'
]" @click="selectLog(log)">
<!-- 小屏行 -->
@@ -330,6 +386,11 @@ watch(() => route.query.task_id, (newTaskId) => {
</span>
<span class="w-12 text-right shrink-0 text-muted-foreground text-xs">{{ formatDuration(log.duration)
}}</span>
<span class="w-8 shrink-0 flex justify-center opacity-100">
<Button variant="ghost" size="icon" class="h-6 w-6 text-muted-foreground hover:text-destructive shrink-0" @click.stop="confirmDeleteLog(log.id)" title="删除该日志">
<Trash2 class="h-3.5 w-3.5" />
</Button>
</span>
</div>
<!-- 大屏行 -->
<div class="hidden sm:flex items-center gap-4 px-4 py-2">
@@ -373,6 +434,11 @@ watch(() => route.query.task_id, (newTaskId) => {
<span v-if="!selectedLog"
class="w-40 text-right shrink-0 text-muted-foreground text-xs hidden md:block">{{ log.start_time ||
log.created_at }}</span>
<span class="w-10 shrink-0 flex justify-center opacity-100">
<Button variant="ghost" size="icon" class="h-6 w-6 text-muted-foreground hover:text-destructive shrink-0" @click.stop="confirmDeleteLog(log.id)" title="删除该日志">
<Trash2 class="h-3.5 w-3.5" />
</Button>
</span>
</div>
</div>
</div>
@@ -390,10 +456,18 @@ watch(() => route.query.task_id, (newTaskId) => {
class="h-6 px-2 text-[10px]" :disabled="isStopping" @click="stopTask">
{{ isStopping ? '停止中...' : '停止任务' }}
</Button>
<Button v-else variant="outline" size="sm" class="h-7 px-3 text-xs text-destructive hover:text-destructive hover:bg-destructive/10 border-destructive/30" @click="confirmDeleteLog(selectedLog.id)">
<Trash2 class="h-3.5 w-3.5 mr-1" /> 删除日志
</Button>
</div>
<div class="flex items-center gap-1">
<Button variant="ghost" size="icon" class="h-7 w-7 text-muted-foreground hover:text-destructive" title="删除该日志" @click="confirmDeleteLog(selectedLog.id)">
<Trash2 class="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" class="h-7 w-7" @click="closeDetail" title="关闭">
<X class="h-3.5 w-3.5" />
</Button>
</div>
<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 items-center h-6">
@@ -466,5 +540,41 @@ watch(() => route.query.task_id, (newTaskId) => {
<!-- 全屏查看日志 -->
<LogViewer v-model:open="showFullscreen" :title="`日志输出 - ${selectedLog?.task_name || ''}`"
:content="decompressedOutput" :status="selectedLog?.status" />
<!-- 清空日志确认弹窗 -->
<AlertDialog :open="showClearDialog" @update:open="showClearDialog = $event">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认清空日志?</AlertDialogTitle>
<AlertDialogDescription>
此操作将永久删除{{ filterTaskId ? '当前任务的' : '所有' }}任务历史记录包括控制台输出并且无法撤销
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction @click="handleClearLogs" class="bg-red-500 text-white hover:bg-red-600 dark:bg-red-600 dark:text-white dark:hover:bg-red-700">
清空
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<!-- 单条删除确认弹窗 -->
<AlertDialog :open="showDeleteDialog" @update:open="showDeleteDialog = $event">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除这条日志?</AlertDialogTitle>
<AlertDialogDescription>
此操作将永久删除该次运行记录和日志文件且不可恢复
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction @click="handleDeleteLog" class="bg-red-500 text-white hover:bg-red-600 dark:bg-red-600 dark:text-white dark:hover:bg-red-700">
删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</template>
+20
View File
@@ -7,6 +7,7 @@ import Pagination from '@/components/Pagination.vue'
import TaskDialog from './TaskDialog.vue'
import RepoDialog from './RepoDialog.vue'
import { Plus, Play, Pencil, Trash2, Search, ScrollText, GitBranch, Terminal, Server, Monitor, X, Loader2, Wifi, WifiOff, Zap, ZapOff, Copy, Tag } from 'lucide-vue-next'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { api, type Agent, type Task } from '@/api'
import { toast } from 'vue-sonner'
import { useSiteSettings } from '@/composables/useSiteSettings'
@@ -29,6 +30,7 @@ const deleteTaskId = ref<number | null>(null)
const filterName = ref('')
const filterTags = ref('')
const filterType = ref('all')
const filterAgentId = ref<number | null>(null)
const currentPage = ref(1)
const total = ref(0)
@@ -69,6 +71,7 @@ async function loadTasks() {
page_size: pageSize.value,
name: filterName.value || undefined,
tags: filterTags.value || undefined,
type: filterType.value === 'all' ? undefined : filterType.value,
agent_id: filterAgentId.value || undefined
})
tasks.value = res.data
@@ -90,6 +93,11 @@ function handleSearch() {
}, 300)
}
function handleTypeChange() {
currentPage.value = 1
loadTasks()
}
function handlePageChange(page: number) {
currentPage.value = page
loadTasks()
@@ -228,6 +236,18 @@ watch(() => route.query.agent_id, (newVal) => {
<Input v-model="filterTags" placeholder="搜索标签..." class="h-9 pl-9 w-full sm:w-32 text-sm"
@input="handleSearch" />
</div>
<div class="relative flex-1 sm:flex-none">
<Select v-model="filterType" @update:model-value="handleTypeChange">
<SelectTrigger class="h-9 w-full sm:w-28 text-sm">
<SelectValue placeholder="所有类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">所有类型</SelectItem>
<SelectItem :value="TASK_TYPE.NORMAL">定时任务</SelectItem>
<SelectItem :value="TASK_TYPE.REPO">仓库同步</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="filterAgentId"
class="flex items-center gap-1 px-2 py-1 bg-primary/10 text-primary rounded-md text-sm">
<Server class="h-3.5 w-3.5" />