feat: support sorting tasks and exit dialogs on ESC key (#148)

This commit is contained in:
duorameng
2026-07-13 08:42:18 +08:00
parent 0983cf1a38
commit ad5939bdb9
6 changed files with 144 additions and 21 deletions
+5 -2
View File
@@ -337,7 +337,10 @@ func (tc *TaskController) GetTasks(c *gin.Context) {
agentID = &agentIDStr agentID = &agentIDStr
} }
tasks, total := tc.taskService.GetTasksWithPagination(p.Page, p.PageSize, name, agentID, tags, taskType) sortBy := c.DefaultQuery("sort_by", "")
order := c.DefaultQuery("order", "")
tasks, total := tc.taskService.GetTasksWithPagination(p.Page, p.PageSize, name, agentID, tags, taskType, sortBy, order)
utils.PaginatedResponse(c, vo.ToTaskVOListFromModels(tasks), total, p) utils.PaginatedResponse(c, vo.ToTaskVOListFromModels(tasks), total, p)
} }
@@ -702,7 +705,7 @@ func (tc *TaskController) BatchDeleteByQuery(c *gin.Context) {
agentID = &agentIDStr agentID = &agentIDStr
} }
tasks, _ := tc.taskService.GetTasksWithPagination(1, 999999, name, agentID, tags, taskType) tasks, _ := tc.taskService.GetTasksWithPagination(1, 999999, name, agentID, tags, taskType, "", "")
if len(tasks) == 0 { if len(tasks) == 0 {
utils.Success(c, gin.H{"count": 0}) utils.Success(c, gin.H{"count": 0})
return return
+14 -2
View File
@@ -111,7 +111,7 @@ func (ts *TaskService) GetTasks() []models.Task {
} }
// GetTasksWithPagination 分页获取任务列表 // GetTasksWithPagination 分页获取任务列表
func (ts *TaskService) GetTasksWithPagination(page, pageSize int, name string, agentID *string, tags string, taskType string) ([]models.Task, int64) { func (ts *TaskService) GetTasksWithPagination(page, pageSize int, name string, agentID *string, tags string, taskType string, sortBy string, order string) ([]models.Task, int64) {
var tasks []models.Task var tasks []models.Task
var total int64 var total int64
@@ -154,8 +154,20 @@ func (ts *TaskService) GetTasksWithPagination(page, pageSize int, name string, a
query = query.Where("agent_id = ?", *agentID) query = query.Where("agent_id = ?", *agentID)
} }
sortColumn := "created_at"
if sortBy != "" {
switch sortBy {
case "name", "next_run", "last_run", "created_at", "enabled":
sortColumn = sortBy
}
}
sortOrder := "DESC"
if strings.ToUpper(order) == "ASC" {
sortOrder = "ASC"
}
query.Count(&total) query.Count(&total)
query.Order("pin_type DESC, created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&tasks) query.Order("pin_type DESC, " + sortColumn + " " + sortOrder).Offset((page - 1) * pageSize).Limit(pageSize).Find(&tasks)
ts.loadTagsAndEnvs(tasks) ts.loadTagsAndEnvs(tasks)
return tasks, total return tasks, total
+3 -1
View File
@@ -140,7 +140,7 @@ export const api = {
request('/auth/register', { method: 'POST', body: JSON.stringify(data) }) request('/auth/register', { method: 'POST', body: JSON.stringify(data) })
}, },
tasks: { tasks: {
list: (params?: { page?: number; page_size?: number; name?: string; agent_id?: string; tags?: string; type?: string }) => { list: (params?: { page?: number; page_size?: number; name?: string; agent_id?: string; tags?: string; type?: string; sort_by?: string; order?: string }) => {
const query = new URLSearchParams() const query = new URLSearchParams()
if (params?.page) query.set('page', String(params.page)) if (params?.page) query.set('page', String(params.page))
if (params?.page_size) query.set('page_size', String(params.page_size)) if (params?.page_size) query.set('page_size', String(params.page_size))
@@ -148,6 +148,8 @@ export const api = {
if (params?.tags) query.set('tags', params.tags) if (params?.tags) query.set('tags', params.tags)
if (params?.agent_id) query.set('agent_id', params.agent_id) if (params?.agent_id) query.set('agent_id', params.agent_id)
if (params?.type) query.set('type', params.type) if (params?.type) query.set('type', params.type)
if (params?.sort_by) query.set('sort_by', params.sort_by)
if (params?.order) query.set('order', params.order)
return request<TaskListResponse>(`/tasks?${query}`) return request<TaskListResponse>(`/tasks?${query}`)
}, },
create: (data: Partial<Task>) => request<Task>('/tasks', { method: 'POST', body: JSON.stringify(data) }), create: (data: Partial<Task>) => request<Task>('/tasks', { method: 'POST', body: JSON.stringify(data) }),
+31 -1
View File
@@ -11,6 +11,7 @@ import {
} from "reka-ui" } from "reka-ui"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import DialogOverlay from "./DialogOverlay.vue" import DialogOverlay from "./DialogOverlay.vue"
import { onMounted, onUnmounted, ref } from "vue"
defineOptions({ defineOptions({
inheritAttrs: false, inheritAttrs: false,
@@ -24,17 +25,46 @@ const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits) const forwarded = useForwardPropsEmits(delegatedProps, emits)
const contentRef = ref<any>(null)
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
const contents = document.querySelectorAll('[data-slot="dialog-content"]')
if (contents.length > 0) {
const topContent = contents[contents.length - 1]
const el = contentRef.value?.$el || contentRef.value
if (el && el === topContent) {
const closeBtn = el.querySelector('[data-slot="dialog-close"]') as HTMLElement
if (closeBtn) {
closeBtn.click()
} else {
emits('escapeKeyDown', event)
}
}
}
}
}
onMounted(() => {
window.addEventListener('keydown', handleKeyDown, true)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeyDown, true)
})
</script> </script>
<template> <template>
<DialogPortal> <DialogPortal>
<DialogOverlay /> <DialogOverlay />
<DialogContent <DialogContent
ref="contentRef"
data-slot="dialog-content" data-slot="dialog-content"
v-bind="{ ...$attrs, ...forwarded }" v-bind="{ ...$attrs, ...forwarded }"
:class=" :class="
cn( cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] max-h-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg overflow-y-auto custom-scrollbar', 'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] max-h-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg overflow-y-auto custom-scrollbar focus:outline-none',
props.class, props.class,
)" )"
> >
+10 -1
View File
@@ -39,19 +39,28 @@ function toggleBodyScroll(lock: boolean) {
} }
} }
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape' && props.open) {
close()
}
}
// 监听打开状态 // 监听打开状态
watch(() => props.open, (val) => { watch(() => props.open, (val) => {
if (val) { if (val) {
isFullscreen.value = false isFullscreen.value = false
toggleBodyScroll(true) toggleBodyScroll(true)
window.addEventListener('keydown', handleKeyDown)
} else { } else {
toggleBodyScroll(false) toggleBodyScroll(false)
window.removeEventListener('keydown', handleKeyDown)
} }
}, { immediate: true }) }, { immediate: true })
// 确保组件卸载时恢复滚动 // 确保组件卸载时恢复滚动并注销事件监听
onUnmounted(() => { onUnmounted(() => {
toggleBodyScroll(false) toggleBodyScroll(false)
window.removeEventListener('keydown', handleKeyDown)
}) })
</script> </script>
+81 -14
View File
@@ -8,7 +8,7 @@ import Pagination from '@/components/Pagination.vue'
import TaskDialog from './TaskDialog.vue' import TaskDialog from './TaskDialog.vue'
import RepoDialog from './RepoDialog.vue' import RepoDialog from './RepoDialog.vue'
import LogViewer from '@/views/history/LogViewer.vue' import LogViewer from '@/views/history/LogViewer.vue'
import { Plus, Play, Pencil, Trash2, Search, ScrollText, GitBranch, Terminal, Server, Monitor, X, Loader2, RefreshCw, Wifi, WifiOff, Zap, ZapOff, Copy, Tag, ChevronDown, Pin, PinOff, MoreHorizontal, CalendarClock, Wrench } from 'lucide-vue-next' import { Plus, Play, Pencil, Trash2, Search, ScrollText, GitBranch, Terminal, Server, Monitor, X, Loader2, RefreshCw, Wifi, WifiOff, Zap, ZapOff, Copy, Tag, ChevronDown, Pin, PinOff, MoreHorizontal, CalendarClock, Wrench, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-vue-next'
import TagInput from '@/components/TagInput.vue' import TagInput from '@/components/TagInput.vue'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
@@ -47,6 +47,28 @@ const showRepoDialog = ref(false)
const editingTask = ref<Partial<Task>>({}) const editingTask = ref<Partial<Task>>({})
const isEdit = ref(false) const isEdit = ref(false)
const sortBy = ref('created_at')
const order = ref('desc')
function toggleSort(field: string) {
if (sortBy.value === field) {
order.value = order.value === 'asc' ? 'desc' : 'asc'
} else {
sortBy.value = field
order.value = 'desc'
}
currentPage.value = 1
loadTasks()
}
function handleSortChange(val: string) {
const parts = val.split(':')
sortBy.value = parts[0] || 'created_at'
order.value = parts[1] || 'desc'
currentPage.value = 1
loadTasks()
}
const showDeleteDialog = ref(false) const showDeleteDialog = ref(false)
const deleteTaskId = ref<string | null>(null) const deleteTaskId = ref<string | null>(null)
const deleteFiles = ref(false) const deleteFiles = ref(false)
@@ -67,7 +89,7 @@ const agentMap = computed(() => {
return map return map
}) })
// 当前筛选 Agent 名称 // 当前筛选 of Agent 名称
const filterAgentName = computed(() => { const filterAgentName = computed(() => {
if (!filterAgentId.value) return '' if (!filterAgentId.value) return ''
const agent = agentMap.value[filterAgentId.value] const agent = agentMap.value[filterAgentId.value]
@@ -97,7 +119,9 @@ async function loadTasks() {
name: filterName.value || undefined, name: filterName.value || undefined,
tags: filterTags.value || undefined, tags: filterTags.value || undefined,
type: filterType.value === 'all' ? undefined : filterType.value, type: filterType.value === 'all' ? undefined : filterType.value,
agent_id: filterAgentId.value || undefined agent_id: filterAgentId.value || undefined,
sort_by: sortBy.value || undefined,
order: order.value || undefined
}) })
tasks.value = res.data tasks.value = res.data
total.value = res.total total.value = res.total
@@ -478,7 +502,9 @@ async function saveView() {
name: filterName.value, name: filterName.value,
tags: filterTags.value, tags: filterTags.value,
agent_id: filterAgentId.value, agent_id: filterAgentId.value,
type: filterType.value type: filterType.value,
sort_by: sortBy.value,
order: order.value
}, },
isDefault: false isDefault: false
} }
@@ -504,6 +530,8 @@ function applyViewWithoutSearch(view: any) {
filterTags.value = view.query.tags || '' filterTags.value = view.query.tags || ''
filterAgentId.value = view.query.agent_id || null filterAgentId.value = view.query.agent_id || null
filterType.value = view.query.type || TASK_TYPE.NORMAL filterType.value = view.query.type || TASK_TYPE.NORMAL
sortBy.value = view.query.sort_by || 'created_at'
order.value = view.query.order || 'desc'
} }
function applyView(view: any) { function applyView(view: any) {
@@ -675,8 +703,8 @@ watch(() => route.query.agent_id, (newVal: any) => {
</div> </div>
<div class="flex items-center gap-2 w-full sm:w-auto sm:justify-end"> <div class="flex items-center gap-2 w-full sm:w-auto sm:justify-end">
<!-- 移动端类型切换 --> <!-- 移动端类型与排序切换 -->
<div class="xl:hidden flex-1 shrink-0"> <div class="xl:hidden flex items-center gap-2 flex-1 shrink-0">
<Select v-model="filterType" @update:model-value="(_v: any) => handleTypeChange()"> <Select v-model="filterType" @update:model-value="(_v: any) => handleTypeChange()">
<SelectTrigger class="h-9 w-full text-sm bg-muted/20 border-muted-foreground/10"> <SelectTrigger class="h-9 w-full text-sm bg-muted/20 border-muted-foreground/10">
<SelectValue /> <SelectValue />
@@ -686,6 +714,21 @@ watch(() => route.query.agent_id, (newVal: any) => {
<SelectItem :value="TASK_TYPE.REPO">仓库同步</SelectItem> <SelectItem :value="TASK_TYPE.REPO">仓库同步</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<Select :model-value="`${sortBy}:${order}`" @update:model-value="(val: any) => handleSortChange(val)">
<SelectTrigger class="h-9 w-full text-sm bg-muted/20 border-muted-foreground/10">
<SelectValue placeholder="排序规则" />
</SelectTrigger>
<SelectContent>
<SelectItem value="created_at:desc">创建时间 (最晚优先)</SelectItem>
<SelectItem value="created_at:asc">创建时间 (最早优先)</SelectItem>
<SelectItem value="name:asc">名称 (A-Z)</SelectItem>
<SelectItem value="name:desc">名称 (Z-A)</SelectItem>
<SelectItem value="next_run:asc">下次执行 (先-后)</SelectItem>
<SelectItem value="next_run:desc">下次执行 (后-先)</SelectItem>
<SelectItem value="enabled:desc">状态 (启用优先)</SelectItem>
<SelectItem value="enabled:asc">状态 (禁用优先)</SelectItem>
</SelectContent>
</Select>
</div> </div>
<div v-if="filterAgentId" <div v-if="filterAgentId"
@@ -742,7 +785,12 @@ watch(() => route.query.agent_id, (newVal: any) => {
<div class="flex items-center gap-4 px-4 py-1.5 border-b bg-muted/20 text-xs text-muted-foreground font-medium"> <div class="flex items-center gap-4 px-4 py-1.5 border-b bg-muted/20 text-xs text-muted-foreground font-medium">
<span class="w-12 shrink-0 pl-1">序号</span> <span class="w-12 shrink-0 pl-1">序号</span>
<span class="w-8 shrink-0 text-center">类型</span> <span class="w-8 shrink-0 text-center">类型</span>
<span class="w-56 shrink-0">名称</span> <span class="w-56 shrink-0 flex items-center gap-1 cursor-pointer select-none hover:text-foreground transition-colors" @click="toggleSort('name')">
名称
<ArrowUpDown v-if="sortBy !== 'name'" class="h-3 w-3 opacity-40 shrink-0" />
<ArrowUp v-else-if="order === 'asc'" class="h-3 w-3 text-primary shrink-0" />
<ArrowDown v-else class="h-3 w-3 text-primary shrink-0" />
</span>
<span class="w-32 shrink-0">执行位置</span> <span class="w-32 shrink-0">执行位置</span>
<span class="flex-1 min-w-0 flex items-center gap-1.5 line-clamp-1"> <span class="flex-1 min-w-0 flex items-center gap-1.5 line-clamp-1">
<GitBranch v-if="filterType === TASK_TYPE.REPO" class="h-3.5 w-3.5 opacity-50" /> <GitBranch v-if="filterType === TASK_TYPE.REPO" class="h-3.5 w-3.5 opacity-50" />
@@ -750,8 +798,18 @@ watch(() => route.query.agent_id, (newVal: any) => {
{{ filterType === TASK_TYPE.REPO ? '仓库地址' : '命令内容' }} {{ filterType === TASK_TYPE.REPO ? '仓库地址' : '命令内容' }}
</span> </span>
<span class="w-28 shrink-0">{{ filterType === TASK_TYPE.REPO ? '同步周期' : '定时规则' }}</span> <span class="w-28 shrink-0">{{ filterType === TASK_TYPE.REPO ? '同步周期' : '定时规则' }}</span>
<span class="w-40 shrink-0">执行时间</span> <span class="w-40 shrink-0 flex items-center gap-1 cursor-pointer select-none hover:text-foreground transition-colors" @click="toggleSort('next_run')">
<span class="w-8 shrink-0 text-center">状态</span> 执行时间
<ArrowUpDown v-if="sortBy !== 'next_run'" class="h-3 w-3 opacity-40 shrink-0" />
<ArrowUp v-else-if="order === 'asc'" class="h-3 w-3 text-primary shrink-0" />
<ArrowDown v-else class="h-3 w-3 text-primary shrink-0" />
</span>
<span class="w-14 shrink-0 text-center flex items-center justify-center gap-1 cursor-pointer select-none hover:text-foreground transition-colors" @click="toggleSort('enabled')">
状态
<ArrowUpDown v-if="sortBy !== 'enabled'" class="h-3 w-3 opacity-40 shrink-0" />
<ArrowUp v-else-if="order === 'asc'" class="h-3 w-3 text-primary shrink-0" />
<ArrowDown v-else class="h-3 w-3 text-primary shrink-0" />
</span>
<span class="w-24 shrink-0 text-center">操作</span> <span class="w-24 shrink-0 text-center">操作</span>
</div> </div>
<!-- 列表 --> <!-- 列表 -->
@@ -798,7 +856,7 @@ watch(() => route.query.agent_id, (newVal: any) => {
<span class="truncate">上: {{ task.last_run || '-' }}</span> <span class="truncate">上: {{ task.last_run || '-' }}</span>
<span class="truncate">下: {{ task.next_run || '-' }}</span> <span class="truncate">下: {{ task.next_run || '-' }}</span>
</div> </div>
<span class="w-8 flex justify-center shrink-0 cursor-pointer group" @click="toggleTask(task, !task.enabled)"> <span class="w-14 flex justify-center shrink-0 cursor-pointer group" @click="toggleTask(task, !task.enabled)">
<div v-if="task.enabled" class="h-6 w-6 rounded-md bg-green-500/10 flex items-center justify-center group-hover:bg-green-500/20"> <div v-if="task.enabled" class="h-6 w-6 rounded-md bg-green-500/10 flex items-center justify-center group-hover:bg-green-500/20">
<Zap class="h-3.5 w-3.5 text-green-500 fill-green-500" /> <Zap class="h-3.5 w-3.5 text-green-500 fill-green-500" />
</div> </div>
@@ -850,14 +908,23 @@ watch(() => route.query.agent_id, (newVal: any) => {
<!-- ========== 2. 中屏布局 (Medium 640px - 1280px) ========== --> <!-- ========== 2. 中屏布局 (Medium 640px - 1280px) ========== -->
<div class="hidden sm:block xl:hidden"> <div class="hidden sm:block xl:hidden">
<!-- 表头 -->
<div class="flex items-center gap-4 px-4 py-1.5 border-b bg-muted/20 text-xs text-muted-foreground font-medium"> <div class="flex items-center gap-4 px-4 py-1.5 border-b bg-muted/20 text-xs text-muted-foreground font-medium">
<span class="w-12 shrink-0 pl-1">序号</span> <span class="w-12 shrink-0 pl-1">序号</span>
<span class="w-48 shrink-0">任务信息</span> <span class="w-48 shrink-0 flex items-center gap-1 cursor-pointer select-none hover:text-foreground transition-colors" @click="toggleSort('name')">
任务信息
<ArrowUpDown v-if="sortBy !== 'name'" class="h-3 w-3 opacity-40 shrink-0" />
<ArrowUp v-else-if="order === 'asc'" class="h-3 w-3 text-primary shrink-0" />
<ArrowDown v-else class="h-3 w-3 text-primary shrink-0" />
</span>
<span class="flex-1 min-w-0"> <span class="flex-1 min-w-0">
{{ filterType === TASK_TYPE.REPO ? '仓库地址' : '命令内容' }} {{ filterType === TASK_TYPE.REPO ? '仓库地址' : '命令内容' }}
</span> </span>
<span class="w-8 shrink-0 text-center">状态</span> <span class="w-14 shrink-0 text-center flex items-center justify-center gap-1 cursor-pointer select-none hover:text-foreground transition-colors" @click="toggleSort('enabled')">
状态
<ArrowUpDown v-if="sortBy !== 'enabled'" class="h-3 w-3 opacity-40 shrink-0" />
<ArrowUp v-else-if="order === 'asc'" class="h-3 w-3 text-primary shrink-0" />
<ArrowDown v-else class="h-3 w-3 text-primary shrink-0" />
</span>
<span class="w-24 shrink-0 text-center">操作</span> <span class="w-24 shrink-0 text-center">操作</span>
</div> </div>
<!-- 列表 --> <!-- 列表 -->
@@ -886,7 +953,7 @@ watch(() => route.query.agent_id, (newVal: any) => {
<code class="flex-1 min-w-0 text-[11px] text-muted-foreground bg-muted/20 px-2 py-1 rounded truncate"> <code class="flex-1 min-w-0 text-[11px] text-muted-foreground bg-muted/20 px-2 py-1 rounded truncate">
{{ task.command }} {{ task.command }}
</code> </code>
<span class="w-8 flex justify-center shrink-0 cursor-pointer group" @click="toggleTask(task, !task.enabled)"> <span class="w-14 flex justify-center shrink-0 cursor-pointer group" @click="toggleTask(task, !task.enabled)">
<div v-if="task.enabled" class="h-6 w-6 rounded-md bg-green-500/5 flex items-center justify-center"> <div v-if="task.enabled" class="h-6 w-6 rounded-md bg-green-500/5 flex items-center justify-center">
<Zap class="h-3.5 w-3.5 text-green-500" /> <Zap class="h-3.5 w-3.5 text-green-500" />
</div> </div>