Files
task_pilot/task_pilot.html
T
CNWei d88c21753d 上传文件至 /
Rust 本地轻量化任务引擎 · Windows 原生调度中心
2026-10-17 16:17:59 +08:00

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;">&lt; 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')">&times;</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')">&times;</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>