@@ -456,3 +456,20 @@ func (tc *TaskController) StopTask(c *gin.Context) {
|
|||||||
|
|
||||||
utils.SuccessMsg(c, "停止请求已发送")
|
utils.SuccessMsg(c, "停止请求已发送")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetTags 获取所有任务标签
|
||||||
|
// @Summary 获取所有任务标签
|
||||||
|
// @Description 获取系统中所有任务已使用的唯一标签列表
|
||||||
|
// @Tags 任务管理
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Success 200 {object} utils.Response{data=[]string}
|
||||||
|
// @Router /tasks/tags [get]
|
||||||
|
func (tc *TaskController) GetTags(c *gin.Context) {
|
||||||
|
tags, err := tc.taskService.GetAllTags()
|
||||||
|
if err != nil {
|
||||||
|
utils.ServerError(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, tags)
|
||||||
|
}
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ func registerTaskRoutes(g *gin.RouterGroup, c *Controllers) {
|
|||||||
tasks.POST("/batch-delete", c.Task.BatchDeleteTasks)
|
tasks.POST("/batch-delete", c.Task.BatchDeleteTasks)
|
||||||
tasks.DELETE("/batch-by-query", c.Task.BatchDeleteByQuery)
|
tasks.DELETE("/batch-by-query", c.Task.BatchDeleteByQuery)
|
||||||
tasks.POST("/stop/:logID", c.Task.StopTask)
|
tasks.POST("/stop/:logID", c.Task.StopTask)
|
||||||
|
tasks.GET("/tags", c.Task.GetTags)
|
||||||
}
|
}
|
||||||
|
|
||||||
execution := g.Group("/execute")
|
execution := g.Group("/execute")
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import (
|
|||||||
"github.com/engigu/baihu-panel/internal/database"
|
"github.com/engigu/baihu-panel/internal/database"
|
||||||
"github.com/engigu/baihu-panel/internal/models"
|
"github.com/engigu/baihu-panel/internal/models"
|
||||||
"github.com/engigu/baihu-panel/internal/utils"
|
"github.com/engigu/baihu-panel/internal/utils"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
type TaskService struct{}
|
type TaskService struct {
|
||||||
|
}
|
||||||
|
|
||||||
func NewTaskService() *TaskService {
|
func NewTaskService() *TaskService {
|
||||||
return &TaskService{}
|
return &TaskService{}
|
||||||
@@ -56,6 +58,8 @@ func (ts *TaskService) CreateTask(name, command, schedule string, timeout int, w
|
|||||||
task.NextRun = nil
|
task.NextRun = nil
|
||||||
}
|
}
|
||||||
database.DB.Select("*").Create(task)
|
database.DB.Select("*").Create(task)
|
||||||
|
|
||||||
|
|
||||||
return task
|
return task
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,10 +78,25 @@ func (ts *TaskService) GetTasksWithPagination(page, pageSize int, name string, a
|
|||||||
if name != "" {
|
if name != "" {
|
||||||
query = query.Where("name LIKE ? OR remark LIKE ?", "%"+name+"%", "%"+name+"%")
|
query = query.Where("name LIKE ? OR remark LIKE ?", "%"+name+"%", "%"+name+"%")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 标签筛选 (并集)
|
||||||
if tags != "" {
|
if tags != "" {
|
||||||
query = query.Where("tags LIKE ?", "%"+tags+"%")
|
tagList := strings.Split(tags, ",")
|
||||||
|
var orConditions []string
|
||||||
|
var orValues []interface{}
|
||||||
|
for _, tag := range tagList {
|
||||||
|
tag = strings.TrimSpace(tag)
|
||||||
|
if tag != "" {
|
||||||
|
orConditions = append(orConditions, "tags LIKE ?")
|
||||||
|
orValues = append(orValues, "%"+tag+"%")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(orConditions) > 0 {
|
||||||
|
query = query.Where(strings.Join(orConditions, " OR "), orValues...)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if taskType != "" {
|
|
||||||
|
if taskType != "" && taskType != "all" {
|
||||||
query = query.Where("type = ?", taskType)
|
query = query.Where("type = ?", taskType)
|
||||||
}
|
}
|
||||||
if agentID != nil {
|
if agentID != nil {
|
||||||
@@ -136,6 +155,7 @@ func (ts *TaskService) UpdateTask(id string, name, command, schedule string, tim
|
|||||||
"RetryCount", "RetryInterval", "RandomRange", "Type",
|
"RetryCount", "RetryInterval", "RandomRange", "Type",
|
||||||
"TriggerType", "Config", "SourceID",
|
"TriggerType", "Config", "SourceID",
|
||||||
).Updates(&task)
|
).Updates(&task)
|
||||||
|
|
||||||
return &task
|
return &task
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,3 +174,26 @@ func (ts *TaskService) BatchDeleteTasks(ids []string) int64 {
|
|||||||
result := database.DB.Where("id IN ?", ids).Delete(&models.Task{})
|
result := database.DB.Where("id IN ?", ids).Delete(&models.Task{})
|
||||||
return result.RowsAffected
|
return result.RowsAffected
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetAllTags 获取所有任务标签
|
||||||
|
func (ts *TaskService) GetAllTags() ([]string, error) {
|
||||||
|
var tasks []models.Task
|
||||||
|
database.DB.Select("tags").Where("tags != ?", "").Find(&tasks)
|
||||||
|
|
||||||
|
tagMap := make(map[string]bool)
|
||||||
|
for _, task := range tasks {
|
||||||
|
tags := strings.Split(task.Tags, ",")
|
||||||
|
for _, tag := range tags {
|
||||||
|
tag = strings.TrimSpace(tag)
|
||||||
|
if tag != "" {
|
||||||
|
tagMap[tag] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]string, 0, len(tagMap))
|
||||||
|
for tag := range tagMap {
|
||||||
|
result = append(result, tag)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -81,7 +81,8 @@ export const api = {
|
|||||||
return request<{ count: number }>(`/tasks/batch-by-query?${query.toString()}`, { method: 'DELETE' })
|
return request<{ count: number }>(`/tasks/batch-by-query?${query.toString()}`, { method: 'DELETE' })
|
||||||
},
|
},
|
||||||
execute: (id: string) => request<ExecutionResult>(`/execute/task/${id}`, { method: 'POST' }),
|
execute: (id: string) => request<ExecutionResult>(`/execute/task/${id}`, { method: 'POST' }),
|
||||||
stop: (logID: string) => request(`/tasks/stop/${logID}`, { method: 'POST' })
|
stop: (logID: string) => request(`/tasks/stop/${logID}`, { method: 'POST' }),
|
||||||
|
tags: () => request<string[]>('/tasks/tags')
|
||||||
},
|
},
|
||||||
scripts: {
|
scripts: {
|
||||||
list: () => request<Script[]>('/scripts'),
|
list: () => request<Script[]>('/scripts'),
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, watch, computed, onUnmounted } from 'vue'
|
||||||
|
import { api } from '@/api'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Loader2, Check } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
modelValue: string
|
||||||
|
placeholder?: string
|
||||||
|
icon?: any
|
||||||
|
multiple?: boolean
|
||||||
|
clearOnSelect?: boolean
|
||||||
|
}>(), {
|
||||||
|
multiple: false,
|
||||||
|
clearOnSelect: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'enter'])
|
||||||
|
|
||||||
|
const open = ref(false)
|
||||||
|
const allTags = ref<string[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const inputValue = ref(props.modelValue)
|
||||||
|
const containerRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (newVal) => {
|
||||||
|
inputValue.value = newVal
|
||||||
|
})
|
||||||
|
|
||||||
|
async function fetchTags() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await api.tasks.tags()
|
||||||
|
allTags.value = res || []
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch tags', e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentTags = computed(() => {
|
||||||
|
return (inputValue.value || '').split(',').map(t => t.trim()).filter(Boolean)
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredTags = computed(() => {
|
||||||
|
const parts = (inputValue.value || '').split(',')
|
||||||
|
const query = parts[parts.length - 1].trim().toLowerCase()
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
return allTags.value.slice(0, 10)
|
||||||
|
}
|
||||||
|
return allTags.value.filter(t => t.toLowerCase().includes(query))
|
||||||
|
})
|
||||||
|
|
||||||
|
function selectTag(tag: string) {
|
||||||
|
if (props.multiple) {
|
||||||
|
const tags = [...currentTags.value]
|
||||||
|
const index = tags.indexOf(tag)
|
||||||
|
if (index > -1) {
|
||||||
|
tags.splice(index, 1)
|
||||||
|
} else {
|
||||||
|
tags.push(tag)
|
||||||
|
}
|
||||||
|
// 多选模式下追加逗号,以便 filteredTags 计算 query 为空,从而显示所有候选
|
||||||
|
inputValue.value = tags.length > 0 ? tags.join(',') + ',' : ''
|
||||||
|
emit('update:modelValue', inputValue.value)
|
||||||
|
} else {
|
||||||
|
if (props.clearOnSelect) {
|
||||||
|
inputValue.value = ''
|
||||||
|
emit('update:modelValue', '')
|
||||||
|
} else {
|
||||||
|
inputValue.value = tag
|
||||||
|
emit('update:modelValue', tag)
|
||||||
|
}
|
||||||
|
open.value = false
|
||||||
|
// 即使清空了也发出 enter 事件,让父组件知道选中了一个值(如果需要通过 enter 处理)
|
||||||
|
// 或者我们传递选中的 tag 给 enter
|
||||||
|
emit('enter', tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onInput() {
|
||||||
|
emit('update:modelValue', inputValue.value)
|
||||||
|
if (!open.value && filteredTags.value.length > 0) {
|
||||||
|
open.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEnter() {
|
||||||
|
open.value = false
|
||||||
|
emit('enter')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClickOutside(e: MouseEvent) {
|
||||||
|
if (containerRef.value && !containerRef.value.contains(e.target as Node)) {
|
||||||
|
if (open.value) {
|
||||||
|
open.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchTags()
|
||||||
|
window.addEventListener('mousedown', handleClickOutside)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="containerRef" class="relative w-full">
|
||||||
|
<div class="relative group">
|
||||||
|
<component
|
||||||
|
v-if="icon"
|
||||||
|
:is="icon"
|
||||||
|
class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground group-focus-within:text-primary transition-colors pointer-events-none"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
v-model="inputValue"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:class="[icon ? 'pl-9' : 'pl-3', $attrs.class]"
|
||||||
|
class="cursor-pointer"
|
||||||
|
@input="onInput"
|
||||||
|
@keydown.enter="onEnter"
|
||||||
|
@click="open = true"
|
||||||
|
@focus="open = true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 模拟下拉列表 -->
|
||||||
|
<div
|
||||||
|
v-if="open"
|
||||||
|
class="absolute z-[100] top-full left-0 mt-1 w-full min-w-[200px] bg-popover text-popover-foreground rounded-md border shadow-xl p-1 animate-in fade-in zoom-in-95 duration-100"
|
||||||
|
>
|
||||||
|
<div v-if="loading" class="flex items-center justify-center py-4">
|
||||||
|
<Loader2 class="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="filteredTags.length === 0" class="py-2 px-3 text-[11px] text-muted-foreground">
|
||||||
|
无匹配标签
|
||||||
|
</div>
|
||||||
|
<div v-else class="max-h-[300px] overflow-y-auto space-y-0.5">
|
||||||
|
<button
|
||||||
|
v-for="tag in filteredTags"
|
||||||
|
:key="tag"
|
||||||
|
class="w-full text-left px-3 py-2 text-xs rounded-md hover:bg-primary/10 hover:text-primary transition-colors flex items-center justify-between group/item"
|
||||||
|
@mousedown.prevent="selectTag(tag)"
|
||||||
|
>
|
||||||
|
<span class="truncate font-medium">{{ tag }}</span>
|
||||||
|
<Check v-if="currentTags.includes(tag)" class="h-3.5 w-3.5 text-primary shrink-0" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -11,6 +11,7 @@ import { ScrollArea } from '@/components/ui/scroll-area'
|
|||||||
import DirTreeSelect from '@/components/DirTreeSelect.vue'
|
import DirTreeSelect from '@/components/DirTreeSelect.vue'
|
||||||
import { Plus, ChevronDown, X, Search, Check, ChevronsUpDown, AlertCircle, Terminal, Zap, Loader2, Lock, Variable } from 'lucide-vue-next'
|
import { Plus, ChevronDown, X, Search, Check, ChevronsUpDown, AlertCircle, Terminal, Zap, Loader2, Lock, Variable } from 'lucide-vue-next'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import TagInput from '@/components/TagInput.vue'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { api, type Task, type EnvVar, type Agent, type MiseLanguage } from '@/api'
|
import { api, type Task, type EnvVar, type Agent, type MiseLanguage } from '@/api'
|
||||||
import { PATHS, TRIGGER_TYPE } from '@/constants'
|
import { PATHS, TRIGGER_TYPE } from '@/constants'
|
||||||
@@ -77,8 +78,8 @@ function onAllEnvsChange(val: boolean) {
|
|||||||
allEnvsEnabled.value = val
|
allEnvsEnabled.value = val
|
||||||
}
|
}
|
||||||
|
|
||||||
function addTag() {
|
function addTag(passedTag?: string) {
|
||||||
const val = tagInput.value.trim()
|
const val = (passedTag || tagInput.value).trim()
|
||||||
if (!val) return
|
if (!val) return
|
||||||
const currentTags = form.value.tags ? form.value.tags.split(',').filter(Boolean) : []
|
const currentTags = form.value.tags ? form.value.tags.split(',').filter(Boolean) : []
|
||||||
if (!currentTags.includes(val)) {
|
if (!currentTags.includes(val)) {
|
||||||
@@ -475,17 +476,18 @@ async function save() {
|
|||||||
<div class="grid grid-cols-1 sm:grid-cols-4 items-start gap-3">
|
<div class="grid grid-cols-1 sm:grid-cols-4 items-start gap-3">
|
||||||
<Label class="sm:text-right text-xs text-foreground/70 uppercase tracking-wider font-bold pt-2.5">任务标签</Label>
|
<Label class="sm:text-right text-xs text-foreground/70 uppercase tracking-wider font-bold pt-2.5">任务标签</Label>
|
||||||
<div class="sm:col-span-3 space-y-2">
|
<div class="sm:col-span-3 space-y-2">
|
||||||
<div class="flex gap-2">
|
<div class="flex flex-wrap gap-1.5 p-2 min-h-[42px] bg-muted/20 border border-muted-foreground/15 rounded-md focus-within:border-primary/30 transition-colors">
|
||||||
<div class="relative flex-1">
|
<span v-for="tag in (form.tags ? form.tags.split(',').filter(Boolean) : [])" :key="tag"
|
||||||
<Input v-model="tagInput" placeholder="输入标签按回车..." :class="cn('h-9 bg-muted/20 border-muted-foreground/15 transition-all pr-12', tagInput ? 'text-sm font-medium' : 'text-[11px] font-normal')" @keydown.enter.prevent="addTag" />
|
class="flex items-center gap-1.5 bg-primary/5 text-primary px-2.5 py-1 rounded-full text-[11px] font-medium border border-primary/10 group transition-all hover:bg-primary/10">
|
||||||
<Button type="button" variant="ghost" size="sm" class="absolute right-1 top-1 h-7 px-2 text-xs hover:bg-primary/10 hover:text-primary transition-colors" @click.prevent="addTag">添加</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="form.tags" class="flex flex-wrap gap-1.5 pt-1">
|
|
||||||
<span v-for="tag in form.tags.split(',').filter(Boolean)" :key="tag" class="flex items-center gap-1.5 bg-primary/5 text-primary px-2.5 py-1 rounded-full text-[11px] font-medium border border-primary/10 group transition-all hover:bg-primary/10">
|
|
||||||
{{ tag }}
|
{{ tag }}
|
||||||
<button type="button" class="text-primary/40 hover:text-destructive transition-colors shrink-0" @click.prevent="removeTag(tag)"><X class="h-3 w-3" /></button>
|
<button type="button" class="text-primary/40 hover:text-destructive transition-colors shrink-0" @click.prevent="removeTag(tag)"><X class="h-3 w-3" /></button>
|
||||||
</span>
|
</span>
|
||||||
|
<div class="flex-1 min-w-[100px]">
|
||||||
|
<TagInput v-model="tagInput" placeholder="输入并回车添加标签..."
|
||||||
|
clearOnSelect
|
||||||
|
class="h-6 border-none bg-transparent shadow-none focus-visible:ring-0 px-0 text-xs"
|
||||||
|
@enter="addTag" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+125
-10
@@ -7,8 +7,10 @@ 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 } 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 } from 'lucide-vue-next'
|
||||||
|
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 { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import { api, type Agent, type Task, type TaskLog } from '@/api'
|
import { api, type Agent, type Task, type TaskLog } from '@/api'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
@@ -314,6 +316,76 @@ async function viewLogs(taskId: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 视图管理
|
||||||
|
const taskViews = ref<any[]>([])
|
||||||
|
const newViewName = ref('')
|
||||||
|
const isSavingView = ref(false)
|
||||||
|
|
||||||
|
async function loadViewsFromSettings() {
|
||||||
|
try {
|
||||||
|
const res = await api.settings.getSection('task_qviews')
|
||||||
|
const val = res['task_views']
|
||||||
|
if (val) {
|
||||||
|
taskViews.value = JSON.parse(val)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load views', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveView() {
|
||||||
|
if (!newViewName.value.trim()) {
|
||||||
|
toast.error('请输入视图名称')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const newView = {
|
||||||
|
name: newViewName.value.trim(),
|
||||||
|
query: {
|
||||||
|
name: filterName.value,
|
||||||
|
tags: filterTags.value,
|
||||||
|
agent_id: filterAgentId.value,
|
||||||
|
type: filterType.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedViews = [...taskViews.value, newView]
|
||||||
|
isSavingView.value = true
|
||||||
|
try {
|
||||||
|
await api.settings.setSection('task_qviews', {
|
||||||
|
'task_views': JSON.stringify(updatedViews)
|
||||||
|
})
|
||||||
|
taskViews.value = updatedViews
|
||||||
|
newViewName.value = ''
|
||||||
|
toast.success('视图已保存')
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('保存失败')
|
||||||
|
} finally {
|
||||||
|
isSavingView.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyView(view: any) {
|
||||||
|
filterName.value = view.query.name || ''
|
||||||
|
filterTags.value = view.query.tags || ''
|
||||||
|
filterAgentId.value = view.query.agent_id || null
|
||||||
|
filterType.value = view.query.type || TASK_TYPE.NORMAL
|
||||||
|
handleSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteView(index: number) {
|
||||||
|
const updatedViews = taskViews.value.filter((_, i) => i !== index)
|
||||||
|
try {
|
||||||
|
await api.settings.setSection('task_qviews', {
|
||||||
|
'task_views': JSON.stringify(updatedViews)
|
||||||
|
})
|
||||||
|
taskViews.value = updatedViews
|
||||||
|
toast.success('视图已删除')
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getTaskTypeTitle(type: string) {
|
function getTaskTypeTitle(type: string) {
|
||||||
return type === TASK_TYPE.REPO ? '仓库同步' : '普通任务'
|
return type === TASK_TYPE.REPO ? '仓库同步' : '普通任务'
|
||||||
}
|
}
|
||||||
@@ -329,6 +401,7 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
loadTasks()
|
loadTasks()
|
||||||
|
loadViewsFromSettings()
|
||||||
})
|
})
|
||||||
|
|
||||||
// 监听路由参数变化
|
// 监听路由参数变化
|
||||||
@@ -343,23 +416,65 @@ watch(() => route.query.agent_id, (newVal: any) => {
|
|||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div class="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
|
<div class="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
|
||||||
<div class="flex flex-col shrink-0">
|
<div class="flex flex-col shrink-0">
|
||||||
<h2 class="text-xl sm:text-2xl font-bold tracking-tight">定时任务</h2>
|
<Popover>
|
||||||
<p class="text-muted-foreground text-sm">管理和调度自动化执行任务</p>
|
<PopoverTrigger as-child>
|
||||||
|
<div class="flex items-center gap-2 cursor-pointer group w-fit">
|
||||||
|
<h2 class="text-xl sm:text-2xl font-bold tracking-tight">{{ filterType === TASK_TYPE.REPO ? '仓库同步' : '定时任务' }}</h2>
|
||||||
|
<div class="flex items-center gap-1 px-1.5 py-0.5 rounded-md bg-muted/50 group-hover:bg-primary/10 transition-colors border border-transparent group-hover:border-primary/20">
|
||||||
|
<span class="text-[10px] font-bold text-muted-foreground group-hover:text-primary uppercase tracking-wider">视图</span>
|
||||||
|
<ChevronDown class="h-3.5 w-3.5 text-muted-foreground group-hover:text-primary transition-colors" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent class="w-64 p-3 shadow-xl border-muted-foreground/10" align="start" :side-offset="8">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between mb-2 px-1">
|
||||||
|
<h4 class="text-sm font-semibold">我的视图</h4>
|
||||||
|
</div>
|
||||||
|
<div v-if="taskViews.length === 0" class="text-xs text-muted-foreground px-1 py-4 text-center border-2 border-dashed rounded-md bg-muted/20">
|
||||||
|
暂无保存的视图
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2 pr-1 max-h-[200px] overflow-y-auto custom-scrollbar">
|
||||||
|
<div v-for="(view, index) in taskViews" :key="index"
|
||||||
|
class="flex items-center gap-1.5 pl-2.5 pr-1.5 py-1 bg-primary/5 text-primary rounded-full text-[12px] font-medium border border-primary/10 hover:bg-primary/10 transition-all cursor-pointer group"
|
||||||
|
@click="applyView(view)">
|
||||||
|
<span class="max-w-[120px] truncate">{{ view.name }}</span>
|
||||||
|
<button type="button" class="p-0.5 rounded-full hover:bg-destructive/10 hover:text-destructive transition-colors"
|
||||||
|
@click.stop="deleteView(index)">
|
||||||
|
<X class="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pt-3 border-t space-y-2.5">
|
||||||
|
<h4 class="text-xs font-semibold px-1 text-muted-foreground uppercase tracking-wider">保存当前过滤为新视图</h4>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Input v-model="newViewName" placeholder="视图名称..." class="h-9 text-xs bg-muted/30 focus:bg-background" @keydown.enter="saveView" />
|
||||||
|
<Button size="sm" class="h-9 px-3" @click="saveView" :disabled="isSavingView">
|
||||||
|
<Plus v-if="!isSavingView" class="h-4 w-4" />
|
||||||
|
<Loader2 v-else class="h-4 w-4 animate-spin" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
<p class="text-muted-foreground text-xs mt-0.5 ml-0.5">管理和调度自动化执行任务</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-row items-center flex-wrap gap-2 w-full lg:w-auto lg:ml-auto lg:justify-end">
|
<div class="flex flex-row items-center flex-wrap gap-2 w-full lg:w-auto lg:ml-auto lg:justify-end">
|
||||||
<!-- 搜索与标签 -->
|
<!-- 搜索与标签 -->
|
||||||
<div class="flex flex-row items-center gap-2 flex-1 sm:flex-1 lg:flex-none lg:w-auto text-sm">
|
<div class="flex flex-row items-center gap-2 w-full sm:flex-1 lg:flex-none lg:w-auto text-sm">
|
||||||
<div class="relative flex-1 sm:flex-1 lg:max-w-[240px] group">
|
<div class="relative flex-1 lg:flex-none lg:w-[240px] group">
|
||||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground group-focus-within:text-primary transition-colors" />
|
<Search class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground group-focus-within:text-primary transition-colors" />
|
||||||
<Input v-model="filterName" placeholder="搜索任务..." class="h-9 pl-9 w-full bg-muted/20 border-muted-foreground/10 focus:bg-background text-sm"
|
<Input v-model="filterName" placeholder="搜索任务..." class="h-9 pl-9 w-full bg-muted/20 border-muted-foreground/10 focus:bg-background text-sm"
|
||||||
@input="handleSearch" />
|
@input="handleSearch" />
|
||||||
</div>
|
</div>
|
||||||
<div class="relative flex-1 sm:flex-1 lg:max-w-[180px] group">
|
<TagInput v-model="filterTags" placeholder="搜索标签..." :icon="Tag" multiple
|
||||||
<Tag class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground group-focus-within:text-primary transition-colors" />
|
class="h-9 flex-1 lg:flex-none lg:w-[180px] bg-muted/20 border-muted-foreground/10 focus:bg-background text-sm"
|
||||||
<Input v-model="filterTags" placeholder="搜索标签..." class="h-9 pl-9 w-full bg-muted/20 border-muted-foreground/10 focus:bg-background text-sm"
|
@enter="handleSearch" @update:modelValue="handleSearch" />
|
||||||
@input="handleSearch" />
|
|
||||||
</div>
|
|
||||||
</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">
|
||||||
|
|||||||
Reference in New Issue
Block a user