|
| 1 | +<!doctype html> |
| 2 | +<html lang="zh-CN"> |
| 3 | +<head> |
| 4 | + <meta charset="utf-8" /> |
| 5 | + <meta name="viewport" content="width=device-width,initial-scale=1" /> |
| 6 | + <title>Cron 表达式生成器</title> |
| 7 | + <style> |
| 8 | + :root{--bg:#0f1724;--card:#0b1220;--muted:#94a3b8;--accent:#7c3aed;--glass: rgba(255,255,255,0.03)} |
| 9 | + html,body{height:100%;margin:0;font-family:Inter,system-ui,-apple-system,Segoe UI,Roboto,'Helvetica Neue',Arial;color:#e6eef8;background:linear-gradient(180deg,#071025 0%, #081226 60%);-webkit-font-smoothing:antialiased} |
| 10 | + .wrap{max-width:980px;margin:28px auto;padding:20px} |
| 11 | + .card{background:linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0.01));border-radius:14px;padding:18px;box-shadow:0 6px 30px rgba(2,6,23,0.6);border:1px solid rgba(255,255,255,0.03)} |
| 12 | + h1{margin:0 0 6px;font-size:20px} |
| 13 | + p.lead{margin:0 0 16px;color:var(--muted);font-size:13px} |
| 14 | + |
| 15 | + .grid{display:grid;grid-template-columns:1fr 320px;gap:16px} |
| 16 | + @media(max-width:880px){.grid{grid-template-columns:1fr} .right{order:2}} |
| 17 | + |
| 18 | + .field{display:flex;flex-direction:column;margin-bottom:10px} |
| 19 | + label{font-size:13px;color:var(--muted);margin-bottom:6px} |
| 20 | + select,input[type="time"],input[type="number"],input[type="text"]{background:var(--glass);border:1px solid rgba(255,255,255,0.04);padding:10px;border-radius:8px;color:#e6eef8} |
| 21 | + .row{display:flex;gap:10px} |
| 22 | + .btn{display:inline-flex;align-items:center;gap:8px;padding:10px 12px;border-radius:10px;border:none;cursor:pointer} |
| 23 | + .btn-primary{background:linear-gradient(90deg,var(--accent),#4f46e5);color:white} |
| 24 | + .btn-ghost{background:transparent;border:1px solid rgba(255,255,255,0.04);color:var(--muted)} |
| 25 | + |
| 26 | + .preset-list{display:flex;flex-wrap:wrap;gap:8px} |
| 27 | + .chip{background:rgba(255,255,255,0.03);padding:8px 10px;border-radius:999px;font-size:13px;color:var(--muted);cursor:pointer;border:1px solid rgba(255,255,255,0.02)} |
| 28 | + |
| 29 | + pre.preview{background:#020617;padding:12px;border-radius:8px;color:#c7f9d7;overflow:auto} |
| 30 | + .meta{font-size:13px;color:var(--muted);margin-top:8px} |
| 31 | + |
| 32 | + .small{font-size:12px;color:var(--muted)} |
| 33 | + .footer{display:flex;justify-content:space-between;align-items:center;margin-top:12px} |
| 34 | + |
| 35 | + /* mobile tweaks */ |
| 36 | + @media(max-width:420px){.wrap{padding:12px} h1{font-size:18px}} |
| 37 | + </style> |
| 38 | +</head> |
| 39 | +<body> |
| 40 | +<div class="wrap"> |
| 41 | + <div class="card"> |
| 42 | + <h1>Cron 表达式生成器</h1> |
| 43 | + <p class="lead">以友好的交互方式选择时间/频率,自动生成支持 6 字段(秒 分 时 日 月 周)格式的 cron 表达式。移动端自适应。</p> |
| 44 | + |
| 45 | + <div class="grid"> |
| 46 | + <div class="left"> |
| 47 | + <div class="field"> |
| 48 | + <label>调度类型</label> |
| 49 | + <select id="mode"> |
| 50 | + <option value="every">每隔(间隔)</option> |
| 51 | + <option value="daily">每天特定时间</option> |
| 52 | + <option value="weekly">每周(指定星期几)</option> |
| 53 | + <option value="monthly">每月(指定日)</option> |
| 54 | + <option value="custom">高级自定义(直接编辑字段)</option> |
| 55 | + </select> |
| 56 | + </div> |
| 57 | + |
| 58 | + <div id="intervalBox" class="field"> |
| 59 | + <label>间隔设置</label> |
| 60 | + <div class="row"> |
| 61 | + <select id="intervalUnit"> |
| 62 | + <option value="minutes">分钟</option> |
| 63 | + <option value="hours">小时</option> |
| 64 | + <option value="days">天</option> |
| 65 | + </select> |
| 66 | + <input id="intervalValue" type="number" min="1" value="5" style="width:100px" /> |
| 67 | + </div> |
| 68 | + <div class="small">示例:每 5 分钟</div> |
| 69 | + </div> |
| 70 | + |
| 71 | + <div id="dailyBox" class="field" style="display:none"> |
| 72 | + <label>每天执行时间</label> |
| 73 | + <input id="dailyTime" type="time" value="09:00" /> |
| 74 | + <div class="small">指定本地时间(例如 Asia/Shanghai)</div> |
| 75 | + </div> |
| 76 | + |
| 77 | + <div id="weeklyBox" class="field" style="display:none"> |
| 78 | + <label>每周选择</label> |
| 79 | + <div class="row" style="flex-wrap:wrap"> |
| 80 | + <label><input type="checkbox" value="0" class="weekday" /> 周日</label> |
| 81 | + <label><input type="checkbox" value="1" class="weekday" /> 周一</label> |
| 82 | + <label><input type="checkbox" value="2" class="weekday" /> 周二</label> |
| 83 | + <label><input type="checkbox" value="3" class="weekday" /> 周三</label> |
| 84 | + <label><input type="checkbox" value="4" class="weekday" /> 周四</label> |
| 85 | + <label><input type="checkbox" value="5" class="weekday" /> 周五</label> |
| 86 | + <label><input type="checkbox" value="6" class="weekday" /> 周六</label> |
| 87 | + </div> |
| 88 | + <div class="field" style="margin-top:8px"> |
| 89 | + <label>执行时间</label> |
| 90 | + <input id="weeklyTime" type="time" value="09:00" /> |
| 91 | + </div> |
| 92 | + </div> |
| 93 | + |
| 94 | + <div id="monthlyBox" class="field" style="display:none"> |
| 95 | + <label>每月的哪一天</label> |
| 96 | + <input id="monthDay" type="number" min="1" max="31" value="1" /> |
| 97 | + <div class="field" style="margin-top:8px"> |
| 98 | + <label>执行时间</label> |
| 99 | + <input id="monthlyTime" type="time" value="09:00" /> |
| 100 | + </div> |
| 101 | + </div> |
| 102 | + |
| 103 | + <div id="customBox" class="field" style="display:none"> |
| 104 | + <label>自定义字段(秒 分 时 日 月 周)</label> |
| 105 | + <input id="customFields" type="text" placeholder="例: 0 0 9 * * *" /> |
| 106 | + <div class="small">请提供 6 个字段的 cron 表达式,秒为首位。</div> |
| 107 | + </div> |
| 108 | + |
| 109 | + <div class="field"> |
| 110 | + <label>时区(可选)</label> |
| 111 | + <select id="timezone"> |
| 112 | + <option value="">默认 (系统时区)</option> |
| 113 | + <option value="Asia/Shanghai">Asia/Shanghai (北京时间)</option> |
| 114 | + <option value="UTC">UTC</option> |
| 115 | + <option value="Europe/London">Europe/London</option> |
| 116 | + <option value="America/New_York">America/New_York</option> |
| 117 | + </select> |
| 118 | + <div class="small">如果你希望在特定时区执行,请选择。否则使用服务器默认时区。</div> |
| 119 | + </div> |
| 120 | + |
| 121 | + <div class="field"> |
| 122 | + <label>执行次数限制(说明)</label> |
| 123 | + <input id="limitCount" type="number" min="0" value="0" /> |
| 124 | + <div class="small">注意:cron 本身无法限制总执行次数。填写 >0 仅会在下方显示建议的实现方式。</div> |
| 125 | + </div> |
| 126 | + |
| 127 | + <div style="margin-top:10px;display:flex;gap:8px;flex-wrap:wrap"> |
| 128 | + <button id="generate" class="btn btn-primary">生成 Cron</button> |
| 129 | + <button id="copy" class="btn btn-ghost">复制到剪贴板</button> |
| 130 | + <button id="reset" class="btn btn-ghost">重置</button> |
| 131 | + </div> |
| 132 | + |
| 133 | + <div style="margin-top:12px"> |
| 134 | + <div class="preset-list"> |
| 135 | + <div class="chip" data-preset="@every_minute">每分钟</div> |
| 136 | + <div class="chip" data-preset="@hourly">每小时</div> |
| 137 | + <div class="chip" data-preset="@daily">每天 00:00</div> |
| 138 | + <div class="chip" data-preset="@weekday9">工作日 09:00</div> |
| 139 | + <div class="chip" data-preset="@monthly1">每月 1 日 09:00</div> |
| 140 | + </div> |
| 141 | + </div> |
| 142 | + |
| 143 | + </div> |
| 144 | + |
| 145 | + <div class="right card" style="padding:14px"> |
| 146 | + <h2 style="margin-top:0">预览</h2> |
| 147 | + <pre id="cronPreview" class="preview"># 生成的 cron 表达式会在这里显示</pre> |
| 148 | + <div class="meta" id="metaZone">时区: <span id="metaTz">系统时区</span></div> |
| 149 | + <div class="meta" id="metaLimit" style="margin-top:6px;color:var(--muted)"></div> |
| 150 | + <div class="footer"> |
| 151 | + <div class="small">支持 6 字段 cron(秒 分 时 日 月 周)</div> |
| 152 | + <div style="display:flex;gap:8px"> |
| 153 | + <button id="explain" class="btn btn-ghost">字段说明</button> |
| 154 | + <button id="download" class="btn btn-ghost">下载 .txt</button> |
| 155 | + </div> |
| 156 | + </div> |
| 157 | + </div> |
| 158 | + </div> |
| 159 | + |
| 160 | + </div> |
| 161 | +</div> |
| 162 | + |
| 163 | +<script> |
| 164 | + // DOM refs |
| 165 | + const mode = document.getElementById('mode'); |
| 166 | + const intervalBox = document.getElementById('intervalBox'); |
| 167 | + const dailyBox = document.getElementById('dailyBox'); |
| 168 | + const weeklyBox = document.getElementById('weeklyBox'); |
| 169 | + const monthlyBox = document.getElementById('monthlyBox'); |
| 170 | + const customBox = document.getElementById('customBox'); |
| 171 | + const intervalUnit = document.getElementById('intervalUnit'); |
| 172 | + const intervalValue = document.getElementById('intervalValue'); |
| 173 | + const dailyTime = document.getElementById('dailyTime'); |
| 174 | + const weeklyTime = document.getElementById('weeklyTime'); |
| 175 | + const monthDay = document.getElementById('monthDay'); |
| 176 | + const monthlyTime = document.getElementById('monthlyTime'); |
| 177 | + const customFields = document.getElementById('customFields'); |
| 178 | + const timezone = document.getElementById('timezone'); |
| 179 | + const generate = document.getElementById('generate'); |
| 180 | + const copyBtn = document.getElementById('copy'); |
| 181 | + const cronPreview = document.getElementById('cronPreview'); |
| 182 | + const metaTz = document.getElementById('metaTz'); |
| 183 | + const limitCount = document.getElementById('limitCount'); |
| 184 | + const reset = document.getElementById('reset'); |
| 185 | + const chips = document.querySelectorAll('.chip'); |
| 186 | + const explain = document.getElementById('explain'); |
| 187 | + const download = document.getElementById('download'); |
| 188 | + |
| 189 | + function showBoxes() { |
| 190 | + intervalBox.style.display = mode.value === 'every' ? 'block' : 'none'; |
| 191 | + dailyBox.style.display = mode.value === 'daily' ? 'block' : 'none'; |
| 192 | + weeklyBox.style.display = mode.value === 'weekly' ? 'block' : 'none'; |
| 193 | + monthlyBox.style.display = mode.value === 'monthly' ? 'block' : 'none'; |
| 194 | + customBox.style.display = mode.value === 'custom' ? 'block' : 'none'; |
| 195 | + } |
| 196 | + |
| 197 | + mode.addEventListener('change', showBoxes); |
| 198 | + showBoxes(); |
| 199 | + |
| 200 | + chips.forEach(c => c.addEventListener('click', (e) => { |
| 201 | + const p = e.currentTarget.dataset.preset; |
| 202 | + if(p==='@every_minute'){ mode.value='every'; intervalUnit.value='minutes'; intervalValue.value=1; } |
| 203 | + if(p==='@hourly'){ mode.value='daily'; dailyTime.value='00:00'; } |
| 204 | + if(p==='@daily'){ mode.value='daily'; dailyTime.value='00:00'; } |
| 205 | + if(p==='@weekday9'){ mode.value='weekly'; weeklyTime.value='09:00'; document.querySelectorAll('.weekday').forEach(cb=>{cb.checked = ['1','2','3','4','5'].includes(cb.value)}); } |
| 206 | + if(p==='@monthly1'){ mode.value='monthly'; monthDay.value=1; monthlyTime.value='09:00'; } |
| 207 | + showBoxes(); |
| 208 | + })); |
| 209 | + |
| 210 | + function pad(v){ return v.toString().padStart(2,'0'); } |
| 211 | + |
| 212 | + function buildCron(){ |
| 213 | + let cron = ''; |
| 214 | + const tz = timezone.value; |
| 215 | + metaTz.textContent = tz || '系统时区'; |
| 216 | + |
| 217 | + if(mode.value==='every'){ |
| 218 | + const unit = intervalUnit.value; |
| 219 | + const val = Math.max(1, Math.floor(Number(intervalValue.value)||1)); |
| 220 | + if(unit==='minutes'){ |
| 221 | + // 每 N 分钟: 秒 分 时 日 月 周 -> 0 */N * * * * |
| 222 | + cron = `0 */${val} * * * *`; |
| 223 | + } else if(unit==='hours'){ |
| 224 | + // 每 N 小时: 0 0 */N * * * |
| 225 | + cron = `0 0 */${val} * * *`; |
| 226 | + } else if(unit==='days'){ |
| 227 | + // 每 N 天: 0 0 0 */N * * (执行于每天 00:00 的间隔) |
| 228 | + cron = `0 0 0 */${val} * *`; |
| 229 | + } |
| 230 | + } |
| 231 | + |
| 232 | + if(mode.value==='daily'){ |
| 233 | + // dailyTime -> HH:MM |
| 234 | + const [hh,mm] = dailyTime.value.split(':'); |
| 235 | + cron = `0 ${parseInt(mm,10)} ${parseInt(hh,10)} * * *`; |
| 236 | + } |
| 237 | + |
| 238 | + if(mode.value==='weekly'){ |
| 239 | + const days = Array.from(document.querySelectorAll('.weekday:checked')).map(i=>i.value); |
| 240 | + const [hh,mm] = weeklyTime.value.split(':'); |
| 241 | + const dayField = days.length ? days.join(',') : '*'; |
| 242 | + cron = `0 ${parseInt(mm,10)} ${parseInt(hh,10)} * * ${dayField}`; |
| 243 | + } |
| 244 | + |
| 245 | + if(mode.value==='monthly'){ |
| 246 | + const day = Math.min(31, Math.max(1, Number(monthDay.value||1))); |
| 247 | + const [hh,mm] = monthlyTime.value.split(':'); |
| 248 | + cron = `0 ${parseInt(mm,10)} ${parseInt(hh,10)} ${day} * *`; |
| 249 | + } |
| 250 | + |
| 251 | + if(mode.value==='custom'){ |
| 252 | + const txt = customFields.value.trim(); |
| 253 | + cron = txt || ''; |
| 254 | + } |
| 255 | + |
| 256 | + if(!cron) cron = '# 请选择调度后点击 生成 Cron'; |
| 257 | + return { cron, tz }; |
| 258 | + } |
| 259 | + |
| 260 | + function validateSixFields(expr){ |
| 261 | + if(!expr) return false; |
| 262 | + const parts = expr.trim().split(/\s+/); |
| 263 | + return parts.length === 6; |
| 264 | + } |
| 265 | + |
| 266 | + generate.addEventListener('click', ()=>{ |
| 267 | + const {cron, tz} = buildCron(); |
| 268 | + cronPreview.textContent = cron + (tz?`\n(timezone: ${tz})`:''); |
| 269 | + |
| 270 | + // 显示 limit 建议 |
| 271 | + const limit = Number(limitCount.value||0); |
| 272 | + const metaLimit = document.getElementById('metaLimit'); |
| 273 | + if(limit>0){ |
| 274 | + metaLimit.textContent = `执行次数限制:你填写了 ${limit} 次。建议在你的任务代码中记录并在达到次数后停止调度,或用外部调度器配合。`; |
| 275 | + } else { metaLimit.textContent = '' } |
| 276 | + }); |
| 277 | + |
| 278 | + copyBtn.addEventListener('click', async ()=>{ |
| 279 | + const text = cronPreview.textContent; |
| 280 | + try{ |
| 281 | + await navigator.clipboard.writeText(text); |
| 282 | + copyBtn.textContent = '已复制'; |
| 283 | + setTimeout(()=>copyBtn.textContent='复制到剪贴板',1200); |
| 284 | + }catch(e){ |
| 285 | + alert('复制失败,请手动复制'); |
| 286 | + } |
| 287 | + }); |
| 288 | + |
| 289 | + reset.addEventListener('click', ()=>{ location.reload(); }); |
| 290 | + |
| 291 | + explain.addEventListener('click', ()=>{ |
| 292 | + alert('字段说明(6 字段):\n秒 分 时 日 月 周\n示例: 0 0 9 * * * -> 每天 09:00 执行(秒:0 分:0 时:9)'); |
| 293 | + }); |
| 294 | + |
| 295 | + download.addEventListener('click', ()=>{ |
| 296 | + const blob = new Blob([cronPreview.textContent], {type:'text/plain;charset=utf-8'}); |
| 297 | + const url = URL.createObjectURL(blob); |
| 298 | + const a = document.createElement('a'); a.href = url; a.download = 'cron.txt'; a.click(); URL.revokeObjectURL(url); |
| 299 | + }); |
| 300 | + |
| 301 | + // 初始生成 |
| 302 | + document.getElementById('generate').click(); |
| 303 | +</script> |
| 304 | +</body> |
| 305 | +</html> |
0 commit comments