feat: add exec env point
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Checkbox } from "./Checkbox.vue"
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user