feat: 计划任务支持访问URL类型

This commit is contained in:
耗子
2026-02-17 21:39:27 +08:00
parent ba6c09ed70
commit e9affee707
5 changed files with 224 additions and 43 deletions
+45 -9
View File
@@ -63,10 +63,17 @@ func (r *cronRepo) Get(id uint) (*biz.Cron, error) {
func (r *cronRepo) Create(ctx context.Context, req *request.CronCreate) error {
config := types.CronConfig{
Type: req.SubType,
Targets: req.Targets,
Storage: req.Storage,
Keep: req.Keep,
Type: req.SubType,
Targets: req.Targets,
Storage: req.Storage,
Keep: req.Keep,
URL: req.URL,
Method: req.Method,
Headers: req.Headers,
Body: req.Body,
Timeout: req.Timeout,
Insecure: req.Insecure,
Retries: req.Retries,
}
script := r.generateScript(req.Type, config, req.Script)
@@ -111,12 +118,19 @@ func (r *cronRepo) Update(ctx context.Context, req *request.CronUpdate) error {
cron.Name = req.Name
// 根据类型重新生成脚本
if req.Type == "backup" || req.Type == "cutoff" {
if req.Type != "shell" {
config := types.CronConfig{
Type: req.SubType,
Targets: req.Targets,
Storage: req.Storage,
Keep: req.Keep,
Type: req.SubType,
Targets: req.Targets,
Storage: req.Storage,
Keep: req.Keep,
URL: req.URL,
Method: req.Method,
Headers: req.Headers,
Body: req.Body,
Timeout: req.Timeout,
Insecure: req.Insecure,
Retries: req.Retries,
}
cron.Config = config
script := r.generateScript(req.Type, config, "")
@@ -270,6 +284,28 @@ func (r *cronRepo) generateScript(typ string, config types.CronConfig, rawScript
sb.WriteString(fmt.Sprintf("acepanel cutoff clear -t container -n '%s' -k '%d' -s '%d'\n", target, config.Keep, config.Storage))
}
}
case "url":
method := config.Method
if method == "" {
method = "GET"
}
sb.WriteString(fmt.Sprintf("curl -sSL -X %s", method))
if config.Timeout > 0 {
sb.WriteString(fmt.Sprintf(" --connect-timeout %d", config.Timeout))
}
if config.Insecure {
sb.WriteString(" -k")
}
if config.Retries > 0 {
sb.WriteString(fmt.Sprintf(" --retry %d", config.Retries))
}
for key, value := range config.Headers {
sb.WriteString(fmt.Sprintf(" -H '%s: %s'", strings.ReplaceAll(key, "'", "'\"'\"'"), strings.ReplaceAll(value, "'", "'\"'\"'")))
}
if config.Body != "" {
sb.WriteString(fmt.Sprintf(" -d '%s'", strings.ReplaceAll(config.Body, "'", "'\"'\"'")))
}
sb.WriteString(fmt.Sprintf(" '%s'\n", strings.ReplaceAll(config.URL, "'", "'\"'\"'")))
}
return sb.String()
+31 -17
View File
@@ -1,26 +1,40 @@
package request
type CronCreate struct {
Name string `form:"name" json:"name" validate:"required|notExists:crons,name"`
Type string `form:"type" json:"type" validate:"required"`
Time string `form:"time" json:"time" validate:"required|cron"`
Script string `form:"script" json:"script"`
SubType string `form:"sub_type" json:"sub_type" validate:"requiredIf:Type,backup,cutoff"`
Storage uint `form:"storage" json:"storage"`
Targets []string `form:"targets" json:"targets" validate:"requiredIf:Type,backup,cutoff"`
Keep uint `form:"keep" json:"keep" validate:"required"`
Name string `form:"name" json:"name" validate:"required|notExists:crons,name"`
Type string `form:"type" json:"type" validate:"required"`
Time string `form:"time" json:"time" validate:"required|cron"`
Script string `form:"script" json:"script"`
SubType string `form:"sub_type" json:"sub_type" validate:"requiredIf:Type,backup,cutoff"`
Storage uint `form:"storage" json:"storage"`
Targets []string `form:"targets" json:"targets" validate:"requiredIf:Type,backup,cutoff"`
Keep uint `form:"keep" json:"keep" validate:"required"`
URL string `form:"url" json:"url"`
Method string `form:"method" json:"method"`
Headers map[string]string `form:"headers" json:"headers"`
Body string `form:"body" json:"body"`
Timeout uint `form:"timeout" json:"timeout"`
Insecure bool `form:"insecure" json:"insecure"`
Retries uint `form:"retries" json:"retries"`
}
type CronUpdate struct {
ID uint `form:"id" json:"id" validate:"required|exists:crons,id"`
Name string `form:"name" json:"name" validate:"required"`
Type string `form:"type" json:"type" validate:"required"`
Time string `form:"time" json:"time" validate:"required|cron"`
Script string `form:"script" json:"script"`
SubType string `form:"sub_type" json:"sub_type"`
Storage uint `form:"storage" json:"storage"`
Targets []string `form:"targets" json:"targets"`
Keep uint `form:"keep" json:"keep"`
ID uint `form:"id" json:"id" validate:"required|exists:crons,id"`
Name string `form:"name" json:"name" validate:"required"`
Type string `form:"type" json:"type" validate:"required"`
Time string `form:"time" json:"time" validate:"required|cron"`
Script string `form:"script" json:"script"`
SubType string `form:"sub_type" json:"sub_type"`
Storage uint `form:"storage" json:"storage"`
Targets []string `form:"targets" json:"targets"`
Keep uint `form:"keep" json:"keep"`
URL string `form:"url" json:"url"`
Method string `form:"method" json:"method"`
Headers map[string]string `form:"headers" json:"headers"`
Body string `form:"body" json:"body"`
Timeout uint `form:"timeout" json:"timeout"`
Insecure bool `form:"insecure" json:"insecure"`
Retries uint `form:"retries" json:"retries"`
}
type CronStatus struct {
+8
View File
@@ -6,4 +6,12 @@ type CronConfig struct {
Targets []string `json:"targets"` // 目标列表
Storage uint `json:"storage"` // 存储 ID0=本地)
Keep uint `json:"keep"` // 保留份数
// URL 任务专用
URL string `json:"url"`
Method string `json:"method"` // GET/POST/PUT/DELETE/PATCH/HEAD
Headers map[string]string `json:"headers"` // 自定义请求头
Body string `json:"body"` // 请求体
Timeout uint `json:"timeout"` // 超时时间(秒)
Insecure bool `json:"insecure"` // 忽略证书校验
Retries uint `json:"retries"` // 失败重试次数
}
+135 -14
View File
@@ -1,9 +1,9 @@
<script setup lang="ts">
import app from '@/api/panel/app'
import container from '@/api/panel/container'
import database from '@/api/panel/database'
import storage from '@/api/panel/backup-storage'
import container from '@/api/panel/container'
import cron from '@/api/panel/cron'
import database from '@/api/panel/database'
import home from '@/api/panel/home'
import website from '@/api/panel/website'
import CronSelector from '@/components/common/CronSelector.vue'
@@ -32,9 +32,18 @@ const defaultModel = () => ({
`#!/bin/bash\nexport PATH=/bin:/sbin:/usr/bin:/usr/sbin:/usr/local/bin:/usr/local/sbin:$PATH\n\n` +
$gettext('# Enter your script content here') +
`\n`,
time: '*/30 * * * *'
time: '*/30 * * * *',
url: '',
method: 'GET',
headers: {} as Record<string, string>,
body: '',
timeout: 10,
insecure: false,
retries: 0
})
const headerList = ref<{ key: string; value: string }[]>([])
const formModel = ref(defaultModel())
const websites = ref<any[]>([])
@@ -65,15 +74,24 @@ const containerInstalled = ref(false)
const handleSubmit = async () => {
loading.value = true
// 提交前将 headerList 同步到 headers map
const headersMap: Record<string, string> = {}
for (const item of headerList.value) {
if (item.key) {
headersMap[item.key] = item.value
}
}
formModel.value.headers = headersMap
const data = { ...formModel.value }
const request =
props.mode === 'create' ? cron.create(data) : cron.update(data.id, data)
const request = props.mode === 'create' ? cron.create(data) : cron.update(data.id, data)
useRequest(request)
.onSuccess(() => {
show.value = false
window.$message.success(
props.mode === 'create' ? $gettext('Created successfully') : $gettext('Modified successfully')
props.mode === 'create'
? $gettext('Created successfully')
: $gettext('Modified successfully')
)
emit('saved')
window.$bus.emit('task:refresh-cron')
@@ -102,12 +120,22 @@ const generateTaskName = () => {
}
const prefix = cutoffTypeMap[formModel.value.sub_type] || $gettext('Log Rotation')
formModel.value.name = targets.length ? `${prefix} - ${targets.join(', ')}` : prefix
} else if (type === 'url') {
formModel.value.name = formModel.value.url
? `${formModel.value.method} - ${formModel.value.url}`
: $gettext('Access URL')
}
}
// 监听类型变更自动生成名称
watch(
() => [formModel.value.type, formModel.value.sub_type, formModel.value.targets],
() => [
formModel.value.type,
formModel.value.sub_type,
formModel.value.targets,
formModel.value.url,
formModel.value.method
],
() => {
if (props.mode === 'create') {
generateTaskName()
@@ -169,20 +197,35 @@ watch(show, (val) => {
keep: config.keep || 1,
sub_type: config.type || '',
storage: config.storage || 0,
script: ''
script: '',
url: config.url || '',
method: config.method || 'GET',
headers: config.headers || {},
body: config.body || '',
timeout: config.timeout ?? 10,
insecure: config.insecure ?? false,
retries: config.retries ?? 0
}
nextTick(() => {
skipClearTargets.value = false
})
// 回填 headerList
headerList.value = Object.entries(config.headers || {}).map(
([key, value]: [string, any]) => ({ key, value })
)
// 加载关联数据
if (props.editData.type === 'cutoff' && config.type === 'container') {
loadContainers()
}
if (props.editData.type === 'backup' && (config.type === 'mysql' || config.type === 'postgres')) {
if (
props.editData.type === 'backup' &&
(config.type === 'mysql' || config.type === 'postgres')
) {
loadDatabases(config.type)
}
} else {
formModel.value = defaultModel()
headerList.value = []
}
}
})
@@ -245,20 +288,24 @@ onMounted(() => {
:options="[
{ label: $gettext('Run Script'), value: 'shell' },
{ label: $gettext('Backup Data'), value: 'backup' },
{ label: $gettext('Log Rotation'), value: 'cutoff' }
{ label: $gettext('Log Rotation'), value: 'cutoff' },
{ label: $gettext('Access URL'), value: 'url' }
]"
/>
</n-form-item>
<n-form-item :label="$gettext('Task Name')">
<n-input v-model:value="formModel.name" :placeholder="$gettext('Task Name')" />
</n-form-item>
<n-grid :cols="formModel.type !== 'shell' ? 2 : 1" :x-gap="16">
<n-grid
:cols="formModel.type === 'backup' || formModel.type === 'cutoff' ? 2 : 1"
:x-gap="16"
>
<n-gi>
<n-form-item :label="$gettext('Task Schedule')">
<cron-selector v-model:value="formModel.time" />
</n-form-item>
</n-gi>
<n-gi v-if="formModel.type !== 'shell'">
<n-gi v-if="formModel.type === 'backup' || formModel.type === 'cutoff'">
<n-form-item :label="$gettext('Retention Count')">
<n-input-number v-model:value="formModel.keep" :min="1" w-full />
</n-form-item>
@@ -268,6 +315,75 @@ onMounted(() => {
<n-text>{{ $gettext('Script Content') }}</n-text>
<common-editor v-model:value="formModel.script" lang="shell" height="40vh" />
</div>
<!-- URL 类型 -->
<template v-if="formModel.type === 'url'">
<n-form-item :label="$gettext('Request Method')">
<n-select
v-model:value="formModel.method"
:options="[
{ label: 'GET', value: 'GET' },
{ label: 'POST', value: 'POST' },
{ label: 'PUT', value: 'PUT' },
{ label: 'DELETE', value: 'DELETE' },
{ label: 'PATCH', value: 'PATCH' },
{ label: 'HEAD', value: 'HEAD' }
]"
/>
</n-form-item>
<n-form-item label="URL">
<n-input v-model:value="formModel.url" placeholder="https://example.com" />
</n-form-item>
<n-form-item :label="$gettext('Request Headers')">
<n-dynamic-input v-model:value="headerList" :on-create="() => ({ key: '', value: '' })">
<template #default="{ value }">
<div style="display: flex; align-items: center; width: 100%; gap: 8px">
<n-input
v-model:value="value.key"
:placeholder="$gettext('Header Name')"
style="flex: 1"
/>
<n-input
v-model:value="value.value"
:placeholder="$gettext('Header Value')"
style="flex: 2"
/>
</div>
</template>
</n-dynamic-input>
</n-form-item>
<n-form-item
v-if="
formModel.method === 'POST' ||
formModel.method === 'PUT' ||
formModel.method === 'PATCH'
"
:label="$gettext('Request Body')"
>
<n-input
v-model:value="formModel.body"
type="textarea"
:placeholder="$gettext('Request Body')"
:autosize="{ minRows: 3, maxRows: 10 }"
/>
</n-form-item>
<n-grid :cols="3" :x-gap="16">
<n-gi>
<n-form-item :label="$gettext('Timeout (seconds)')">
<n-input-number v-model:value="formModel.timeout" :min="0" w-full />
</n-form-item>
</n-gi>
<n-gi>
<n-form-item :label="$gettext('Retry Count')">
<n-input-number v-model:value="formModel.retries" :min="0" w-full />
</n-form-item>
</n-gi>
<n-gi>
<n-form-item :label="$gettext('Ignore Certificate')">
<n-switch v-model:value="formModel.insecure" />
</n-form-item>
</n-gi>
</n-grid>
</template>
<!-- 备份类型 -->
<n-form-item v-if="formModel.type === 'backup'" :label="$gettext('Backup Type')">
<n-radio-group v-model:value="formModel.sub_type">
@@ -284,7 +400,9 @@ onMounted(() => {
<n-form-item v-if="formModel.type === 'cutoff'" :label="$gettext('Rotation Type')">
<n-radio-group v-model:value="formModel.sub_type">
<n-radio value="website">{{ $gettext('Website') }}</n-radio>
<n-radio value="container" :disabled="!containerInstalled">{{ $gettext('Container') }}</n-radio>
<n-radio value="container" :disabled="!containerInstalled">{{
$gettext('Container')
}}</n-radio>
</n-radio-group>
</n-form-item>
<!-- 网站多选 -->
@@ -304,7 +422,10 @@ onMounted(() => {
</n-form-item>
<!-- 数据库多选 -->
<n-form-item
v-if="formModel.type === 'backup' && (formModel.sub_type === 'mysql' || formModel.sub_type === 'postgres')"
v-if="
formModel.type === 'backup' &&
(formModel.sub_type === 'mysql' || formModel.sub_type === 'postgres')
"
:label="$gettext('Select Database')"
>
<n-select
+5 -3
View File
@@ -50,7 +50,7 @@ const columns: any = [
return h(
NTag,
{
type: row.type === 'shell' ? 'warning' : row.type === 'backup' ? 'success' : 'info'
type: row.type === 'shell' ? 'warning' : row.type === 'backup' ? 'success' : row.type === 'url' ? 'default' : 'info'
},
{
default: () => {
@@ -58,7 +58,9 @@ const columns: any = [
? $gettext('Run Script')
: row.type === 'backup'
? $gettext('Backup Data')
: $gettext('Log Rotation')
: row.type === 'url'
? $gettext('Access URL')
: $gettext('Log Rotation')
}
}
)
@@ -209,7 +211,7 @@ const handleRun = (row: any) => {
}
const handleEdit = (row: any) => {
if (row.type === 'backup' || row.type === 'cutoff') {
if (row.type === 'backup' || row.type === 'cutoff' || row.type === 'url') {
// 可视化编辑
useRequest(cron.get(row.id)).onSuccess(({ data }) => {
visualEditData.value = data