feat: add task pin #89

This commit is contained in:
duorameng
2026-04-29 18:02:43 +08:00
parent b4709ef873
commit 685d79b54e
10 changed files with 186 additions and 65 deletions
+4
View File
@@ -133,6 +133,10 @@ const (
TaskTypeNormal = "task" TaskTypeNormal = "task"
TaskTypeRepo = "repo" TaskTypeRepo = "repo"
// 任务置顶类型
PinTypeNone = "none"
PinTypeTop = "top"
// 触发类型 // 触发类型
TriggerTypeCron = "cron" TriggerTypeCron = "cron"
TriggerTypeBaihuStartup = "baihu_startup" TriggerTypeBaihuStartup = "baihu_startup"
+5 -3
View File
@@ -73,6 +73,7 @@ func (tc *TaskController) CreateTask(c *gin.Context) {
RetryCount int `json:"retry_count"` RetryCount int `json:"retry_count"`
RetryInterval int `json:"retry_interval"` RetryInterval int `json:"retry_interval"`
RandomRange int `json:"random_range"` RandomRange int `json:"random_range"`
PinType string `json:"pin_type"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
@@ -116,12 +117,12 @@ func (tc *TaskController) CreateTask(c *gin.Context) {
if sourceID != "" { if sourceID != "" {
task = tc.taskService.GetTaskBySourceID(sourceID) task = tc.taskService.GetTaskBySourceID(sourceID)
if task != nil { if task != nil {
task = tc.taskService.UpdateTask(task.ID, req.Name, req.Command, req.Schedule, req.Timeout, workDir, req.CleanConfig, req.Envs, true, req.Type, req.Config, req.AgentID, req.Languages, req.TriggerType, req.Tags, req.RetryCount, req.RetryInterval, req.RandomRange, sourceID) task = tc.taskService.UpdateTask(task.ID, req.Name, req.Command, req.Schedule, req.Timeout, workDir, req.CleanConfig, req.Envs, true, req.Type, req.Config, req.AgentID, req.Languages, req.TriggerType, req.Tags, req.RetryCount, req.RetryInterval, req.RandomRange, sourceID, req.PinType)
} }
} }
if task == nil { if task == nil {
task = tc.taskService.CreateTask(req.Name, req.Command, req.Schedule, req.Timeout, workDir, req.CleanConfig, req.Envs, req.Type, req.Config, req.AgentID, req.Languages, req.TriggerType, req.Tags, req.RetryCount, req.RetryInterval, req.RandomRange, sourceID) task = tc.taskService.CreateTask(req.Name, req.Command, req.Schedule, req.Timeout, workDir, req.CleanConfig, req.Envs, req.Type, req.Config, req.AgentID, req.Languages, req.TriggerType, req.Tags, req.RetryCount, req.RetryInterval, req.RandomRange, sourceID, req.PinType)
} }
// 如果是 Agent 任务,通知 Agent;否则添加到本地 cron // 如果是 Agent 任务,通知 Agent;否则添加到本地 cron
@@ -237,6 +238,7 @@ func (tc *TaskController) UpdateTask(c *gin.Context) {
RetryCount int `json:"retry_count"` RetryCount int `json:"retry_count"`
RetryInterval int `json:"retry_interval"` RetryInterval int `json:"retry_interval"`
RandomRange int `json:"random_range"` RandomRange int `json:"random_range"`
PinType string `json:"pin_type"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
@@ -270,7 +272,7 @@ func (tc *TaskController) UpdateTask(c *gin.Context) {
sourceID = oldTask.SourceID sourceID = oldTask.SourceID
} }
task := tc.taskService.UpdateTask(id, req.Name, req.Command, req.Schedule, req.Timeout, workDir, req.CleanConfig, req.Envs, req.Enabled, req.Type, req.Config, req.AgentID, req.Languages, req.TriggerType, req.Tags, req.RetryCount, req.RetryInterval, req.RandomRange, sourceID) task := tc.taskService.UpdateTask(id, req.Name, req.Command, req.Schedule, req.Timeout, workDir, req.CleanConfig, req.Envs, req.Enabled, req.Type, req.Config, req.AgentID, req.Languages, req.TriggerType, req.Tags, req.RetryCount, req.RetryInterval, req.RandomRange, sourceID, req.PinType)
if task == nil { if task == nil {
utils.NotFound(c, "任务不存在") utils.NotFound(c, "任务不存在")
return return
+1
View File
@@ -71,6 +71,7 @@ type Task struct {
ID string `json:"id" gorm:"primaryKey;size:20"` ID string `json:"id" gorm:"primaryKey;size:20"`
Name string `json:"name" gorm:"size:255;not null"` Name string `json:"name" gorm:"size:255;not null"`
Remark string `json:"remark" gorm:"size:255;default:''"` Remark string `json:"remark" gorm:"size:255;default:''"`
PinType string `json:"pin_type" gorm:"size:20;default:none;index"` // 置顶类型: constant.PinTypeNone, constant.PinTypeTop
Command BigText `json:"command"` // 普通任务的命令 Command BigText `json:"command"` // 普通任务的命令
Tags string `json:"tags" gorm:"size:255;default:''"` // 标签,逗号分隔 Tags string `json:"tags" gorm:"size:255;default:''"` // 标签,逗号分隔
Type string `json:"type" gorm:"size:20;default:'task'"` // 任务类型: constant.TaskTypeNormal, constant.TaskTypeRepo Type string `json:"type" gorm:"size:20;default:'task'"` // 任务类型: constant.TaskTypeNormal, constant.TaskTypeRepo
+2
View File
@@ -27,6 +27,7 @@ type TaskVO struct {
RetryCount int `json:"retry_count"` RetryCount int `json:"retry_count"`
RetryInterval int `json:"retry_interval"` RetryInterval int `json:"retry_interval"`
RandomRange int `json:"random_range"` RandomRange int `json:"random_range"`
PinType string `json:"pin_type"`
LastRun *models.LocalTime `json:"last_run"` LastRun *models.LocalTime `json:"last_run"`
NextRun *models.LocalTime `json:"next_run"` NextRun *models.LocalTime `json:"next_run"`
CreatedAt models.LocalTime `json:"created_at"` CreatedAt models.LocalTime `json:"created_at"`
@@ -58,6 +59,7 @@ func ToTaskVO(task *models.Task) *TaskVO {
RetryCount: task.RetryCount, RetryCount: task.RetryCount,
RetryInterval: task.RetryInterval, RetryInterval: task.RetryInterval,
RandomRange: task.RandomRange, RandomRange: task.RandomRange,
PinType: task.PinType,
LastRun: task.LastRun, LastRun: task.LastRun,
NextRun: task.NextRun, NextRun: task.NextRun,
CreatedAt: task.CreatedAt, CreatedAt: task.CreatedAt,
+9 -4
View File
@@ -24,17 +24,21 @@ func (ts *TaskService) GetTaskBySourceID(sourceID string) *models.Task {
return &task return &task
} }
func (ts *TaskService) CreateTask(name, command, schedule string, timeout int, workDir, cleanConfig, envs, taskType, config string, agentID *string, languages models.TaskLanguages, triggerType string, tags string, retryCount int, retryInterval int, randomRange int, sourceID string) *models.Task { func (ts *TaskService) CreateTask(name, command, schedule string, timeout int, workDir, cleanConfig, envs, taskType, config string, agentID *string, languages models.TaskLanguages, triggerType string, tags string, retryCount int, retryInterval int, randomRange int, sourceID string, pinType string) *models.Task {
if taskType == "" { if taskType == "" {
taskType = "task" taskType = "task"
} }
if triggerType == "" { if triggerType == "" {
triggerType = constant.TriggerTypeCron triggerType = constant.TriggerTypeCron
} }
if pinType == "" {
pinType = constant.PinTypeNone
}
task := &models.Task{ task := &models.Task{
ID: utils.GenerateID(), ID: utils.GenerateID(),
Name: name, Name: name,
Command: models.BigText(command), Command: models.BigText(command),
PinType: pinType,
Tags: tags, Tags: tags,
Type: taskType, Type: taskType,
TriggerType: triggerType, TriggerType: triggerType,
@@ -104,7 +108,7 @@ func (ts *TaskService) GetTasksWithPagination(page, pageSize int, name string, a
} }
query.Count(&total) query.Count(&total)
query.Order("id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&tasks) query.Order("pin_type DESC, updated_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&tasks)
return tasks, total return tasks, total
} }
@@ -118,7 +122,7 @@ func (ts *TaskService) GetTaskByID(id string) *models.Task {
return &task return &task
} }
func (ts *TaskService) UpdateTask(id string, name, command, schedule string, timeout int, workDir, cleanConfig, envs string, enabled bool, taskType, config string, agentID *string, languages models.TaskLanguages, triggerType string, tags string, retryCount int, retryInterval int, randomRange int, sourceID string) *models.Task { func (ts *TaskService) UpdateTask(id string, name, command, schedule string, timeout int, workDir, cleanConfig, envs string, enabled bool, taskType, config string, agentID *string, languages models.TaskLanguages, triggerType string, tags string, retryCount int, retryInterval int, randomRange int, sourceID string, pinType string) *models.Task {
var task models.Task var task models.Task
res := database.DB.Where("id = ?", id).Limit(1).Find(&task) res := database.DB.Where("id = ?", id).Limit(1).Find(&task)
if res.Error != nil || res.RowsAffected == 0 { if res.Error != nil || res.RowsAffected == 0 {
@@ -126,6 +130,7 @@ func (ts *TaskService) UpdateTask(id string, name, command, schedule string, tim
} }
task.Name = name task.Name = name
task.Command = models.BigText(command) task.Command = models.BigText(command)
task.PinType = pinType
task.Tags = tags task.Tags = tags
task.Schedule = schedule task.Schedule = schedule
task.Timeout = timeout task.Timeout = timeout
@@ -153,7 +158,7 @@ func (ts *TaskService) UpdateTask(id string, name, command, schedule string, tim
"Name", "Command", "Tags", "Schedule", "Timeout", "WorkDir", "Name", "Command", "Tags", "Schedule", "Timeout", "WorkDir",
"CleanConfig", "Envs", "Enabled", "AgentID", "Languages", "CleanConfig", "Envs", "Enabled", "AgentID", "Languages",
"RetryCount", "RetryInterval", "RandomRange", "Type", "RetryCount", "RetryInterval", "RandomRange", "Type",
"TriggerType", "Config", "SourceID", "TriggerType", "Config", "SourceID", "PinType",
).Updates(&task) ).Updates(&task)
return &task return &task
+4 -3
View File
@@ -49,9 +49,10 @@ func CheckWSOrigin(r *http.Request) bool {
} }
} }
// 3. 兜底策略:如果是开发环境常见的 localhost/127.0.0.1,且端口不一致的情况, // 3. 允许来自 localhost127.0.0.1 的请求 (方便本地开发和同机部署)
// 如果用户没有配置允许列表,我们在非 Release 模式下可以考虑放行, if strings.HasPrefix(u.Host, "localhost") || strings.HasPrefix(u.Host, "127.0.0.1") {
// 但为了安全,默认应严格限制。建议开发时通过 BH_ALLOWED_ORIGINS=localhost:5173 显式开启。 return true
}
return false return false
} }
+1
View File
@@ -356,6 +356,7 @@ export interface Task {
retry_count: number retry_count: number
retry_interval: number retry_interval: number
random_range: number random_range: number
pin_type: 'none' | 'top'
languages: { name: string; version: string }[] languages: { name: string; version: string }[]
agent_id: string | null agent_id: string | null
enabled: boolean enabled: boolean
+1
View File
@@ -291,6 +291,7 @@ watch(() => props.open, async (val: boolean) => {
retry_interval: props.task?.retry_interval ?? 0, retry_interval: props.task?.retry_interval ?? 0,
random_range: props.task?.random_range ?? 0, random_range: props.task?.random_range ?? 0,
timeout: props.task?.timeout ?? 30, timeout: props.task?.timeout ?? 30,
pin_type: props.task?.pin_type ?? 'none',
...props.task ...props.task
} }
// 解析清理配置 // 解析清理配置
+1
View File
@@ -239,6 +239,7 @@ watch(() => props.open, async (val: boolean) => {
retry_interval: props.task?.retry_interval ?? 0, retry_interval: props.task?.retry_interval ?? 0,
random_range: props.task?.random_range ?? 0, random_range: props.task?.random_range ?? 0,
timeout: props.task?.timeout ?? 30, timeout: props.task?.timeout ?? 30,
pin_type: props.task?.pin_type ?? 'none',
...props.task ...props.task
} }
// 解析清理配置 // 解析清理配置
+158 -55
View File
@@ -7,11 +7,18 @@ 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 } 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 } 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'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu'
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'
import { useSiteSettings } from '@/composables/useSiteSettings' import { useSiteSettings } from '@/composables/useSiteSettings'
@@ -223,6 +230,15 @@ async function toggleTask(task: Task, enabled: boolean) {
} catch { toast.error('操作失败') } } catch { toast.error('操作失败') }
} }
async function togglePin(task: Task) {
const newType = task.pin_type === 'top' ? 'none' : 'top'
try {
await api.tasks.update(task.id, { ...task, pin_type: newType })
toast.success(newType === 'top' ? '任务已置顶' : '已取消置顶')
loadTasks()
} catch { toast.error('置顶操作失败') }
}
const showLogViewer = ref(false) const showLogViewer = ref(false)
const selectedLog = ref<TaskLog | null>(null) const selectedLog = ref<TaskLog | null>(null)
const logContent = ref('') const logContent = ref('')
@@ -414,7 +430,7 @@ watch(() => route.query.agent_id, (newVal: any) => {
<template> <template>
<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 xl:flex-row xl:items-center justify-between gap-4">
<div class="flex flex-col shrink-0"> <div class="flex flex-col shrink-0">
<Popover> <Popover>
<PopoverTrigger as-child> <PopoverTrigger as-child>
@@ -464,22 +480,22 @@ watch(() => route.query.agent_id, (newVal: any) => {
<p class="text-muted-foreground text-xs mt-0.5 ml-0.5">管理和调度自动化执行任务</p> <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 xl:w-auto xl:ml-auto xl:justify-end">
<!-- 搜索与标签 --> <!-- 搜索与标签 -->
<div class="flex flex-row items-center gap-2 w-full 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 xl:flex-none xl:w-auto text-sm">
<div class="relative flex-1 lg:flex-none lg:w-[240px] group"> <div class="relative flex-1 xl:flex-none xl: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>
<TagInput v-model="filterTags" placeholder="搜索标签..." :icon="Tag" multiple <TagInput v-model="filterTags" placeholder="搜索标签..." :icon="Tag" multiple
class="h-9 flex-1 lg:flex-none lg:w-[180px] bg-muted/20 border-muted-foreground/10 focus:bg-background text-sm" class="h-9 flex-1 xl:flex-none xl:w-[180px] bg-muted/20 border-muted-foreground/10 focus:bg-background text-sm"
@enter="handleSearch" @update:modelValue="handleSearch" /> @enter="handleSearch" @update:modelValue="handleSearch" />
</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="lg:hidden flex-1 shrink-0"> <div class="xl:hidden 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 />
@@ -503,18 +519,18 @@ watch(() => route.query.agent_id, (newVal: any) => {
</Button> </Button>
<div class="flex items-center gap-2 shrink-0 ml-auto sm:ml-0"> <div class="flex items-center gap-2 shrink-0 ml-auto sm:ml-0">
<Button variant="outline" class="shrink-0 px-2 lg:px-3 h-9 shadow-sm text-destructive border-destructive/20 hover:bg-destructive/10" @click="confirmBatchDelete" title="批量删除"> <Button variant="outline" class="shrink-0 px-2 xl:px-3 h-9 shadow-sm text-destructive border-destructive/20 hover:bg-destructive/10" @click="confirmBatchDelete" title="批量删除">
<Trash2 class="h-4 w-4 lg:mr-2" /> <span class="hidden lg:inline">批量删除</span> <Trash2 class="h-4 w-4 xl:mr-2" /> <span class="hidden xl:inline">批量删除</span>
</Button> </Button>
<Button v-if="filterType === TASK_TYPE.NORMAL" @click="openCreate" class="shrink-0 px-2 lg:px-3 h-9 shadow-sm font-medium" title="新建任务"> <Button v-if="filterType === TASK_TYPE.NORMAL" @click="openCreate" class="shrink-0 px-2 xl:px-3 h-9 shadow-sm font-medium" title="新建任务">
<Plus class="h-4 w-4 lg:mr-2" /> <span class="hidden lg:inline">新建任务</span> <Plus class="h-4 w-4 xl:mr-2" /> <span class="hidden xl:inline">新建任务</span>
</Button> </Button>
<Button v-else-if="filterType === TASK_TYPE.REPO" @click="openCreateRepo" class="shrink-0 px-2 lg:px-3 h-9 shadow-sm font-medium" title="同步仓库"> <Button v-else-if="filterType === TASK_TYPE.REPO" @click="openCreateRepo" class="shrink-0 px-2 xl:px-3 h-9 shadow-sm font-medium" title="同步仓库">
<GitBranch class="h-4 w-4 lg:mr-2" /> <span class="hidden lg:inline">同步仓库</span> <GitBranch class="h-4 w-4 xl:mr-2" /> <span class="hidden xl:inline">同步仓库</span>
</Button> </Button>
<!-- 桌面端类型切换 --> <!-- 桌面端类型切换 -->
<Tabs :model-value="filterType" @update:model-value="(v: string | number) => { filterType = String(v); handleTypeChange() }" class="shrink-0 hidden lg:block"> <Tabs :model-value="filterType" @update:model-value="(v: string | number) => { filterType = String(v); handleTypeChange() }" class="shrink-0 hidden xl:block">
<TabsList class="h-9 p-1 bg-muted/30 border"> <TabsList class="h-9 p-1 bg-muted/30 border">
<TabsTrigger :value="TASK_TYPE.NORMAL" class="px-4 h-7 text-sm">定时任务</TabsTrigger> <TabsTrigger :value="TASK_TYPE.NORMAL" class="px-4 h-7 text-sm">定时任务</TabsTrigger>
<TabsTrigger :value="TASK_TYPE.REPO" class="px-4 h-7 text-sm">仓库同步</TabsTrigger> <TabsTrigger :value="TASK_TYPE.REPO" class="px-4 h-7 text-sm">仓库同步</TabsTrigger>
@@ -524,7 +540,7 @@ watch(() => route.query.agent_id, (newVal: any) => {
</div> </div>
<!-- 移动端/平板 agent 过滤标签 --> <!-- 移动端/平板 agent 过滤标签 -->
<div v-if="filterAgentId" <div v-if="filterAgentId"
class="flex lg:hidden items-center gap-1 px-2 py-1 bg-primary/10 text-primary rounded-md text-sm w-fit mt-1"> class="flex xl:hidden items-center gap-1 px-2 py-1 bg-primary/10 text-primary rounded-md text-sm w-fit mt-1">
<Server class="h-3.5 w-3.5" /> <Server class="h-3.5 w-3.5" />
<span>{{ filterAgentName }}</span> <span>{{ filterAgentName }}</span>
<X class="h-3.5 w-3.5 cursor-pointer hover:text-destructive" @click="clearAgentFilter" /> <X class="h-3.5 w-3.5 cursor-pointer hover:text-destructive" @click="clearAgentFilter" />
@@ -533,8 +549,8 @@ watch(() => route.query.agent_id, (newVal: any) => {
</div> </div>
<div class="rounded-lg border bg-card overflow-hidden"> <div class="rounded-lg border bg-card overflow-hidden">
<!-- ========== 1. 大屏布局 (Large >= 1024px) - 用户调好 ========== --> <!-- ========== 1. 大屏布局 (Large >= 1280px) ========== -->
<div class="hidden lg:block"> <div class="hidden xl:block">
<!-- 表头 --> <!-- 表头 -->
<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>
@@ -542,12 +558,14 @@ watch(() => route.query.agent_id, (newVal: any) => {
<span class="w-56 shrink-0">名称</span> <span class="w-56 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">
<Terminal 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" />
<Terminal v-else class="h-3.5 w-3.5 opacity-50" />
{{ filterType === TASK_TYPE.REPO ? '仓库地址' : '命令内容' }}
</span> </span>
<span class="w-28 shrink-0">定时规则</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">执行时间</span>
<span class="w-8 shrink-0 text-center">状态</span> <span class="w-8 shrink-0 text-center">状态</span>
<span class="w-36 shrink-0 text-center">操作</span> <span class="w-24 shrink-0 text-center">操作</span>
</div> </div>
<!-- 列表 --> <!-- 列表 -->
<div class="divide-y text-sm"> <div class="divide-y text-sm">
@@ -555,11 +573,17 @@ watch(() => route.query.agent_id, (newVal: any) => {
class="flex items-center gap-4 px-4 py-1.5 hover:bg-muted/30 transition-colors"> class="flex items-center gap-4 px-4 py-1.5 hover:bg-muted/30 transition-colors">
<div class="w-12 shrink-0 pl-1 text-muted-foreground tabular-nums">#{{ total - (currentPage - 1) * pageSize - index }}</div> <div class="w-12 shrink-0 pl-1 text-muted-foreground tabular-nums">#{{ total - (currentPage - 1) * pageSize - index }}</div>
<span class="w-8 shrink-0 flex justify-center" :title="getTaskTypeTitle(task.type || 'task')"> <span class="w-8 shrink-0 flex justify-center" :title="getTaskTypeTitle(task.type || 'task')">
<GitBranch v-if="task.type === TASK_TYPE.REPO" class="h-4 w-4 text-primary" /> <div class="relative">
<Terminal v-else class="h-4 w-4 text-primary" /> <GitBranch v-if="task.type === TASK_TYPE.REPO" class="h-4 w-4 text-primary" />
<Terminal v-else class="h-4 w-4 text-primary" />
<div v-if="task.pin_type === 'top'" class="absolute -top-1 -right-1 h-2 w-2 bg-primary rounded-full border-2 border-background" />
</div>
</span> </span>
<div class="w-56 shrink-0 flex flex-col justify-center gap-0.5 overflow-hidden"> <div class="w-56 shrink-0 flex flex-col justify-center gap-0.5 overflow-hidden">
<span class="font-medium truncate cursor-help" :title="task.name">{{ task.name }}</span> <div class="flex items-center gap-1.5 overflow-hidden">
<span class="font-medium truncate cursor-help" :title="task.name">{{ task.name }}</span>
<Pin v-if="task.pin_type === 'top'" class="h-3 w-3 text-primary fill-primary shrink-0 rotate-45" />
</div>
<div v-if="task.tags" class="flex items-center gap-1 overflow-hidden"> <div v-if="task.tags" class="flex items-center gap-1 overflow-hidden">
<span v-for="tag in task.tags.split(',').filter(Boolean).slice(0, 3)" :key="tag" <span v-for="tag in task.tags.split(',').filter(Boolean).slice(0, 3)" :key="tag"
class="truncate text-[10px] leading-none px-1 py-0.5 bg-secondary text-secondary-foreground rounded border">{{ tag }}</span> class="truncate text-[10px] leading-none px-1 py-0.5 bg-secondary text-secondary-foreground rounded border">{{ tag }}</span>
@@ -574,7 +598,7 @@ watch(() => route.query.agent_id, (newVal: any) => {
<span class="truncate">{{ getExecutorName(task) }}</span> <span class="truncate">{{ getExecutorName(task) }}</span>
</span> </span>
<code class="flex-1 min-w-0 text-muted-foreground truncate text-xs bg-muted/40 px-2 py-1 rounded"> <code class="flex-1 min-w-0 text-muted-foreground truncate text-xs bg-muted/40 px-2 py-1 rounded">
<TextOverflow :text="task.command" :title="task.type === TASK_TYPE.REPO ? '同步地址' : '执行命令'" class="truncate" /> <TextOverflow :text="task.command" :title="task.type === TASK_TYPE.REPO ? '仓库地址' : '执行命令'" class="truncate" />
</code> </code>
<div class="w-28 shrink-0 flex flex-col items-start justify-center gap-1 overflow-hidden"> <div class="w-28 shrink-0 flex flex-col items-start justify-center gap-1 overflow-hidden">
<span v-if="task.trigger_type === TRIGGER_TYPE.BAIHU_STARTUP" class="text-[10px] leading-tight bg-blue-500/10 text-blue-500 px-2 py-1 rounded-md">服务启动时</span> <span v-if="task.trigger_type === TRIGGER_TYPE.BAIHU_STARTUP" class="text-[10px] leading-tight bg-blue-500/10 text-blue-500 px-2 py-1 rounded-md">服务启动时</span>
@@ -592,40 +616,70 @@ watch(() => route.query.agent_id, (newVal: any) => {
<ZapOff class="h-3.5 w-3.5 text-muted-foreground" /> <ZapOff class="h-3.5 w-3.5 text-muted-foreground" />
</div> </div>
</span> </span>
<span class="w-36 shrink-0 flex justify-center gap-1"> <span class="w-24 shrink-0 flex justify-center">
<Button variant="ghost" size="icon" class="h-7 w-7" @click="runTask(task.id)" :disabled="executingTaskId === task.id"> <Button variant="ghost" size="icon" class="h-6 w-6" @click="runTask(task.id)" :disabled="executingTaskId === task.id">
<Loader2 v-if="executingTaskId === task.id" class="h-3.5 w-3.5 animate-spin" /> <Loader2 v-if="executingTaskId === task.id" class="h-3 w-3 animate-spin" />
<Play v-else class="h-3.5 w-3.5" /> <Play v-else class="h-3 w-3" />
</Button> </Button>
<Button variant="ghost" size="icon" class="h-7 w-7" @click="viewLogs(task.id)"><ScrollText class="h-3.5 w-3.5" /></Button> <Button variant="ghost" size="icon" class="h-6 w-6" @click="viewLogs(task.id)"><ScrollText class="h-3 w-3" /></Button>
<Button variant="ghost" size="icon" class="h-7 w-7" @click="openEdit(task)"><Pencil class="h-3.5 w-3.5" /></Button> <Button variant="ghost" size="icon" class="h-6 w-6" @click="openEdit(task)"><Pencil class="h-3 w-3" /></Button>
<Button variant="ghost" size="icon" class="h-7 w-7" @click="duplicateTask(task)"><Copy class="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="icon" class="h-7 w-7 text-destructive" @click="confirmDelete(task.id)"><Trash2 class="h-3.5 w-3.5" /></Button> <DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon" class="h-6 w-6"><MoreHorizontal class="h-3 w-3" /></Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-32">
<DropdownMenuItem @click="togglePin(task)">
<Pin v-if="task.pin_type !== 'top'" class="h-3.5 w-3.5 mr-2" />
<PinOff v-else class="h-3.5 w-3.5 mr-2 text-primary" />
<span>{{ task.pin_type === 'top' ? '取消置顶' : '置顶任务' }}</span>
</DropdownMenuItem>
<DropdownMenuItem @click="duplicateTask(task)">
<Copy class="h-3.5 w-3.5 mr-2" />
<span>复制任务</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem class="text-destructive focus:text-destructive" @click="confirmDelete(task.id)">
<Trash2 class="h-3.5 w-3.5 mr-2" />
<span>删除任务</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</span> </span>
</div> </div>
</div> </div>
</div> </div>
<!-- ========== 2. 中屏布局 (Medium 640px - 1024px) - 新抽取优化 ========== --> <!-- ========== 2. 中屏布局 (Medium 640px - 1280px) ========== -->
<div class="hidden sm:block lg: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-48 shrink-0">任务信息</span> <span class="w-48 shrink-0">任务信息</span>
<span class="flex-1 min-w-0">命令内容</span> <span class="flex-1 min-w-0">
{{ filterType === TASK_TYPE.REPO ? '仓库地址' : '命令内容' }}
</span>
<span class="w-8 shrink-0 text-center">状态</span> <span class="w-8 shrink-0 text-center">状态</span>
<span class="w-24 shrink-0 text-center">操作</span> <span class="w-24 shrink-0 text-center">操作</span>
</div> </div>
<!-- 列表 --> <!-- 列表 -->
<div class="divide-y text-sm"> <div class="divide-y text-sm">
<div v-for="task in tasks" :key="`medium-${task.id}`" <div v-for="(task, index) in tasks" :key="`medium-${task.id}`"
class="flex items-center gap-4 px-4 py-2.5 hover:bg-muted/30 transition-colors"> class="flex items-center gap-4 px-4 py-2.5 hover:bg-muted/30 transition-colors">
<div class="w-12 shrink-0 pl-1 text-muted-foreground tabular-nums text-xs">#{{ total - (currentPage - 1) * pageSize - index }}</div>
<div class="w-48 shrink-0 flex items-center gap-2 overflow-hidden"> <div class="w-48 shrink-0 flex items-center gap-2 overflow-hidden">
<span class="shrink-0" :title="getTaskTypeTitle(task.type || 'task')"> <span class="shrink-0" :title="getTaskTypeTitle(task.type || 'task')">
<GitBranch v-if="task.type === TASK_TYPE.REPO" class="h-3.5 w-3.5 text-primary" /> <div class="relative">
<Terminal v-else class="h-3.5 w-3.5 text-primary" /> <GitBranch v-if="task.type === TASK_TYPE.REPO" class="h-3.5 w-3.5 text-primary" />
<Terminal v-else class="h-3.5 w-3.5 text-primary" />
<div v-if="task.pin_type === 'top'" class="absolute -top-1 -right-1 h-1.5 w-1.5 bg-primary rounded-full border border-background" />
</div>
</span> </span>
<div class="flex flex-col min-w-0"> <div class="flex flex-col min-w-0">
<span class="font-medium truncate">{{ task.name }}</span> <div class="flex items-center gap-1.5 overflow-hidden">
<span class="font-medium truncate">{{ task.name }}</span>
<Pin v-if="task.pin_type === 'top'" class="h-3 w-3 text-primary fill-primary shrink-0 rotate-45" />
</div>
<span v-if="task.schedule" class="text-[10px] text-muted-foreground font-mono truncate">{{ task.schedule }}</span> <span v-if="task.schedule" class="text-[10px] text-muted-foreground font-mono truncate">{{ task.schedule }}</span>
</div> </div>
</div> </div>
@@ -640,19 +694,41 @@ watch(() => route.query.agent_id, (newVal: any) => {
<ZapOff class="h-3.5 w-3.5 text-muted-foreground" /> <ZapOff class="h-3.5 w-3.5 text-muted-foreground" />
</div> </div>
</span> </span>
<div class="w-24 shrink-0 flex justify-center gap-0.5"> <div class="w-24 shrink-0 flex justify-center">
<Button variant="ghost" size="icon" class="h-8 w-8" @click="runTask(task.id)" :disabled="executingTaskId === task.id"> <Button variant="ghost" size="icon" class="h-6 w-6" @click="runTask(task.id)" :disabled="executingTaskId === task.id">
<Loader2 v-if="executingTaskId === task.id" class="h-3.5 w-3.5 animate-spin" /> <Loader2 v-if="executingTaskId === task.id" class="h-3 w-3 animate-spin" />
<Play v-else class="h-3.5 w-3.5" /> <Play v-else class="h-3 w-3" />
</Button> </Button>
<Button variant="ghost" size="icon" class="h-8 w-8" @click="viewLogs(task.id)"><ScrollText class="h-3.5 w-3.5" /></Button> <Button variant="ghost" size="icon" class="h-6 w-6" @click="viewLogs(task.id)"><ScrollText class="h-3 w-3" /></Button>
<Button variant="ghost" size="icon" class="h-8 w-8" @click="openEdit(task)"><Pencil class="h-3.5 w-3.5" /></Button> <Button variant="ghost" size="icon" class="h-6 w-6" @click="openEdit(task)"><Pencil class="h-3 w-3" /></Button>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon" class="h-6 w-6"><MoreHorizontal class="h-3 w-3" /></Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-32">
<DropdownMenuItem @click="togglePin(task)">
<Pin v-if="task.pin_type !== 'top'" class="h-3.5 w-3.5 mr-2" />
<PinOff v-else class="h-3.5 w-3.5 mr-2 text-primary" />
<span>{{ task.pin_type === 'top' ? '取消置顶' : '置顶任务' }}</span>
</DropdownMenuItem>
<DropdownMenuItem @click="duplicateTask(task)">
<Copy class="h-3.5 w-3.5 mr-2" />
<span>复制任务</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem class="text-destructive focus:text-destructive" @click="confirmDelete(task.id)">
<Trash2 class="h-3.5 w-3.5 mr-2" />
<span>删除任务</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- ========== 3. 小屏布局 (Small < 640px) - 用户调好 ========== --> <!-- ========== 3. 小屏布局 (Small < 640px) ========== -->
<div class="divide-y sm:hidden"> <div class="divide-y sm:hidden">
<div v-if="tasks.length === 0" class="text-sm text-muted-foreground text-center py-8">暂无任务</div> <div v-if="tasks.length === 0" class="text-sm text-muted-foreground text-center py-8">暂无任务</div>
<div v-for="(task, index) in tasks" :key="`small-${task.id}`" class="p-3 hover:bg-muted/50 transition-colors"> <div v-for="(task, index) in tasks" :key="`small-${task.id}`" class="p-3 hover:bg-muted/50 transition-colors">
@@ -660,10 +736,16 @@ watch(() => route.query.agent_id, (newVal: any) => {
<div class="flex items-center gap-2 flex-1 min-w-0 pr-2"> <div class="flex items-center gap-2 flex-1 min-w-0 pr-2">
<span class="text-xs text-muted-foreground tabular-nums flex-shrink-0">#{{ total - (currentPage - 1) * pageSize - index }}</span> <span class="text-xs text-muted-foreground tabular-nums flex-shrink-0">#{{ total - (currentPage - 1) * pageSize - index }}</span>
<span class="shrink-0"> <span class="shrink-0">
<GitBranch v-if="task.type === TASK_TYPE.REPO" class="h-3.5 w-3.5 text-primary" /> <div class="relative">
<Terminal v-else class="h-3.5 w-3.5 text-primary" /> <GitBranch v-if="task.type === TASK_TYPE.REPO" class="h-3.5 w-3.5 text-primary" />
<Terminal v-else class="h-3.5 w-3.5 text-primary" />
<div v-if="task.pin_type === 'top'" class="absolute -top-1 -right-1 h-1.5 w-1.5 bg-primary rounded-full border border-background" />
</div>
</span> </span>
<span class="font-bold text-sm truncate">{{ task.name }}</span> <div class="flex items-center gap-1.5 min-w-0 flex-1">
<span class="font-bold text-sm truncate">{{ task.name }}</span>
<Pin v-if="task.pin_type === 'top'" class="h-3 w-3 text-primary fill-primary shrink-0 rotate-45" />
</div>
</div> </div>
<span @click="toggleTask(task, !task.enabled)" class="cursor-pointer"> <span @click="toggleTask(task, !task.enabled)" class="cursor-pointer">
<div v-if="task.enabled" class="h-6 w-6 rounded-md bg-green-500/10 flex items-center justify-center"><Zap class="h-3.5 w-3.5 text-green-500 fill-green-500" /></div> <div v-if="task.enabled" class="h-6 w-6 rounded-md bg-green-500/10 flex items-center justify-center"><Zap class="h-3.5 w-3.5 text-green-500 fill-green-500" /></div>
@@ -672,7 +754,7 @@ watch(() => route.query.agent_id, (newVal: any) => {
</div> </div>
<div class="space-y-1.5 text-xs text-muted-foreground mb-3 px-1"> <div class="space-y-1.5 text-xs text-muted-foreground mb-3 px-1">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="w-10 shrink-0 font-medium opacity-70">定时:</span> <span class="w-10 shrink-0 font-medium opacity-70">{{ task.type === TASK_TYPE.REPO ? '周期:' : '定时:' }}</span>
<span v-if="task.trigger_type === TRIGGER_TYPE.BAIHU_STARTUP" class="text-[10px] leading-tight bg-blue-500/10 text-blue-500 px-1.5 py-0.5 rounded font-medium">服务启动时</span> <span v-if="task.trigger_type === TRIGGER_TYPE.BAIHU_STARTUP" class="text-[10px] leading-tight bg-blue-500/10 text-blue-500 px-1.5 py-0.5 rounded font-medium">服务启动时</span>
<div v-else-if="task.schedule" class="flex items-center gap-1.5 flex-1 min-w-0"> <div v-else-if="task.schedule" class="flex items-center gap-1.5 flex-1 min-w-0">
<span class="text-xs text-foreground bg-muted/40 px-1.5 py-0.5 rounded shrink-0">{{ task.schedule }}</span> <span class="text-xs text-foreground bg-muted/40 px-1.5 py-0.5 rounded shrink-0">{{ task.schedule }}</span>
@@ -683,7 +765,7 @@ watch(() => route.query.agent_id, (newVal: any) => {
</div> </div>
</div> </div>
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<span class="w-10 shrink-0 font-medium mt-0.5 opacity-70">命令:</span> <span class="w-10 shrink-0 font-medium mt-0.5 opacity-70">{{ task.type === TASK_TYPE.REPO ? '地址:' : '命令:' }}</span>
<div class="flex-1 min-w-0 overflow-hidden text-foreground"><TextOverflow :text="task.command" class="truncate opacity-80" /></div> <div class="flex-1 min-w-0 overflow-hidden text-foreground"><TextOverflow :text="task.command" class="truncate opacity-80" /></div>
</div> </div>
<div v-if="task.remark" class="flex items-start gap-3"> <div v-if="task.remark" class="flex items-start gap-3">
@@ -694,7 +776,7 @@ watch(() => route.query.agent_id, (newVal: any) => {
<div class="grid grid-cols-4 items-center pt-2 mt-2 border-t border-border/40 -mx-1"> <div class="grid grid-cols-4 items-center pt-2 mt-2 border-t border-border/40 -mx-1">
<Button variant="ghost" class="h-9 px-0 text-xs gap-1.5 hover:bg-primary/5 rounded-none" @click="runTask(task.id)" :disabled="executingTaskId === task.id"> <Button variant="ghost" class="h-9 px-0 text-xs gap-1.5 hover:bg-primary/5 rounded-none" @click="runTask(task.id)" :disabled="executingTaskId === task.id">
<Loader2 v-if="executingTaskId === task.id" class="h-3.5 w-3.5 animate-spin" /> <Loader2 v-if="executingTaskId === task.id" class="h-3.5 w-3.5 animate-spin" />
<Play v-else class="h-3.5 w-3.5" />执行 <Play v-else class="h-3.5 w-3.5" />{{ task.type === TASK_TYPE.REPO ? '同步' : '执行' }}
</Button> </Button>
<Button variant="ghost" class="h-9 px-0 text-xs gap-1.5 hover:bg-primary/5 rounded-none border-l border-border/10" @click="viewLogs(task.id)"> <Button variant="ghost" class="h-9 px-0 text-xs gap-1.5 hover:bg-primary/5 rounded-none border-l border-border/10" @click="viewLogs(task.id)">
<ScrollText class="h-3.5 w-3.5" />日志 <ScrollText class="h-3.5 w-3.5" />日志
@@ -702,9 +784,30 @@ watch(() => route.query.agent_id, (newVal: any) => {
<Button variant="ghost" class="h-9 px-0 text-xs gap-1.5 hover:bg-primary/5 rounded-none border-l border-border/10" @click="openEdit(task)"> <Button variant="ghost" class="h-9 px-0 text-xs gap-1.5 hover:bg-primary/5 rounded-none border-l border-border/10" @click="openEdit(task)">
<Pencil class="h-3.5 w-3.5" />编辑 <Pencil class="h-3.5 w-3.5" />编辑
</Button> </Button>
<Button variant="ghost" class="h-9 px-0 text-xs gap-1.5 hover:bg-destructive/5 text-destructive rounded-none border-l border-border/10" @click="confirmDelete(task.id)">
<Trash2 class="h-3.5 w-3.5" />删除 <DropdownMenu>
</Button> <DropdownMenuTrigger as-child>
<Button variant="ghost" class="h-9 px-0 text-xs gap-1.5 hover:bg-primary/5 rounded-none border-l border-border/10 w-full">
<MoreHorizontal class="h-3.5 w-3.5" />更多
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-40">
<DropdownMenuItem @click="togglePin(task)">
<Pin v-if="task.pin_type !== 'top'" class="h-4 w-4 mr-2" />
<PinOff v-else class="h-4 w-4 mr-2 text-primary" />
<span>{{ task.pin_type === 'top' ? '取消置顶' : '置顶任务' }}</span>
</DropdownMenuItem>
<DropdownMenuItem @click="duplicateTask(task)">
<Copy class="h-4 w-4 mr-2" />
<span>复制任务</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem class="text-destructive focus:text-destructive" @click="confirmDelete(task.id)">
<Trash2 class="h-4 w-4 mr-2" />
<span>删除任务</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
</div> </div>
</div> </div>