1125 lines
44 KiB
HTML
1125 lines
44 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>TaskPilot - 定时任务平台</title>
|
|
<!-- 引入 Alright 主题常用的人文无衬线与等宽字体 -->
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
|
|
<style>
|
|
:root {
|
|
/* Alright 风格极简设计变量 */
|
|
--bg-color: #f7f7f8;
|
|
--card-bg: #ffffff;
|
|
--text-main: #111827;
|
|
--text-muted: #8c939d;
|
|
--text-light: #b5b9c0;
|
|
--primary: #111827; /* 标志性冷墨黑 */
|
|
--primary-hover: #262e3d;
|
|
--border-color: #f0f1f3;
|
|
--badge-bg: #f3f4f6;
|
|
--success: #10b981;
|
|
--danger: #f43f5e;
|
|
--warning: #f59e0b;
|
|
--radius-sm: 8px;
|
|
--radius-md: 12px;
|
|
--radius-lg: 18px;
|
|
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.02);
|
|
--shadow-card: 0 8px 30px rgba(0, 0, 0, 0.03);
|
|
--shadow-modal: 0 20px 40px rgba(15, 23, 42, 0.08);
|
|
--font-sans: "Plus Jakarta Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
|
--font-mono: "JetBrains Mono", Consolas, "Courier New", monospace;
|
|
}
|
|
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body {
|
|
font-family: var(--font-sans);
|
|
background-color: var(--bg-color);
|
|
color: var(--text-main);
|
|
padding: 48px 24px;
|
|
-webkit-font-smoothing: antialiased;
|
|
-moz-osx-font-smoothing: grayscale;
|
|
}
|
|
|
|
.container { max-width: 1040px; margin: 0 auto; }
|
|
|
|
/* 顶部导航 */
|
|
header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: flex-end;
|
|
margin-bottom: 32px;
|
|
}
|
|
.header-title h1 {
|
|
font-size: 26px;
|
|
font-weight: 700;
|
|
letter-spacing: -0.03em;
|
|
color: var(--text-main);
|
|
}
|
|
.header-title p {
|
|
font-size: 13px;
|
|
color: var(--text-muted);
|
|
margin-top: 6px;
|
|
font-weight: 400;
|
|
}
|
|
|
|
/* 统计指标卡片 */
|
|
.metrics {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, 1fr);
|
|
gap: 18px;
|
|
margin-bottom: 28px;
|
|
}
|
|
.metric-card {
|
|
background: var(--card-bg);
|
|
padding: 20px;
|
|
border-radius: var(--radius-md);
|
|
box-shadow: var(--shadow-sm);
|
|
border: 1px solid var(--border-color);
|
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
|
}
|
|
.metric-card:hover {
|
|
box-shadow: var(--shadow-card);
|
|
transform: translateY(-2px);
|
|
}
|
|
.metric-card span {
|
|
font-size: 12px;
|
|
color: var(--text-muted);
|
|
font-weight: 500;
|
|
letter-spacing: 0.02em;
|
|
}
|
|
.metric-card strong {
|
|
display: block;
|
|
font-size: 26px;
|
|
font-weight: 700;
|
|
letter-spacing: -0.03em;
|
|
margin-top: 8px;
|
|
color: var(--text-main);
|
|
}
|
|
|
|
/* 主卡片与表格 */
|
|
.card {
|
|
background: var(--card-bg);
|
|
border-radius: var(--radius-lg);
|
|
border: 1px solid var(--border-color);
|
|
box-shadow: var(--shadow-card);
|
|
overflow: hidden;
|
|
}
|
|
.table-container { width: 100%; overflow-x: auto; }
|
|
table { width: 100%; border-collapse: collapse; text-align: left; }
|
|
th {
|
|
background: #fafafa;
|
|
padding: 14px 22px;
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
color: var(--text-muted);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
border-bottom: 1px solid var(--border-color);
|
|
}
|
|
td {
|
|
padding: 18px 22px;
|
|
border-bottom: 1px solid var(--border-color);
|
|
font-size: 14px;
|
|
vertical-align: middle;
|
|
transition: background-color 0.15s ease;
|
|
}
|
|
tr:last-child td { border-bottom: none; }
|
|
tr:hover td { background-color: #fafbfd; }
|
|
|
|
/* 状态与代码高亮 */
|
|
.badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 4px 10px;
|
|
border-radius: 9999px;
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
}
|
|
.badge-dot { width: 6px; height: 6px; border-radius: 50%; }
|
|
.badge-active { background: #eefbf5; color: #059669; }
|
|
.badge-active .badge-dot { background: var(--success); }
|
|
.badge-paused { background: #f3f4f6; color: var(--text-muted); }
|
|
.badge-paused .badge-dot { background: #9ca3af; }
|
|
|
|
code {
|
|
font-family: var(--font-mono);
|
|
font-size: 12px;
|
|
background: #f3f4f6;
|
|
color: #374151;
|
|
padding: 3px 8px;
|
|
border-radius: 6px;
|
|
}
|
|
|
|
/* 极简按钮样式 */
|
|
.btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 6px;
|
|
padding: 9px 18px;
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
border-radius: var(--radius-sm);
|
|
border: 1px solid transparent;
|
|
cursor: pointer;
|
|
font-family: inherit;
|
|
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
|
}
|
|
.btn-primary {
|
|
background: var(--primary);
|
|
color: #fff;
|
|
}
|
|
.btn-primary:hover {
|
|
background: var(--primary-hover);
|
|
transform: translateY(-1px);
|
|
}
|
|
.btn-secondary {
|
|
background: #ffffff;
|
|
border-color: #e5e7eb;
|
|
color: #374151;
|
|
}
|
|
.btn-secondary:hover {
|
|
background: #f9fafb;
|
|
border-color: #d1d5db;
|
|
}
|
|
.btn-danger-outline {
|
|
background: #fff;
|
|
border-color: #fecdd3;
|
|
color: var(--danger);
|
|
}
|
|
.btn-danger-outline:hover {
|
|
background: #fff1f2;
|
|
}
|
|
.btn-danger-text {
|
|
color: var(--danger);
|
|
background: none;
|
|
border: none;
|
|
cursor: pointer;
|
|
padding: 6px 10px;
|
|
font-size: 13px;
|
|
font-family: inherit;
|
|
}
|
|
.btn-danger-text:hover { opacity: 0.8; text-decoration: underline; }
|
|
.btn-sm { padding: 6px 12px; font-size: 12px; border-radius: 6px; }
|
|
|
|
/* 抽屉弹窗 (Modal) */
|
|
.modal-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(17, 24, 39, 0.25);
|
|
backdrop-filter: blur(8px);
|
|
-webkit-backdrop-filter: blur(8px);
|
|
display: none;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 100;
|
|
}
|
|
.modal-overlay.open { display: flex; }
|
|
.modal-card {
|
|
background: var(--card-bg);
|
|
border-radius: var(--radius-lg);
|
|
width: 100%;
|
|
max-width: 660px;
|
|
box-shadow: var(--shadow-modal);
|
|
overflow: hidden;
|
|
animation: modalFadeIn 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
|
border: 1px solid var(--border-color);
|
|
}
|
|
@keyframes modalFadeIn {
|
|
from { transform: scale(0.97) translateY(6px); opacity: 0; }
|
|
to { transform: scale(1) translateY(0); opacity: 1; }
|
|
}
|
|
.modal-header {
|
|
padding: 20px 28px;
|
|
border-bottom: 1px solid var(--border-color);
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
.modal-header h3 {
|
|
font-size: 16px;
|
|
font-weight: 700;
|
|
letter-spacing: -0.02em;
|
|
}
|
|
.modal-close-btn {
|
|
border: none;
|
|
background: none;
|
|
font-size: 20px;
|
|
cursor: pointer;
|
|
color: var(--text-muted);
|
|
transition: color 0.15s;
|
|
}
|
|
.modal-close-btn:hover { color: var(--text-main); }
|
|
.modal-body { padding: 24px 28px; max-height: 75vh; overflow-y: auto; }
|
|
.modal-footer {
|
|
padding: 16px 28px;
|
|
border-top: 1px solid var(--border-color);
|
|
background: #fafafb;
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 12px;
|
|
}
|
|
|
|
/* 表单组件 */
|
|
.form-group { margin-bottom: 18px; }
|
|
.form-group label {
|
|
display: block;
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
margin-bottom: 8px;
|
|
color: #374151;
|
|
}
|
|
.form-group .hint {
|
|
font-size: 12px;
|
|
color: var(--text-muted);
|
|
margin-top: 5px;
|
|
}
|
|
.form-control {
|
|
width: 100%;
|
|
padding: 10px 14px;
|
|
font-size: 13px;
|
|
border: 1px solid #e5e7eb;
|
|
border-radius: var(--radius-sm);
|
|
background: #ffffff;
|
|
color: var(--text-main);
|
|
font-family: inherit;
|
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
|
}
|
|
.form-control:focus {
|
|
outline: none;
|
|
border-color: #111827;
|
|
box-shadow: 0 0 0 3px rgba(17, 24, 39, 0.06);
|
|
}
|
|
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
|
|
|
/* Cron 预览专属区块 */
|
|
.cron-header-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 8px;
|
|
}
|
|
.cron-n-selector {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
font-size: 12px;
|
|
color: var(--text-muted);
|
|
}
|
|
.cron-n-selector select {
|
|
padding: 2px 6px;
|
|
font-size: 12px;
|
|
font-family: inherit;
|
|
border-radius: 4px;
|
|
border: 1px solid #e5e7eb;
|
|
background: #fff;
|
|
color: var(--text-main);
|
|
cursor: pointer;
|
|
}
|
|
.cron-preview-box {
|
|
margin-top: 8px;
|
|
background: #fafafa;
|
|
border: 1px solid #eeeff2;
|
|
border-radius: var(--radius-sm);
|
|
padding: 10px 12px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
}
|
|
.cron-run-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-size: 12px;
|
|
color: #374151;
|
|
font-family: var(--font-mono);
|
|
}
|
|
.cron-run-index {
|
|
color: var(--text-muted);
|
|
font-size: 11px;
|
|
width: 28px;
|
|
text-align: right;
|
|
}
|
|
.cron-run-time {
|
|
background: #ffffff;
|
|
padding: 2px 8px;
|
|
border-radius: 4px;
|
|
border: 1px solid #e5e7eb;
|
|
color: #111827;
|
|
font-weight: 500;
|
|
}
|
|
|
|
/* 动态联动区块 */
|
|
.dynamic-fields {
|
|
background: #fafafa;
|
|
border: 1px solid #eeeff2;
|
|
border-radius: var(--radius-md);
|
|
padding: 20px;
|
|
margin-top: 10px;
|
|
}
|
|
|
|
/* 虚拟环境探测状态行 */
|
|
.env-detector-bar {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin-top: 8px;
|
|
padding: 8px 12px;
|
|
border-radius: 6px;
|
|
font-size: 12px;
|
|
background: #ffffff;
|
|
border: 1px solid #eef0f3;
|
|
}
|
|
.env-status-badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
font-weight: 500;
|
|
}
|
|
.env-status-badge.ok { color: var(--success); }
|
|
.env-status-badge.warn { color: var(--warning); }
|
|
|
|
/* 动态键值对列表 */
|
|
.dynamic-rows-container { margin-top: 10px; display: flex; flex-direction: column; gap: 8px; }
|
|
.key-value-row { display: flex; gap: 8px; align-items: center; }
|
|
.key-value-row input { flex: 1; }
|
|
|
|
/* 终端预览窗口 */
|
|
.cli-preview {
|
|
margin-top: 18px;
|
|
background: #18191c;
|
|
border-radius: var(--radius-sm);
|
|
padding: 14px 18px;
|
|
}
|
|
.cli-preview-header {
|
|
font-size: 11px;
|
|
color: #71767f;
|
|
margin-bottom: 8px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
border-bottom: 1px solid #282a30;
|
|
padding-bottom: 6px;
|
|
}
|
|
.cli-preview-body {
|
|
font-family: var(--font-mono);
|
|
font-size: 12px;
|
|
line-height: 1.6;
|
|
}
|
|
.cli-line-env {
|
|
color: #9da3af;
|
|
margin-bottom: 4px;
|
|
}
|
|
.cli-line-cmd {
|
|
color: #34d399;
|
|
font-weight: 500;
|
|
word-break: break-all;
|
|
}
|
|
|
|
/* 日志弹窗 */
|
|
.log-viewer {
|
|
background: #18191c;
|
|
color: #f3f4f6;
|
|
font-family: var(--font-mono);
|
|
font-size: 12px;
|
|
padding: 18px;
|
|
border-radius: var(--radius-sm);
|
|
height: 320px;
|
|
overflow-y: auto;
|
|
white-space: pre-wrap;
|
|
line-height: 1.6;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div class="container">
|
|
<header>
|
|
<div class="header-title">
|
|
<h1>TaskPilot</h1>
|
|
<p>Rust 本地轻量化任务引擎 · Windows 原生调度中心</p>
|
|
</div>
|
|
<div>
|
|
<button class="btn btn-primary" onclick="openEditModal()">+ 新建任务</button>
|
|
</div>
|
|
</header>
|
|
|
|
<section class="metrics">
|
|
<div class="metric-card">
|
|
<span>已托管任务</span>
|
|
<strong id="m-total">3</strong>
|
|
</div>
|
|
<div class="metric-card">
|
|
<span>正常运行中</span>
|
|
<strong style="color: var(--success);" id="m-active">2</strong>
|
|
</div>
|
|
<div class="metric-card">
|
|
<span>今日已执行</span>
|
|
<strong id="m-runs">48 次</strong>
|
|
</div>
|
|
<div class="metric-card">
|
|
<span>平均延迟</span>
|
|
<strong style="color: #4b5563;">< 1 ms</strong>
|
|
</div>
|
|
</section>
|
|
|
|
<div class="card">
|
|
<div class="table-container">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>名称 / 任务标识</th>
|
|
<th>调度周期</th>
|
|
<th>类型</th>
|
|
<th>状态</th>
|
|
<th>上次耗时</th>
|
|
<th style="text-align: right;">快捷操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="task-table-body">
|
|
<tr>
|
|
<td>
|
|
<div style="font-weight: 600; color: var(--text-main);">每日数据库快照</div>
|
|
<div style="font-size: 12px; color: var(--text-muted); font-family: var(--font-mono); margin-top: 2px;">db_backup_daily</div>
|
|
</td>
|
|
<td><code>0 0 2 * * *</code></td>
|
|
<td><span style="font-size: 12px; color: #4b5563;">Python 脚本</span></td>
|
|
<td><span class="badge badge-active"><span class="badge-dot"></span>活跃中</span></td>
|
|
<td style="color: #4b5563;">1.42s</td>
|
|
<td style="text-align: right;">
|
|
<button class="btn btn-secondary btn-sm" onclick="triggerRun('db_backup_daily')">运行</button>
|
|
<button class="btn btn-secondary btn-sm" onclick="showLogs('db_backup_daily')">日志</button>
|
|
<button class="btn btn-secondary btn-sm" onclick="openEditModal('db_backup_daily')">编辑</button>
|
|
<button class="btn btn-danger-outline btn-sm" onclick="deleteTask('db_backup_daily')">删除</button>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td>
|
|
<div style="font-weight: 600; color: var(--text-main);">网关探活心跳</div>
|
|
<div style="font-size: 12px; color: var(--text-muted); font-family: var(--font-mono); margin-top: 2px;">api_health_ping</div>
|
|
</td>
|
|
<td><code>*/5 * * * * *</code></td>
|
|
<td><span style="font-size: 12px; color: #4b5563;">HTTP 请求</span></td>
|
|
<td><span class="badge badge-active"><span class="badge-dot"></span>活跃中</span></td>
|
|
<td style="color: #4b5563;">45ms</td>
|
|
<td style="text-align: right;">
|
|
<button class="btn btn-secondary btn-sm" onclick="triggerRun('api_health_ping')">运行</button>
|
|
<button class="btn btn-secondary btn-sm" onclick="showLogs('api_health_ping')">日志</button>
|
|
<button class="btn btn-secondary btn-sm" onclick="openEditModal('api_health_ping')">编辑</button>
|
|
<button class="btn btn-danger-outline btn-sm" onclick="deleteTask('api_health_ping')">删除</button>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td>
|
|
<div style="font-weight: 600; color: var(--text-main);">数据流转守护进程</div>
|
|
<div style="font-size: 12px; color: var(--text-muted); font-family: var(--font-mono); margin-top: 2px;">stream_processor</div>
|
|
</td>
|
|
<td><code>@reboot</code></td>
|
|
<td><span style="font-size: 12px; color: #4b5563;">二进制程序</span></td>
|
|
<td><span class="badge badge-paused"><span class="badge-dot"></span>已暂停</span></td>
|
|
<td style="color: #9ca3af;">--</td>
|
|
<td style="text-align: right;">
|
|
<button class="btn btn-secondary btn-sm" onclick="triggerRun('stream_processor')">运行</button>
|
|
<button class="btn btn-secondary btn-sm" onclick="showLogs('stream_processor')">日志</button>
|
|
<button class="btn btn-secondary btn-sm" onclick="openEditModal('stream_processor')">编辑</button>
|
|
<button class="btn btn-danger-outline btn-sm" onclick="deleteTask('stream_processor')">删除</button>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 任务新建/编辑抽屉弹窗 -->
|
|
<div class="modal-overlay" id="task-modal">
|
|
<div class="modal-card">
|
|
<div class="modal-header">
|
|
<h3 id="modal-title">新建定时任务</h3>
|
|
<button class="modal-close-btn" onclick="closeModal('task-modal')">×</button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<form id="task-form" onsubmit="event.preventDefault();">
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>任务标识 (Task ID)</label>
|
|
<input type="text" class="form-control" id="task-id" placeholder="例如: sync_data" required>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>名称</label>
|
|
<input type="text" class="form-control" id="task-name" placeholder="业务可读名称" required>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Cron 配置与动态 N 次预览 -->
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<div class="cron-header-row">
|
|
<label style="margin: 0;">Cron 表达式</label>
|
|
<div class="cron-n-selector">
|
|
<span>预览未来</span>
|
|
<select id="cron-n-count" onchange="triggerCronPreview()">
|
|
<option value="3">3 次</option>
|
|
<option value="5" selected>5 次</option>
|
|
<option value="8">8 次</option>
|
|
<option value="10">10 次</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<input type="text" class="form-control" id="task-cron" placeholder="0 0 2 * * *" required oninput="triggerCronPreview()">
|
|
|
|
<!-- 渲染 N 次运行节点的容器 -->
|
|
<div class="cron-preview-box" id="cron-preview-box">
|
|
<div style="font-size: 12px; color: var(--text-muted);">等待输入有效 Cron 表达式...</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>超时时间 (秒)</label>
|
|
<input type="number" class="form-control" id="task-timeout" value="300" min="1">
|
|
<div class="hint">超时后将被 Windows JobObject 强杀</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>任务类型 (Action Type)</label>
|
|
<select class="form-control" id="task-type" onchange="renderDynamicFields()">
|
|
<option value="script">执行脚本 (Python / Shell / Node / Lua)</option>
|
|
<option value="http">发起 HTTP 请求</option>
|
|
<option value="process">管理常驻/独立二进制</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- 动态表单配置区 -->
|
|
<div class="dynamic-fields" id="dynamic-container"></div>
|
|
|
|
<div class="form-group" style="margin-top: 18px; display: flex; align-items: center; gap: 8px;">
|
|
<input type="checkbox" id="task-active" checked style="accent-color: var(--primary);">
|
|
<label for="task-active" style="margin: 0; font-weight: normal; cursor: pointer; color: #4b5563;">立即激活此任务调度</label>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button class="btn btn-secondary" onclick="closeModal('task-modal')">取消</button>
|
|
<button class="btn btn-primary" onclick="submitTaskForm()">保存并热刷新</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 日志查看弹窗 -->
|
|
<div class="modal-overlay" id="log-modal">
|
|
<div class="modal-card">
|
|
<div class="modal-header">
|
|
<h3 id="log-title">任务实时日志</h3>
|
|
<button class="modal-close-btn" onclick="closeModal('log-modal')">×</button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="log-viewer" id="log-viewer-body">正在拉取末尾 100 行日志...</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button class="btn btn-secondary" onclick="refreshCurrentLogs()">刷新</button>
|
|
<button class="btn btn-primary" onclick="closeModal('log-modal')">关闭</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
let detectedVenv = 'python';
|
|
let argList = [];
|
|
let envList = [];
|
|
let detectTimer = null;
|
|
let cronTimer = null;
|
|
|
|
function renderDynamicFields() {
|
|
const type = document.getElementById('task-type').value;
|
|
const container = document.getElementById('dynamic-container');
|
|
|
|
if (type === 'script') {
|
|
container.innerHTML = `
|
|
<div class="form-group">
|
|
<label>项目工作目录 (Working Directory)</label>
|
|
<input type="text" class="form-control" id="work-dir"
|
|
placeholder="例如: C:\\Projects\\my_service 或 D:/workspace/sync"
|
|
oninput="onWorkDirChange(this.value)">
|
|
|
|
<div class="env-detector-bar">
|
|
<span id="env-badge" class="env-status-badge warn">
|
|
● 请输入有效的工作目录以探测环境
|
|
</span>
|
|
<span style="font-size: 11px; color: var(--text-muted);" id="env-interpreter-tag"></span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 自定义环境变量 -->
|
|
<div class="form-group">
|
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
|
<div>
|
|
<label style="margin: 0;">自定义环境变量 (Environment Variables)</label>
|
|
<div class="hint">例如 PYTHONUNBUFFERED=1, API_SECRET 等</div>
|
|
</div>
|
|
<button type="button" class="btn btn-secondary btn-sm" onclick="addEnvRow()">+ 添加变量</button>
|
|
</div>
|
|
<div class="dynamic-rows-container" id="env-container"></div>
|
|
</div>
|
|
|
|
<!-- 目标执行脚本 -->
|
|
<div class="form-group">
|
|
<label>目标执行脚本 (文件名或相对路径)</label>
|
|
<input type="text" class="form-control" id="script-target"
|
|
placeholder="例如: main.py 或 scripts\\sync.py"
|
|
oninput="updateCliPreview()">
|
|
</div>
|
|
|
|
<div class="form-group" style="margin-bottom: 0;">
|
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
|
<label style="margin: 0;">运行参数 (Arguments)</label>
|
|
<button type="button" class="btn btn-secondary btn-sm" onclick="addArgRow()">+ 添加参数</button>
|
|
</div>
|
|
<div class="dynamic-rows-container" id="args-container"></div>
|
|
</div>
|
|
|
|
<!-- 终端预览 -->
|
|
<div class="cli-preview">
|
|
<div class="cli-preview-header">
|
|
<span>调用预览 (Runtime Preview)</span>
|
|
<span>Windows 原生环境</span>
|
|
</div>
|
|
<div class="cli-preview-body" id="cli-preview-body">
|
|
<div class="cli-line-cmd">等待输入...</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
renderEnvRows();
|
|
renderArgRows();
|
|
updateCliPreview();
|
|
} else if (type === 'http') {
|
|
container.innerHTML = `
|
|
<div class="form-group">
|
|
<label>请求 URL</label>
|
|
<input type="url" class="form-control" id="http-url" placeholder="https://api.example.com/health" required>
|
|
</div>
|
|
<div class="form-row" style="margin-bottom: 0;">
|
|
<div class="form-group" style="margin-bottom: 0;">
|
|
<label>请求方式</label>
|
|
<select class="form-control" id="http-method">
|
|
<option value="GET">GET</option>
|
|
<option value="POST">POST</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group" style="margin-bottom: 0;">
|
|
<label>Body 载荷 (JSON, 选填)</label>
|
|
<input type="text" class="form-control" id="http-body" placeholder='{"trigger": "manual"}'>
|
|
</div>
|
|
</div>
|
|
`;
|
|
} else if (type === 'process') {
|
|
container.innerHTML = `
|
|
<div class="form-group">
|
|
<label>可执行程序路径 (.exe)</label>
|
|
<input type="text" class="form-control" id="proc-path" placeholder="C:\\Tools\\exporter.exe" required>
|
|
</div>
|
|
<div class="form-group" style="margin-bottom: 0;">
|
|
<label>启动参数 (空格分隔)</label>
|
|
<input type="text" class="form-control" id="proc-args" placeholder="--port 8080 --workers 4">
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
function onWorkDirChange(path) {
|
|
clearTimeout(detectTimer);
|
|
const badge = document.getElementById('env-badge');
|
|
const tag = document.getElementById('env-interpreter-tag');
|
|
|
|
if (!path.trim()) {
|
|
badge.className = 'env-status-badge warn';
|
|
badge.innerHTML = '● 请输入有效的工作目录以探测环境';
|
|
tag.textContent = '';
|
|
detectedVenv = 'python';
|
|
updateCliPreview();
|
|
return;
|
|
}
|
|
|
|
badge.className = 'env-status-badge';
|
|
badge.innerHTML = '⏳ 正在探测解释器...';
|
|
|
|
detectTimer = setTimeout(() => {
|
|
const lower = path.toLowerCase();
|
|
if (lower.includes('sync') || lower.includes('venv') || lower.includes('project')) {
|
|
detectedVenv = '.venv\\Scripts\\python.exe';
|
|
badge.className = 'env-status-badge ok';
|
|
badge.innerHTML = '● 已关联虚拟环境: <code>.venv\\Scripts\\python.exe</code>';
|
|
tag.textContent = 'Rust 自动前置 PATH 及 VIRTUAL_ENV';
|
|
} else if (lower.includes('node') || lower.includes('js')) {
|
|
detectedVenv = 'node.exe';
|
|
badge.className = 'env-status-badge ok';
|
|
badge.innerHTML = '● 识别为 Node.js: <code>node.exe</code>';
|
|
tag.textContent = '';
|
|
} else {
|
|
detectedVenv = 'python';
|
|
badge.className = 'env-status-badge ok';
|
|
badge.innerHTML = '● 使用系统解释器: <code>python.exe</code>';
|
|
tag.textContent = '';
|
|
}
|
|
updateCliPreview();
|
|
}, 250);
|
|
}
|
|
|
|
function renderEnvRows() {
|
|
const container = document.getElementById('env-container');
|
|
if (!container) return;
|
|
container.innerHTML = '';
|
|
|
|
envList.forEach((env, index) => {
|
|
const row = document.createElement('div');
|
|
row.className = 'key-value-row';
|
|
row.innerHTML = `
|
|
<input type="text" class="form-control" placeholder="变量名 (如 PYTHONPATH)"
|
|
value="${env.key}" oninput="updateEnvKey(${index}, this.value)">
|
|
<input type="text" class="form-control" placeholder="变量值 (如 1 或 C:\\custom\\path)"
|
|
value="${env.val}" oninput="updateEnvVal(${index}, this.value)">
|
|
<button type="button" class="btn-danger-text" onclick="removeEnvRow(${index})">删除</button>
|
|
`;
|
|
container.appendChild(row);
|
|
});
|
|
updateCliPreview();
|
|
}
|
|
|
|
function addEnvRow() {
|
|
envList.push({ key: '', val: '' });
|
|
renderEnvRows();
|
|
}
|
|
|
|
function removeEnvRow(index) {
|
|
envList.splice(index, 1);
|
|
renderEnvRows();
|
|
}
|
|
|
|
function updateEnvKey(index, val) {
|
|
envList[index].key = val.trim();
|
|
updateCliPreview();
|
|
}
|
|
|
|
function updateEnvVal(index, val) {
|
|
envList[index].val = val.trim();
|
|
updateCliPreview();
|
|
}
|
|
|
|
function renderArgRows() {
|
|
const container = document.getElementById('args-container');
|
|
if (!container) return;
|
|
container.innerHTML = '';
|
|
|
|
argList.forEach((arg, index) => {
|
|
const row = document.createElement('div');
|
|
row.className = 'key-value-row';
|
|
row.innerHTML = `
|
|
<input type="text" class="form-control" placeholder="参数名 (如 --mode 或 -v)"
|
|
value="${arg.key}" oninput="updateArgKey(${index}, this.value)">
|
|
<input type="text" class="form-control" placeholder="参数值 (可选,无值可留空)"
|
|
value="${arg.val}" oninput="updateArgVal(${index}, this.value)">
|
|
<button type="button" class="btn-danger-text" onclick="removeArgRow(${index})">删除</button>
|
|
`;
|
|
container.appendChild(row);
|
|
});
|
|
updateCliPreview();
|
|
}
|
|
|
|
function addArgRow() {
|
|
argList.push({ key: '', val: '' });
|
|
renderArgRows();
|
|
}
|
|
|
|
function removeArgRow(index) {
|
|
argList.splice(index, 1);
|
|
renderArgRows();
|
|
}
|
|
|
|
function updateArgKey(index, val) {
|
|
argList[index].key = val.trim();
|
|
updateCliPreview();
|
|
}
|
|
|
|
function updateArgVal(index, val) {
|
|
argList[index].val = val.trim();
|
|
updateCliPreview();
|
|
}
|
|
|
|
function updateCliPreview() {
|
|
const previewBody = document.getElementById('cli-preview-body');
|
|
if (!previewBody) return;
|
|
|
|
const scriptInput = document.getElementById('script-target');
|
|
const script = (scriptInput && scriptInput.value.trim()) || 'main.py';
|
|
|
|
const formattedArgs = [];
|
|
argList.forEach(item => {
|
|
if (item.key) formattedArgs.push(item.key);
|
|
if (item.val) {
|
|
const valFormatted = item.val.includes(' ') ? `"${item.val}"` : item.val;
|
|
formattedArgs.push(valFormatted);
|
|
}
|
|
});
|
|
const argsStr = formattedArgs.length > 0 ? ' ' + formattedArgs.join(' ') : '';
|
|
|
|
let envLinesHtml = '';
|
|
const validEnvs = envList.filter(item => item.key);
|
|
if (validEnvs.length > 0) {
|
|
const envStrings = validEnvs.map(item => `set ${item.key}=${item.val || '""'}`).join('<br>');
|
|
envLinesHtml = `<div class="cli-line-env">${envStrings}</div>`;
|
|
}
|
|
|
|
const cmdLineHtml = `<div class="cli-line-cmd">${detectedVenv} ${script}${argsStr}</div>`;
|
|
previewBody.innerHTML = `${envLinesHtml}${cmdLineHtml}`;
|
|
}
|
|
|
|
// ==========================================
|
|
// Cron 解析与最近 N 次时间预测逻辑
|
|
// ==========================================
|
|
function triggerCronPreview() {
|
|
clearTimeout(cronTimer);
|
|
cronTimer = setTimeout(() => {
|
|
const expr = document.getElementById('task-cron').value.trim();
|
|
const n = parseInt(document.getElementById('cron-n-count').value, 10) || 5;
|
|
const box = document.getElementById('cron-preview-box');
|
|
|
|
if (!expr) {
|
|
box.innerHTML = `<div style="font-size: 12px; color: var(--text-muted);">等待输入有效 Cron 表达式...</div>`;
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const runs = getNextCronRuns(expr, n);
|
|
if (runs.length === 0) {
|
|
box.innerHTML = `<div style="font-size: 12px; color: var(--danger);">无法推导下次运行时间,请检查表达式语法</div>`;
|
|
return;
|
|
}
|
|
|
|
box.innerHTML = runs.map((timeStr, idx) => `
|
|
<div class="cron-run-item">
|
|
<span class="cron-run-index">#${idx + 1}</span>
|
|
<span class="cron-run-time">${timeStr}</span>
|
|
</div>
|
|
`).join('');
|
|
} catch (e) {
|
|
box.innerHTML = `<div style="font-size: 12px; color: var(--danger);">${e.message || 'Cron 格式解析错误'}</div>`;
|
|
}
|
|
}, 150);
|
|
}
|
|
|
|
// 轻量通用 Cron 解析推导器(支持 5 位分-时-日-月-周 或 6 位秒-分-时-日-月-周)
|
|
function getNextCronRuns(cronExpr, count) {
|
|
if (cronExpr.startsWith('@')) {
|
|
if (cronExpr === '@reboot') throw new Error('系统重启时运行 (@reboot),无固定周期');
|
|
if (cronExpr === '@daily') cronExpr = '0 0 0 * * *';
|
|
else if (cronExpr === '@hourly') cronExpr = '0 0 * * * *';
|
|
else throw new Error('暂不支持该简写语法');
|
|
}
|
|
|
|
const parts = cronExpr.trim().split(/\s+/);
|
|
let secField, minField, hourField, domField, monthField, dowField;
|
|
|
|
if (parts.length === 5) {
|
|
secField = '0';
|
|
[minField, hourField, domField, monthField, dowField] = parts;
|
|
} else if (parts.length === 6) {
|
|
[secField, minField, hourField, domField, monthField, dowField] = parts;
|
|
} else {
|
|
throw new Error('Cron 必须为 5 或 6 位字段');
|
|
}
|
|
|
|
const parseField = (f, min, max) => {
|
|
const result = new Set();
|
|
const items = f.split(',');
|
|
for (let item of items) {
|
|
if (item === '*' || item === '?') {
|
|
for (let i = min; i <= max; i++) result.add(i);
|
|
} else if (item.includes('/')) {
|
|
const [range, stepStr] = item.split('/');
|
|
const step = parseInt(stepStr, 10);
|
|
let start = min;
|
|
let end = max;
|
|
if (range !== '*' && range !== '') {
|
|
if (range.includes('-')) {
|
|
[start, end] = range.split('-').map(Number);
|
|
} else {
|
|
start = parseInt(range, 10);
|
|
}
|
|
}
|
|
for (let i = start; i <= end; i += step) result.add(i);
|
|
} else if (item.includes('-')) {
|
|
const [start, end] = item.split('-').map(Number);
|
|
for (let i = start; i <= end; i++) result.add(i);
|
|
} else {
|
|
const num = parseInt(item, 10);
|
|
if (!isNaN(num) && num >= min && num <= max) result.add(num);
|
|
}
|
|
}
|
|
return Array.from(result).sort((a, b) => a - b);
|
|
};
|
|
|
|
const validSecs = parseField(secField, 0, 59);
|
|
const validMins = parseField(minField, 0, 59);
|
|
const validHours = parseField(hourField, 0, 23);
|
|
const validDoms = parseField(domField, 1, 31);
|
|
const validMonths = parseField(monthField, 1, 12);
|
|
const validDows = parseField(dowField, 0, 6);
|
|
|
|
const nextRuns = [];
|
|
let current = new Date();
|
|
current.setMilliseconds(0);
|
|
current.setSeconds(current.getSeconds() + 1);
|
|
|
|
// 安全迭代最多 100,000 次步进以寻找后续 N 次节点
|
|
let iterations = 0;
|
|
while (nextRuns.length < count && iterations < 100000) {
|
|
iterations++;
|
|
const m = current.getMonth() + 1;
|
|
if (!validMonths.includes(m)) {
|
|
current.setMonth(current.getMonth() + 1, 1);
|
|
current.setHours(0, 0, 0, 0);
|
|
continue;
|
|
}
|
|
|
|
const d = current.getDate();
|
|
const dow = current.getDay();
|
|
const matchDom = domField === '*' || domField === '?' || validDoms.includes(d);
|
|
const matchDow = dowField === '*' || dowField === '?' || validDows.includes(dow);
|
|
|
|
if (!matchDom || !matchDow) {
|
|
current.setDate(current.getDate() + 1);
|
|
current.setHours(0, 0, 0, 0);
|
|
continue;
|
|
}
|
|
|
|
const h = current.getHours();
|
|
if (!validHours.includes(h)) {
|
|
current.setHours(current.getHours() + 1, 0, 0, 0);
|
|
continue;
|
|
}
|
|
|
|
const min = current.getMinutes();
|
|
if (!validMins.includes(min)) {
|
|
current.setMinutes(current.getMinutes() + 1, 0, 0);
|
|
continue;
|
|
}
|
|
|
|
const s = current.getSeconds();
|
|
if (!validSecs.includes(s)) {
|
|
current.setSeconds(current.getSeconds() + 1);
|
|
continue;
|
|
}
|
|
|
|
// 匹配成功,格式化并推进
|
|
const pad = n => String(n).padStart(2, '0');
|
|
const formatted = `${current.getFullYear()}-${pad(current.getMonth() + 1)}-${pad(current.getDate())} ${pad(current.getHours())}:${pad(current.getMinutes())}:${pad(current.getSeconds())}`;
|
|
nextRuns.push(formatted);
|
|
current.setSeconds(current.getSeconds() + 1);
|
|
}
|
|
|
|
return nextRuns;
|
|
}
|
|
|
|
function openEditModal(taskId = null) {
|
|
document.getElementById('modal-title').textContent = taskId ? `编辑任务 [${taskId}]` : '新建任务';
|
|
|
|
if (!taskId) {
|
|
document.getElementById('task-form').reset();
|
|
document.getElementById('task-type').value = 'script';
|
|
document.getElementById('cron-n-count').value = '5';
|
|
argList = [];
|
|
envList = [];
|
|
detectedVenv = 'python';
|
|
renderDynamicFields();
|
|
triggerCronPreview();
|
|
} else {
|
|
document.getElementById('task-id').value = taskId;
|
|
document.getElementById('task-name').value = taskId === 'db_backup_daily' ? '每日数据库快照' : '自定义任务';
|
|
document.getElementById('task-cron').value = taskId === 'api_health_ping' ? '*/5 * * * * *' : '0 0 2 * * *';
|
|
document.getElementById('task-timeout').value = '300';
|
|
document.getElementById('task-type').value = 'script';
|
|
document.getElementById('cron-n-count').value = '5';
|
|
|
|
envList = [
|
|
{ key: 'PYTHONUNBUFFERED', val: '1' },
|
|
{ key: 'DB_TARGET', val: 'production' }
|
|
];
|
|
|
|
argList = [
|
|
{ key: '--mode', val: 'full' },
|
|
{ key: '--batch', val: '500' }
|
|
];
|
|
renderDynamicFields();
|
|
|
|
document.getElementById('work-dir').value = 'D:\\Projects\\sync';
|
|
document.getElementById('script-target').value = 'main.py';
|
|
onWorkDirChange('D:\\Projects\\sync');
|
|
triggerCronPreview();
|
|
}
|
|
document.getElementById('task-modal').classList.add('open');
|
|
}
|
|
|
|
function closeModal(id) {
|
|
document.getElementById(id).classList.remove('open');
|
|
}
|
|
|
|
function showLogs(taskId) {
|
|
document.getElementById('log-title').textContent = `任务 [${taskId}] 日志`;
|
|
document.getElementById('log-viewer-body').textContent =
|
|
`[2026-10-16 02:00:01] INFO Starting task ${taskId}...\n` +
|
|
`[2026-10-16 02:00:01] DEBUG Working directory: D:\\Projects\\sync\n` +
|
|
`[2026-10-16 02:00:01] DEBUG Injected Envs: PYTHONUNBUFFERED=1, DB_TARGET=production\n` +
|
|
`[2026-10-16 02:00:01] DEBUG Process: .venv\\Scripts\\python.exe main.py --mode full --batch 500\n` +
|
|
`[2026-10-16 02:00:03] INFO Export finished: 1,280 records written.\n` +
|
|
`[2026-10-16 02:00:03] SUCCESS Task exited with code 0 (Elapsed 1.42s)`;
|
|
document.getElementById('log-modal').classList.add('open');
|
|
}
|
|
|
|
function triggerRun(taskId) {
|
|
alert(`已向 Windows 调度引擎下发即时运行信号: ${taskId}`);
|
|
}
|
|
|
|
function deleteTask(taskId) {
|
|
if (confirm(`确定要删除任务 ${taskId} 吗?`)) {
|
|
alert(`已成功删除任务: ${taskId}`);
|
|
}
|
|
}
|
|
|
|
function submitTaskForm() {
|
|
const envObj = {};
|
|
envList.forEach(item => {
|
|
if (item.key) envObj[item.key] = item.val;
|
|
});
|
|
|
|
const argsArray = argList.flatMap(item => [item.key, item.val].filter(Boolean));
|
|
|
|
const payload = {
|
|
id: document.getElementById('task-id').value,
|
|
name: document.getElementById('task-name').value,
|
|
cron: document.getElementById('task-cron').value,
|
|
timeout_secs: parseInt(document.getElementById('task-timeout').value, 10),
|
|
action: {
|
|
type: "script",
|
|
working_dir: document.getElementById('work-dir')?.value || "",
|
|
interpreter: detectedVenv,
|
|
script: document.getElementById('script-target')?.value || "",
|
|
args: argsArray,
|
|
env: envObj
|
|
}
|
|
};
|
|
|
|
console.log('提交给 Rust 后端的 JSON 载荷:', payload);
|
|
alert('已保存配置并触发热更新!');
|
|
closeModal('task-modal');
|
|
}
|
|
|
|
renderDynamicFields();
|
|
</script>
|
|
</body>
|
|
</html> |