基于 dash + feffery-antd-components 单文件实现定时任务调度平台
实现效果
列表展示定时任务
支持搜索, 添加, 启用. 停用. 编辑, 和删除功能
新增/编辑/删除定时任务
支持 周期/日期/corn 三种调度模式
支持 python/shell/api 三种任务类型
删除进行二次确认后即可删除
触发记录查看
支持详情查看, 以及一些便捷 搜索/筛选/分页功能
日志详情
可查看任务的执行结果, 展示原始执行脚本, 和标准以及异常输出
完整代码
""" 依赖模块 dash>=2.18.2 feffery_antd_components>=0.3.11 feffery_dash_utils>=0.1.5 feffery_utils_components>=0.2.0rc25 feffery-markdown-components peewee flask requests~=2.32.3
apscheduler """ import io import sys import dash import requests import datetime import subprocess from dash import html import feffery_antd_components as fac from feffery_dash_utils.style_utils import style from dash.dependencies import Input, Output, State from apscheduler.triggers.cron import CronTrigger from apscheduler.schedulers.background import BackgroundScheduler from peewee import Model, MySQLDatabase, TextField, CharField, BooleanField, DateTimeField, IntegerField # ------------------------------------------- 模型类 ------------------------------------------- # 数据库连接 db = MySQLDatabase( 'apex', user='root', password='123456', host='17.0.186.117', port=3306 ) # 定时任务模型 class Task(Model): task_name = CharField(null=True) # 任务名 task_type = CharField(null=True) # 脚本类型: Python 脚本 / Shell 脚本 / API 调用 command = TextField(null=True) # 脚本命令: 脚本内容 / API 地址 schedule_type = CharField(null=True) # 调用类型: 周期调用, date, cron schedule_config = CharField(null=True) # 调用配置: 调用秒数 / 调用时间 / cron表达式 is_active = BooleanField(default=False) # 是否激活, 默认不激活 class Meta: database = db # 定时任务触发结果模型 class TaskRecord(Model): task_id = IntegerField(null=True) # 任务id task_type = CharField(null=True) # 脚本类型: Python 脚本 / Shell 脚本 / API 调用 command = TextField(null=True) # 脚本命令: 脚本内容 / API 地址 run_date = DateTimeField(null=True) # 运行日期 result_type = CharField(null=True) # 调用状态: success / error output = TextField(null=True) # 输出正确内容 error = TextField(null=True) # 输出异常内容 class Meta: database = db # ------------------------------------------- 定时任务 ------------------------------------------- class Scheduler: def __init__(self): self.scheduler = BackgroundScheduler() self.scheduler.start() self.load() @staticmethod def execute_task(task): output = "" error = "" if task.task_type == 'Python 脚本': try: old_stdout = sys.stdout result = io.StringIO() sys.stdout = result exec(task.command, {}) # 定义一个命名空间避免影响当前环境的变量 sys.stdout = old_stdout output = result.getvalue() except Exception as e: error = str(e) elif task.task_type == 'Shell 脚本': try: # 捕获标准输出和标准错误 process = subprocess.run( task.command, shell=True, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) output = process.stdout except subprocess.CalledProcessError as e: error = e.stderr elif task.task_type == 'API 调用': try: response = requests.get(task.command) response.raise_for_status() # 检查响应状态码 output = response.json() if response.headers.get( 'Content-Type') == 'application/json' else response.text except requests.RequestException as e: error = str(e) output and print(output) # 打印正常输出 error and print(error) # 打印异常输出 TaskRecord.create( task_id=task.id, task_type=task.task_type, command=task.command, run_date=datetime.datetime.now(), result_type="error" if error else "success", output=output, error=error, ) def create(self, task): if task.schedule_type == '周期调用': self.scheduler.add_job( self.execute_task, 'interval', seconds=int(task.schedule_config), args=[task], id=f"{task.id}") elif task.schedule_type == '日期调用(单次)': # str -> datetime run_date = datetime.datetime.strptime(task.schedule_config, "%Y-%m-%d %H:%M:%S") self.scheduler.add_job( self.execute_task, 'date', run_date=run_date, args=[task], id=f"{task.id}") elif task.schedule_type == 'Cron 调用': self.scheduler.add_job( self.execute_task, CronTrigger.from_crontab(task.schedule_config), args=[task], id=f"{task.id}") def destroy(self, task=None, task_id=None): if task_id: self.scheduler.remove_job(job_id=f"{task_id}") else: self.scheduler.remove_job(job_id=f"{task.id}") def load(self): for task in Task.select().where(Task.is_active == True): self.create(task) # ------------------------------------------- 操作入口 ------------------------------------------- class TaskHandler: def __init__(self): # 初始化数据库 db.connect() # 初始化定时任务调度器 self.scheduler = Scheduler() # 创建 dash 应用 self.app = dash.Dash(__name__) # 定义标签骨架 self.layout() # 加载回调 self.register_callbacks() @staticmethod def reset_db(): db.drop_tables([Task, TaskRecord]) # 按需重建数据库 db.create_tables([Task, TaskRecord]) # 按需重建数据库 @staticmethod def make_tabel_data(query): tasks = [] for i in query: tasks.append({ 'id': i.id, 'task_name': i.task_name, 'task_type': i.task_type, 'command': i.command, 'schedule_type': i.schedule_type, 'schedule_config': i.schedule_config, "is_active": { 'checked': i.is_active, 'checkedChildren': '启用', 'unCheckedChildren': '停用', }, 'del_row': {'content': "删除", 'type': 'dashed', 'danger': True}, 'edit_row': {'content': "编辑", 'type': 'default', "color": "blue"}, 'log_row': {'content': f"查看 ({len(TaskRecord.select().where(TaskRecord.task_id == i.id))})", 'type': 'dashed', "color": "blue"} }) return tasks def layout(self): self.app.layout = html.Div([ # 页眉 fac.AntdFlex([ fac.AntdSpace( [ # logo html.Img( src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwM" "C9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWx" "sPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva" "2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWN" "pZGUgbHVjaWRlLWNsaXBib2FyZC1saXN0Ij48cmVjdCB3aWR0aD0iOCIgaGVpZ2h0PSI0Ii" "B4PSI4IiB5PSIyIiByeD0iMSIgcnk9IjEiLz48cGF0aCBkPSJNMTYgNGgyYTIgMiAwIDAgM" "SAyIDJ2MTRhMiAyIDAgMCAxLTIgMkg2YTIgMiAwIDAgMS0yLTJWNmEyIDIgMCAwIDEgMi0y" "aDIiLz48cGF0aCBkPSJNMTIgMTFoNCIvPjxwYXRoIGQ9Ik0xMiAxNmg0Ii8+PHBhdGggZD0" "iTTggMTFoLjAxIi8+PHBhdGggZD0iTTggMTZoLjAxIi8+PC9zdmc+", height=25, style=style(position="relative", top=3) ), fac.AntdText("定时任务调度脚本", strong=True, style=style(fontSize=27)), fac.AntdText("v1.0.0", className="global-help-text", style=style(fontSize=20)), ], align="baseline", size=5, id="core-header-title", ), fac.AntdSpace( [ fac.AntdButton( "添加任务", id="task-add", icon=fac.AntdIcon(icon="md-add", style=style(marginTop=4)), variant="outlined", motionType='happy-work', style=style(color="grey", marginRight=10)), fac.AntdInput( id="task-search", placeholder='搜索任务名', mode='search', style=style(width=500), allowClear=True)]), ], justify="space-between", style=style(padding=10)), # 新增 / 编辑抽屉 fac.AntdDrawer( fac.AntdForm( id='task-form', children=[ fac.AntdFormItem( label='任务名', children=fac.AntdInput(id='task-name') ), fac.AntdFormItem( label='调度类型', children=fac.AntdRadioGroup( id='schedule-type', options=[ {'label': '周期调用', 'value': '周期调用'}, {'label': '日期调用(单次)', 'value': '日期调用(单次)'}, {'label': 'Cron 调用', 'value': 'Cron 调用'} ], optionType='button', buttonStyle='solid', defaultValue='周期调用' ) ), fac.AntdFormItem( id="schedule-config-form-item", children=[ fac.AntdInputNumber(id='config-input-cycle', min=10, step=10, defaultValue=10, style=style(display="none")), fac.AntdDatePicker(id='config-input-date', showTime=True, style=style(display="none")), fac.AntdInput(id='config-input-corn', style=style(display="none")), ] ), fac.AntdFormItem( label='任务类型', children=fac.AntdRadioGroup( id='task-type', options=[ {'label': 'Python 脚本', 'value': 'Python 脚本'}, {'label': 'Shell 脚本', 'value': 'Shell 脚本'}, {'label': 'API 调用', 'value': 'API 调用'} ], optionType='button', buttonStyle='solid', defaultValue="Python 脚本" ), ), fac.AntdFormItem( label='命令', id='command-form-item', children=fac.AntdInput(id='command', mode="text-area", ) ), fac.AntdFormItem( children=fac.AntdButton('保存任务', id='task-form-button', type='primary') ) ] ), id=f'task-drawer', title='新增任务', width="25vw", ), # 触发记录抽屉 fac.AntdDrawer( [ fac.AntdFlex([ fac.AntdButton( "仅异常", id="res-error", icon=fac.AntdIcon(icon="antd-info-circle", style=style(marginTop=0)), variant="outlined", motionType='happy-work', style=style(color="grey", marginRight=10)), fac.AntdButton( "最近7日", id="res-7-day", icon=fac.AntdIcon(icon="antd-search", style=style(marginTop=0)), variant="outlined", motionType='happy-work', style=style(color="grey", marginRight=10)), fac.AntdButton( "最近30日", id="res-30-day", icon=fac.AntdIcon(icon="antd-search", style=style(marginTop=0)), variant="outlined", motionType='happy-work', style=style(color="grey", marginRight=10)), fac.AntdDateRangePicker( id="res-dt", placeholder=['开始日期时间', '结束日期时间'], showTime=True, needConfirm=True, style=style(marginRight=10) ), fac.AntdInput( id="res-search", placeholder='模糊查询', mode='search', style=style(width=400), allowClear=True) ], justify="flex-start", style=style(padding=15)), fac.AntdTable( id='res-table', columns=[ {'title': '编号', 'dataIndex': 'id', "width": "5%"}, {'title': "状态", 'dataIndex': "result_type", "width": "30%", 'renderOptions': {'renderType': 'status-badge'}}, {'title': '触发时间', 'dataIndex': 'run_date', "width": "20%"}, {'title': "查看日志", 'dataIndex': 'detail_log', 'renderOptions': {'renderType': 'button'}, "width": "5%"}, ], data=[], pagination={ 'total': 0, 'current': 1, 'pageSize': 10, 'showSizeChanger': True, 'pageSizeOptions': [10, 20, 30], 'position': 'bottomCenter', 'showQuickJumper': True, } ) ], id=f'log-drawer', title='触发记录', width="60vw", ), # 查看触发记录详细内容抽屉 fac.AntdDrawer( id=f'log-detail-drawer', title='输出日志', width="50vw", ), # 列表 fac.AntdTable( id='task-table', columns=[ {'title': '任务名字', 'dataIndex': 'task_name'}, {'title': '任务类型', 'dataIndex': 'task_type'}, {'title': '调度类型', 'dataIndex': 'schedule_type'}, {'title': '调度配置', 'dataIndex': 'schedule_config'}, {'title': "状态", 'dataIndex': "is_active", 'renderOptions': {'renderType': 'switch'}}, {'title': "触发记录", 'dataIndex': 'log_row', 'renderOptions': {'renderType': 'button'}, "width": "5%"}, {'title': "操作", 'dataIndex': 'edit_row', 'renderOptions': {'renderType': 'button'}, "width": "5%"}, {'title': "删除", 'dataIndex': 'del_row', "width": "5%", 'renderOptions': { 'renderType': 'button', 'renderButtonPopConfirmProps': {'title': '确认删除?', 'okText': '确认', 'cancelText': '取消'}, }}, ], data=[], pagination={ 'total': len(Task.select()), 'current': 1, 'pageSize': 10, 'showSizeChanger': True, 'pageSizeOptions': [10, 20, 30], 'position': 'bottomCenter', 'showQuickJumper': True, } ) ], style=style(padding=10)) def register_callbacks(self): # =========================== 渲染 定时任务列表 =========================== @self.app.callback(Output(f'task-table', 'data'), Output(f'task-table', 'pagination'), Input(f'task-table', 'pagination'), Input(f'task-search', 'value')) def table_list_show(pagination, s_value): query = Task.select() query = query.paginate(pagination['current'], pagination['pageSize']) if s_value: query = query.where((Task.task_name.contains(s_value))) return self.make_tabel_data(query), {**pagination, 'total': len(query)} # =========================== 点击 "添加任务" , 弹出抽屉, 更新内容 =========================== @self.app.callback(Input('task-add', 'nClicks')) def open_drawer(_): # 更新相关文本展示 dash.set_props("task-form-button", {"children": "创建保存"}) dash.set_props("task-drawer", {"title": "新增任务"}) # 更新表单value dash.set_props("task-name", {"value": None}) dash.set_props("task-type", {"value": "Python 脚本"}) dash.set_props("command", {"value": None}) dash.set_props("config-input-cycle", {"value": None}) dash.set_props("config-input-date", {"value": None}) dash.set_props("config-input-corn", {"value": None}) dash.set_props("schedule-type", {"value": "周期调用"}) # 弹出抽屉 dash.set_props("task-drawer", {"visible": True}) # =========================== 渲染 按钮按下改变显示效果 =========================== @self.app.callback(Output(f'res-error', 'color'), Input(f'res-error', 'nClicks'), State(f'res-error', 'color')) def button_variant_change(_, color): if color == "default": dash.set_props("res-error", {"style": style(marginRight=10)}) return "primary" dash.set_props("res-error", {"style": style(color="grey", marginRight=10)}) return "default" @self.app.callback(Output(f'res-7-day', 'color'), Input(f'res-7-day', 'nClicks'), State(f'res-7-day', 'color')) def button_variant_change(_, color): if color == "default": dash.set_props("res-7-day", {"style": style(marginRight=10)}) return "primary" dash.set_props("res-7-day", {"style": style(color="grey", marginRight=10)}) return "default" @self.app.callback(Output(f'res-30-day', 'color'), Input(f'res-30-day', 'nClicks'), State(f'res-30-day', 'color')) def button_variant_change(_, color): if color == "default": dash.set_props("res-30-day", {"style": style(marginRight=10)}) return "primary" dash.set_props("res-30-day", {"style": style(color="grey", marginRight=10)}) return "default" # =========================== 渲染 触发记录列表 =========================== @self.app.callback(Output(f'res-table', 'data'), Output(f'res-table', 'pagination'), Input(f'res-search', 'value'), Input(f'res-error', 'color'), Input(f'res-7-day', 'color'), Input(f'res-30-day', 'color'), Input(f'res-dt', 'value'), State(f'task-table', 'recentlyButtonClickedRow'), State(f'res-table', 'pagination'), prevent_initial_call=True) def table_list_show(s_value, error_color, day_7_click, day_30_click, dt_range, row_data, pagination): if row_data: query = TaskRecord.select().where(TaskRecord.task_id == row_data["id"]) if s_value: query = query.where(TaskRecord.output.contains(s_value) | TaskRecord.error.contains(s_value)) if error_color == "primary": query = query.where(TaskRecord.result_type == "error") if day_7_click == "primary": day_7_ago = datetime.datetime.now() - datetime.timedelta(days=7) query = query.where(TaskRecord.run_date >= day_7_ago) if day_30_click == "primary": day_30_ago = datetime.datetime.now() - datetime.timedelta(days=30) query = query.where(TaskRecord.run_date >= day_30_ago) if dt_range: query = query.where(TaskRecord.run_date <= dt_range[1]).where(TaskRecord.run_date >= dt_range[0]) tabel_data = [{ "id": i.id, "run_date": str(i.run_date)[:19], "result_type": {'status': i.result_type, 'text': i.result_type}, 'detail_log': {'content': "查看", 'type': 'default', "color": "blue"}, } for i in query.paginate(pagination['current'], pagination['pageSize'])] pagination = { 'total': len(query), 'current': 1, 'pageSize': 10, 'showSizeChanger': True, 'pageSizeOptions': [10, 20, 30], 'position': 'bottomCenter', 'showQuickJumper': True, } return tabel_data, pagination return dash.no_update, dash.no_update # =========================== 渲染 触发记录列表 =========================== @self.app.callback( Input(f'task-table', 'recentlyButtonClickedRow')) def res_list_show(row_data): query = TaskRecord.select().where(TaskRecord.task_id == row_data["id"]) dash.set_props("res-table", {"data": [{ "id": i.id, "run_date": str(i.run_date)[:19], "result_type": {'status': i.result_type, 'text': i.result_type}, 'detail_log': {'content': "查看", 'type': 'default', "color": "blue"}, } for i in query.paginate(1, 10)]}) dash.set_props("res-table", {"pagination": { 'total': len(query), 'current': 1, 'pageSize': 10, 'showSizeChanger': True, 'pageSizeOptions': [10, 20, 30], 'position': 'bottomCenter', 'showQuickJumper': True, }}) # =========================== 渲染 触发记录详情 =========================== @self.app.callback( Input(f'res-table', 'recentlyButtonClickedDataIndex'), Input(f'res-table', 'recentlyButtonClickedRow')) def res_detail_show(_, row_data): if _: obj = TaskRecord.get_or_none(row_data["id"]) dash.set_props( "log-detail-drawer", { "children": [ fac.AntdCollapse( fac.AntdInput(value=obj.command, mode="text-area", style=style(border=0)), title='执行信息', style=style(marginBottom=10) ), fac.AntdCollapse( fac.AntdInput(value=obj.output, mode="text-area", style=style(border=0)), title='标准输出', style=style(marginBottom=10) ), fac.AntdCollapse( fac.AntdInput(value=obj.error, mode="text-area", style=style(border=0)), title='异常输出', ), ] } ) dash.set_props("log-detail-drawer", {"visible": True}) dash.set_props("res-table", {"recentlyButtonClickedDataIndex": None}) # =========================== 点击 "编辑"/"查看" 弹出抽屉, 更新内容 =========================== @self.app.callback( Input(f'task-table', 'recentlyButtonClickedDataIndex'), Input(f'task-table', 'recentlyButtonClickedRow'), prevent_initial_call=True) def open_edit_drawer(c_index, row_data): # 编辑 if c_index == "edit_row": # 渲染展示文本 dash.set_props("task-form-button", {"children": "编辑保存"}) dash.set_props("task-drawer", {"title": "编辑任务"}) dash.set_props("task-table", {"recentlyButtonClickedDataIndex": ""}) # 更新表单value dash.set_props("task-name", {"value": row_data["task_name"]}) dash.set_props("task-type", {"value": row_data["task_type"]}) dash.set_props("command", {"value": row_data["command"]}) schedule_type = row_data["schedule_type"] schedule_config = row_data["schedule_config"] if schedule_type == "周期调用": dash.set_props("config-input-cycle", {"value": int(schedule_config)}) elif schedule_type == "日期调用(单次)": dash.set_props("config-input-date", {"value": datetime.datetime.strptime(schedule_config, "%Y-%m-%d %H:%M:%S")}) elif schedule_type == 'Cron 调用': dash.set_props("config-input-corn", {"value": int(schedule_config)}) dash.set_props("schedule-type", {"value": schedule_type}) # 打开抽屉 dash.set_props("task-drawer", {"visible": True}) # 触发记录 if c_index == "log_row": dash.set_props("log-drawer", {"visible": True}) dash.set_props("task-table", {"recentlyButtonClickedDataIndex": ""}) dash.set_props("res-table", {"pagination": { 'total': len(TaskRecord.select().where(TaskRecord.task_id == row_data["id"])), 'current': 1, 'pageSize': 10, 'showSizeChanger': True, 'pageSizeOptions': [10, 20, 30], 'position': 'bottomCenter', 'showQuickJumper': True, }}) # =========================== 切换 "调度类型",调整输入框展示信息 =========================== @self.app.callback(Input('schedule-type', 'value')) def schedule_switch(schedule_type): dash.set_props("config-input-cycle", {"style": style(display="none")}) dash.set_props("config-input-date", {"style": style(display="none")}) dash.set_props("config-input-corn", {"style": style(display="none")}) if schedule_type == '周期调用': dash.set_props("schedule-config-form-item", {"label": "周期(秒)"}) dash.set_props("config-input-cycle", {"style": None}) elif schedule_type == '日期调用(单次)': dash.set_props("schedule-config-form-item", {"label": "运行日期"}) dash.set_props("config-input-date", {"style": None}) elif schedule_type == 'Cron 调用': dash.set_props("schedule-config-form-item", {"label": "Cron 表达式"}) dash.set_props("config-input-corn", {"style": None}) # =========================== 切换 "任务类型", 调整输入框展示信息 =========================== @self.app.callback(Input('task-type', 'value')) def type_switch(task_type): if task_type == 'Python 脚本': dash.set_props("command-form-item", {"label": "python 脚本"}) elif task_type == 'Shell 脚本': dash.set_props("command-form-item", {"label": "shell 脚本"}) elif task_type == 'API 调用': dash.set_props("command-form-item", {"label": "URL"}) # =========================== 点击 "创建保存", 提交表单, 创建任务 =========================== @self.app.callback( Input('task-form-button', 'nClicks'), State('task-name', 'value'), State('task-type', 'value'), State('command', 'value'), State('schedule-type', 'value'), State('config-input-cycle', 'value'), State('config-input-date', 'value'), State('config-input-corn', 'value')) def add_task(_, task_name, task_type, command, schedule_type, cycle, date, corn): schedule_config = "" if schedule_type == "周期调用": schedule_config = cycle elif schedule_type == "日期调用(单次)": schedule_config = str(date)[:19] elif schedule_type == 'Cron 调用': schedule_config = corn Task.create( task_name=task_name, task_type=task_type, command=command, schedule_type=schedule_type, schedule_config=schedule_config, ) # 创建任务 # self.create_task(task) # 创建后, 默认不激活 dash.set_props("task-table", {"data": self.make_tabel_data(Task.select())}) dash.set_props("task-drawer", {"visible": False}) # =========================== 任务状态修改 =========================== @self.app.callback( Input(f'task-table', 'recentlySwitchDataIndex'), Input(f'task-table', 'recentlySwitchStatus'), Input(f'task-table', 'recentlySwitchRow'), prevent_initial_call=True) def task_table_row_change(sw_k, sw_v, row_data): task_id = row_data["id"] task = Task.get_by_id(task_id) if sw_k == "is_active": # 更新记录 task.is_active = sw_v task.save() # 停止或重新启动任务 if task.is_active: self.scheduler.create(task) else: self.scheduler.destroy(task) # =========================== 表内删除 操作 =========================== @self.app.callback( Output(f'task-table', 'data', allow_duplicate=True), Output(f'task-table', 'pagination', allow_duplicate=True), Input(f'task-table', 'recentlyButtonClickedDataIndex'), Input(f'task-table', 'recentlyButtonClickedRow'), State(f'task-table', 'pagination'), prevent_initial_call=True) def task_del(c_index, row_data, pagination): task_id = row_data.get('id') # 获取项目ID' if c_index == "del_row": # 若启动则删除任务 if row_data["is_active"]["checked"]: self.scheduler.destroy(task_id=task_id) # 删除任务对象 Task.delete().where(Task.id == task_id).execute() # 重新获取数据 query = Task.select() if pagination: query = query.paginate(pagination['current'], pagination['pageSize']) return self.make_tabel_data(query), {**pagination, 'total': len(query)} if pagination else dash.no_update return dash.no_update, dash.no_update def start(self): self.app.run_server(debug=True) # 启动应用 if __name__ == '__main__': td = TaskHandler() # td.reset_db() # 如有数据库重建需求 td.start()
安装依赖后 解除 td.reset_db() 行进行初始化数据库创建
数据库创建完成后, 再次启动服务, 则直接访问 127.0.0.1:5080 即可进行所有功能
相关说明
1. 仅支持了本地化执行, 不支持远程作业调度
2. 若想实现远程调度则需要在开发新页面和建模表实现ssh访问凭证的curd
3. 定时任务基于 apscheduler, 相关的详细使用文档可参考 这个博客里面写的很详细
4. 相当于实现了一个前后端不分离的实现作业调度虽小但全的麻雀demo, 简单便捷, 堪堪可用
5. 还是要吐槽一句前端写起来属实蛋疼,,,,,, 真特么是每个交互顺序都要一点一点扣出来,,,,麻
本文来自博客园,作者:羊驼之歌,转载请注明原文链接:https://www.cnblogs.com/shijieli/p/18727108
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 10年+ .NET Coder 心语 ── 封装的思维:从隐藏、稳定开始理解其本质意义
· 地球OL攻略 —— 某应届生求职总结
· 提示词工程——AI应用必不可少的技术
· 字符编码:从基础到乱码解决
· Open-Sora 2.0 重磅开源!
2019-02-20 MySQL 练习题 附答案,未完
2019-02-20 MySQL 报错 1055