定时任务

This commit is contained in:
zhang zhuo 2025-10-22 17:17:27 +08:00
parent 89e792dcca
commit 40e8bcb05d
12 changed files with 492 additions and 34 deletions

View File

@ -6,4 +6,4 @@ VITE_APP_ENV='development'
# 开发环境
VITE_API_BASE='https://server.leapy.cn/admin/'
VITE_WS_URL='wss://dev.api.leapy.cn/mms'
VITE_WS_URL='wss://server.leapy.cn/ws'

View File

@ -6,4 +6,4 @@ VITE_APP_ENV='production'
# 生产环境
VITE_API_BASE='https://server.leapy.cn/admin/'
VITE_WS_URL='wss://dev.api.leapy.cn/mms'
VITE_WS_URL='wss://server.leapy.cn/ws'

View File

@ -12,8 +12,10 @@
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"axios": "1.12.0",
"cron-parser": "^4.9",
"cropperjs": "^1.6.2",
"crypto-js": "^4.2.0",
"dayjs": "^1.11.18",
"echarts": "^6.0.0",
"element-plus": "2.11.3",
"image-conversion": "^2.1.1",

View File

@ -101,5 +101,22 @@ export default {
quit: async function (data, config = {}) {
return await http.get("online/quit", data, config);
}
},
crontab: {
list: async function (data = {}) {
return await http.get("crontab/list", data);
},
add: async function (data = {}) {
return await http.post("crontab/add", data);
},
edit: async function (data = {}) {
return await http.put("crontab/edit", data);
},
del: async function (data = {}) {
return await http.delete("crontab/del", data);
},
option: async function (data = {}) {
return await http.get("crontab/option", data);
},
}
}

View File

@ -0,0 +1,3 @@
<template>
<svg t="1761117902048" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="10379" width="256" height="256"><path d="M512 952.32c-242.176 0-440.32-198.144-440.32-440.32s198.144-440.32 440.32-440.32 440.32 198.144 440.32 440.32-198.144 440.32-440.32 440.32z m0-792.576c-193.536 0-352.256 158.72-352.256 352.256s158.72 352.256 352.256 352.256 352.256-158.72 352.256-352.256-158.72-352.256-352.256-352.256zM335.872 512c0 97.28 78.848 176.128 176.128 176.128s176.128-78.848 176.128-176.128S609.28 335.872 512 335.872 335.872 414.72 335.872 512z" p-id="10380"></path></svg>
</template>

View File

@ -0,0 +1,176 @@
<template>
<div style="width: 100%;">
<el-input v-model="value">
<template #suffix>
<el-icon size="15" @click="showPick">
<component :is="'pi-icon-choice'"/>
</el-icon>
</template>
</el-input>
</div>
<pi-dialog title="Cron 表达式生成器" v-model="visible" :width="800" destroy-on-close @closed="visible=false">
<el-form :model="form" label-width="120px">
<el-form-item label="类型" prop="type">
<el-select v-model="form.type" placeholder="请选择" @change="generateCron">
<el-option label="每 N 秒" value="every_n_seconds"/>
<el-option label="每 N 分钟" value="every_n_minutes"/>
<el-option label="每天在指定时间" value="daily_at"/>
<el-option label="每周在指定星期和时间" value="weekly_at"/>
<el-option label="每月在指定日和时间" value="monthly_at"/>
<el-option label="自定义表达式" value="custom"/>
</el-select>
</el-form-item>
<el-form-item label="间隔 (秒)" v-if="form.type === 'every_n_seconds'" prop="everySeconds">
<el-input-number v-model="form.everySeconds" :min="0" :max="59" @change="generateCron"/>
</el-form-item>
<el-form-item label="间隔 (分钟)" v-if="form.type === 'every_n_minutes'" prop="everyMinutes">
<el-input-number v-model="form.everyMinutes" :min="1" @change="generateCron"/>
</el-form-item>
<el-form-item label="起始秒" v-if="form.type === 'every_n_minutes'" prop="startSecond">
<el-input-number v-model="form.startSecond" :min="0" :max="59" @change="generateCron"/>
</el-form-item>
<el-form-item label="星期" v-if="form.type === 'weekly_at'" prop="weekday">
<el-select v-model="form.weekday" placeholder="选择星期" @change="generateCron">
<el-option v-for="(label, idx) in weekOptions" :key="idx" :label="label" :value="idx"/>
</el-select>
</el-form-item>
<el-form-item label="日期 (1-31)" v-if="form.type === 'monthly_at'" prop="dayOfMonth">
<el-input-number v-model="form.dayOfMonth" :min="1" :max="31" @change="generateCron"/>
</el-form-item>
<el-form-item label="选择时间"
v-if="form.type === 'daily_at' || form.type === 'weekly_at' || form.type === 'monthly_at'"
prop="dailyTime">
<el-time-picker v-model="form.dailyTime" placeholder="选择时间" @change="generateCron"/>
</el-form-item>
<el-form-item label="Cron 表达式" v-if="form.type === 'custom'" prop="customCron">
<el-input v-model="form.customCron" placeholder="例: 0 0/30 9-17 * * 1-5" @change="generateCron"/>
</el-form-item>
</el-form>
<el-divider/>
<div>
<div class="mb-2 font-medium">生成的 Cron 表达式</div>
<el-input type="textarea" v-model="value" readonly :rows="2"/>
</div>
<el-alert v-if="error" type="error" class="mt-2" :closable="false" :title="error"/>
<el-divider/>
<div>
<div class="mb-2 font-medium"> 5 次执行时间</div>
<el-table :data="nextRuns.map((t, i) => ({ index: i + 1, time: t }))" stripe>
<el-table-column prop="index" label="#" width="50"/>
<el-table-column prop="time" label="执行时间"/>
</el-table>
</div>
<template #footer>
<el-button @click="visible=false"> </el-button>
<el-button type="primary" @click="confirm()"> </el-button>
</template>
</pi-dialog>
</template>
<script setup>
import {getCurrentInstance, ref, watch} from "vue";
import parser from 'cron-parser'
import dayjs from 'dayjs'
const {proxy} = getCurrentInstance()
let value = ref("")
let visible = ref(false)
const prop = defineProps({
modelValue: {type: String, default: ""}
})
let form = ref({
type: 'every_n_seconds',
everySeconds: 0,
everyMinutes: 1,
startSecond: 0,
dailyTime: null,
dayOfMonth: 1,
customCron: null
})
const error = ref('')
const nextRuns = ref([])
const tzName = Intl.DateTimeFormat().resolvedOptions().timeZone || 'local'
const weekOptions = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
watch(() => prop.modelValue, (val) => {
value.value = val
if (val) {
computeNextRuns()
}
}, {immediate: true})
function confirm() {
proxy.$emit('update:modelValue', value.value)
visible.value = false
}
function showPick() {
visible.value = true
}
function generateCron() {
try {
error.value = ''
let expression = ''
if (form.value.type === 'every_n_seconds') {
if (form.value.everySeconds === null) return;
expression = `*/${form.value.everySeconds} * * * * *`
} else if (form.value.type === 'every_n_minutes') {
if (form.value.startSecond === null) return;
if (form.value.everyMinutes === null) return;
expression = `${form.value.startSecond} */${form.value.everyMinutes} * * * *`
} else if (form.value.type === 'daily_at' && form.value.dailyTime) {
const h = dayjs(form.value.dailyTime).hour()
const m = dayjs(form.value.dailyTime).minute()
expression = `0 ${m} ${h} * * *`
} else if (form.value.type === 'weekly_at' && form.value.dailyTime) {
if (form.value.weekday == null) return;
const h = dayjs(form.value.dailyTime).hour()
const m = dayjs(form.value.dailyTime).minute()
expression = `0 ${m} ${h} * * ${form.value.weekday}`
} else if (form.value.type === 'monthly_at' && form.value.dailyTime) {
if (form.value.dayOfMonth == null) return;
const h = dayjs(form.value.dailyTime).hour()
const m = dayjs(form.value.dailyTime).minute()
expression = `0 ${m} ${h} ${form.value.dayOfMonth} * *`
} else if (form.value.type === 'custom') {
if (form.value.customCron == null) return;
expression = form.value.customCron.trim()
}
value.value = expression
computeNextRuns()
} catch (err) {
error.value = err.message
}
}
function computeNextRuns() {
nextRuns.value = []
try {
let expr = value.value.trim()
if (!expr) return
const parts = expr.split(/\s+/)
if (parts.length === 5) expr = '0 ' + expr
const interval = parser.parseExpression(expr, {iterator: true, tz: tzName})
const arr = []
for (let i = 0; i < 5; i++) {
const obj = interval.next()
arr.push(dayjs(obj.value.toDate()).format('YYYY-MM-DD HH:mm:ss'))
}
nextRuns.value = arr
} catch (err) {
error.value = err.message
}
}
</script>
<style lang="scss" scoped>
:deep(.el-form-item) {
margin-bottom: 18px;
}
</style>

View File

@ -1,6 +1,6 @@
<template>
<div class="pi-dialog" ref="piDialog">
<el-dialog ref="dialog" v-model="dialogVisible" :fullscreen="isFullscreen" v-bind="$attrs" :show-close="false">
<el-dialog ref="dialog" v-model="dialogVisible" :fullscreen="isFullscreen" v-bind="attrs" :show-close="false">
<template #header>
<slot name="header">
<span class="el-dialog__title">{{ title }}</span>
@ -26,13 +26,14 @@
</template>
<script setup>
import {ref, onMounted, watch} from "vue";
import {ref, onMounted, watch, useAttrs} from "vue";
const attrs = useAttrs()
const props = defineProps({
modelValue: { type: Boolean, default: false },
title: { type: String, default: "" },
showClose: { type: Boolean, default: true },
showFullscreen: { type: Boolean, default: true },
showFullscreen: { type: Boolean, default: false },
loading: { type: Boolean, default: false }
});

View File

@ -0,0 +1,127 @@
<template>
<pi-table ref="tableRef" :apiObj="api.system.crontab.list" @selection-change="selectionChange">
<template #do>
<el-button v-auth="'crontab:add'" type="primary" icon="el-icon-plus" @click="add"></el-button>
<el-button v-auth="'crontab:edit'" type="primary" icon="el-icon-edit" @click="edit()"
:disabled="selection.length!==1"></el-button>
<el-button v-auth="'crontab:del'" type="danger" plain icon="el-icon-delete" :disabled="selection.length===0"
@click="batch_del"></el-button>
</template>
<template #search>
<el-input v-model="search.crontab_name" placeholder="任务名称" clearable style="width: 200px;"
@keydown.enter="upsearch"></el-input>
<el-select v-model="search.enable" placeholder="任务状态" clearable style="width: 200px;">
<el-option label="启用" :value="1"></el-option>
<el-option label="停用" :value="0"></el-option>
</el-select>
<el-button type="primary" icon="el-icon-search" @click="upsearch"></el-button>
</template>
<el-table-column type="selection" width="50"></el-table-column>
<el-table-column label="任务编号" prop="crontab_id" width="80"></el-table-column>
<el-table-column label="任务名称" prop="crontab_name"></el-table-column>
<el-table-column label="调用目标" prop="crontab_name"></el-table-column>
<el-table-column label="cron执行表达式" prop="rule"></el-table-column>
<el-table-column label="状态" prop="enable">
<template #default="scope">
<el-tag v-if="scope.row.enable===1" type="success">启用</el-tag>
<el-tag v-if="scope.row.enable===0" type="danger">停用</el-tag>
</template>
</el-table-column>
<el-table-column label="备注" prop="memo"></el-table-column>
<el-table-column label="操作" fixed="right" align="right" width="170">
<template #default="scope">
<el-button-group>
<el-button text type="primary" size="small" @click="show(scope.row, scope.$index)">查看
</el-button>
<el-button v-auth="'post:edit'" text type="primary" size="small"
@click="edit(scope.row, scope.$index)">编辑
</el-button>
<el-popconfirm title="确定删除吗?" @confirm="del(scope.row, scope.$index)">
<template #reference>
<el-button v-auth="'post:del'" text type="primary" size="small">删除</el-button>
</template>
</el-popconfirm>
</el-button-group>
</template>
</el-table-column>
</pi-table>
<save-dialog v-if="dialogShow" ref="dialogRef" @success="tableRef.refresh()"
@closed="dialogShow=false"></save-dialog>
</template>
<script setup>
import saveDialog from './save'
import api from "@/api/index";
import {getCurrentInstance, nextTick, ref} from "vue";
defineOptions({
name: "monitorCrontab"
})
const {proxy} = getCurrentInstance()
const tableRef = ref(null)
const dialogRef = ref(null)
let dialogShow = ref(false)
let selection = ref([])
let search = ref({
crontab_name: null,
enable: null
})
//
function add() {
dialogShow.value = true
nextTick(() => {
dialogRef.value.open()
})
}
//
async function edit(row) {
dialogShow.value = true
nextTick(() => {
dialogRef.value.open('edit', row ?? selection.value[0])
})
}
//
async function show(row) {
dialogShow.value = true
nextTick(() => {
dialogRef.value.open('show', row)
})
}
//
async function del(row) {
const loading = proxy.$loading();
const res = await api.system.crontab.del({ids: row.crontab_id});
tableRef.value.refresh()
loading.close();
proxy.$message.success(res.msg)
}
//
async function batch_del() {
proxy.$confirm(`确定删除选中的 ${selection.value.length} 项吗?如果删除项中含有子集将会被一并删除`, '提示', {
type: 'warning'
}).then(async () => {
const loading = proxy.$loading();
const res = await api.system.crontab.del({ids: selection.value.map(item => item.crontab_id).toString()});
tableRef.value.refresh()
loading.close();
proxy.$message.success(res.msg)
})
}
//
function selectionChange(e) {
selection.value = e;
}
//
function upsearch() {
tableRef.value.upData(search.value)
}
</script>

View File

@ -0,0 +1,96 @@
<template>
<el-dialog :title="titleMap[mode]" v-model="visible" :width="500" destroy-on-close @closed="$emit('closed')">
<el-form :model="form" :rules="rules" :disabled="mode==='show'" ref="formRef" label-width="100px">
<el-form-item label="任务名称" prop="crontab_name">
<el-input type="text" v-model="form.crontab_name" placeholder="请输入任务名称" clearable></el-input>
</el-form-item>
<el-form-item label="调用方法" prop="callback">
<el-input type="text" v-model="form.callback" placeholder="请输入调用方法" clearable></el-input>
</el-form-item>
<el-form-item label="调用参数" prop="params">
<el-input type="textarea" v-model="form.params" placeholder="请输入调用参数" clearable></el-input>
</el-form-item>
<el-form-item label="cron表达式" prop="rule">
<pi-cron v-model="form.rule"></pi-cron>
</el-form-item>
<el-form-item label="是否并发" prop="singleton">
<el-switch v-model="form.singleton" :active-value="1" :inactive-value="0"></el-switch>
</el-form-item>
<el-form-item label="是否记录日志" prop="skip_log">
<el-switch v-model="form.skip_log" :active-value="1" :inactive-value="0"></el-switch>
</el-form-item>
<el-form-item label="备注" prop="memo">
<el-input type="textarea" v-model="form.memo" placeholder="请输入备注" clearable></el-input>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible=false" > </el-button>
<el-button v-if="mode!=='show'" type="primary" :loading="isSaveing" @click="submit()"> </el-button>
</template>
</el-dialog>
</template>
<script setup>
import {getCurrentInstance, ref} from 'vue'
import api from "@/api/index.js"
import piCron from "@/components/piCron"
defineExpose({
open
})
const emit = defineEmits(['success', 'closed'])
const formRef = ref(null)
const {proxy} = getCurrentInstance()
let mode = ref('add')
let titleMap = ref({
add: '新增',
edit: '编辑',
show: '查看'
})
let visible = ref(false)
let isSaveing = ref(false)
let form = ref({
crontab_id: null,
crontab_name: '',
singleton: 1,
enable: 0,
skip_log: 1,
rule: '',
params: '',
callback: ''
})
const rules = ref({
crontab_name:[
{required: true, message: '请填写任务名称'}
],
rule:[
{required: true, message: '请填写cron表达式'}
],
params:[
{required: true, message: '请填写调用参数'}
],
callback:[
{required: true, message: '请填写调用方法'}
]
})
function open(m = 'add', data = null) {
mode.value = m
visible.value = true
Object.assign(form.value, data)
}
async function submit(){
//
const validate = await formRef.value.validate().catch(() => {});
if(!validate){ return false }
isSaveing.value = true;
const res = form.value.crontab_id? await api.system.crontab.edit(form.value) : await api.system.crontab.add(form.value);
isSaveing.value = false;
emit('success')
visible.value = false;
proxy.$message.success(res.msg)
}
</script>

View File

@ -0,0 +1,58 @@
<template>
<pi-table ref="tableRef" :apiObj="api.system.online.list">
<template #do>
<el-input v-model="search.username" placeholder="用户名" clearable style="width: 200px;" @keyup.enter="upsearch"></el-input>
<el-button type="primary" icon="el-icon-search" @click="upsearch"></el-button>
</template>
<el-table-column label="#" type="index" width="50"></el-table-column>
<el-table-column label="会话编号" prop="session_id"></el-table-column>
<el-table-column label="用户名" prop="username"></el-table-column>
<el-table-column label="部门名称" prop="account.dept.dept_name"></el-table-column>
<el-table-column label="主机" prop="ip"></el-table-column>
<el-table-column label="登录地点" prop="location"></el-table-column>
<el-table-column label="浏览器" prop="os.browser"></el-table-column>
<el-table-column label="操作系统" prop="os.os"></el-table-column>
<el-table-column label="登录时间" prop="online_time"></el-table-column>
<el-table-column label="操作" fixed="right" align="right" width="100">
<template #default="scope">
<el-button-group>
<el-popconfirm title="确定强制退出吗?" @confirm="quit(scope.row, scope.$index)">
<template #reference>
<el-button v-auth="'online:quit'" text type="primary" size="small">强退</el-button>
</template>
</el-popconfirm>
</el-button-group>
</template>
</el-table-column>
</pi-table>
</template>
<script setup>
import api from "@/api/index";
import {getCurrentInstance, ref} from "vue";
defineOptions({
name: "monitorOnline"
})
const {proxy} = getCurrentInstance()
const tableRef = ref(null)
let search = ref({
username: null
})
//
async function quit(row) {
const loading = proxy.$loading();
const res = await api.system.online.quit({session_id: row.session_id});
tableRef.value.refresh()
loading.close();
proxy.$message.success(res.msg)
}
//
function upsearch() {
tableRef.value.upData(search.value)
}
</script>

View File

@ -1,10 +1,9 @@
<template>
<pi-table ref="tableRef" :apiObj="api.system.online.list" @selection-change="selectionChange">
<pi-table ref="tableRef" :apiObj="api.system.online.list">
<template #do>
<el-input v-model="search.username" placeholder="用户名" clearable style="width: 200px;" @keyup.enter="upsearch"></el-input>
<el-button type="primary" icon="el-icon-search" @click="upsearch"></el-button>
</template>
<el-table-column type="selection" width="50"></el-table-column>
<el-table-column label="#" type="index" width="50"></el-table-column>
<el-table-column label="会话编号" prop="session_id"></el-table-column>
<el-table-column label="用户名" prop="username"></el-table-column>
@ -17,7 +16,7 @@
<el-table-column label="操作" fixed="right" align="right" width="100">
<template #default="scope">
<el-button-group>
<el-popconfirm title="确定删除吗?" @confirm="table_del(scope.row, scope.$index)">
<el-popconfirm title="确定强制退出吗?" @confirm="quit(scope.row, scope.$index)">
<template #reference>
<el-button v-auth="'online:quit'" text type="primary" size="small">强退</el-button>
</template>
@ -30,7 +29,7 @@
<script setup>
import api from "@/api/index";
import {getCurrentInstance, nextTick, ref} from "vue";
import {getCurrentInstance, ref} from "vue";
defineOptions({
name: "monitorOnline"
@ -38,41 +37,20 @@ defineOptions({
const {proxy} = getCurrentInstance()
const tableRef = ref(null)
const dialogRef = ref(null)
let dialogShow = ref(false)
let selection = ref([])
let search = ref({
username: null
})
//
async function table_del(row) {
async function quit(row) {
const loading = proxy.$loading();
const res = await api.system.post.del({ids: row.post_id});
const res = await api.system.online.quit({session_id: row.session_id});
tableRef.value.refresh()
loading.close();
proxy.$message.success(res.msg)
}
//
async function batch_del() {
proxy.$confirm(`确定删除选中的 ${selection.value.length} 项吗?如果删除项中含有子集将会被一并删除`, '提示', {
type: 'warning'
}).then(async () => {
const loading = proxy.$loading();
const res = await api.system.post.del({ids: selection.value.map(item => item.post_id).toString()});
tableRef.value.refresh()
loading.close();
proxy.$message.success(res.msg)
})
}
//
function selectionChange(e) {
selection.value = e;
}
//
function upsearch() {
tableRef.value.upData(search.value)

View File

@ -58,8 +58,8 @@
</el-card>
</el-col>
<el-col :lg="16" :xs="24" :sm="24" :md="24">
<el-card shadow="never">
<el-tabs class="pi-right">
<el-card shadow="never" class="pi-right">
<el-tabs>
<el-tab-pane label="基本信息" name="0">
<el-form ref="infoRef" :model="form" style="max-width: 500px;" label-width="120px">
<el-form-item label="头像" prop="avatar">