定时任务增加系统定时任务

This commit is contained in:
2025-11-25 14:45:11 +08:00
parent 5d14957eba
commit 24c5188eb0
14 changed files with 988 additions and 30 deletions

View File

@@ -30,8 +30,8 @@ export const crontabApi = {
* 获取可创建的定时任务列表(从数据库获取任务元数据)
* @returns Promise<ResultDomain<TaskMeta>>
*/
async getEnabledCrontabList(): Promise<ResultDomain<TaskMeta>> {
const response = await api.get<TaskMeta>(`${this.baseUrl}/getEnabledCrontabList`);
async getEnabledCrontabList(param: string): Promise<ResultDomain<TaskMeta>> {
const response = await api.get<TaskMeta>(`${this.baseUrl}/getEnabledCrontabList`, { param });
return response.data;
},

View File

@@ -152,12 +152,12 @@
</div>
<!-- 分页 -->
<div class="pagination-container" v-if="total > 0">
<div class="pagination-container" v-if="pageParam.totalElements! > 0">
<el-pagination
v-model:current-page="pageParam.pageNumber"
v-model:page-size="pageParam.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
:total="pageParam.totalElements"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handlePageChange"
@@ -413,7 +413,6 @@ defineOptions({
const loading = ref(false);
const submitting = ref(false);
const crawlerList = ref<CrontabTask[]>([]);
const total = ref(0);
// 爬虫元数据
const taskMetaList = ref<TaskMeta[]>([]);
@@ -440,7 +439,9 @@ const searchForm = reactive({
// 分页参数
const pageParam = reactive<PageParam>({
pageNumber: 1,
pageSize: 10
pageSize: 10,
totalElements: 0,
totalPages: 0
});
// 对话框状态
@@ -705,7 +706,7 @@ function resetUserSelector() {
// 加载爬虫模板从数据库加载TaskMeta转换为CrontabItem结构
async function loadCrawlerTemplates() {
try {
const result = await crontabApi.getEnabledCrontabList();
const result = await crontabApi.getEnabledCrontabList("新闻爬取");
if (result.success && result.dataList) {
taskMetaList.value = result.dataList;
@@ -746,7 +747,7 @@ async function loadCrawlerList() {
loading.value = true;
try {
const filter: Partial<CrontabTask> = {
taskGroup: ''
taskGroup: '新闻爬取'
};
if (searchForm.taskName) filter.taskName = searchForm.taskName;
if (searchForm.status !== undefined) filter.status = searchForm.status;
@@ -754,25 +755,17 @@ async function loadCrawlerList() {
const result = await crontabApi.getTaskPage(filter, pageParam);
if (result.success) {
// 根据后端返回结构处理数据
if (result.pageDomain) {
crawlerList.value = result.pageDomain.dataList || [];
total.value = result.pageDomain.pageParam?.totalElements || 0;
} else if (result.dataList) {
crawlerList.value = result.dataList;
total.value = result.pageParam?.totalElements || 0;
} else {
crawlerList.value = [];
total.value = 0;
crawlerList.value = result.pageDomain?.dataList || [];
if (result.pageDomain?.pageParam) {
Object.assign(pageParam, result.pageDomain.pageParam);
}
} else {
ElMessage.error(result.message || '加载爬虫列表失败');
crawlerList.value = [];
total.value = 0;
}
} catch (error) {
ElMessage.error('加载爬虫列表失败');
crawlerList.value = [];
total.value = 0;
} finally {
loading.value = false;
}
@@ -1210,14 +1203,38 @@ onMounted(() => {
.crawler-list {
min-height: 400px;
.crawler-card {
// 让同一行的列等高
:deep(.el-row) {
align-items: stretch;
}
// el-col 作为 flex 容器,使卡片能撑满高度
:deep(.el-col) {
display: flex;
margin-bottom: 20px;
}
.crawler-card {
transition: all 0.3s;
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
&:hover {
transform: translateY(-4px);
}
:deep(.el-card__body) {
flex: 1;
display: flex;
flex-direction: column;
}
:deep(.el-card__footer) {
margin-top: auto;
}
.card-header {
display: flex;
justify-content: space-between;

View File

@@ -0,0 +1,714 @@
<template>
<AdminLayout title="系统定时任务" subtitle="系统定时任务配置">
<div class="news-crawler">
<div class="header">
<h2>系统定时任务配置</h2>
<el-button type="primary" @click="handleAddTask">
<el-icon><Plus /></el-icon>
新增任务
</el-button>
</div>
<!-- 说明卡片 -->
<el-alert
title="系统定时任务说明"
type="info"
:closable="false"
style="margin-bottom: 20px"
>
<p>系统定时任务用于执行平台内部的各类系统作业例如热门资源推荐等</p>
<p>配置完成后系统会按照设定的 Cron 表达式定时执行任务</p>
</el-alert>
<!-- 搜索筛选区域 -->
<div class="search-bar">
<div class="search-item">
<span class="search-label">任务名称</span>
<el-input
v-model="searchForm.taskName"
placeholder="请输入任务名称"
clearable
@keyup.enter="loadTaskList"
style="width: 200px"
/>
</div>
<div class="search-item">
<span class="search-label">状态</span>
<el-select
v-model="searchForm.status"
placeholder="请选择状态"
clearable
style="width: 150px"
>
<el-option label="运行中" :value="1" />
<el-option label="已暂停" :value="0" />
</el-select>
</div>
<div class="search-actions">
<el-button type="primary" @click="loadTaskList">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="handleReset">
<el-icon><Refresh /></el-icon>
重置
</el-button>
</div>
</div>
<!-- 空状态 -->
<el-empty
v-if="!loading && taskList.length === 0"
description="暂无系统任务"
style="margin-top: 40px"
/>
<!-- 系统任务列表 -->
<div v-else class="crawler-list">
<el-row :gutter="20">
<el-col :span="8" v-for="task in taskList" :key="task.taskId">
<el-card class="crawler-card" shadow="hover">
<template #header>
<div class="card-header">
<div class="card-title">
<el-icon class="title-icon"><Timer /></el-icon>
<span>{{ task.taskName }}</span>
</div>
<el-tag
:type="task.status === 1 ? 'success' : 'info'"
size="small"
>
{{ task.status === 1 ? '运行中' : '已暂停' }}
</el-tag>
</div>
</template>
<div class="card-content">
<div class="info-item">
<span class="info-label">定时任务类型</span>
<span class="info-value">{{ task.taskGroup }}</span>
</div>
<div class="info-item">
<span class="info-label">定时任务</span>
<span class="info-value">{{ task.metaName }}</span>
</div>
<div class="info-item">
<span class="info-label">执行周期</span>
<span class="info-value">{{ task.cronExpression }}</span>
</div>
<div class="info-item" v-if="task.description">
<span class="info-label">描述</span>
<span class="info-value">{{ task.description }}</span>
</div>
</div>
<template #footer>
<div class="card-actions">
<el-button
v-if="task.status === 0"
type="success"
size="small"
@click="handleToggleStatus(task)"
>
<el-icon><VideoPlay /></el-icon>
启动
</el-button>
<el-button
v-else
type="warning"
size="small"
@click="handleToggleStatus(task)"
>
<el-icon><VideoPause /></el-icon>
暂停
</el-button>
<el-button
type="primary"
size="small"
@click="handleExecuteNow(task)"
>
<el-icon><Promotion /></el-icon>
执行一次
</el-button>
<el-button
type="primary"
size="small"
@click="handleEdit(task)"
>
<el-icon><Edit /></el-icon>
编辑
</el-button>
<el-button
type="danger"
size="small"
@click="handleDelete(task)"
>
<el-icon><Delete /></el-icon>
删除
</el-button>
</div>
</template>
</el-card>
</el-col>
</el-row>
</div>
<!-- 分页 -->
<div class="pagination-container" v-if="pageParam.totalElements! > 0">
<el-pagination
v-model:current-page="pageParam.pageNumber"
v-model:page-size="pageParam.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="pageParam.totalElements"
layout="total, sizes, prev, pager, next, jumper"
@size-change="loadTaskList"
@current-change="loadTaskList"
/>
</div>
<!-- 新增/编辑任务对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="600px"
@close="handleDialogClose"
>
<el-form
ref="taskFormRef"
:model="taskForm"
:rules="taskFormRules"
label-width="120px"
>
<el-form-item label="任务类型" prop="metaId">
<el-select
v-model="taskForm.metaId"
placeholder="请选择任务类型"
style="width: 100%"
@change="handleMetaChange"
>
<el-option
v-for="meta in taskMetaList"
:key="meta.metaId"
:label="meta.name"
:value="meta.metaId"
>
<span>{{ meta.name }}</span>
<span style="float: right; color: #8492a6; font-size: 12px">
{{ meta.description }}
</span>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="任务名称" prop="taskName">
<el-input
v-model="taskForm.taskName"
placeholder="请输入任务名称"
/>
</el-form-item>
<el-form-item label="任务描述" prop="description">
<el-input
v-model="taskForm.description"
type="textarea"
:rows="3"
placeholder="请输入任务描述"
/>
</el-form-item>
<el-form-item label="Cron表达式" prop="cronExpression">
<el-input
v-model="taskForm.cronExpression"
placeholder="例如0 0 1 * * ?每天凌晨1点"
>
<template #append>
<el-popover
placement="bottom"
:width="300"
trigger="click"
>
<template #reference>
<el-button>示例</el-button>
</template>
<div class="cron-examples">
<div>0 0 1 * * ? - 每天凌晨1点</div>
<div>0 0 */2 * * ? - 2</div>
<div>0 */30 * * * ? - 30</div>
<div>0 0 0 * * ? - 每天零点</div>
<div>0 0 12 * * ? - 每天中午12点</div>
</div>
</el-popover>
</template>
</el-input>
</el-form-item>
<el-form-item label="任务参数" prop="methodParams">
<el-input
v-model="taskForm.methodParams"
type="textarea"
:rows="4"
placeholder="请输入JSON格式的参数例如{}"
/>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="taskForm.status">
<el-radio :label="1">启用</el-radio>
<el-radio :label="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</div>
</AdminLayout>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
import {
Plus,
Search,
Refresh,
Timer,
VideoPlay,
VideoPause,
Promotion,
Edit,
Delete
} from '@element-plus/icons-vue';
import { crontabApi } from '@/apis/crontab';
import AdminLayout from '@/views/admin/AdminLayout.vue';
import type { CrontabTask, TaskMeta, PageParam, CreateTaskRequest } from '@/types';
// 搜索表单
const searchForm = reactive({
taskName: '',
status: undefined as number | undefined
});
// 分页参数
const pageParam = reactive<PageParam>({
pageNumber: 1,
pageSize: 10,
totalPages: 0,
totalElements: 0
});
// 任务列表
const taskList = ref<CrontabTask[]>([]);
const taskMetaList = ref<TaskMeta[]>([]);
const loading = ref(false);
// 对话框
const dialogVisible = ref(false);
const dialogTitle = ref('新增任务');
const isEdit = ref(false);
const taskFormRef = ref<FormInstance>();
// 任务表单
const taskForm = reactive<Partial<CrontabTask>>({
taskId: '',
metaId: '',
taskName: '',
description: '',
cronExpression: '0 0 1 * * ?',
methodParams: '{}',
status: 1
});
// 表单验证规则
const taskFormRules: FormRules = {
metaId: [{ required: true, message: '请选择任务类型', trigger: 'change' }],
taskName: [{ required: true, message: '请输入任务名称', trigger: 'blur' }],
cronExpression: [{ required: true, message: '请输入Cron表达式', trigger: 'blur' }]
};
// 加载任务元数据列表
async function loadTaskMetaList() {
try {
const result = await crontabApi.getEnabledCrontabList('系统内部任务');
if (result.success && result.dataList) {
taskMetaList.value = result.dataList;
}
} catch (error) {
console.error('加载任务元数据失败:', error);
ElMessage.error('加载任务类型失败');
}
}
// 加载任务列表
async function loadTaskList() {
loading.value = true;
try {
const filter: Partial<CrontabTask> = {
taskGroup: '系统内部任务'
};
if (searchForm.taskName) filter.taskName = searchForm.taskName;
if (searchForm.status !== undefined) filter.status = searchForm.status;
const result = await crontabApi.getTaskPage(filter, pageParam);
if (result.success) {
taskList.value = result.pageDomain?.dataList || [];
if (result.pageDomain?.pageParam) {
Object.assign(pageParam, result.pageDomain.pageParam);
}
} else {
taskList.value = [];
pageParam.totalElements = pageParam.totalElements;
pageParam.totalPages = pageParam.totalPages;
ElMessage.error(result.message || '加载任务列表失败');
}
} catch (error) {
console.error('加载任务列表失败:', error);
ElMessage.error('加载任务列表失败');
} finally {
loading.value = false;
}
}
// 重置搜索
function handleReset() {
searchForm.taskName = '';
searchForm.status = undefined;
pageParam.pageNumber = 1;
loadTaskList();
}
// 新增任务
function handleAddTask() {
isEdit.value = false;
dialogTitle.value = '新增系统任务';
Object.assign(taskForm, {
taskId: '',
metaId: '',
taskName: '',
description: '',
cronExpression: '0 0 1 * * ?',
methodParams: '{}',
status: 1
});
dialogVisible.value = true;
}
// 编辑任务
function handleEdit(task: CrontabTask) {
isEdit.value = true;
dialogTitle.value = '编辑系统任务';
Object.assign(taskForm, {
id: task.id,
taskId: task.taskId,
metaId: task.metaId,
taskName: task.taskName,
description: task.description,
cronExpression: task.cronExpression,
methodParams: task.methodParams || '{}',
status: task.status
});
dialogVisible.value = true;
}
// 元数据变更
function handleMetaChange(metaId: string) {
const meta = taskMetaList.value.find(m => m.metaId === metaId);
if (meta) {
taskForm.taskName = meta.name;
taskForm.description = meta.description;
}
}
// 提交表单
async function handleSubmit() {
if (!taskFormRef.value) return;
await taskFormRef.value.validate(async (valid) => {
if (!valid) return;
try {
// 验证JSON格式
try {
JSON.parse(taskForm.methodParams || '{}');
} catch {
ElMessage.error('任务参数必须是有效的JSON格式');
return;
}
const meta = taskMetaList.value.find(m => m.metaId === taskForm.metaId);
if (!meta) {
ElMessage.error('未找到对应的任务类型');
return;
}
const taskData: CrontabTask = {
...taskForm,
beanName: meta.beanName,
methodName: meta.methodName || 'execute',
methodParams: taskForm.methodParams || '{}'
} as CrontabTask;
const requestData: CreateTaskRequest = {
task: taskData,
metaId: taskForm.metaId!
};
let result;
if (isEdit.value) {
result = await crontabApi.updateTask(requestData);
} else {
result = await crontabApi.createTask(requestData);
}
if (result.success) {
ElMessage.success(isEdit.value ? '更新成功' : '创建成功');
dialogVisible.value = false;
loadTaskList();
} else {
ElMessage.error(result.message || '操作失败');
}
} catch (error) {
console.error('提交失败:', error);
ElMessage.error('操作失败');
}
});
}
// 关闭对话框
function handleDialogClose() {
taskFormRef.value?.resetFields();
}
// 切换状态
async function handleToggleStatus(task: CrontabTask) {
try {
let result;
if (task.status === 1) {
result = await crontabApi.pauseTask(task.taskId!);
} else {
result = await crontabApi.startTask(task.taskId!);
}
if (result.success) {
ElMessage.success(task.status === 1 ? '已禁用' : '已启用');
loadTaskList();
} else {
ElMessage.error(result.message || '操作失败');
}
} catch (error) {
console.error('切换状态失败:', error);
ElMessage.error('操作失败');
}
}
// 立即执行
async function handleExecuteNow(task: CrontabTask) {
try {
await ElMessageBox.confirm(
`确定要立即执行任务"${task.taskName}"吗?`,
'提示',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
);
const result = await crontabApi.executeTaskOnce(task.taskId!);
if (result.success) {
ElMessage.success('任务已提交执行');
} else {
ElMessage.error(result.message || '执行失败');
}
} catch (error) {
if (error !== 'cancel') {
console.error('执行任务失败:', error);
ElMessage.error('执行失败');
}
}
}
// 删除任务
async function handleDelete(task: CrontabTask) {
try {
await ElMessageBox.confirm(
`确定要删除任务"${task.taskName}"吗?此操作不可恢复。`,
'警告',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
);
const result = await crontabApi.deleteTask(task);
if (result.success) {
ElMessage.success('删除成功');
loadTaskList();
} else {
ElMessage.error(result.message || '删除失败');
}
} catch (error) {
if (error !== 'cancel') {
console.error('删除任务失败:', error);
ElMessage.error('删除失败');
}
}
}
// 初始化
onMounted(() => {
loadTaskMetaList();
loadTaskList();
});
</script>
<style scoped lang="scss">
.news-crawler {
padding: 20px;
background-color: #ffffff;
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: #303133;
}
}
.search-bar {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
padding: 15px;
background-color: #f8f9fa;
border-radius: 8px;
flex-wrap: wrap;
.search-item {
display: flex;
align-items: center;
gap: 8px;
.search-label {
font-size: 14px;
color: #606266;
white-space: nowrap;
}
}
.search-actions {
display: flex;
gap: 8px;
margin-left: auto;
}
}
.crawler-list {
min-height: 400px;
// 让同一行的列等高
:deep(.el-row) {
align-items: stretch;
}
// el-col 作为 flex 容器,使卡片能撑满高度
:deep(.el-col) {
display: flex;
margin-bottom: 20px;
}
.crawler-card {
transition: all 0.3s;
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
&:hover {
transform: translateY(-4px);
}
:deep(.el-card__body) {
flex: 1;
display: flex;
flex-direction: column;
}
:deep(.el-card__footer) {
margin-top: auto;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
.card-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 600;
color: #303133;
.title-icon {
font-size: 20px;
color: #409eff;
}
}
}
.card-content {
.info-item {
display: flex;
margin-bottom: 12px;
font-size: 14px;
.info-label {
min-width: 80px;
color: #909399;
}
.info-value {
flex: 1;
color: #606266;
word-break: break-all;
}
}
}
.card-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
}
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
.cron-examples {
div {
padding: 6px 0;
font-family: 'Courier New', monospace;
font-size: 13px;
}
}
}
</style>