2
0
mirror of https://github.com/acepanel/panel.git synced 2026-02-04 06:47:20 +08:00

feat: 支持实时日志流

This commit is contained in:
耗子
2024-10-21 01:08:10 +08:00
parent 354705ec5b
commit 84703391a3
25 changed files with 896 additions and 1246 deletions

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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) {

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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
View File

@@ -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)

View File

@@ -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>

View 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>

View File

@@ -41,9 +41,9 @@
"cache": "缓存更新成功",
"warning": "更新应用前强烈建议先备份/快照,以免出现问题时无法回滚!",
"setup": "设置成功",
"install": "任务已提交,请稍后查看任务进度",
"update": "任务已提交,请前往后台任务查看任务进度",
"uninstall": "任务已提交,请前往后台任务查看任务进度"
"install": "任务已提交,请前往任务->后台任务查看任务进度",
"update": "任务已提交,请前往任务->后台任务查看任务进度",
"uninstall": "任务已提交,请前往任务->后台任务查看任务进度"
},
"buttons": {
"updateCache": "更新缓存",

View File

@@ -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(() => {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -7,7 +7,7 @@ export default {
path: '/ssh',
component: Layout,
meta: {
order: 80
order: 70
},
children: [
{

View 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>

View 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>

View File

@@ -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>

View 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>

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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>

View File

@@ -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"