Commit eb56fa8e authored by 郭阔's avatar 郭阔

feat(panel): schedule board — 24h timeline visualization + schedule CRUD

- new /schedules 调度中心: per-source row with a 24h timeline (green dots
  at fire times, next fire highlighted amber, red now-line), 定时/下次触发
  (countdown)/上次运行 columns, sorted by next fire, 60s auto-refresh
- create/update: POST /schedule-time (HH:MM list, validated) →
  nh_schedule_write + enable (+skill enable for new rows)
- delete: POST /schedule-delete/<name> → new platform.sh nh_schedule_remove
  (bootout + rm plist incl. disabled + entry script + per-source conf;
  skill source and history kept; digest/web protected)
- guards: active harvests block time changes/deletes
- nav link 调度中心; schedule_times() string parsed to list
parent f0f61e25
......@@ -224,6 +224,24 @@ nh_schedule_disable() { # <name> 真正禁用:卸载 + 文件级禁用(重启
fi
}
nh_schedule_remove() { # <name> 彻底移除调度:卸载+删调度文件(含禁用态)+入口脚本+栏目conf;技能源与运行历史保留
local name="$1" conf
if [ "$name" = "digest" ] || [ "$name" = "web" ]; then
echo "❌ 不允许移除 ${name}(系统调度)" >&2
return 1
fi
if nh_is_darwin; then
launchctl bootout "gui/$(id -u)/com.newsharvester.${name}" >/dev/null 2>&1 || true
else
systemctl disable --now "news-harvester@${name}.timer" >/dev/null 2>&1 || true
systemctl daemon-reload >/dev/null 2>&1 || true
fi
rm -f "$(nh_schedule_file "$name")" "$(nh_schedule_disabled_file "$name")" "$(nh_entry_path "$name")"
conf="$CONF_DIR/${name}.conf"
if [ -n "${conf:-}" ] && [ -f "$conf" ]; then rm -f "$conf"; fi
echo "✅ 已移除 ${name} 的调度(技能源与运行历史保留,可随时重建)"
}
# --- 服务操作 ---
nh_service_trigger() { # <name> 立即触发一次(digest 传 digest)
local name="$1"
......
......@@ -382,6 +382,11 @@ def schedule_disable(name):
return call("nh_schedule_disable", name)
def schedule_remove(name):
"""彻底移除某源的调度(调度文件+入口脚本+栏目conf);技能源与运行历史保留。"""
return call("nh_schedule_remove", name, timeout=30)
def schedule_create(name):
"""用 add-source.sh 创建调度(自动错峰);返回 (rc, out, err)。"""
try:
......
......@@ -1181,6 +1181,116 @@ def login_manual(site):
return redirect(url_for("login_list"))
# ── 调度中心:可视化时间轴 + 增删改查 ────────────────────────
def _next_fire(times, now):
"""从 "HH:MM" 列表算下一次触发时间(已过则顺延一天)。"""
cands = []
for t in times:
parts = t.split(":")
if len(parts) < 2:
continue
try:
c = now.replace(hour=int(parts[0]), minute=int(parts[1]), second=0, microsecond=0)
except ValueError:
continue
if c <= now:
c += timedelta(days=1)
cands.append(c)
return min(cands) if cands else None
@app.route("/schedules")
@require_auth
def schedule_board():
now = datetime.now()
skills = pl.skill_sources()
names = sorted((set(skills) | set(pl.list_schedules())) - {"digest"})
rows = []
for n in names:
state = pl.schedule_state(n)
times = [t for t in pl.schedule_times(n).split(",") if t.strip()]
nxt = _next_fire(times, now)
log = parse_last_log(n)
rows.append({
"name": n,
"display": skills.get(n, {}).get("name") or n,
"enabled": skills.get(n, {}).get("enabled", False),
"state": state,
"times": times,
"next_time": nxt.strftime("%H:%M") if nxt else "",
"next": nxt.strftime("%m-%d %H:%M") if nxt else "",
"next_in_min": int((nxt - now).total_seconds() // 60) if nxt else None,
"last": log,
})
rows.sort(key=lambda r: (r["next_in_min"] is None, r["next_in_min"] if r["next_in_min"] is not None else 0))
digest_times = [t for t in pl.schedule_times("digest").split(",") if t.strip()]
return render_template("schedules.html", rows=rows, now=now,
digest_next=_next_fire(digest_times, now),
digest_times=digest_times)
def _valid_times(raw):
"""校验 "HH:MM[,HH:MM...]",返回规范化列表或 None。"""
out = []
for part in raw.replace(";", ",").split(","):
part = part.strip()
m = re.match(r"^([01]?\d|2[0-3]):([0-5]\d)$", part)
if not m:
return None
out.append(f"{int(m.group(1)):02d}:{m.group(2)}")
# 排序去重
return sorted(set(out)) or None
@app.route("/schedule-time", methods=["POST"])
@require_auth
def schedule_time_set():
"""创建/修改某源的运行时间(可多个,逗号分隔)。"""
name = request.form.get("name", "").strip()
times = _valid_times(request.form.get("times", ""))
if not re.match(r"^[a-zA-Z0-9_-]+$", name) or name in ("digest", "web"):
flash("栏目名不合法", "error")
return redirect(url_for("schedule_board"))
if not times:
flash("时间格式错误,应为 HH:MM(24小时制,多个用逗号分隔,如 09:00,14:30)", "error")
return redirect(url_for("schedule_board"))
if name not in {r["name"] for r in pl.source_rows()}:
flash(f"未知栏目 {name}(须为技能仓已装源)", "error")
return redirect(url_for("schedule_board"))
if name in _active_harvests():
flash(f"{name} 正在采集,请等任务结束后再改时间", "error")
return redirect(url_for("schedule_board"))
joined = ",".join(times)
rc, out, err = pl.call("nh_schedule_write", name, joined, timeout=60)
if rc != 0:
flash(f"写入调度失败: {err or out}", "error")
return redirect(url_for("schedule_board"))
rc2, out2, err2 = pl.call("nh_schedule_enable", name, timeout=60)
# 顺带确保技能源启用(新建排程场景)
if not pl.skill_sources().get(name, {}).get("enabled"):
pl.skill_set_enabled(name, True)
ok = rc2 == 0
flash(f"{'✅' if ok else '⚠️'} {name} 运行时间已设为 {joined}"
+ ("" if ok else f"(加载调度失败: {err2 or out2})"), "success" if ok else "error")
return redirect(url_for("schedule_board"))
@app.route("/schedule-delete/<name>", methods=["POST"])
@require_auth
def schedule_delete(name):
"""彻底移除某源的调度(技能源与运行历史保留)。"""
if not re.match(r"^[a-zA-Z0-9_-]+$", name) or name in ("digest", "web"):
abort(404)
if name in _active_harvests():
flash(f"{name} 正在采集,请等任务结束后再删除调度", "error")
return redirect(url_for("schedule_board"))
rc, out, err = pl.schedule_remove(name)
flash(f"{name} 调度已移除" if rc == 0 else f"移除失败: {err or out}",
"success" if rc == 0 else "error")
return redirect(url_for("schedule_board"))
def call_platform(fn, *args):
"""供路由直接调用 platform.sh 函数的便捷包装。"""
return pl.call(fn, *args, timeout=180)
......
......@@ -216,6 +216,7 @@
<nav>
<a class="logo" href="{{ url_for('index') }}">📰 News Harvester</a>
<a href="{{ url_for('index') }}">栏目列表</a>
<a href="{{ url_for('schedule_board') }}">调度中心</a>
<a href="{{ url_for('add_source') }}">添加栏目</a>
<a href="{{ url_for('provider_list') }}">Provider</a>
<a href="{{ url_for('login_list') }}">站点登录</a>
......
{% extends "base.html" %}
{% block title %}调度中心 — News Harvester{% endblock %}
{% block extra_head %}
<meta http-equiv="refresh" content="60">
<style>
.tl { position:relative; height:30px; background:linear-gradient(90deg,#181b24,#1e2230); border:1px solid #2a2d37; border-radius:5px; min-width:300px; }
.tl .h { position:absolute; top:0; bottom:0; width:1px; background:#2a2d37; }
.tl .dot { position:absolute; top:9px; width:11px; height:11px; margin-left:-5px; border-radius:50%; background:#3f7d52; cursor:default; }
.tl .dot.next { background:#e8c34a; box-shadow:0 0 7px #e8c34a; }
.tl .dot.dis { background:#5a5f6e; }
.tl .now { position:absolute; top:-2px; bottom:-2px; width:2px; background:#e05555; z-index:2; }
.tl .lbl { position:absolute; top:-12px; font-size:10px; color:#e8c34a; transform:translateX(-50%); white-space:nowrap; }
.hourbar { display:flex; justify-content:space-between; color:#666; font-size:10px; margin-top:2px; }
.time-input { width:110px; background:#1a1d27; border:1px solid #2a2d37; border-radius:4px; color:#d4d4d4; padding:3px 6px; font-family:monospace; }
.next-cell { font-weight:bold; }
.next-cell .in { color:#e8c34a; font-size:12px; }
</style>
{% endblock %}
{% block content %}
<h1>调度中心</h1>
<p style="color:#666;font-size:13px">
每源一行:24 小时时间轴上标出全部触发时刻(<span style="color:#3f7d52">●</span>),
<span style="color:#e8c34a">黄色高亮</span>为下次触发,红色竖线为当前时刻,按下次触发排序。
每日汇总 digest:{{ digest_times|join(', ') if digest_times else '未排' }}{% if digest_next %},下次 {{ digest_next.strftime('%m-%d %H:%M') }}{% endif %}。
时间格式 HH:MM,多个用逗号分隔(如 09:00,14:30);删除仅移除调度,技能源与历史保留。
</p>
{% with messages = get_flashed_messages() %}
{% if messages %}{% for m in messages %}<div class="flash">{{ m }}</div>{% endfor %}{% endif %}
{% endwith %}
<div class="table-wrap">
<table>
<thead>
<tr><th>栏目</th><th style="min-width:320px">24h 时间轴</th><th>定时</th><th>下次触发</th><th>上次运行</th><th>操作</th></tr>
</thead>
<tbody>
{% for r in rows %}
<tr>
<td>
<code>{{ r.name }}</code>{% if r.display and r.display != r.name %} <span style="color:#888">{{ r.display }}</span>{% endif %}<br>
{% if r.state == 'active' %}<span class="badge ok">调度中</span>
{% elif r.state == 'disabled' %}<span class="badge inactive">已禁用</span>
{% else %}<span class="badge unknown">未排程</span>{% endif %}
{% if not r.enabled %}<span class="badge unknown" style="margin-left:3px">技能停用</span>{% endif %}
</td>
<td>
{% if r.times %}
<div class="tl">
<div class="h" style="left:25%"></div><div class="h" style="left:50%"></div><div class="h" style="left:75%"></div>
{% for t in r.times %}
{% set h = t.split(':')[0]|int %}{% set m = t.split(':')[1]|int %}
{% set pct = ((h*60+m)/1440*100)|round(2) %}
<div class="dot {{ 'next' if r.next_time == t else '' }}{{ ' dis' if r.state != 'active' else '' }}"
style="left:{{ pct }}%" title="{{ t }}{% if r.state != 'active' %}(未启用){% endif %}"></div>
{% endfor %}
<div class="now" style="left:{{ ((now.hour*60+now.minute)/1440*100)|round(2) }}%" title="现在 {{ now.strftime('%H:%M') }}"></div>
</div>
<div class="hourbar"><span>00</span><span>06</span><span>12</span><span>18</span><span>24</span></div>
{% else %}
<span style="color:#666">— 未排程 —</span>
{% endif %}
</td>
<td><code>{{ r.times|join(', ') if r.times else '—' }}</code></td>
<td class="next-cell">
{% if r.next_in_min is not none %}
{{ r.next }}<br><span class="in">{{ r.next_in_min }} 分钟后</span>
{% else %}—{% endif %}
</td>
<td>
{% if r.last %}
{{ r.last.mtime }}<br>
{% if '成功' in (r.last.status or '') %}<span class="badge ok">{{ r.last.status }}</span>
{% elif r.last.status %}<span class="badge fail">{{ r.last.status }}</span>{% endif %}
{% else %}<span style="color:#666">从未</span>{% endif %}
</td>
<td>
<form method="POST" action="{{ url_for('schedule_time_set') }}" style="display:inline">
<input type="hidden" name="name" value="{{ r.name }}">
<input class="time-input" name="times" value="{{ r.times|join(',') if r.times else '09:00' }}"
pattern="([01]?\d|2[0-3]):[0-5]\d(,([01]?\d|2[0-3]):[0-5]\d)*" title="HH:MM,多个逗号分隔">
<button type="submit" class="btn btn-sm btn-primary">{{ '保存' if r.times else '创建排程' }}</button>
</form>
{% if r.state != 'none' %}
<form method="POST" action="{{ url_for('schedule_delete', name=r.name) }}" style="display:inline"
onsubmit="return confirm('移除 {{ r.name }} 的调度?(技能源与运行历史保留,可随时重建)')">
<button type="submit" class="btn btn-sm btn-danger">删除</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment