feat: add exec env point

This commit is contained in:
engigu
2025-12-21 23:34:34 +08:00
parent 86fa6a4340
commit db6a5574e6
22 changed files with 342 additions and 89 deletions
+1
View File
@@ -58,6 +58,7 @@ RUN sed -i 's@deb.debian.org@mirrors.tuna.tsinghua.edu.cn@g' /etc/apt/sources.li
&& "${CONDA_DIR}"/bin/conda config --set show_channel_urls yes \
&& "${CONDA_DIR}"/bin/conda config --set channel_priority strict \
&& "${CONDA_DIR}"/bin/conda init \
&& "${CONDA_DIR}"/bin/python -m pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple \
&& "${CONDA_DIR}"/bin/conda clean -afy
WORKDIR /app
+6
View File
@@ -43,6 +43,12 @@ func (ec *EnvController) GetEnvVars(c *gin.Context) {
utils.PaginatedResponse(c, envVars, total, p)
}
func (ec *EnvController) GetAllEnvVars(c *gin.Context) {
userID := 1
envVars := ec.envService.GetEnvVarsByUserID(userID)
utils.Success(c, envVars)
}
func (ec *EnvController) GetEnvVar(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
+1 -18
View File
@@ -62,24 +62,7 @@ func (sc *SettingsController) ChangePassword(c *gin.Context) {
utils.SuccessMsg(c, "密码修改成功")
}
// CleanLogs 清理日志
func (sc *SettingsController) CleanLogs(c *gin.Context) {
var req struct {
Days int `json:"days" binding:"required,min=1"`
}
if err := c.ShouldBindJSON(&req); err != nil {
utils.BadRequest(c, "参数错误")
return
}
cutoff := time.Now().AddDate(0, 0, -req.Days)
result := database.DB.Where("created_at < ?", cutoff).Delete(&models.TaskLog{})
utils.Success(c, gin.H{
"deleted": result.RowsAffected,
})
}
// CleanLogs 清理日志 - 已移除,改为任务级别的日志清理配置
// GetSiteSettings 获取站点设置
func (sc *SettingsController) GetSiteSettings(c *gin.Context) {
+4 -2
View File
@@ -28,6 +28,7 @@ func (tc *TaskController) CreateTask(c *gin.Context) {
Schedule string `json:"schedule" binding:"required"`
Timeout int `json:"timeout"`
CleanConfig string `json:"clean_config"`
Envs string `json:"envs"`
}
if err := c.ShouldBindJSON(&req); err != nil {
@@ -40,7 +41,7 @@ func (tc *TaskController) CreateTask(c *gin.Context) {
return
}
task := tc.taskService.CreateTask(req.Name, req.Command, req.Schedule, req.Timeout, req.CleanConfig)
task := tc.taskService.CreateTask(req.Name, req.Command, req.Schedule, req.Timeout, req.CleanConfig, req.Envs)
tc.cronService.AddTask(task)
utils.Success(c, task)
@@ -83,6 +84,7 @@ func (tc *TaskController) UpdateTask(c *gin.Context) {
Schedule string `json:"schedule"`
Timeout int `json:"timeout"`
CleanConfig string `json:"clean_config"`
Envs string `json:"envs"`
Enabled bool `json:"enabled"`
}
@@ -98,7 +100,7 @@ func (tc *TaskController) UpdateTask(c *gin.Context) {
}
}
task := tc.taskService.UpdateTask(id, req.Name, req.Command, req.Schedule, req.Timeout, req.CleanConfig, req.Enabled)
task := tc.taskService.UpdateTask(id, req.Name, req.Command, req.Schedule, req.Timeout, req.CleanConfig, req.Envs, req.Enabled)
if task == nil {
utils.NotFound(c, "任务不存在")
return
+1
View File
@@ -20,6 +20,7 @@ type Task struct {
Schedule string `json:"schedule" gorm:"size:100"` // cron expression
Timeout int `json:"timeout" gorm:"default:30"` // 超时时间(分钟),默认30分钟
CleanConfig string `json:"clean_config" gorm:"size:255;default:''"` // 清理配置 JSON
Envs string `json:"envs" gorm:"size:255;default:''"` // 环境变量ID列表,逗号分隔
Enabled bool `json:"enabled" gorm:"default:true"`
LastRun *LocalTime `json:"last_run"`
NextRun *LocalTime `json:"next_run"`
+1 -1
View File
@@ -128,6 +128,7 @@ func Setup(c *Controllers) *gin.Engine {
{
env.POST("", c.Env.CreateEnvVar)
env.GET("", c.Env.GetEnvVars)
env.GET("/all", c.Env.GetAllEnvVars)
env.GET("/:id", c.Env.GetEnvVar)
env.PUT("/:id", c.Env.UpdateEnvVar)
env.DELETE("/:id", c.Env.DeleteEnvVar)
@@ -171,7 +172,6 @@ func Setup(c *Controllers) *gin.Engine {
settings := authorized.Group("/settings")
{
settings.POST("/password", c.Settings.ChangePassword)
settings.POST("/cleanlogs", c.Settings.CleanLogs)
settings.GET("/site", c.Settings.GetSiteSettings)
settings.PUT("/site", c.Settings.UpdateSiteSettings)
settings.GET("/about", c.Settings.GetAbout)
+32
View File
@@ -1,6 +1,9 @@
package services
import (
"strconv"
"strings"
"baihu/internal/database"
"baihu/internal/models"
)
@@ -66,3 +69,32 @@ func (es *EnvService) DeleteEnvVar(id int) bool {
result := database.DB.Delete(&models.EnvironmentVariable{}, id)
return result.RowsAffected > 0
}
// GetEnvVarsByIDs 根据逗号分隔的ID字符串获取环境变量列表,返回 NAME=VALUE 格式
func (es *EnvService) GetEnvVarsByIDs(envIDs string) []string {
if envIDs == "" {
return nil
}
var envVars []string
ids := splitEnvIDs(envIDs)
for _, id := range ids {
env := es.GetEnvVarByID(id)
if env != nil {
envVars = append(envVars, env.Name+"="+env.Value)
}
}
return envVars
}
// splitEnvIDs 解析逗号分隔的ID字符串
func splitEnvIDs(envIDs string) []int {
var ids []int
for _, s := range strings.Split(envIDs, ",") {
s = strings.TrimSpace(s)
if id, err := strconv.Atoi(s); err == nil {
ids = append(ids, id)
}
}
return ids
}
+16 -1
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"os"
"os/exec"
"sync"
"time"
@@ -179,12 +180,16 @@ func (es *ExecutorService) ExecuteTask(taskID int) *ExecutionResult {
es.runningTasks[taskID] = true
es.mu.Unlock()
// 加载环境变量
envService := NewEnvService()
envVars := envService.GetEnvVarsByIDs(task.Envs)
// 使用任务配置的超时时间
timeout := task.Timeout
if timeout <= 0 {
timeout = constant.DefaultTaskTimeout
}
result := es.ExecuteCommandWithTimeout(task.Command, time.Duration(timeout)*time.Minute)
result := es.ExecuteCommandWithEnv(task.Command, time.Duration(timeout)*time.Minute, envVars)
result.TaskID = taskID
// 标记任务结束
@@ -212,6 +217,11 @@ func (es *ExecutorService) ExecuteCommand(command string) *ExecutionResult {
// ExecuteCommandWithTimeout executes a shell command with specified timeout
func (es *ExecutorService) ExecuteCommandWithTimeout(command string, timeout time.Duration) *ExecutionResult {
return es.ExecuteCommandWithEnv(command, timeout, nil)
}
// ExecuteCommandWithEnv executes a shell command with specified timeout and environment variables
func (es *ExecutorService) ExecuteCommandWithEnv(command string, timeout time.Duration, envVars []string) *ExecutionResult {
result := &ExecutionResult{
Success: false,
Start: time.Now(),
@@ -226,6 +236,11 @@ func (es *ExecutorService) ExecuteCommandWithTimeout(command string, timeout tim
cmd.Stdout = &stdout
cmd.Stderr = &stderr
// 设置环境变量:继承系统环境变量 + 自定义环境变量
if len(envVars) > 0 {
cmd.Env = append(os.Environ(), envVars...)
}
err := cmd.Run()
result.End = time.Now()
+4 -2
View File
@@ -11,13 +11,14 @@ func NewTaskService() *TaskService {
return &TaskService{}
}
func (ts *TaskService) CreateTask(name, command, schedule string, timeout int, cleanConfig string) *models.Task {
func (ts *TaskService) CreateTask(name, command, schedule string, timeout int, cleanConfig, envs string) *models.Task {
task := &models.Task{
Name: name,
Command: command,
Schedule: schedule,
Timeout: timeout,
CleanConfig: cleanConfig,
Envs: envs,
Enabled: true,
}
database.DB.Create(task)
@@ -54,7 +55,7 @@ func (ts *TaskService) GetTaskByID(id int) *models.Task {
return &task
}
func (ts *TaskService) UpdateTask(id int, name, command, schedule string, timeout int, cleanConfig string, enabled bool) *models.Task {
func (ts *TaskService) UpdateTask(id int, name, command, schedule string, timeout int, cleanConfig, envs string, enabled bool) *models.Task {
var task models.Task
if err := database.DB.First(&task, id).Error; err != nil {
return nil
@@ -64,6 +65,7 @@ func (ts *TaskService) UpdateTask(id int, name, command, schedule string, timeou
task.Schedule = schedule
task.Timeout = timeout
task.CleanConfig = cleanConfig
task.Envs = envs
task.Enabled = enabled
database.DB.Save(&task)
return &task
+2 -2
View File
@@ -81,6 +81,7 @@ export const api = {
if (params?.name) query.set('name', params.name)
return request<EnvListResponse>(`/env?${query}`)
},
all: () => request<EnvVar[]>('/env/all'),
create: (data: Partial<EnvVar>) => request<EnvVar>('/env', { method: 'POST', body: JSON.stringify(data) }),
update: (id: number, data: Partial<EnvVar>) => request<EnvVar>(`/env/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
delete: (id: number) => request(`/env/${id}`, { method: 'DELETE' })
@@ -109,8 +110,6 @@ export const api = {
settings: {
changePassword: (data: { old_password: string; new_password: string }) =>
request('/settings/password', { method: 'POST', body: JSON.stringify(data) }),
cleanLogs: (days: number) =>
request<{ deleted: number }>('/settings/cleanlogs', { method: 'POST', body: JSON.stringify({ days }) }),
getSite: () => request<SiteSettings>('/settings/site'),
getPublicSite: () => request<{ title: string; subtitle: string; icon: string }>('/settings/public'),
updateSite: (data: SiteSettings) =>
@@ -188,6 +187,7 @@ export interface Task {
schedule: string
timeout: number
clean_config: string
envs: string
enabled: boolean
last_run: string
next_run: string
@@ -0,0 +1,35 @@
<script setup lang="ts">
import type { CheckboxRootEmits, CheckboxRootProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Check } from "lucide-vue-next"
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<CheckboxRootEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<CheckboxRoot
v-slot="slotProps"
data-slot="checkbox"
v-bind="forwarded"
:class="
cn('peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
props.class)"
>
<CheckboxIndicator
data-slot="checkbox-indicator"
class="grid place-content-center text-current transition-none"
>
<slot v-bind="slotProps">
<Check class="size-3.5" />
</slot>
</CheckboxIndicator>
</CheckboxRoot>
</template>
+1
View File
@@ -0,0 +1 @@
export { default as Checkbox } from "./Checkbox.vue"
+19
View File
@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { PopoverRootEmits, PopoverRootProps } from "reka-ui"
import { PopoverRoot, useForwardPropsEmits } from "reka-ui"
const props = defineProps<PopoverRootProps>()
const emits = defineEmits<PopoverRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<PopoverRoot
v-slot="slotProps"
data-slot="popover"
v-bind="forwarded"
>
<slot v-bind="slotProps" />
</PopoverRoot>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { PopoverAnchorProps } from "reka-ui"
import { PopoverAnchor } from "reka-ui"
const props = defineProps<PopoverAnchorProps>()
</script>
<template>
<PopoverAnchor
data-slot="popover-anchor"
v-bind="props"
>
<slot />
</PopoverAnchor>
</template>
@@ -0,0 +1,45 @@
<script setup lang="ts">
import type { PopoverContentEmits, PopoverContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
PopoverContent,
PopoverPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(
defineProps<PopoverContentProps & { class?: HTMLAttributes["class"] }>(),
{
align: "center",
sideOffset: 4,
},
)
const emits = defineEmits<PopoverContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<PopoverPortal>
<PopoverContent
data-slot="popover-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 rounded-md border p-4 shadow-md origin-(--reka-popover-content-transform-origin) outline-hidden',
props.class,
)
"
>
<slot />
</PopoverContent>
</PopoverPortal>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { PopoverTriggerProps } from "reka-ui"
import { PopoverTrigger } from "reka-ui"
const props = defineProps<PopoverTriggerProps>()
</script>
<template>
<PopoverTrigger
data-slot="popover-trigger"
v-bind="props"
>
<slot />
</PopoverTrigger>
</template>
+4
View File
@@ -0,0 +1,4 @@
export { default as Popover } from "./Popover.vue"
export { default as PopoverAnchor } from "./PopoverAnchor.vue"
export { default as PopoverContent } from "./PopoverContent.vue"
export { default as PopoverTrigger } from "./PopoverTrigger.vue"
+23 -3
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { ref, onMounted, computed, watch } from 'vue'
import { useRoute } from 'vue-router'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import Pagination from '@/components/Pagination.vue'
@@ -10,12 +11,14 @@ import { toast } from 'vue-sonner'
import pako from 'pako'
import { useSiteSettings } from '@/composables/useSiteSettings'
const route = useRoute()
const { pageSize } = useSiteSettings()
const logs = ref<TaskLog[]>([])
const selectedLog = ref<TaskLog | null>(null)
const logDetail = ref<LogDetail | null>(null)
const filterKeyword = ref('')
const filterTaskId = ref<number | undefined>(undefined)
const currentPage = ref(1)
const total = ref(0)
let searchTimer: ReturnType<typeof setTimeout> | null = null
@@ -45,10 +48,13 @@ const decompressedOutput = computed(() => {
async function loadLogs() {
try {
const params: { page: number; page_size: number; task_name?: string } = {
const params: { page: number; page_size: number; task_id?: number; task_name?: string } = {
page: currentPage.value,
page_size: pageSize.value
}
if (filterTaskId.value) {
params.task_id = filterTaskId.value
}
if (filterKeyword.value.trim()) {
params.task_name = filterKeyword.value.trim()
}
@@ -94,7 +100,21 @@ function formatDuration(ms: number): string {
return `${(ms / 60000).toFixed(1)}m`
}
onMounted(loadLogs)
onMounted(() => {
// 从 URL 读取 task_id 参数
const taskIdParam = route.query.task_id
if (taskIdParam) {
filterTaskId.value = Number(taskIdParam)
}
loadLogs()
})
// 监听路由变化
watch(() => route.query.task_id, (newTaskId) => {
filterTaskId.value = newTaskId ? Number(newTaskId) : undefined
currentPage.value = 1
loadLogs()
})
</script>
<template>
-38
View File
@@ -1,38 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { api } from '@/api'
import { toast } from 'vue-sonner'
const cleanDays = ref(30)
const cleanResult = ref<number | null>(null)
async function cleanLogs() {
if (cleanDays.value < 1) {
toast.error('天数必须大于0')
return
}
try {
const res = await api.settings.cleanLogs(cleanDays.value)
cleanResult.value = res.deleted
toast.success(`已清理 ${res.deleted} 条日志`)
} catch {
toast.error('清理失败')
}
}
</script>
<template>
<div class="space-y-4">
<div class="space-y-2">
<Label>清理多少天前的日志</Label>
<Input v-model.number="cleanDays" type="number" class="w-32" min="1" />
</div>
<Button variant="destructive" @click="cleanLogs">清理日志</Button>
<p v-if="cleanResult !== null" class="text-sm text-muted-foreground">
上次清理了 {{ cleanResult }} 条日志
</p>
</div>
</template>
-14
View File
@@ -3,7 +3,6 @@ import { ref } from 'vue'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import PasswordSettings from './PasswordSettings.vue'
import LogsSettings from './LogsSettings.vue'
import SiteSettings from './SiteSettings.vue'
import AboutSettings from './AboutSettings.vue'
@@ -20,7 +19,6 @@ const activeTab = ref('password')
<Tabs v-model="activeTab" class="w-full">
<TabsList>
<TabsTrigger value="password">密码修改</TabsTrigger>
<TabsTrigger value="logs">日志清理</TabsTrigger>
<TabsTrigger value="site">站点设置</TabsTrigger>
<TabsTrigger value="about">关于</TabsTrigger>
</TabsList>
@@ -37,18 +35,6 @@ const activeTab = ref('password')
</Card>
</TabsContent>
<TabsContent value="logs" class="mt-6">
<Card class="max-w-lg">
<CardHeader>
<CardTitle>日志清理</CardTitle>
<CardDescription>清理历史执行日志以释放存储空间</CardDescription>
</CardHeader>
<CardContent>
<LogsSettings />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="site" class="mt-6">
<Card class="max-w-xl">
<CardHeader>
+1 -1
View File
@@ -64,7 +64,7 @@ onMounted(loadSettings)
<Label class="text-right">站点图标</Label>
<div class="col-span-3 flex items-center gap-2">
<Input v-model="form.icon" placeholder="<svg>...</svg>" class="flex-1 font-mono text-xs" />
<div v-if="iconPreview" class="p-1.5 border rounded bg-muted/50 w-8 h-8 flex items-center justify-center shrink-0 [&>svg]:w-5 [&>svg]:h-5" v-html="iconPreview" />
<div v-if="iconPreview" class="p-1.5 border rounded bg-white dark:bg-white w-8 h-8 flex items-center justify-center shrink-0 [&>svg]:w-5 [&>svg]:h-5" v-html="iconPreview" />
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
+116 -7
View File
@@ -6,12 +6,16 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Badge } from '@/components/ui/badge'
import Pagination from '@/components/Pagination.vue'
import { Plus, Play, Pencil, Trash2, Search } from 'lucide-vue-next'
import { api, type Task } from '@/api'
import { Plus, Play, Pencil, Trash2, Search, ScrollText, ChevronDown, X } from 'lucide-vue-next'
import { api, type Task, type EnvVar } from '@/api'
import { toast } from 'vue-sonner'
import { useSiteSettings } from '@/composables/useSiteSettings'
import { useRouter } from 'vue-router'
const router = useRouter()
const { pageSize } = useSiteSettings()
const tasks = ref<Task[]>([])
@@ -25,6 +29,11 @@ const deleteTaskId = ref<number | null>(null)
const cleanType = ref('')
const cleanKeep = ref(30)
// 环境变量
const allEnvVars = ref<EnvVar[]>([])
const selectedEnvIds = ref<number[]>([])
const envSearchQuery = ref('')
const filterName = ref('')
const currentPage = ref(1)
const total = ref(0)
@@ -48,6 +57,45 @@ const cleanConfig = computed(() => {
return JSON.stringify({ type: cleanType.value, keep: cleanKeep.value })
})
// 过滤后的环境变量列表(排除已选中的)
const filteredEnvVars = computed(() => {
return allEnvVars.value.filter(env => {
const matchSearch = !envSearchQuery.value || env.name.toLowerCase().includes(envSearchQuery.value.toLowerCase())
const notSelected = !selectedEnvIds.value.includes(env.id)
return matchSearch && notSelected
})
})
// 已选中的环境变量对象列表
const selectedEnvs = computed(() => {
return selectedEnvIds.value
.map(id => allEnvVars.value.find(e => e.id === id))
.filter((e): e is EnvVar => e !== undefined)
})
// 计算 envs 字符串
const envsString = computed(() => selectedEnvIds.value.join(','))
async function loadEnvVars() {
try {
allEnvVars.value = await api.env.all()
} catch { /* ignore */ }
}
function addEnv(id: number) {
if (!selectedEnvIds.value.includes(id)) {
selectedEnvIds.value.push(id)
}
envSearchQuery.value = ''
}
function removeEnv(id: number) {
const idx = selectedEnvIds.value.indexOf(id)
if (idx !== -1) {
selectedEnvIds.value.splice(idx, 1)
}
}
async function loadTasks() {
try {
const res = await api.tasks.list({ page: currentPage.value, page_size: pageSize.value, name: filterName.value || undefined })
@@ -70,9 +118,11 @@ function handlePageChange(page: number) {
}
function openCreate() {
editingTask.value = { name: '', command: '', schedule: '0 * * * * *', timeout: 30, enabled: true, clean_config: '' }
editingTask.value = { name: '', command: '', schedule: '0 * * * * *', timeout: 30, enabled: true, clean_config: '', envs: '' }
cleanType.value = 'none'
cleanKeep.value = 30
selectedEnvIds.value = []
envSearchQuery.value = ''
isEdit.value = false
showDialog.value = true
}
@@ -93,6 +143,13 @@ function openEdit(task: Task) {
cleanType.value = 'none'
cleanKeep.value = 30
}
// 解析环境变量
if (task.envs) {
selectedEnvIds.value = task.envs.split(',').map(s => parseInt(s.trim())).filter(n => !isNaN(n))
} else {
selectedEnvIds.value = []
}
envSearchQuery.value = ''
isEdit.value = true
showDialog.value = true
}
@@ -100,6 +157,7 @@ function openEdit(task: Task) {
async function saveTask() {
try {
editingTask.value.clean_config = cleanConfig.value
editingTask.value.envs = envsString.value
if (isEdit.value && editingTask.value.id) {
await api.tasks.update(editingTask.value.id, editingTask.value)
toast.success('任务已更新')
@@ -134,13 +192,20 @@ async function runTask(id: number) {
async function toggleTask(task: Task, enabled: boolean) {
try {
await api.tasks.update(task.id, { name: task.name, command: task.command, schedule: task.schedule, timeout: task.timeout, clean_config: task.clean_config, enabled })
await api.tasks.update(task.id, { name: task.name, command: task.command, schedule: task.schedule, timeout: task.timeout, clean_config: task.clean_config, envs: task.envs, enabled })
toast.success(enabled ? '任务已启用' : '任务已禁用')
loadTasks()
} catch { toast.error('操作失败') }
}
onMounted(loadTasks)
function viewLogs(taskId: number) {
router.push({ path: '/history', query: { task_id: String(taskId) } })
}
onMounted(() => {
loadTasks()
loadEnvVars()
})
</script>
<template>
@@ -171,7 +236,7 @@ onMounted(loadTasks)
<span class="w-40 shrink-0">上次执行</span>
<span class="w-40 shrink-0">下次执行</span>
<span class="w-12 shrink-0 text-center">状态</span>
<span class="w-28 shrink-0 text-center">操作</span>
<span class="w-36 shrink-0 text-center">操作</span>
</div>
<!-- 列表 -->
<div class="divide-y">
@@ -192,10 +257,13 @@ onMounted(loadTasks)
<span class="w-12 flex justify-center shrink-0 cursor-pointer" @click="toggleTask(task, !task.enabled)" :title="task.enabled ? '点击禁用' : '点击启用'">
<span :class="['w-2 h-2 rounded-full', task.enabled ? 'bg-green-500' : 'bg-gray-400']" />
</span>
<span class="w-28 shrink-0 flex justify-center gap-1">
<span class="w-36 shrink-0 flex justify-center gap-1">
<Button variant="ghost" size="icon" class="h-7 w-7" @click="runTask(task.id)" title="执行">
<Play class="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" class="h-7 w-7" @click="viewLogs(task.id)" title="日志">
<ScrollText class="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" class="h-7 w-7" @click="openEdit(task)" title="编辑">
<Pencil class="h-3.5 w-3.5" />
</Button>
@@ -263,6 +331,47 @@ onMounted(loadTasks)
<Input v-if="cleanType && cleanType !== 'none'" v-model.number="cleanKeep" type="number" :placeholder="cleanType === 'day' ? '保留天数' : '保留条数'" class="flex-1" />
</div>
</div>
<div class="grid grid-cols-4 items-start gap-4">
<Label class="text-right pt-2">环境变量</Label>
<div class="col-span-3 space-y-2">
<Popover>
<PopoverTrigger as-child>
<Button variant="outline" class="w-full justify-between font-normal">
<span class="text-muted-foreground">搜索并添加环境变量...</span>
<ChevronDown class="h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent class="w-[300px] p-2" align="start">
<Input v-model="envSearchQuery" placeholder="搜索环境变量..." class="mb-2 h-8" />
<div v-if="filteredEnvVars.length === 0" class="text-sm text-muted-foreground text-center py-2">
{{ allEnvVars.length === 0 ? '暂无环境变量' : '无匹配结果' }}
</div>
<div v-else class="max-h-[160px] overflow-y-auto space-y-1">
<div
v-for="env in filteredEnvVars"
:key="env.id"
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-muted cursor-pointer text-sm"
@click="addEnv(env.id)"
>
<Plus class="h-3.5 w-3.5 text-muted-foreground" />
<span class="truncate">{{ env.name }}</span>
</div>
</div>
</PopoverContent>
</Popover>
<div v-if="selectedEnvs.length > 0" class="flex flex-wrap gap-1.5">
<Badge
v-for="env in selectedEnvs"
:key="env.id"
variant="secondary"
class="gap-1 pr-1"
>
{{ env.name }}
<X class="h-3 w-3 cursor-pointer hover:text-destructive" @click="removeEnv(env.id)" />
</Badge>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="showDialog = false">取消</Button>