mirror of
https://github.com/acepanel/panel.git
synced 2026-02-04 06:47:20 +08:00
feat: 支持实时日志流
This commit is contained in:
@@ -26,5 +26,4 @@ type CronRepo interface {
|
||||
Update(req *request.CronUpdate) error
|
||||
Delete(id uint) error
|
||||
Status(id uint, status bool) error
|
||||
Log(id uint) (string, error)
|
||||
}
|
||||
|
||||
@@ -147,8 +147,7 @@ func (r *websiteRepo) Get(id uint) (*types.WebsiteSetting, error) {
|
||||
rewrite, _ := io.Read(filepath.Join(app.Root, "server/vhost/rewrite", website.Name+".conf"))
|
||||
setting.Rewrite = rewrite
|
||||
// 访问日志
|
||||
log, _ := shell.Execf(`tail -n 100 '%s/wwwlogs/%s.log'`, app.Root, website.Name)
|
||||
setting.Log = log
|
||||
setting.Log = fmt.Sprintf("%s/wwwlogs/%s.log", app.Root, website.Name)
|
||||
|
||||
return setting, err
|
||||
}
|
||||
|
||||
@@ -128,7 +128,6 @@ func Http(r chi.Router) {
|
||||
r.Get("/{id}", cron.Get)
|
||||
r.Delete("/{id}", cron.Delete)
|
||||
r.Post("/{id}/status", cron.Status)
|
||||
r.Get("/{id}/log", cron.Log)
|
||||
})
|
||||
|
||||
r.Route("/safe", func(r chi.Router) {
|
||||
|
||||
@@ -114,19 +114,3 @@ func (s *CronService) Status(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
Success(w, nil)
|
||||
}
|
||||
|
||||
func (s *CronService) Log(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := Bind[request.ID](r)
|
||||
if err != nil {
|
||||
Error(w, http.StatusUnprocessableEntity, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
log, err := s.cronRepo.Log(req.ID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
Success(w, log)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"github.com/TheTNB/panel/internal/biz"
|
||||
"github.com/TheTNB/panel/internal/data"
|
||||
"github.com/TheTNB/panel/internal/http/request"
|
||||
"github.com/TheTNB/panel/pkg/shell"
|
||||
)
|
||||
|
||||
type TaskService struct {
|
||||
@@ -59,11 +58,6 @@ func (s *TaskService) Get(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
log, err := shell.Execf(`tail -n 500 '%s'`, task.Log)
|
||||
if err == nil {
|
||||
task.Log = log
|
||||
}
|
||||
|
||||
Success(w, task)
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@guolao/vue-monaco-editor": "^1.5.4",
|
||||
"@vue-js-cron/naive-ui": "^2.0.5",
|
||||
"@vueuse/core": "^11.1.0",
|
||||
"@xterm/addon-attach": "^0.11.0",
|
||||
"@xterm/addon-clipboard": "^0.1.0",
|
||||
@@ -29,6 +30,7 @@
|
||||
"@xterm/addon-webgl": "^0.18.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"axios": "^1.7.7",
|
||||
"cronstrue": "^2.50.0",
|
||||
"echarts": "^5.5.1",
|
||||
"install": "^0.13.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
|
||||
32
web/pnpm-lock.yaml
generated
32
web/pnpm-lock.yaml
generated
@@ -11,6 +11,9 @@ importers:
|
||||
'@guolao/vue-monaco-editor':
|
||||
specifier: ^1.5.4
|
||||
version: 1.5.4(monaco-editor@0.52.0)(vue@3.5.12(typescript@5.6.3))
|
||||
'@vue-js-cron/naive-ui':
|
||||
specifier: ^2.0.5
|
||||
version: 2.0.5
|
||||
'@vueuse/core':
|
||||
specifier: ^11.1.0
|
||||
version: 11.1.0(vue@3.5.12(typescript@5.6.3))
|
||||
@@ -35,6 +38,9 @@ importers:
|
||||
axios:
|
||||
specifier: ^1.7.7
|
||||
version: 1.7.7
|
||||
cronstrue:
|
||||
specifier: ^2.50.0
|
||||
version: 2.50.0
|
||||
echarts:
|
||||
specifier: ^5.5.1
|
||||
version: 5.5.1
|
||||
@@ -1235,6 +1241,12 @@ packages:
|
||||
'@volar/typescript@2.4.6':
|
||||
resolution: {integrity: sha512-NMIrA7y5OOqddL9VtngPWYmdQU03htNKFtAYidbYfWA0TOhyGVd9tfcP4TsLWQ+RBWDZCbBqsr8xzU0ZOxYTCQ==}
|
||||
|
||||
'@vue-js-cron/core@5.2.0':
|
||||
resolution: {integrity: sha512-Vc7Xbj6K/7D4M2yjO+lipeTBeE4OYXCf6FdDDIYKKMjbdGFG+Fs+V7W+6bvnH31lTZ8xSmSgqPF68/JhIfytZQ==}
|
||||
|
||||
'@vue-js-cron/naive-ui@2.0.5':
|
||||
resolution: {integrity: sha512-ngNsoRmkX5YpzAXJoP3vwvwRkDUMoe18iMEGYWz5txaNtR/0JUu+AanhIqmsOnmqhu7IDW+Mc11ceRbEc4R0mA==}
|
||||
|
||||
'@vue/compiler-core@3.5.12':
|
||||
resolution: {integrity: sha512-ISyBTRMmMYagUxhcpyEH0hpXRd/KqDU4ymofPgl2XAkY9ZhQ+h0ovEZJIiPop13UmR/54oA2cgMDjgroRelaEw==}
|
||||
|
||||
@@ -1568,6 +1580,10 @@ packages:
|
||||
crelt@1.0.6:
|
||||
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
|
||||
|
||||
cronstrue@2.50.0:
|
||||
resolution: {integrity: sha512-ULYhWIonJzlScCCQrPUG5uMXzXxSixty4djud9SS37DoNxDdkeRocxzHuAo4ImRBUK+mAuU5X9TSwEDccnnuPg==}
|
||||
hasBin: true
|
||||
|
||||
cross-spawn@7.0.3:
|
||||
resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -2425,6 +2441,10 @@ packages:
|
||||
muggle-string@0.4.1:
|
||||
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
|
||||
|
||||
mustache@4.2.0:
|
||||
resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==}
|
||||
hasBin: true
|
||||
|
||||
naive-ui@2.40.1:
|
||||
resolution: {integrity: sha512-3NkL+vLRQZKQxCHXa+7xiD6oM74OrQELaehDkGYRYpr6kjT+JJB+Z7h+5LC70gn8VkbgCAETv0+uRWF+6MLlgQ==}
|
||||
peerDependencies:
|
||||
@@ -4496,6 +4516,14 @@ snapshots:
|
||||
path-browserify: 1.0.1
|
||||
vscode-uri: 3.0.8
|
||||
|
||||
'@vue-js-cron/core@5.2.0':
|
||||
dependencies:
|
||||
mustache: 4.2.0
|
||||
|
||||
'@vue-js-cron/naive-ui@2.0.5':
|
||||
dependencies:
|
||||
'@vue-js-cron/core': 5.2.0
|
||||
|
||||
'@vue/compiler-core@3.5.12':
|
||||
dependencies:
|
||||
'@babel/parser': 7.25.8
|
||||
@@ -4885,6 +4913,8 @@ snapshots:
|
||||
|
||||
crelt@1.0.6: {}
|
||||
|
||||
cronstrue@2.50.0: {}
|
||||
|
||||
cross-spawn@7.0.3:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
@@ -5842,6 +5872,8 @@ snapshots:
|
||||
|
||||
muggle-string@0.4.1: {}
|
||||
|
||||
mustache@4.2.0: {}
|
||||
|
||||
naive-ui@2.40.1(vue@3.5.12(typescript@5.6.3)):
|
||||
dependencies:
|
||||
'@css-render/plugin-bem': 0.15.14(css-render@0.15.14)
|
||||
|
||||
@@ -1,417 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const emit = defineEmits(['update'])
|
||||
|
||||
const minutes = [...Array(60).keys()]
|
||||
const hours = [...Array(24).keys()]
|
||||
const days = [...Array(31).keys()].map((n) => n + 1)
|
||||
const months = [...Array(12).keys()].map((n) => n + 1)
|
||||
const weeks = [...Array(7).keys()].map((n) => n + 1)
|
||||
|
||||
const selectedMinuteOption = ref<string>('every')
|
||||
const selectedMinutes = ref<any>([])
|
||||
const cycleMinuteStart = ref<number>(0)
|
||||
const cycleMinuteEnd = ref<number>(0)
|
||||
const pointMinuteStart = ref<number>(0)
|
||||
const pointMinuteEnd = ref<number>(0)
|
||||
|
||||
const selectedHourOption = ref<string>('every')
|
||||
const selectedHours = ref<any>([])
|
||||
const cycleHourStart = ref<number>(0)
|
||||
const cycleHourEnd = ref<number>(0)
|
||||
const pointHourStart = ref<number>(0)
|
||||
const pointHourEnd = ref<number>(0)
|
||||
|
||||
const selectedDayOption = ref<string>('every')
|
||||
const selectedDays = ref<any>([])
|
||||
const cycleDayStart = ref<number>(1)
|
||||
const cycleDayEnd = ref<number>(1)
|
||||
const pointDayStart = ref<number>(1)
|
||||
const pointDayEnd = ref<number>(1)
|
||||
|
||||
const selectedMonthOption = ref<string>('every')
|
||||
const selectedMonths = ref<any>([])
|
||||
const cycleMonthStart = ref<number>(1)
|
||||
const cycleMonthEnd = ref<number>(1)
|
||||
const pointMonthStart = ref<number>(1)
|
||||
const pointMonthEnd = ref<number>(1)
|
||||
|
||||
const selectedWeekOption = ref<string>('every')
|
||||
const selectedWeeks = ref<any>([])
|
||||
const cycleWeekStart = ref<number>(1)
|
||||
const cycleWeekEnd = ref<number>(1)
|
||||
const pointWeekStart = ref<number>(1)
|
||||
const pointWeekEnd = ref<number>(1)
|
||||
|
||||
const cronExpression = ref<string>('')
|
||||
|
||||
function generateCronExpression() {
|
||||
let minuteStr = '*'
|
||||
if (selectedMinuteOption.value === 'specific') {
|
||||
if (selectedMinutes.value.length == 0) {
|
||||
minuteStr = '*'
|
||||
} else {
|
||||
minuteStr = selectedMinutes.value.join(',')
|
||||
}
|
||||
} else if (selectedMinuteOption.value === 'cycle') {
|
||||
minuteStr = `${cycleMinuteStart.value}-${cycleMinuteEnd.value}`
|
||||
} else if (selectedMinuteOption.value === 'point') {
|
||||
minuteStr = `${pointMinuteStart.value}/${pointMinuteEnd.value}`
|
||||
}
|
||||
|
||||
let hourStr = '*'
|
||||
if (selectedHourOption.value === 'specific') {
|
||||
if (selectedHours.value.length === 0) {
|
||||
hourStr = '*'
|
||||
} else {
|
||||
hourStr = selectedHours.value.join(',')
|
||||
}
|
||||
} else if (selectedHourOption.value === 'cycle') {
|
||||
hourStr = `${cycleHourStart.value}-${cycleHourEnd.value}`
|
||||
} else if (selectedHourOption.value === 'point') {
|
||||
hourStr = `${pointHourStart.value}/${pointHourEnd.value}`
|
||||
}
|
||||
|
||||
let dayStr = '*'
|
||||
if (selectedDayOption.value === 'specific') {
|
||||
if (selectedDays.value.length === 0) {
|
||||
dayStr = '*'
|
||||
} else {
|
||||
dayStr = selectedDays.value.join(',')
|
||||
}
|
||||
} else if (selectedDayOption.value === 'cycle') {
|
||||
dayStr = `${cycleDayStart.value}-${cycleDayEnd.value}`
|
||||
} else if (selectedDayOption.value === 'point') {
|
||||
dayStr = `${pointDayStart.value}/${pointDayEnd.value}`
|
||||
}
|
||||
|
||||
let monthStr = '*'
|
||||
if (selectedMonthOption.value === 'specific') {
|
||||
if (selectedMonths.value.length === 0) {
|
||||
monthStr = '*'
|
||||
} else {
|
||||
monthStr = selectedMonths.value.join(',')
|
||||
}
|
||||
} else if (selectedMonthOption.value === 'cycle') {
|
||||
monthStr = `${cycleMonthStart.value}-${cycleMonthEnd.value}`
|
||||
} else if (selectedMonthOption.value === 'point') {
|
||||
monthStr = `${pointMonthStart.value}/${pointMonthEnd.value}`
|
||||
}
|
||||
|
||||
let weekStr = '*'
|
||||
if (selectedWeekOption.value === 'specific') {
|
||||
if (selectedWeeks.value.length === 0) {
|
||||
weekStr = '*'
|
||||
} else {
|
||||
weekStr = selectedWeeks.value.join(',')
|
||||
}
|
||||
} else if (selectedWeekOption.value === 'cycle') {
|
||||
weekStr = `${cycleWeekStart.value}-${cycleWeekEnd.value}`
|
||||
} else if (selectedWeekOption.value === 'point') {
|
||||
weekStr = `${pointWeekStart.value}/${pointWeekEnd.value}`
|
||||
}
|
||||
|
||||
cronExpression.value = `${minuteStr} ${hourStr} ${dayStr} ${monthStr} ${weekStr}`
|
||||
}
|
||||
|
||||
watch(
|
||||
[
|
||||
selectedMinutes,
|
||||
selectedHours,
|
||||
selectedDays,
|
||||
selectedMonths,
|
||||
selectedWeeks,
|
||||
selectedMinuteOption,
|
||||
selectedHourOption,
|
||||
selectedDayOption,
|
||||
selectedMonthOption,
|
||||
selectedWeekOption
|
||||
],
|
||||
() => {
|
||||
generateCronExpression()
|
||||
emit('update', cronExpression.value)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-card>
|
||||
<n-tabs type="line" animated>
|
||||
<n-tab-pane name="minute" tab="分">
|
||||
<n-space vertical>
|
||||
<n-radio
|
||||
value="every"
|
||||
@change="selectedMinuteOption = 'every'"
|
||||
:checked="selectedMinuteOption === 'every'"
|
||||
>
|
||||
每分
|
||||
</n-radio>
|
||||
<n-space>
|
||||
<n-radio
|
||||
value="cycle"
|
||||
@change="selectedMinuteOption = 'cycle'"
|
||||
:checked="selectedMinuteOption === 'cycle'"
|
||||
>
|
||||
周期
|
||||
</n-radio>
|
||||
从
|
||||
<n-input-number v-model:value="cycleMinuteStart" size="tiny" w-100></n-input-number>
|
||||
-
|
||||
<n-input-number v-model:value="cycleMinuteEnd" size="tiny" w-100></n-input-number>
|
||||
分 (0-59)
|
||||
</n-space>
|
||||
<n-space>
|
||||
<n-radio
|
||||
value="point"
|
||||
@change="selectedMinuteOption = 'point'"
|
||||
:checked="selectedMinuteOption === 'point'"
|
||||
>
|
||||
按照
|
||||
</n-radio>
|
||||
从
|
||||
<n-input-number v-model:value="pointMinuteStart" size="tiny" w-100></n-input-number>
|
||||
分开始,每
|
||||
<n-input-number v-model:value="pointMinuteEnd" size="tiny" w-100></n-input-number>
|
||||
分执行一次 (0/60)
|
||||
</n-space>
|
||||
<n-radio
|
||||
value="specific"
|
||||
@change="selectedMinuteOption = 'specific'"
|
||||
:checked="selectedMinuteOption === 'specific'"
|
||||
>
|
||||
指定
|
||||
</n-radio>
|
||||
<n-space>
|
||||
<n-checkbox-group
|
||||
:value="selectedMinutes"
|
||||
:disabled="selectedMinuteOption !== 'specific'"
|
||||
@update:value="selectedMinutes = $event"
|
||||
>
|
||||
<n-checkbox v-for="item in minutes" :key="item" :value="item" :label="String(item)" />
|
||||
</n-checkbox-group>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="hour" tab="时">
|
||||
<n-space vertical>
|
||||
<n-radio
|
||||
value="every"
|
||||
@change="selectedHourOption = 'every'"
|
||||
:checked="selectedHourOption === 'every'"
|
||||
>
|
||||
每时
|
||||
</n-radio>
|
||||
<n-space>
|
||||
<n-radio
|
||||
value="cycle"
|
||||
@change="selectedHourOption = 'cycle'"
|
||||
:checked="selectedHourOption === 'cycle'"
|
||||
>
|
||||
周期
|
||||
</n-radio>
|
||||
从
|
||||
<n-input-number v-model:value="cycleHourStart" size="tiny" w-100></n-input-number>
|
||||
-
|
||||
<n-input-number v-model:value="cycleHourEnd" size="tiny" w-100></n-input-number>
|
||||
时 (0-23)
|
||||
</n-space>
|
||||
<n-space>
|
||||
<n-radio
|
||||
value="point"
|
||||
@change="selectedHourOption = 'point'"
|
||||
:checked="selectedHourOption === 'point'"
|
||||
>
|
||||
按照
|
||||
</n-radio>
|
||||
从
|
||||
<n-input-number v-model:value="pointHourStart" size="tiny" w-100></n-input-number>
|
||||
时开始,每
|
||||
<n-input-number v-model:value="pointHourEnd" size="tiny" w-100></n-input-number>
|
||||
时执行一次 (0/24)
|
||||
</n-space>
|
||||
<n-radio
|
||||
value="specific"
|
||||
@change="selectedHourOption = 'specific'"
|
||||
:checked="selectedHourOption === 'specific'"
|
||||
>
|
||||
指定
|
||||
</n-radio>
|
||||
<n-space>
|
||||
<n-checkbox-group
|
||||
:value="selectedHours"
|
||||
:disabled="selectedHourOption !== 'specific'"
|
||||
@update:value="selectedHours = $event"
|
||||
>
|
||||
<n-checkbox v-for="item in hours" :key="item" :value="item" :label="String(item)" />
|
||||
</n-checkbox-group>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="day" tab="日">
|
||||
<n-space vertical>
|
||||
<n-radio
|
||||
value="every"
|
||||
@change="selectedDayOption = 'every'"
|
||||
:checked="selectedDayOption === 'every'"
|
||||
>
|
||||
每日
|
||||
</n-radio>
|
||||
<n-space>
|
||||
<n-radio
|
||||
value="cycle"
|
||||
@change="selectedDayOption = 'cycle'"
|
||||
:checked="selectedDayOption === 'cycle'"
|
||||
>
|
||||
周期
|
||||
</n-radio>
|
||||
从
|
||||
<n-input-number v-model:value="cycleDayStart" size="tiny" w-100></n-input-number>
|
||||
-
|
||||
<n-input-number v-model:value="cycleDayEnd" size="tiny" w-100></n-input-number>
|
||||
日 (1-31)
|
||||
</n-space>
|
||||
<n-space>
|
||||
<n-radio
|
||||
value="point"
|
||||
@change="selectedDayOption = 'point'"
|
||||
:checked="selectedDayOption === 'point'"
|
||||
>
|
||||
按照
|
||||
</n-radio>
|
||||
从
|
||||
<n-input-number v-model:value="pointDayStart" size="tiny" w-100></n-input-number>
|
||||
日开始,每
|
||||
<n-input-number v-model:value="pointDayEnd" size="tiny" w-100></n-input-number>
|
||||
日执行一次 (1/31)
|
||||
</n-space>
|
||||
<n-radio
|
||||
value="specific"
|
||||
@change="selectedDayOption = 'specific'"
|
||||
:checked="selectedDayOption === 'specific'"
|
||||
>
|
||||
指定
|
||||
</n-radio>
|
||||
<n-space>
|
||||
<n-checkbox-group
|
||||
:value="selectedDays"
|
||||
:disabled="selectedDayOption !== 'specific'"
|
||||
@update:value="selectedDays = $event"
|
||||
>
|
||||
<n-checkbox v-for="item in days" :key="item" :value="item" :label="String(item)" />
|
||||
</n-checkbox-group>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="month" tab="月">
|
||||
<n-space vertical>
|
||||
<n-radio
|
||||
value="every"
|
||||
@change="selectedMonthOption = 'every'"
|
||||
:checked="selectedMonthOption === 'every'"
|
||||
>
|
||||
每月
|
||||
</n-radio>
|
||||
<n-space>
|
||||
<n-radio
|
||||
value="cycle"
|
||||
@change="selectedMonthOption = 'cycle'"
|
||||
:checked="selectedMonthOption === 'cycle'"
|
||||
>
|
||||
周期
|
||||
</n-radio>
|
||||
从
|
||||
<n-input-number v-model:value="cycleMonthStart" size="tiny" w-100></n-input-number>
|
||||
-
|
||||
<n-input-number v-model:value="cycleMonthEnd" size="tiny" w-100></n-input-number>
|
||||
月 (1-12)
|
||||
</n-space>
|
||||
<n-space>
|
||||
<n-radio
|
||||
value="point"
|
||||
@change="selectedMonthOption = 'point'"
|
||||
:checked="selectedMonthOption === 'point'"
|
||||
>
|
||||
按照
|
||||
</n-radio>
|
||||
从
|
||||
<n-input-number v-model:value="pointMonthStart" size="tiny" w-100></n-input-number>
|
||||
月开始,每
|
||||
<n-input-number v-model:value="pointMonthEnd" size="tiny" w-100></n-input-number>
|
||||
月执行一次 (1/12)
|
||||
</n-space>
|
||||
<n-radio
|
||||
value="specific"
|
||||
@change="selectedMonthOption = 'specific'"
|
||||
:checked="selectedMonthOption === 'specific'"
|
||||
>
|
||||
指定
|
||||
</n-radio>
|
||||
<n-space>
|
||||
<n-checkbox-group
|
||||
:value="selectedMonths"
|
||||
:disabled="selectedMonthOption !== 'specific'"
|
||||
@update:value="selectedMonths = $event"
|
||||
>
|
||||
<n-checkbox v-for="item in months" :key="item" :value="item" :label="String(item)" />
|
||||
</n-checkbox-group>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="week" tab="周">
|
||||
<n-space vertical>
|
||||
<n-radio
|
||||
value="every"
|
||||
@change="selectedWeekOption = 'every'"
|
||||
:checked="selectedWeekOption === 'every'"
|
||||
>
|
||||
每周
|
||||
</n-radio>
|
||||
<n-space>
|
||||
<n-radio
|
||||
value="cycle"
|
||||
@change="selectedWeekOption = 'cycle'"
|
||||
:checked="selectedWeekOption === 'cycle'"
|
||||
>
|
||||
周期
|
||||
</n-radio>
|
||||
从
|
||||
<n-input-number v-model:value="cycleWeekStart" size="tiny" w-100></n-input-number>
|
||||
-
|
||||
<n-input-number v-model:value="cycleWeekEnd" size="tiny" w-100></n-input-number>
|
||||
周 (1-7)
|
||||
</n-space>
|
||||
<n-space>
|
||||
<n-radio
|
||||
value="point"
|
||||
@change="selectedWeekOption = 'point'"
|
||||
:checked="selectedWeekOption === 'point'"
|
||||
>
|
||||
按照
|
||||
</n-radio>
|
||||
第
|
||||
<n-input-number v-model:value="pointWeekStart" size="tiny" w-100></n-input-number>
|
||||
周的星期
|
||||
<n-input-number v-model:value="pointWeekEnd" size="tiny" w-100></n-input-number>
|
||||
(1-4 / 1-7)
|
||||
</n-space>
|
||||
<n-radio
|
||||
value="specific"
|
||||
@change="selectedWeekOption = 'specific'"
|
||||
:checked="selectedWeekOption === 'specific'"
|
||||
>
|
||||
指定
|
||||
</n-radio>
|
||||
<n-space>
|
||||
<n-checkbox-group
|
||||
:value="selectedWeeks"
|
||||
:disabled="selectedWeekOption !== 'specific'"
|
||||
@update:value="selectedWeeks = $event"
|
||||
>
|
||||
<n-checkbox v-for="item in weeks" :key="item" :value="item" :label="String(item)" />
|
||||
</n-checkbox-group>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-card>
|
||||
</template>
|
||||
76
web/src/components/common/RealtimeLog.vue
Normal file
76
web/src/components/common/RealtimeLog.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
import ws from '@/api/ws'
|
||||
import type { LogInst } from 'naive-ui'
|
||||
|
||||
const show = defineModel<boolean>('show', { type: Boolean, required: true })
|
||||
const props = defineProps({
|
||||
path: String
|
||||
})
|
||||
|
||||
const log = ref('')
|
||||
const logRef = ref<LogInst | null>(null)
|
||||
let logWs: WebSocket | null = null
|
||||
|
||||
const init = async () => {
|
||||
const cmd = `tail -n 40 -f ${props.path}`
|
||||
ws.exec(cmd)
|
||||
.then((ws: WebSocket) => {
|
||||
logWs = ws
|
||||
ws.onmessage = (event) => {
|
||||
log.value += event.data + '\n'
|
||||
const lines = log.value.split('\n')
|
||||
if (lines.length > 2000) {
|
||||
log.value = lines.slice(lines.length - 2000).join('\n')
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
window.$message.error('获取日志流失败')
|
||||
})
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
if (logWs) {
|
||||
logWs.close()
|
||||
}
|
||||
log.value = ''
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.path,
|
||||
() => {
|
||||
handleClose()
|
||||
init()
|
||||
}
|
||||
)
|
||||
|
||||
watchEffect(() => {
|
||||
if (log.value) {
|
||||
nextTick(() => {
|
||||
logRef.value?.scrollTo({ position: 'bottom', silent: true })
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
init
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-modal
|
||||
v-model:show="show"
|
||||
preset="card"
|
||||
title="日志"
|
||||
style="width: 80vw"
|
||||
size="huge"
|
||||
:bordered="false"
|
||||
:segmented="false"
|
||||
@close="handleClose"
|
||||
@mask-click="handleClose"
|
||||
>
|
||||
<n-log ref="logRef" :log="log" trim :rows="40" />
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -41,9 +41,9 @@
|
||||
"cache": "缓存更新成功",
|
||||
"warning": "更新应用前强烈建议先备份/快照,以免出现问题时无法回滚!",
|
||||
"setup": "设置成功",
|
||||
"install": "任务已提交,请稍后查看任务进度",
|
||||
"update": "任务已提交,请前往后台任务查看任务进度",
|
||||
"uninstall": "任务已提交,请前往后台任务查看任务进度"
|
||||
"install": "任务已提交,请前往任务->后台任务查看任务进度",
|
||||
"update": "任务已提交,请前往任务->后台任务查看任务进度",
|
||||
"uninstall": "任务已提交,请前往任务->后台任务查看任务进度"
|
||||
},
|
||||
"buttons": {
|
||||
"updateCache": "更新缓存",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import '@/styles/index.scss'
|
||||
import '@/styles/reset.css'
|
||||
import '@vue-js-cron/naive-ui/dist/naive-ui.css'
|
||||
import 'uno.css'
|
||||
|
||||
import { createApp } from 'vue'
|
||||
@@ -13,6 +14,7 @@ import { setupNaiveDiscreteApi } from './utils'
|
||||
import { install as VueMonacoEditorPlugin } from '@guolao/vue-monaco-editor'
|
||||
|
||||
import dashboard from '@/api/panel/dashboard'
|
||||
import CronNaivePlugin, { CronNaive } from '@vue-js-cron/naive-ui'
|
||||
|
||||
async function setupApp() {
|
||||
const app = createApp(App)
|
||||
@@ -24,6 +26,8 @@ async function setupApp() {
|
||||
availableLanguages: { '*': 'zh-cn' }
|
||||
}
|
||||
})
|
||||
app.use(CronNaivePlugin)
|
||||
app.component('CronNaive', CronNaive)
|
||||
await setupStore(app)
|
||||
await setupNaiveDiscreteApi()
|
||||
await setupPanel().then(() => {
|
||||
|
||||
@@ -8,12 +8,12 @@ import ImageView from '@/views/container/ImageView.vue'
|
||||
import NetworkView from '@/views/container/NetworkView.vue'
|
||||
import VolumeView from '@/views/container/VolumeView.vue'
|
||||
|
||||
const currentTab = ref('container')
|
||||
const current = ref('container')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<common-page show-footer>
|
||||
<n-tabs v-model:value="currentTab" type="line" animated size="large">
|
||||
<n-tabs v-model:value="current" type="line" animated size="large">
|
||||
<n-tab-pane name="container" tab="容器">
|
||||
<container-view />
|
||||
</n-tab-pane>
|
||||
|
||||
@@ -1,475 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'cron-index'
|
||||
})
|
||||
|
||||
import Editor from '@guolao/vue-monaco-editor'
|
||||
import { NButton, NDataTable, NInput, NPopconfirm, NSwitch } from 'naive-ui'
|
||||
|
||||
import cron from '@/api/panel/cron'
|
||||
import dashboard from '@/api/panel/dashboard'
|
||||
import file from '@/api/panel/file'
|
||||
import website from '@/api/panel/website'
|
||||
import { formatDateTime, renderIcon } from '@/utils'
|
||||
import type { CronTask } from '@/views/cron/types'
|
||||
|
||||
const addModel = ref({
|
||||
name: '',
|
||||
type: 'shell',
|
||||
target: '',
|
||||
save: 1,
|
||||
backup_type: 'website',
|
||||
backup_path: '',
|
||||
script: '# 在此输入您要执行的脚本内容',
|
||||
time: '* * * * *'
|
||||
})
|
||||
|
||||
const cronSelectModal = ref(false)
|
||||
const taskLogModal = ref(false)
|
||||
const editTaskModal = ref(false)
|
||||
|
||||
const installedDbAndPhp = ref({
|
||||
php: [
|
||||
{
|
||||
label: '',
|
||||
value: ''
|
||||
}
|
||||
],
|
||||
db: [
|
||||
{
|
||||
label: '',
|
||||
value: ''
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const mySQLInstalled = computed(() => {
|
||||
return installedDbAndPhp.value.db.find((item) => item.value === 'mysql')
|
||||
})
|
||||
|
||||
const postgreSQLInstalled = computed(() => {
|
||||
return installedDbAndPhp.value.db.find((item) => item.value === 'postgresql')
|
||||
})
|
||||
|
||||
const websites = ref<any>([])
|
||||
|
||||
const columns: any = [
|
||||
{ type: 'selection', fixed: 'left' },
|
||||
{
|
||||
title: '任务名',
|
||||
key: 'name',
|
||||
minWidth: 150,
|
||||
resizable: true,
|
||||
ellipsis: { tooltip: true }
|
||||
},
|
||||
{
|
||||
title: '任务类型',
|
||||
key: 'type',
|
||||
width: 100,
|
||||
resizable: true,
|
||||
render(row: any) {
|
||||
return row.type === 'shell' ? '运行脚本' : row.type === 'backup' ? '备份数据' : '切割日志'
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '启用',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
resizable: true,
|
||||
render(row: any) {
|
||||
return h(NSwitch, {
|
||||
size: 'small',
|
||||
rubberBand: false,
|
||||
value: row.status,
|
||||
onUpdateValue: () => handleStatusChange(row)
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '任务周期',
|
||||
key: 'time',
|
||||
width: 100,
|
||||
resizable: true,
|
||||
ellipsis: { tooltip: true }
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
key: 'created_at',
|
||||
width: 200,
|
||||
resizable: true,
|
||||
ellipsis: { tooltip: true },
|
||||
render(row: any): string {
|
||||
return formatDateTime(row.created_at)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '最后更新时间',
|
||||
key: 'updated_at',
|
||||
width: 200,
|
||||
ellipsis: { tooltip: true },
|
||||
render(row: any): string {
|
||||
return formatDateTime(row.updated_at)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 280,
|
||||
align: 'center',
|
||||
hideInExcel: true,
|
||||
render(row: any) {
|
||||
return [
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
type: 'warning',
|
||||
secondary: true,
|
||||
onClick: () => handleShowLog(row)
|
||||
},
|
||||
{
|
||||
default: () => '日志',
|
||||
icon: renderIcon('majesticons:eye-line', { size: 14 })
|
||||
}
|
||||
),
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
type: 'primary',
|
||||
style: 'margin-left: 15px;',
|
||||
onClick: () => handleEdit(row)
|
||||
},
|
||||
{
|
||||
default: () => '修改',
|
||||
icon: renderIcon('material-symbols:edit-outline', { size: 14 })
|
||||
}
|
||||
),
|
||||
h(
|
||||
NPopconfirm,
|
||||
{
|
||||
onPositiveClick: () => handleDelete(row.id)
|
||||
},
|
||||
{
|
||||
default: () => {
|
||||
return '确定删除任务吗?'
|
||||
},
|
||||
trigger: () => {
|
||||
return h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
type: 'error',
|
||||
style: 'margin-left: 15px;'
|
||||
},
|
||||
{
|
||||
default: () => '删除',
|
||||
icon: renderIcon('material-symbols:delete-outline', { size: 14 })
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
pageCount: 1,
|
||||
pageSize: 20,
|
||||
itemCount: 0,
|
||||
showQuickJumper: true,
|
||||
showSizePicker: true,
|
||||
pageSizes: [20, 50, 100, 200]
|
||||
})
|
||||
|
||||
const data = ref<CronTask[]>([] as CronTask[])
|
||||
|
||||
const taskLog = ref('')
|
||||
const editTask = ref({
|
||||
id: 0,
|
||||
name: '',
|
||||
time: '',
|
||||
script: ''
|
||||
})
|
||||
|
||||
const getTaskList = async (page: number, limit: number) => {
|
||||
const { data } = await cron.list(page, limit)
|
||||
return data
|
||||
}
|
||||
|
||||
const getWebsiteList = async (page: number, limit: number) => {
|
||||
const { data } = await website.list(page, limit)
|
||||
for (const item of data.items) {
|
||||
websites.value.push({
|
||||
label: item.name,
|
||||
value: item.name
|
||||
})
|
||||
}
|
||||
addModel.value.target = websites.value[0]?.value
|
||||
}
|
||||
|
||||
const getPhpAndDb = async () => {
|
||||
const { data } = await dashboard.installedDbAndPhp()
|
||||
installedDbAndPhp.value = data
|
||||
}
|
||||
|
||||
const onPageChange = (page: number) => {
|
||||
pagination.page = page
|
||||
getTaskList(page, pagination.pageSize).then((res) => {
|
||||
data.value = res.items
|
||||
pagination.itemCount = res.total
|
||||
pagination.pageCount = res.total / pagination.pageSize + 1
|
||||
})
|
||||
}
|
||||
|
||||
const onPageSizeChange = (pageSize: number) => {
|
||||
pagination.pageSize = pageSize
|
||||
onPageChange(1)
|
||||
}
|
||||
|
||||
const handleStatusChange = async (row: any) => {
|
||||
cron.status(row.id, !row.status).then(() => {
|
||||
row.status = !row.status
|
||||
window.$message.success('修改成功')
|
||||
})
|
||||
}
|
||||
|
||||
const handleShowLog = async (row: any) => {
|
||||
cron.log(row.id).then((res) => {
|
||||
taskLog.value = res.data
|
||||
taskLogModal.value = true
|
||||
})
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
await cron.create(addModel.value).then(() => {
|
||||
window.$message.success('创建成功')
|
||||
})
|
||||
onPageChange(pagination.page)
|
||||
}
|
||||
|
||||
const handleEdit = async (row: any) => {
|
||||
await cron.get(row.id).then(async (res) => {
|
||||
await file.content(res.data.shell).then((res) => {
|
||||
editTask.value.id = row.id
|
||||
editTask.value.name = row.name
|
||||
editTask.value.time = row.time
|
||||
editTask.value.script = res.data
|
||||
editTaskModal.value = true
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
await cron.delete(id).then(() => {
|
||||
window.$message.success('删除成功')
|
||||
})
|
||||
onPageChange(pagination.page)
|
||||
}
|
||||
|
||||
const saveTaskEdit = async () => {
|
||||
cron
|
||||
.update(editTask.value.id, editTask.value.name, editTask.value.time, editTask.value.script)
|
||||
.then(() => {
|
||||
window.$message.success('修改成功')
|
||||
})
|
||||
}
|
||||
|
||||
const handleCronSelectUpdate = (value: string) => {
|
||||
if (editTaskModal.value) {
|
||||
editTask.value.time = value
|
||||
return
|
||||
}
|
||||
|
||||
addModel.value.time = value
|
||||
}
|
||||
|
||||
watch(addModel, (value) => {
|
||||
if (value.backup_type === 'website') {
|
||||
addModel.value.target = websites.value[0]?.value
|
||||
} else {
|
||||
addModel.value.target = ''
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
getPhpAndDb()
|
||||
getWebsiteList(1, 10000)
|
||||
onPageChange(pagination.page)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<common-page show-footer>
|
||||
<n-space vertical>
|
||||
<n-card flex-1 rounded-10 title="创建计划任务">
|
||||
<n-space vertical>
|
||||
<n-alert type="info">
|
||||
面板的计划任务均基于脚本运行,若任务类型满足不了需求,可自行修改对应的脚本。
|
||||
</n-alert>
|
||||
<n-form>
|
||||
<n-form-item label="任务类型">
|
||||
<n-select
|
||||
v-model:value="addModel.type"
|
||||
:options="[
|
||||
{ label: '运行脚本', value: 'shell' },
|
||||
{ label: '备份数据', value: 'backup' },
|
||||
{ label: '切割日志', value: 'cutoff' }
|
||||
]"
|
||||
>
|
||||
</n-select>
|
||||
</n-form-item>
|
||||
<n-form-item label="任务名称">
|
||||
<n-input v-model:value="addModel.name" placeholder="任务名称" />
|
||||
</n-form-item>
|
||||
<n-form-item label="任务周期">
|
||||
<n-input
|
||||
v-model:value="addModel.time"
|
||||
placeholder="* * * * *"
|
||||
@click="cronSelectModal = true"
|
||||
/>
|
||||
</n-form-item>
|
||||
<div v-if="addModel.type === 'shell'">
|
||||
<n-text>脚本内容</n-text>
|
||||
<Editor
|
||||
v-model:value="addModel.script"
|
||||
language="shell"
|
||||
theme="vs-dark"
|
||||
height="40vh"
|
||||
mt-8
|
||||
:options="{
|
||||
automaticLayout: true,
|
||||
formatOnType: true,
|
||||
formatOnPaste: true
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<n-form-item v-if="addModel.type === 'backup'" label="备份类型">
|
||||
<n-radio-group v-model:value="addModel.backup_type">
|
||||
<n-radio value="website">网站目录</n-radio>
|
||||
<n-radio value="mysql" :disabled="!mySQLInstalled"> MySQL 数据库</n-radio>
|
||||
<n-radio value="postgres" :disabled="!postgreSQLInstalled">
|
||||
PostgreSQL 数据库
|
||||
</n-radio>
|
||||
</n-radio-group>
|
||||
</n-form-item>
|
||||
<n-form-item
|
||||
v-if="
|
||||
(addModel.backup_type === 'website' && addModel.type === 'backup') ||
|
||||
addModel.type === 'cutoff'
|
||||
"
|
||||
label="选择网站"
|
||||
>
|
||||
<n-select
|
||||
v-model:value="addModel.target"
|
||||
:options="websites"
|
||||
placeholder="选择网站"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item
|
||||
v-if="addModel.backup_type !== 'website' && addModel.type === 'backup'"
|
||||
label="数据库名"
|
||||
>
|
||||
<n-input v-model:value="addModel.target" placeholder="数据库名" />
|
||||
</n-form-item>
|
||||
<n-form-item v-if="addModel.type === 'backup'" label="保存目录">
|
||||
<n-input v-model:value="addModel.backup_path" placeholder="保存目录" />
|
||||
</n-form-item>
|
||||
<n-form-item v-if="addModel.type !== 'shell'" label="保留份数">
|
||||
<n-input-number v-model:value="addModel.save" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-button type="primary" @click="handleCreate">创建</n-button>
|
||||
</n-space>
|
||||
</n-card>
|
||||
<n-card flex-1 rounded-10 title="计划任务列表">
|
||||
<n-data-table
|
||||
striped
|
||||
remote
|
||||
:scroll-x="1000"
|
||||
:data="data"
|
||||
:columns="columns"
|
||||
:row-key="(row: any) => row.id"
|
||||
:pagination="pagination"
|
||||
:bordered="false"
|
||||
:loading="false"
|
||||
@update:page="onPageChange"
|
||||
@update:page-size="onPageSizeChange"
|
||||
/>
|
||||
</n-card>
|
||||
</n-space>
|
||||
</common-page>
|
||||
<n-modal
|
||||
v-model:show="cronSelectModal"
|
||||
preset="card"
|
||||
title="Cron 表达式生成"
|
||||
style="width: 60vw"
|
||||
size="huge"
|
||||
:bordered="false"
|
||||
:segmented="false"
|
||||
>
|
||||
<cron-select @update="handleCronSelectUpdate" />
|
||||
</n-modal>
|
||||
<n-modal
|
||||
v-model:show="taskLogModal"
|
||||
preset="card"
|
||||
title="任务日志"
|
||||
style="width: 80vw"
|
||||
size="huge"
|
||||
:bordered="false"
|
||||
:segmented="false"
|
||||
>
|
||||
<Editor
|
||||
v-model:value="taskLog"
|
||||
language="ini"
|
||||
theme="vs-dark"
|
||||
height="60vh"
|
||||
mt-8
|
||||
:options="{
|
||||
automaticLayout: true,
|
||||
formatOnType: true,
|
||||
formatOnPaste: true,
|
||||
readOnly: true
|
||||
}"
|
||||
/>
|
||||
</n-modal>
|
||||
<n-modal
|
||||
v-model:show="editTaskModal"
|
||||
preset="card"
|
||||
title="编辑任务"
|
||||
style="width: 80vw"
|
||||
size="huge"
|
||||
:bordered="false"
|
||||
:segmented="false"
|
||||
@close="saveTaskEdit"
|
||||
>
|
||||
<n-form inline>
|
||||
<n-form-item label="任务名称">
|
||||
<n-input v-model:value="editTask.name" placeholder="任务名称" />
|
||||
</n-form-item>
|
||||
<n-form-item label="任务周期">
|
||||
<n-input
|
||||
v-model:value="editTask.time"
|
||||
placeholder="* * * * *"
|
||||
@click="cronSelectModal = true"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<Editor
|
||||
v-model:value="editTask.script"
|
||||
language="shell"
|
||||
theme="vs-dark"
|
||||
height="60vh"
|
||||
mt-8
|
||||
:options="{
|
||||
automaticLayout: true,
|
||||
formatOnType: true,
|
||||
formatOnPaste: true
|
||||
}"
|
||||
/>
|
||||
</n-modal>
|
||||
</template>
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { RouteType } from '~/types/router'
|
||||
|
||||
const Layout = () => import('@/layout/IndexView.vue')
|
||||
|
||||
export default {
|
||||
name: 'cron',
|
||||
path: '/cron',
|
||||
component: Layout,
|
||||
meta: {
|
||||
order: 70
|
||||
},
|
||||
children: [
|
||||
{
|
||||
name: 'cron-index',
|
||||
path: '',
|
||||
component: () => import('./IndexView.vue'),
|
||||
meta: {
|
||||
title: 'cronIndex.title',
|
||||
icon: 'mdi:timer-outline',
|
||||
role: ['admin'],
|
||||
requireAuth: true
|
||||
}
|
||||
}
|
||||
]
|
||||
} as RouteType
|
||||
@@ -1,11 +0,0 @@
|
||||
export interface CronTask {
|
||||
id: number
|
||||
name: string
|
||||
status: boolean
|
||||
type: string
|
||||
time: string
|
||||
shell: string
|
||||
log: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
@@ -16,8 +16,8 @@ export default {
|
||||
path: 'home',
|
||||
component: () => import('./IndexView.vue'),
|
||||
meta: {
|
||||
title: '仪表盘',
|
||||
icon: 'mdi:speedometer',
|
||||
title: '首页',
|
||||
icon: 'mdi:home-outline',
|
||||
role: ['admin'],
|
||||
requireAuth: true
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ export default {
|
||||
path: '/ssh',
|
||||
component: Layout,
|
||||
meta: {
|
||||
order: 80
|
||||
order: 70
|
||||
},
|
||||
children: [
|
||||
{
|
||||
|
||||
171
web/src/views/task/CreateModal.vue
Normal file
171
web/src/views/task/CreateModal.vue
Normal file
@@ -0,0 +1,171 @@
|
||||
<script setup lang="ts">
|
||||
import cron from '@/api/panel/cron'
|
||||
import dashboard from '@/api/panel/dashboard'
|
||||
import website from '@/api/panel/website'
|
||||
import Editor from '@guolao/vue-monaco-editor'
|
||||
import { CronNaive } from '@vue-js-cron/naive-ui'
|
||||
import { NInput } from 'naive-ui'
|
||||
|
||||
const show = defineModel<boolean>('show', { type: Boolean, required: true })
|
||||
const loading = ref(false)
|
||||
|
||||
const createModel = ref({
|
||||
name: '',
|
||||
type: 'shell',
|
||||
target: '',
|
||||
save: 1,
|
||||
backup_type: 'website',
|
||||
backup_path: '',
|
||||
script: '# 在此输入您要执行的脚本内容',
|
||||
time: '* * * * *'
|
||||
})
|
||||
|
||||
const websites = ref<any>([])
|
||||
const installedDbAndPhp = ref({
|
||||
php: [
|
||||
{
|
||||
label: '',
|
||||
value: ''
|
||||
}
|
||||
],
|
||||
db: [
|
||||
{
|
||||
label: '',
|
||||
value: ''
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const mySQLInstalled = computed(() => {
|
||||
return installedDbAndPhp.value.db.find((item) => item.value === 'mysql')
|
||||
})
|
||||
|
||||
const postgreSQLInstalled = computed(() => {
|
||||
return installedDbAndPhp.value.db.find((item) => item.value === 'postgresql')
|
||||
})
|
||||
|
||||
const getWebsiteList = async (page: number, limit: number) => {
|
||||
const { data } = await website.list(page, limit)
|
||||
for (const item of data.items) {
|
||||
websites.value.push({
|
||||
label: item.name,
|
||||
value: item.name
|
||||
})
|
||||
}
|
||||
createModel.value.target = websites.value[0]?.value
|
||||
}
|
||||
|
||||
const getPhpAndDb = async () => {
|
||||
const { data } = await dashboard.installedDbAndPhp()
|
||||
installedDbAndPhp.value = data
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
loading.value = true
|
||||
await cron
|
||||
.create(createModel.value)
|
||||
.then(() => {
|
||||
window.$message.success('创建成功')
|
||||
loading.value = false
|
||||
show.value = false
|
||||
})
|
||||
.catch(() => {
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
watch(createModel, (value) => {
|
||||
if (value.backup_type === 'website') {
|
||||
createModel.value.target = websites.value[0]?.value
|
||||
} else {
|
||||
createModel.value.target = ''
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
getPhpAndDb()
|
||||
getWebsiteList(1, 10000)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-modal
|
||||
v-model:show="show"
|
||||
preset="card"
|
||||
title="创建计划任务"
|
||||
style="width: 60vw"
|
||||
size="huge"
|
||||
:bordered="false"
|
||||
:segmented="false"
|
||||
>
|
||||
<n-form>
|
||||
<n-form-item label="任务类型">
|
||||
<n-select
|
||||
v-model:value="createModel.type"
|
||||
:options="[
|
||||
{ label: '运行脚本', value: 'shell' },
|
||||
{ label: '备份数据', value: 'backup' },
|
||||
{ label: '切割日志', value: 'cutoff' }
|
||||
]"
|
||||
>
|
||||
</n-select>
|
||||
</n-form-item>
|
||||
<n-form-item label="任务名称">
|
||||
<n-input v-model:value="createModel.name" placeholder="任务名称" />
|
||||
</n-form-item>
|
||||
<n-form-item label="任务周期">
|
||||
<cron-naive v-model="createModel.time" locale="zh-cn"></cron-naive>
|
||||
</n-form-item>
|
||||
<div v-if="createModel.type === 'shell'">
|
||||
<n-text>脚本内容</n-text>
|
||||
<Editor
|
||||
v-model:value="createModel.script"
|
||||
language="shell"
|
||||
theme="vs-dark"
|
||||
height="40vh"
|
||||
mt-8
|
||||
:options="{
|
||||
automaticLayout: true,
|
||||
formatOnType: true,
|
||||
formatOnPaste: true
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<n-form-item v-if="createModel.type === 'backup'" label="备份类型">
|
||||
<n-radio-group v-model:value="createModel.backup_type">
|
||||
<n-radio value="website">网站目录</n-radio>
|
||||
<n-radio value="mysql" :disabled="!mySQLInstalled"> MySQL 数据库</n-radio>
|
||||
<n-radio value="postgres" :disabled="!postgreSQLInstalled"> PostgreSQL 数据库 </n-radio>
|
||||
</n-radio-group>
|
||||
</n-form-item>
|
||||
<n-form-item
|
||||
v-if="
|
||||
(createModel.backup_type === 'website' && createModel.type === 'backup') ||
|
||||
createModel.type === 'cutoff'
|
||||
"
|
||||
label="选择网站"
|
||||
>
|
||||
<n-select v-model:value="createModel.target" :options="websites" placeholder="选择网站" />
|
||||
</n-form-item>
|
||||
<n-form-item
|
||||
v-if="createModel.backup_type !== 'website' && createModel.type === 'backup'"
|
||||
label="数据库名"
|
||||
>
|
||||
<n-input v-model:value="createModel.target" placeholder="数据库名" />
|
||||
</n-form-item>
|
||||
<n-form-item v-if="createModel.type === 'backup'" label="保存目录">
|
||||
<n-input v-model:value="createModel.backup_path" placeholder="保存目录" />
|
||||
</n-form-item>
|
||||
<n-form-item v-if="createModel.type !== 'shell'" label="保留份数">
|
||||
<n-input-number v-model:value="createModel.save" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-row :gutter="[0, 24]" pt-20>
|
||||
<n-col :span="24">
|
||||
<n-button type="info" block :loading="loading" @click="handleSubmit"> 提交 </n-button>
|
||||
</n-col>
|
||||
</n-row>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
302
web/src/views/task/CronView.vue
Normal file
302
web/src/views/task/CronView.vue
Normal file
@@ -0,0 +1,302 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'cron-index'
|
||||
})
|
||||
|
||||
import CreateModal from '@/views/task/CreateModal.vue'
|
||||
import cronstrue from 'cronstrue'
|
||||
import 'cronstrue/locales/zh_CN'
|
||||
|
||||
import Editor from '@guolao/vue-monaco-editor'
|
||||
import { NButton, NDataTable, NFlex, NInput, NPopconfirm, NSwitch, NTag } from 'naive-ui'
|
||||
|
||||
import cron from '@/api/panel/cron'
|
||||
import file from '@/api/panel/file'
|
||||
import { formatDateTime, renderIcon } from '@/utils'
|
||||
import type { CronTask } from '@/views/task/types'
|
||||
import { CronNaive } from '@vue-js-cron/naive-ui'
|
||||
|
||||
const logPath = ref('')
|
||||
const logModal = ref(false)
|
||||
const editModal = ref(false)
|
||||
const createModal = ref(false)
|
||||
|
||||
const columns: any = [
|
||||
{ type: 'selection', fixed: 'left' },
|
||||
{
|
||||
title: '任务名',
|
||||
key: 'name',
|
||||
minWidth: 150,
|
||||
resizable: true,
|
||||
ellipsis: { tooltip: true }
|
||||
},
|
||||
{
|
||||
title: '任务类型',
|
||||
key: 'type',
|
||||
width: 100,
|
||||
resizable: true,
|
||||
render(row: any) {
|
||||
return h(
|
||||
NTag,
|
||||
{
|
||||
type: row.type === 'shell' ? 'warning' : row.type === 'backup' ? 'success' : 'info'
|
||||
},
|
||||
{
|
||||
default: () => {
|
||||
return row.type === 'shell'
|
||||
? '运行脚本'
|
||||
: row.type === 'backup'
|
||||
? '备份数据'
|
||||
: '切割日志'
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '启用',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
resizable: true,
|
||||
render(row: any) {
|
||||
return h(NSwitch, {
|
||||
size: 'small',
|
||||
rubberBand: false,
|
||||
value: row.status,
|
||||
onUpdateValue: () => handleStatusChange(row)
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '任务周期',
|
||||
key: 'time',
|
||||
width: 200,
|
||||
resizable: true,
|
||||
ellipsis: { tooltip: true },
|
||||
render(row: any) {
|
||||
return cronstrue.toString(row.time, { locale: 'zh_CN' })
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
key: 'created_at',
|
||||
width: 200,
|
||||
resizable: true,
|
||||
ellipsis: { tooltip: true },
|
||||
render(row: any): string {
|
||||
return formatDateTime(row.created_at)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '最后更新时间',
|
||||
key: 'updated_at',
|
||||
width: 200,
|
||||
ellipsis: { tooltip: true },
|
||||
render(row: any): string {
|
||||
return formatDateTime(row.updated_at)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 280,
|
||||
align: 'center',
|
||||
hideInExcel: true,
|
||||
render(row: any) {
|
||||
return [
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
type: 'warning',
|
||||
secondary: true,
|
||||
onClick: () => {
|
||||
logPath.value = row.log
|
||||
logModal.value = true
|
||||
}
|
||||
},
|
||||
{
|
||||
default: () => '日志',
|
||||
icon: renderIcon('majesticons:eye-line', { size: 14 })
|
||||
}
|
||||
),
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
type: 'primary',
|
||||
style: 'margin-left: 15px;',
|
||||
onClick: () => handleEdit(row)
|
||||
},
|
||||
{
|
||||
default: () => '修改',
|
||||
icon: renderIcon('material-symbols:edit-outline', { size: 14 })
|
||||
}
|
||||
),
|
||||
h(
|
||||
NPopconfirm,
|
||||
{
|
||||
onPositiveClick: () => handleDelete(row.id)
|
||||
},
|
||||
{
|
||||
default: () => {
|
||||
return '确定删除任务吗?'
|
||||
},
|
||||
trigger: () => {
|
||||
return h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
type: 'error',
|
||||
style: 'margin-left: 15px;'
|
||||
},
|
||||
{
|
||||
default: () => '删除',
|
||||
icon: renderIcon('material-symbols:delete-outline', { size: 14 })
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
pageCount: 1,
|
||||
pageSize: 20,
|
||||
itemCount: 0,
|
||||
showQuickJumper: true,
|
||||
showSizePicker: true,
|
||||
pageSizes: [20, 50, 100, 200]
|
||||
})
|
||||
|
||||
const data = ref<CronTask[]>([] as CronTask[])
|
||||
|
||||
const editTask = ref({
|
||||
id: 0,
|
||||
name: '',
|
||||
time: '',
|
||||
script: ''
|
||||
})
|
||||
|
||||
const getTaskList = async (page: number, limit: number) => {
|
||||
const { data } = await cron.list(page, limit)
|
||||
return data
|
||||
}
|
||||
|
||||
const onPageChange = (page: number) => {
|
||||
pagination.page = page
|
||||
getTaskList(page, pagination.pageSize).then((res) => {
|
||||
data.value = res.items
|
||||
pagination.itemCount = res.total
|
||||
pagination.pageCount = res.total / pagination.pageSize + 1
|
||||
})
|
||||
}
|
||||
|
||||
const onPageSizeChange = (pageSize: number) => {
|
||||
pagination.pageSize = pageSize
|
||||
onPageChange(1)
|
||||
}
|
||||
|
||||
const handleStatusChange = async (row: any) => {
|
||||
cron.status(row.id, !row.status).then(() => {
|
||||
row.status = !row.status
|
||||
window.$message.success('修改成功')
|
||||
})
|
||||
}
|
||||
|
||||
const handleEdit = async (row: any) => {
|
||||
await cron.get(row.id).then(async (res) => {
|
||||
await file.content(res.data.shell).then((res) => {
|
||||
editTask.value.id = row.id
|
||||
editTask.value.name = row.name
|
||||
editTask.value.time = row.time
|
||||
editTask.value.script = res.data
|
||||
editModal.value = true
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
await cron.delete(id).then(() => {
|
||||
window.$message.success('删除成功')
|
||||
})
|
||||
onPageChange(pagination.page)
|
||||
}
|
||||
|
||||
const saveTaskEdit = async () => {
|
||||
cron
|
||||
.update(editTask.value.id, editTask.value.name, editTask.value.time, editTask.value.script)
|
||||
.then(() => {
|
||||
window.$message.success('修改成功')
|
||||
})
|
||||
}
|
||||
|
||||
watch(createModal, () => {
|
||||
onPageChange(pagination.page)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
onPageChange(pagination.page)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex vertical>
|
||||
<n-card flex-1 rounded-10>
|
||||
<n-button type="primary" @click="createModal = true">创建计划任务</n-button>
|
||||
</n-card>
|
||||
<n-card flex-1 rounded-10>
|
||||
<n-data-table
|
||||
striped
|
||||
remote
|
||||
:scroll-x="1300"
|
||||
:data="data"
|
||||
:columns="columns"
|
||||
:row-key="(row: any) => row.id"
|
||||
:pagination="pagination"
|
||||
:bordered="false"
|
||||
:loading="false"
|
||||
@update:page="onPageChange"
|
||||
@update:page-size="onPageSizeChange"
|
||||
/>
|
||||
</n-card>
|
||||
</n-flex>
|
||||
<create-modal v-model:show="createModal" />
|
||||
<realtime-log v-model:show="logModal" :path="logPath" />
|
||||
<n-modal
|
||||
v-model:show="editModal"
|
||||
preset="card"
|
||||
title="编辑任务"
|
||||
style="width: 80vw"
|
||||
size="huge"
|
||||
:bordered="false"
|
||||
:segmented="false"
|
||||
@close="saveTaskEdit"
|
||||
>
|
||||
<n-form inline>
|
||||
<n-form-item label="任务名称">
|
||||
<n-input v-model:value="editTask.name" placeholder="任务名称" />
|
||||
</n-form-item>
|
||||
<n-form-item label="任务周期">
|
||||
<cron-naive v-model="editTask.time" locale="zh-cn"></cron-naive>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<Editor
|
||||
v-model:value="editTask.script"
|
||||
language="shell"
|
||||
theme="vs-dark"
|
||||
height="60vh"
|
||||
mt-8
|
||||
:options="{
|
||||
automaticLayout: true,
|
||||
formatOnType: true,
|
||||
formatOnPaste: true
|
||||
}"
|
||||
/>
|
||||
</n-modal>
|
||||
</template>
|
||||
@@ -1,235 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'task-index'
|
||||
})
|
||||
import CronView from '@/views/task/CronView.vue'
|
||||
import TaskView from '@/views/task/TaskView.vue'
|
||||
|
||||
import type { LogInst } from 'naive-ui'
|
||||
import { NButton, NDataTable, NPopconfirm } from 'naive-ui'
|
||||
|
||||
import task from '@/api/panel/task'
|
||||
import ws from '@/api/ws'
|
||||
import { formatDateTime, renderIcon } from '@/utils'
|
||||
import type { Task } from '@/views/task/types'
|
||||
|
||||
const taskLogModal = ref(false)
|
||||
const taskLog = ref('')
|
||||
const logRef = ref<LogInst | null>(null)
|
||||
let logWs: WebSocket | null = null
|
||||
|
||||
const columns: any = [
|
||||
{ type: 'selection', fixed: 'left' },
|
||||
{
|
||||
title: '任务名',
|
||||
key: 'name',
|
||||
minWidth: 200,
|
||||
resizable: true,
|
||||
ellipsis: { tooltip: true }
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 150,
|
||||
ellipsis: { tooltip: true },
|
||||
render(row: any) {
|
||||
return row.status === 'finished'
|
||||
? '已完成'
|
||||
: row.status === 'waiting'
|
||||
? '等待中'
|
||||
: row.status === 'failed'
|
||||
? '已失败'
|
||||
: '运行中'
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
key: 'created_at',
|
||||
width: 200,
|
||||
ellipsis: { tooltip: true },
|
||||
render(row: any): string {
|
||||
return formatDateTime(row.created_at)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '完成时间',
|
||||
key: 'updated_at',
|
||||
width: 200,
|
||||
ellipsis: { tooltip: true },
|
||||
render(row: any): string {
|
||||
return formatDateTime(row.updated_at)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 200,
|
||||
align: 'center',
|
||||
hideInExcel: true,
|
||||
render(row: any) {
|
||||
return [
|
||||
row.status != 'waiting'
|
||||
? h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
type: 'warning',
|
||||
secondary: true,
|
||||
onClick: () => {
|
||||
handleShowLog(row.log)
|
||||
taskLogModal.value = true
|
||||
}
|
||||
},
|
||||
{
|
||||
default: () => '日志',
|
||||
icon: renderIcon('material-symbols:visibility', { size: 14 })
|
||||
}
|
||||
)
|
||||
: null,
|
||||
row.status != 'waiting' && row.status != 'running'
|
||||
? h(
|
||||
NPopconfirm,
|
||||
{
|
||||
onPositiveClick: () => handleDelete(row.id)
|
||||
},
|
||||
{
|
||||
default: () => {
|
||||
return '确定要删除吗?'
|
||||
},
|
||||
trigger: () => {
|
||||
return h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
type: 'error',
|
||||
style: 'margin-left: 15px;'
|
||||
},
|
||||
{
|
||||
default: () => '删除',
|
||||
icon: renderIcon('material-symbols:delete-outline', { size: 14 })
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
: null
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const tasks = ref<Task[]>([] as Task[])
|
||||
|
||||
const selectedRowKeys = ref<any>([])
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
pageCount: 1,
|
||||
pageSize: 20,
|
||||
itemCount: 0,
|
||||
showQuickJumper: true,
|
||||
showSizePicker: true,
|
||||
pageSizes: [20, 50, 100, 200]
|
||||
})
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
task.delete(id).then(() => {
|
||||
window.$message.success('删除成功')
|
||||
onPageChange(pagination.page)
|
||||
})
|
||||
}
|
||||
|
||||
const handleShowLog = (path: string) => {
|
||||
const cmd = `tail -f ${path}`
|
||||
ws.exec(cmd)
|
||||
.then((ws: WebSocket) => {
|
||||
logWs = ws
|
||||
taskLogModal.value = true
|
||||
ws.onmessage = (event) => {
|
||||
taskLog.value += event.data + '\n'
|
||||
const lines = taskLog.value.split('\n')
|
||||
if (lines.length > 2000) {
|
||||
taskLog.value = lines.slice(lines.length - 2000).join('\n')
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
window.$message.error('获取日志流失败')
|
||||
})
|
||||
}
|
||||
|
||||
const handleCloseLog = () => {
|
||||
if (logWs) {
|
||||
logWs.close()
|
||||
}
|
||||
taskLogModal.value = false
|
||||
taskLog.value = ''
|
||||
}
|
||||
|
||||
const fetchTaskList = async (page: number, limit: number) => {
|
||||
const { data } = await task.list(page, limit)
|
||||
return data
|
||||
}
|
||||
|
||||
const onChecked = (rowKeys: any) => {
|
||||
selectedRowKeys.value = rowKeys
|
||||
}
|
||||
|
||||
const onPageChange = (page: number) => {
|
||||
pagination.page = page
|
||||
fetchTaskList(page, pagination.pageSize).then((res) => {
|
||||
tasks.value = res.items
|
||||
pagination.itemCount = res.total
|
||||
pagination.pageCount = res.total / pagination.pageSize + 1
|
||||
})
|
||||
}
|
||||
|
||||
const onPageSizeChange = (pageSize: number) => {
|
||||
pagination.pageSize = pageSize
|
||||
onPageChange(1)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onPageChange(pagination.page)
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
if (taskLog.value) {
|
||||
nextTick(() => {
|
||||
logRef.value?.scrollTo({ position: 'bottom', silent: true })
|
||||
})
|
||||
}
|
||||
})
|
||||
const current = ref('cron')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<common-page show-footer>
|
||||
<n-flex vertical>
|
||||
<n-alert type="info">若日志无法加载,请关闭广告拦截应用!</n-alert>
|
||||
<n-data-table
|
||||
striped
|
||||
remote
|
||||
:scroll-x="1000"
|
||||
:loading="false"
|
||||
:columns="columns"
|
||||
:data="tasks"
|
||||
:row-key="(row: any) => row.id"
|
||||
:pagination="pagination"
|
||||
@update:checked-row-keys="onChecked"
|
||||
@update:page="onPageChange"
|
||||
@update:page-size="onPageSizeChange"
|
||||
/>
|
||||
</n-flex>
|
||||
<n-tabs v-model:value="current" type="line" animated size="large">
|
||||
<n-tab-pane name="cron" tab="计划任务">
|
||||
<cron-view />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="task" tab="后台任务">
|
||||
<task-view />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</common-page>
|
||||
<n-modal
|
||||
v-model:show="taskLogModal"
|
||||
preset="card"
|
||||
title="任务日志"
|
||||
style="width: 80vw"
|
||||
size="huge"
|
||||
:bordered="false"
|
||||
:segmented="false"
|
||||
@close="handleCloseLog"
|
||||
@mask-click="handleCloseLog"
|
||||
>
|
||||
<n-log ref="logRef" :log="taskLog" trim :rows="40" />
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
|
||||
184
web/src/views/task/TaskView.vue
Normal file
184
web/src/views/task/TaskView.vue
Normal file
@@ -0,0 +1,184 @@
|
||||
<script setup lang="ts">
|
||||
import RealtimeLog from '@/components/common/RealtimeLog.vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'task-index'
|
||||
})
|
||||
|
||||
import { NButton, NDataTable, NPopconfirm } from 'naive-ui'
|
||||
|
||||
import task from '@/api/panel/task'
|
||||
import { formatDateTime, renderIcon } from '@/utils'
|
||||
import type { Task } from '@/views/task/types'
|
||||
|
||||
const logModal = ref(false)
|
||||
const logPath = ref('')
|
||||
|
||||
const columns: any = [
|
||||
{ type: 'selection', fixed: 'left' },
|
||||
{
|
||||
title: '任务名',
|
||||
key: 'name',
|
||||
minWidth: 200,
|
||||
resizable: true,
|
||||
ellipsis: { tooltip: true }
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 150,
|
||||
ellipsis: { tooltip: true },
|
||||
render(row: any) {
|
||||
return row.status === 'finished'
|
||||
? '已完成'
|
||||
: row.status === 'waiting'
|
||||
? '等待中'
|
||||
: row.status === 'failed'
|
||||
? '已失败'
|
||||
: '运行中'
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
key: 'created_at',
|
||||
width: 200,
|
||||
ellipsis: { tooltip: true },
|
||||
render(row: any): string {
|
||||
return formatDateTime(row.created_at)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '完成时间',
|
||||
key: 'updated_at',
|
||||
width: 200,
|
||||
ellipsis: { tooltip: true },
|
||||
render(row: any): string {
|
||||
return formatDateTime(row.updated_at)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 200,
|
||||
align: 'center',
|
||||
hideInExcel: true,
|
||||
render(row: any) {
|
||||
return [
|
||||
row.status != 'waiting'
|
||||
? h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
type: 'warning',
|
||||
secondary: true,
|
||||
onClick: () => {
|
||||
logPath.value = row.log
|
||||
logModal.value = true
|
||||
}
|
||||
},
|
||||
{
|
||||
default: () => '日志',
|
||||
icon: renderIcon('material-symbols:visibility', { size: 14 })
|
||||
}
|
||||
)
|
||||
: null,
|
||||
row.status != 'waiting' && row.status != 'running'
|
||||
? h(
|
||||
NPopconfirm,
|
||||
{
|
||||
onPositiveClick: () => handleDelete(row.id)
|
||||
},
|
||||
{
|
||||
default: () => {
|
||||
return '确定要删除吗?'
|
||||
},
|
||||
trigger: () => {
|
||||
return h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
type: 'error',
|
||||
style: 'margin-left: 15px;'
|
||||
},
|
||||
{
|
||||
default: () => '删除',
|
||||
icon: renderIcon('material-symbols:delete-outline', { size: 14 })
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
: null
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const tasks = ref<Task[]>([] as Task[])
|
||||
|
||||
const selectedRowKeys = ref<any>([])
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
pageCount: 1,
|
||||
pageSize: 20,
|
||||
itemCount: 0,
|
||||
showQuickJumper: true,
|
||||
showSizePicker: true,
|
||||
pageSizes: [20, 50, 100, 200]
|
||||
})
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
task.delete(id).then(() => {
|
||||
window.$message.success('删除成功')
|
||||
onPageChange(pagination.page)
|
||||
})
|
||||
}
|
||||
|
||||
const fetchTaskList = async (page: number, limit: number) => {
|
||||
const { data } = await task.list(page, limit)
|
||||
return data
|
||||
}
|
||||
|
||||
const onChecked = (rowKeys: any) => {
|
||||
selectedRowKeys.value = rowKeys
|
||||
}
|
||||
|
||||
const onPageChange = (page: number) => {
|
||||
pagination.page = page
|
||||
fetchTaskList(page, pagination.pageSize).then((res) => {
|
||||
tasks.value = res.items
|
||||
pagination.itemCount = res.total
|
||||
pagination.pageCount = res.total / pagination.pageSize + 1
|
||||
})
|
||||
}
|
||||
|
||||
const onPageSizeChange = (pageSize: number) => {
|
||||
pagination.pageSize = pageSize
|
||||
onPageChange(1)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onPageChange(pagination.page)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex vertical>
|
||||
<n-alert type="info">若日志无法加载,请关闭广告拦截应用!</n-alert>
|
||||
<n-data-table
|
||||
striped
|
||||
remote
|
||||
:scroll-x="1000"
|
||||
:loading="false"
|
||||
:columns="columns"
|
||||
:data="tasks"
|
||||
:row-key="(row: any) => row.id"
|
||||
:pagination="pagination"
|
||||
@update:checked-row-keys="onChecked"
|
||||
@update:page="onPageChange"
|
||||
@update:page-size="onPageSizeChange"
|
||||
/>
|
||||
</n-flex>
|
||||
<realtime-log v-model:show="logModal" :path="logPath" />
|
||||
</template>
|
||||
@@ -7,7 +7,7 @@ export default {
|
||||
path: '/task',
|
||||
component: Layout,
|
||||
meta: {
|
||||
order: 100
|
||||
order: 80
|
||||
},
|
||||
children: [
|
||||
{
|
||||
@@ -16,7 +16,7 @@ export default {
|
||||
component: () => import('./IndexView.vue'),
|
||||
meta: {
|
||||
title: '任务',
|
||||
icon: 'mdi:table-sync',
|
||||
icon: 'mdi:timetable',
|
||||
role: ['admin'],
|
||||
requireAuth: true
|
||||
}
|
||||
|
||||
@@ -7,3 +7,15 @@ export interface Task {
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface CronTask {
|
||||
id: number
|
||||
name: string
|
||||
status: boolean
|
||||
type: string
|
||||
time: string
|
||||
shell: string
|
||||
log: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import ws from '@/api/ws'
|
||||
|
||||
defineOptions({
|
||||
name: 'website-edit'
|
||||
})
|
||||
|
||||
import Editor from '@guolao/vue-monaco-editor'
|
||||
import { NButton } from 'naive-ui'
|
||||
import { type LogInst, NButton } from 'naive-ui'
|
||||
|
||||
import dashboard from '@/api/panel/dashboard'
|
||||
import website from '@/api/panel/website'
|
||||
import type { WebsiteListen, WebsiteSetting } from '@/views/website/types'
|
||||
|
||||
const current = ref('listen')
|
||||
const route = useRoute()
|
||||
const { id } = route.params
|
||||
|
||||
const log = ref('')
|
||||
const logRef = ref<LogInst | null>(null)
|
||||
let logWs: WebSocket | null = null
|
||||
const setting = ref<WebsiteSetting>({
|
||||
id: 0,
|
||||
name: '',
|
||||
@@ -95,6 +101,24 @@ const handleReset = async () => {
|
||||
})
|
||||
}
|
||||
|
||||
const initLog = async () => {
|
||||
const cmd = `tail -n 40 -f ${setting.value.log}`
|
||||
ws.exec(cmd)
|
||||
.then((ws: WebSocket) => {
|
||||
logWs = ws
|
||||
ws.onmessage = (event) => {
|
||||
log.value += event.data + '\n'
|
||||
const lines = log.value.split('\n')
|
||||
if (lines.length > 2000) {
|
||||
log.value = lines.slice(lines.length - 2000).join('\n')
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
window.$message.error('获取日志流失败')
|
||||
})
|
||||
}
|
||||
|
||||
const clearLog = async () => {
|
||||
await website.clearLog(Number(id)).then(() => {
|
||||
getWebsiteSetting()
|
||||
@@ -117,26 +141,61 @@ const onCreateListen = () => {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getWebsiteSetting()
|
||||
getPhpAndDb()
|
||||
watchEffect(() => {
|
||||
if (log.value) {
|
||||
nextTick(() => {
|
||||
logRef.value?.scrollTo({ position: 'bottom', silent: true })
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await getWebsiteSetting()
|
||||
await getPhpAndDb()
|
||||
await initLog()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (logWs) {
|
||||
logWs.close()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<common-page show-footer :title="title">
|
||||
<template #action>
|
||||
<div flex items-center>
|
||||
<n-tag type="warning">如果您修改了原文,那么点击保存后,其余的修改将不会生效!</n-tag>
|
||||
<n-button class="ml-16" type="primary" @click="handleSave">
|
||||
<n-flex>
|
||||
<n-tag v-if="current === 'config'" type="warning">
|
||||
如果您修改了原文,那么点击保存后,其余的修改将不会生效!
|
||||
</n-tag>
|
||||
<n-popconfirm v-if="current === 'config'" @positive-click="handleReset">
|
||||
<template #trigger>
|
||||
<n-button type="success">
|
||||
<TheIcon :size="18" icon="material-symbols:refresh" />
|
||||
重置配置
|
||||
</n-button>
|
||||
</template>
|
||||
确定要重置配置吗?
|
||||
</n-popconfirm>
|
||||
<n-button v-if="current !== 'log'" class="ml-16" type="primary" @click="handleSave">
|
||||
<TheIcon :size="18" icon="material-symbols:save-outline" />
|
||||
保存
|
||||
</n-button>
|
||||
</div>
|
||||
<n-popconfirm v-if="current === 'log'" @positive-click="clearLog">
|
||||
<template #trigger>
|
||||
<n-button type="primary">
|
||||
<TheIcon :size="18" icon="material-symbols:delete-outline" />
|
||||
清空日志
|
||||
</n-button>
|
||||
</template>
|
||||
确定要清空吗?
|
||||
</n-popconfirm>
|
||||
</n-flex>
|
||||
</template>
|
||||
|
||||
<n-tabs type="line" animated>
|
||||
<n-tab-pane name="domain" tab="域名监听">
|
||||
<n-tabs v-model:value="current" type="line" animated>
|
||||
<n-tab-pane name="listen" tab="域名监听">
|
||||
<n-form v-if="setting">
|
||||
<n-form-item label="域名">
|
||||
<n-dynamic-input
|
||||
@@ -262,8 +321,8 @@ onMounted(() => {
|
||||
<n-skeleton v-else text :repeat="10" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="rewrite" tab="伪静态">
|
||||
<n-space vertical>
|
||||
<n-alert type="info">
|
||||
<n-flex vertical>
|
||||
<n-alert type="info" w-full>
|
||||
设置伪静态规则,填入
|
||||
<n-tag>location</n-tag>
|
||||
部分即可
|
||||
@@ -280,24 +339,13 @@ onMounted(() => {
|
||||
formatOnPaste: true
|
||||
}"
|
||||
/>
|
||||
</n-space>
|
||||
</n-flex>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="config" tab="配置原文">
|
||||
<n-space vertical>
|
||||
<n-space flex items-center>
|
||||
<n-alert type="warning">
|
||||
如果您不了解配置规则,请勿随意修改,否则可能会导致网站无法访问或面板功能异常!如果已经遇到问题,可尝试重置配置!
|
||||
</n-alert>
|
||||
<n-popconfirm @positive-click="handleReset">
|
||||
<template #trigger>
|
||||
<n-button type="success">
|
||||
<TheIcon :size="18" icon="material-symbols:refresh" />
|
||||
重置配置
|
||||
</n-button>
|
||||
</template>
|
||||
确定要重置配置吗?
|
||||
</n-popconfirm>
|
||||
</n-space>
|
||||
<n-flex vertical>
|
||||
<n-alert type="warning" w-full>
|
||||
如果您不了解配置规则,请勿随意修改,否则可能会导致网站无法访问或面板功能异常!如果已经遇到问题,可尝试重置配置!
|
||||
</n-alert>
|
||||
<Editor
|
||||
v-if="setting"
|
||||
v-model:value="setting.raw"
|
||||
@@ -310,33 +358,19 @@ onMounted(() => {
|
||||
formatOnPaste: true
|
||||
}"
|
||||
/>
|
||||
</n-space>
|
||||
</n-flex>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="log" tab="访问日志">
|
||||
<n-space vertical>
|
||||
<n-popconfirm @positive-click="clearLog">
|
||||
<template #trigger>
|
||||
<n-button type="primary">
|
||||
<TheIcon :size="18" icon="material-symbols:delete-outline" />
|
||||
清空日志
|
||||
</n-button>
|
||||
</template>
|
||||
确定要清空吗?
|
||||
</n-popconfirm>
|
||||
<Editor
|
||||
v-if="setting"
|
||||
v-model:value="setting.log"
|
||||
language="ini"
|
||||
theme="vs-dark"
|
||||
height="60vh"
|
||||
:options="{
|
||||
automaticLayout: true,
|
||||
formatOnType: true,
|
||||
formatOnPaste: true,
|
||||
readOnly: true
|
||||
}"
|
||||
/>
|
||||
</n-space>
|
||||
<n-flex vertical>
|
||||
<n-flex flex items-center>
|
||||
<n-alert type="warning" w-full>
|
||||
全部日志可通过下载文件
|
||||
<n-tag>{{ setting.log }}</n-tag>
|
||||
查看。
|
||||
</n-alert>
|
||||
</n-flex>
|
||||
<n-log ref="logRef" :log="log" trim :rows="40" />
|
||||
</n-flex>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</common-page>
|
||||
|
||||
@@ -370,7 +370,7 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<common-page show-footer>
|
||||
<n-space vertical size="large">
|
||||
<n-flex vertical size="large">
|
||||
<n-card rounded-10>
|
||||
<n-space>
|
||||
<n-button type="primary" @click="createModal = true">
|
||||
@@ -400,7 +400,7 @@ onMounted(() => {
|
||||
@update:page="onPageChange"
|
||||
@update:page-size="onPageSizeChange"
|
||||
/>
|
||||
</n-space>
|
||||
</n-flex>
|
||||
</common-page>
|
||||
<n-modal
|
||||
v-model:show="createModal"
|
||||
|
||||
Reference in New Issue
Block a user