feat(schedule): move system jobs to DB-driven config and dashboard management
This commit is contained in:
111
admin/dashboard/blueprints/system_jobs.py
Normal file
111
admin/dashboard/blueprints/system_jobs.py
Normal file
@@ -0,0 +1,111 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from flask import Blueprint, current_app, jsonify, render_template, request
|
||||
|
||||
from utils.decorator.async_job import async_job
|
||||
from .auth import login_required
|
||||
|
||||
|
||||
system_jobs_bp = Blueprint("system_jobs", __name__, url_prefix="/system_jobs")
|
||||
|
||||
|
||||
@system_jobs_bp.route("/")
|
||||
@login_required
|
||||
def page_system_jobs():
|
||||
return render_template("system_jobs.html")
|
||||
|
||||
|
||||
@system_jobs_bp.route("/api/jobs", methods=["GET"])
|
||||
@login_required
|
||||
def api_list_jobs():
|
||||
server = current_app.dashboard_server
|
||||
db_rows = server.system_job_db.list_jobs()
|
||||
runtime_rows = async_job.get_jobs_snapshot()
|
||||
runtime_by_key = {row.get("job_key", ""): row for row in runtime_rows if row.get("job_key")}
|
||||
|
||||
result = []
|
||||
for row in db_rows:
|
||||
job_key = row.get("job_key")
|
||||
runtime = runtime_by_key.get(job_key, {})
|
||||
result.append(
|
||||
{
|
||||
"job_key": job_key,
|
||||
"name": row.get("name", ""),
|
||||
"description": row.get("description", ""),
|
||||
"trigger_type": row.get("trigger_type", ""),
|
||||
"trigger_config": row.get("trigger_config", {}),
|
||||
"enabled": bool(row.get("enabled", 0)),
|
||||
"runtime_job_id": runtime.get("id"),
|
||||
"runtime_enabled": runtime.get("enabled"),
|
||||
"running": runtime.get("running", False),
|
||||
"trigger_text": runtime.get("trigger_text", ""),
|
||||
"last_run_at": runtime.get("last_run_at"),
|
||||
"last_status": runtime.get("last_status"),
|
||||
"last_error": runtime.get("last_error"),
|
||||
"last_duration_ms": runtime.get("last_duration_ms"),
|
||||
"next_run_at": runtime.get("next_run_at"),
|
||||
"run_count": runtime.get("run_count", 0),
|
||||
"success_count": runtime.get("success_count", 0),
|
||||
"fail_count": runtime.get("fail_count", 0),
|
||||
}
|
||||
)
|
||||
|
||||
return jsonify({"success": True, "data": result})
|
||||
|
||||
|
||||
@system_jobs_bp.route("/api/jobs/<job_key>", methods=["PUT"])
|
||||
@login_required
|
||||
def api_update_job(job_key: str):
|
||||
server = current_app.dashboard_server
|
||||
payload = request.get_json(silent=True) or {}
|
||||
|
||||
updates = {}
|
||||
for key in ("name", "description", "trigger_type", "trigger_config", "enabled"):
|
||||
if key in payload:
|
||||
updates[key] = payload[key]
|
||||
|
||||
if not updates:
|
||||
return jsonify({"success": False, "message": "没有可更新字段"}), 400
|
||||
|
||||
ok = server.system_job_db.update_job(job_key, updates)
|
||||
if not ok:
|
||||
return jsonify({"success": False, "message": "数据库更新失败"}), 500
|
||||
|
||||
# 配置变更后立即重载调度器,确保实时生效
|
||||
server.system_job_loader.reload_from_db()
|
||||
return jsonify({"success": True, "message": "更新成功"})
|
||||
|
||||
|
||||
@system_jobs_bp.route("/api/jobs/<job_key>/trigger", methods=["POST"])
|
||||
@login_required
|
||||
def api_trigger_job(job_key: str):
|
||||
server = current_app.dashboard_server
|
||||
job_id = async_job.get_job_id_by_key(job_key)
|
||||
if not job_id:
|
||||
server.system_job_loader.reload_from_db()
|
||||
job_id = async_job.get_job_id_by_key(job_key)
|
||||
if not job_id:
|
||||
return jsonify({"success": False, "message": "任务未加载或已禁用"}), 404
|
||||
|
||||
ok, msg = async_job.trigger_job_now(job_id, operator="dashboard")
|
||||
code = 200 if ok else 400
|
||||
return jsonify({"success": ok, "message": msg}), code
|
||||
|
||||
|
||||
@system_jobs_bp.route("/api/jobs/<job_key>/logs", methods=["GET"])
|
||||
@login_required
|
||||
def api_job_logs(job_key: str):
|
||||
job_id = async_job.get_job_id_by_key(job_key)
|
||||
if not job_id:
|
||||
return jsonify({"success": True, "data": []})
|
||||
|
||||
limit = int(request.args.get("limit", 100))
|
||||
logs = async_job.get_job_logs(job_id, limit=limit)
|
||||
return jsonify({"success": True, "data": logs})
|
||||
|
||||
|
||||
@system_jobs_bp.route("/api/reload", methods=["POST"])
|
||||
@login_required
|
||||
def api_reload_jobs():
|
||||
server = current_app.dashboard_server
|
||||
server.system_job_loader.reload_from_db()
|
||||
return jsonify({"success": True, "message": "已按数据库配置重载系统定时任务"})
|
||||
@@ -46,6 +46,8 @@ class DashboardServer:
|
||||
self.contact_db: ContactsDBOperator = ContactsDBOperator(self.db_manager)
|
||||
self.member_context_db = MemberContextDBOperator(self.db_manager)
|
||||
self.task_db: TaskDBOperator = TaskDBOperator(self.db_manager)
|
||||
self.system_job_db = robot_instance.system_job_db
|
||||
self.system_job_loader = robot_instance.system_job_loader
|
||||
# 获取联系人管理器实例
|
||||
self.contact_manager = robot_instance.contact_manager
|
||||
self.plugin_manager = robot_instance.plugin_manager
|
||||
@@ -148,6 +150,7 @@ class DashboardServer:
|
||||
from admin.dashboard.blueprints.file_browser import file_browser_bp
|
||||
from admin.dashboard.blueprints.message_push import message_push_bp
|
||||
from admin.dashboard.blueprints.friend_circle import friend_circle_bp
|
||||
from admin.dashboard.blueprints.system_jobs import system_jobs_bp
|
||||
|
||||
# 在app.register_blueprint部分添加
|
||||
app.register_blueprint(virtual_group_bp, url_prefix='/virtual_group')
|
||||
@@ -162,6 +165,7 @@ class DashboardServer:
|
||||
app.register_blueprint(file_browser_bp)
|
||||
app.register_blueprint(message_push_bp)
|
||||
app.register_blueprint(friend_circle_bp)
|
||||
app.register_blueprint(system_jobs_bp)
|
||||
|
||||
self.LOG.info("所有蓝图已注册")
|
||||
|
||||
|
||||
@@ -779,7 +779,8 @@
|
||||
defaultPath: '/messages',
|
||||
items: [
|
||||
{ label: '消息列表', path: '/messages' },
|
||||
{ label: '定时推送', path: '/message_push' }
|
||||
{ label: '定时推送', path: '/message_push' },
|
||||
{ label: '系统定时任务', path: '/system_jobs' }
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
287
admin/dashboard/templates/system_jobs.html
Normal file
287
admin/dashboard/templates/system_jobs.html
Normal file
@@ -0,0 +1,287 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}系统定时任务 - 机器人管理后台{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-shell">
|
||||
<div class="page-hero">
|
||||
<div class="page-hero-copy">
|
||||
<div class="page-eyebrow">System Scheduler</div>
|
||||
<h1>系统定时任务</h1>
|
||||
<p>任务配置存储在数据库表 `t_system_jobs`,支持在线启停、调度调整、手动触发与执行日志查看。</p>
|
||||
</div>
|
||||
<div class="page-hero-actions">
|
||||
<el-button type="primary" plain @click="reloadFromDB">按表重载</el-button>
|
||||
<el-button type="success" @click="loadJobs">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="hover">
|
||||
<el-table :data="jobs" style="width:100%" v-loading="loading">
|
||||
<el-table-column prop="job_key" label="任务Key" min-width="180"></el-table-column>
|
||||
<el-table-column prop="name" label="任务名称" min-width="160"></el-table-column>
|
||||
<el-table-column prop="trigger_text" label="当前调度" min-width="180"></el-table-column>
|
||||
<el-table-column label="状态" width="140" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-tag :type="scope.row.enabled ? 'success' : 'info'">{% raw %}{{ scope.row.enabled ? '启用' : '停用' }}{% endraw %}</el-tag>
|
||||
<el-tag style="margin-left:6px" :type="scope.row.running ? 'warning' : ''">{% raw %}{{ scope.row.running ? '执行中' : '空闲' }}{% endraw %}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="next_run_at" label="下次执行" min-width="170"></el-table-column>
|
||||
<el-table-column prop="last_run_at" label="上次执行" min-width="170"></el-table-column>
|
||||
<el-table-column label="最近结果" width="120" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-tag :type="statusTag(scope.row.last_status)">{% raw %}{{ scope.row.last_status || 'never' }}{% endraw %}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" min-width="280">
|
||||
<template slot-scope="scope">
|
||||
<div class="action-row">
|
||||
<el-button size="mini" type="primary" plain @click="openEdit(scope.row)">编辑</el-button>
|
||||
<el-button size="mini" type="success" plain @click="triggerNow(scope.row)">立即触发</el-button>
|
||||
<el-button
|
||||
size="mini"
|
||||
:type="scope.row.enabled ? 'warning' : 'success'"
|
||||
plain
|
||||
@click="toggleEnabled(scope.row)">
|
||||
{% raw %}{{ scope.row.enabled ? '停用' : '启用' }}{% endraw %}
|
||||
</el-button>
|
||||
<el-button size="mini" type="text" @click="viewLogs(scope.row)">日志</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-dialog title="编辑任务调度" :visible.sync="editDialogVisible" width="520px">
|
||||
<el-form :model="editForm" label-width="100px">
|
||||
<el-form-item label="任务名称">
|
||||
<el-input v-model="editForm.name"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="任务说明">
|
||||
<el-input v-model="editForm.description"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="启用状态">
|
||||
<el-switch v-model="editForm.enabled"></el-switch>
|
||||
</el-form-item>
|
||||
<el-form-item label="触发类型">
|
||||
<el-select v-model="editForm.trigger_type" @change="onTriggerTypeChange">
|
||||
<el-option label="每天固定时间" value="at_times"></el-option>
|
||||
<el-option label="固定间隔(秒)" value="every_seconds"></el-option>
|
||||
<el-option label="每周固定时间" value="every_weekday_time"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="editForm.trigger_type === 'at_times'" label="时间列表">
|
||||
<el-input v-model="editForm.time_list_text" placeholder="例如 09:00,21:00"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="editForm.trigger_type === 'every_seconds'" label="间隔秒数">
|
||||
<el-input-number v-model="editForm.seconds" :min="1"></el-input-number>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="editForm.trigger_type === 'every_weekday_time'" label="星期">
|
||||
<el-select v-model="editForm.weekday">
|
||||
<el-option label="周一" :value="0"></el-option>
|
||||
<el-option label="周二" :value="1"></el-option>
|
||||
<el-option label="周三" :value="2"></el-option>
|
||||
<el-option label="周四" :value="3"></el-option>
|
||||
<el-option label="周五" :value="4"></el-option>
|
||||
<el-option label="周六" :value="5"></el-option>
|
||||
<el-option label="周日" :value="6"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="editForm.trigger_type === 'every_weekday_time'" label="时间">
|
||||
<el-input v-model="editForm.time_str" placeholder="例如 10:00"></el-input>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div slot="footer">
|
||||
<el-button @click="editDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveEdit">保存</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog title="执行日志" :visible.sync="logsDialogVisible" width="760px">
|
||||
<el-table :data="logs" style="width:100%">
|
||||
<el-table-column prop="time" label="时间" width="180"></el-table-column>
|
||||
<el-table-column prop="level" label="级别" width="90"></el-table-column>
|
||||
<el-table-column prop="message" label="内容"></el-table-column>
|
||||
</el-table>
|
||||
</el-dialog>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
new Vue({
|
||||
el: '#app',
|
||||
mixins: [baseApp],
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
jobs: [],
|
||||
editDialogVisible: false,
|
||||
logsDialogVisible: false,
|
||||
logs: [],
|
||||
currentJobKey: '',
|
||||
editForm: {
|
||||
job_key: '',
|
||||
name: '',
|
||||
description: '',
|
||||
enabled: true,
|
||||
trigger_type: 'at_times',
|
||||
time_list_text: '',
|
||||
seconds: 60,
|
||||
weekday: 0,
|
||||
time_str: '09:00'
|
||||
}
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.loadJobs();
|
||||
},
|
||||
methods: {
|
||||
statusTag(status) {
|
||||
if (status === 'success') return 'success';
|
||||
if (status === 'failed') return 'danger';
|
||||
if (status === 'running') return 'warning';
|
||||
return 'info';
|
||||
},
|
||||
async loadJobs() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const resp = await axios.get('/system_jobs/api/jobs');
|
||||
if (resp.data.success) {
|
||||
this.jobs = resp.data.data || [];
|
||||
} else {
|
||||
this.$message.error(resp.data.message || '加载失败');
|
||||
}
|
||||
} catch (e) {
|
||||
this.$message.error('加载任务失败');
|
||||
}
|
||||
this.loading = false;
|
||||
},
|
||||
onTriggerTypeChange() {},
|
||||
openEdit(row) {
|
||||
this.currentJobKey = row.job_key;
|
||||
this.editForm.job_key = row.job_key;
|
||||
this.editForm.name = row.name || '';
|
||||
this.editForm.description = row.description || '';
|
||||
this.editForm.enabled = !!row.enabled;
|
||||
this.editForm.trigger_type = row.trigger_type || 'at_times';
|
||||
const cfg = row.trigger_config || {};
|
||||
this.editForm.time_list_text = (cfg.time_list || []).join(',');
|
||||
this.editForm.seconds = Number(cfg.seconds || 60);
|
||||
this.editForm.weekday = Number(cfg.weekday || 0);
|
||||
this.editForm.time_str = cfg.time_str || '09:00';
|
||||
this.editDialogVisible = true;
|
||||
},
|
||||
buildTriggerConfig() {
|
||||
if (this.editForm.trigger_type === 'at_times') {
|
||||
const list = String(this.editForm.time_list_text || '')
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean);
|
||||
return { time_list: list };
|
||||
}
|
||||
if (this.editForm.trigger_type === 'every_seconds') {
|
||||
return { seconds: Number(this.editForm.seconds || 60) };
|
||||
}
|
||||
if (this.editForm.trigger_type === 'every_weekday_time') {
|
||||
return { weekday: Number(this.editForm.weekday || 0), time_str: String(this.editForm.time_str || '09:00') };
|
||||
}
|
||||
return {};
|
||||
},
|
||||
async saveEdit() {
|
||||
try {
|
||||
const payload = {
|
||||
name: this.editForm.name,
|
||||
description: this.editForm.description,
|
||||
enabled: this.editForm.enabled,
|
||||
trigger_type: this.editForm.trigger_type,
|
||||
trigger_config: this.buildTriggerConfig()
|
||||
};
|
||||
const resp = await axios.put(`/system_jobs/api/jobs/${this.currentJobKey}`, payload);
|
||||
if (resp.data.success) {
|
||||
this.$message.success('保存成功');
|
||||
this.editDialogVisible = false;
|
||||
await this.loadJobs();
|
||||
} else {
|
||||
this.$message.error(resp.data.message || '保存失败');
|
||||
}
|
||||
} catch (e) {
|
||||
this.$message.error('保存失败');
|
||||
}
|
||||
},
|
||||
async triggerNow(row) {
|
||||
try {
|
||||
const resp = await axios.post(`/system_jobs/api/jobs/${row.job_key}/trigger`);
|
||||
if (resp.data.success) {
|
||||
this.$message.success(resp.data.message || '触发成功');
|
||||
} else {
|
||||
this.$message.warning(resp.data.message || '触发失败');
|
||||
}
|
||||
await this.loadJobs();
|
||||
} catch (e) {
|
||||
this.$message.error('触发失败');
|
||||
}
|
||||
},
|
||||
async toggleEnabled(row) {
|
||||
try {
|
||||
const payload = {
|
||||
enabled: !row.enabled,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
trigger_type: row.trigger_type,
|
||||
trigger_config: row.trigger_config
|
||||
};
|
||||
const resp = await axios.put(`/system_jobs/api/jobs/${row.job_key}`, payload);
|
||||
if (resp.data.success) {
|
||||
this.$message.success(row.enabled ? '已停用' : '已启用');
|
||||
await this.loadJobs();
|
||||
} else {
|
||||
this.$message.error(resp.data.message || '更新失败');
|
||||
}
|
||||
} catch (e) {
|
||||
this.$message.error('更新失败');
|
||||
}
|
||||
},
|
||||
async viewLogs(row) {
|
||||
try {
|
||||
const resp = await axios.get(`/system_jobs/api/jobs/${row.job_key}/logs`);
|
||||
if (resp.data.success) {
|
||||
this.logs = resp.data.data || [];
|
||||
this.logsDialogVisible = true;
|
||||
} else {
|
||||
this.$message.error('加载日志失败');
|
||||
}
|
||||
} catch (e) {
|
||||
this.$message.error('加载日志失败');
|
||||
}
|
||||
},
|
||||
async reloadFromDB() {
|
||||
try {
|
||||
const resp = await axios.post('/system_jobs/api/reload');
|
||||
if (resp.data.success) {
|
||||
this.$message.success(resp.data.message || '重载成功');
|
||||
await this.loadJobs();
|
||||
} else {
|
||||
this.$message.error(resp.data.message || '重载失败');
|
||||
}
|
||||
} catch (e) {
|
||||
this.$message.error('重载失败');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block styles %}
|
||||
<style>
|
||||
.page-shell{display:flex;flex-direction:column;gap:16px}
|
||||
.page-hero{display:flex;align-items:flex-end;justify-content:space-between;gap:18px;padding:24px 26px;border-radius:24px;background:linear-gradient(135deg, rgba(79,70,229,.10), rgba(59,130,246,.08), rgba(255,255,255,.9));border:1px solid rgba(148,163,184,.16);box-shadow:0 18px 40px rgba(15,23,42,.06)}
|
||||
.page-hero-actions{display:flex;align-items:center;gap:12px}
|
||||
.page-eyebrow{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:#6366f1;font-weight:700;margin-bottom:8px}
|
||||
.page-hero-copy h1{font-size:30px;line-height:1.1;margin-bottom:10px;color:#0f172a}
|
||||
.page-hero-copy p{color:#64748b;font-size:14px}
|
||||
.action-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||||
</style>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user