Commit ae5bd0a9 authored by 郭阔's avatar 郭阔

feat(panel): per-run window maintenance for multi-daily sources

freq dropdown now spawns 第1次..第6次 window selectors (凌晨批次/上午/
下午/晚间), each run placed independently — e.g. reuters-world 2×/day as
overnight+afternoon → 02:00 + 12:00. Sources without explicit windows keep
auto semantics (first run in the deadline batch + extras evenly across
day_window); legacy string windows normalized. JS toggles run selectors
with the freq value; intent persists as a runs list in schedule-plan.json.
parent ffb4c56b
...@@ -56,48 +56,72 @@ WINDOW_PRESETS = { ...@@ -56,48 +56,72 @@ WINDOW_PRESETS = {
} }
def normalize_runs(freq, windows_entry):
"""把 windows 配置归一化为逐次列表(长度=freq)。
新格式:列表,每项 "overnight"(凌晨批次)|"morning"|"afternoon"|"evening"。
兼容旧格式:字符串 w → freq==1 视为 [w];freq>1 视为 [overnight, w×(freq-1)]。
"""
if isinstance(windows_entry, list):
runs = [w for w in windows_entry if w == "overnight" or w in WINDOW_PRESETS]
elif windows_entry in WINDOW_PRESETS:
runs = [windows_entry] if freq == 1 else ["overnight"] + [windows_entry] * (freq - 1)
else:
runs = []
while len(runs) < freq:
runs.append("overnight")
return runs[:freq]
def plan(conf): def plan(conf):
gap = timedelta(minutes=conf["min_gap_min"]) gap = timedelta(minutes=conf["min_gap_min"])
day = timedelta(days=1) day = timedelta(days=1)
fixed = conf.get("fixed_times", {}) fixed = conf.get("fixed_times", {})
windows = conf.get("windows", {}) # {"<source>": "morning|afternoon|evening"} windows = conf.get("windows", {})
# 分组:固定时刻 / 时段偏好 / 常规(晨间批次+白天均布)
# 时段偏好在多次场景的语义:首跑留在凌晨交付批次,其余跑进偏好时段均布
# (例:每日2次+上午 = 凌晨1次(6点前交付) + 上午1次)
fixed_src = [s for s in conf["frequencies"] if s in fixed]
single_win = [s for s in conf["frequencies"]
if s not in fixed and windows.get(s) in WINDOW_PRESETS
and conf["frequencies"][s] == 1]
multi_win = [s for s in conf["frequencies"]
if s not in fixed and windows.get(s) in WINDOW_PRESETS
and conf["frequencies"][s] > 1]
sources = sorted([s for s in conf["frequencies"]
if s not in fixed and s not in single_win and s not in multi_win]
+ multi_win, # 多次偏好源参与晨间批次拿首跑槽
key=lambda s: -conf["frequencies"][s])
# 晨间槽:deadline-safety 为最后起始槽,逆序铺(多次源拿到最新鲜的槽);
# 负时间归一化到前晚(22:10 等合法:早于次日交付线)
last = hm(conf["deadline"]) - timedelta(minutes=conf["safety_min"])
morning = {s: (last - i * gap) % day for i, s in enumerate(sources)}
dw0, dw1 = hm(conf["day_window"][0]), hm(conf["day_window"][1]) dw0, dw1 = hm(conf["day_window"][0]), hm(conf["day_window"][1])
result = {} # 逐次解析:有显式逐次配置的源按配置;没有的走"自动"语义——
for s in sources: # 首跑凌晨批次 + 其余在 day_window 均布(兼容旧行为)
freq = conf["frequencies"][s] runs = {}
times = [morning[s]] auto_extra = {}
if s in multi_win: for s, f in conf["frequencies"].items():
# 首跑在凌晨批次,其余均布进偏好时段 if s in fixed:
w0, w1 = (hm(x) for x in WINDOW_PRESETS[windows[s]]) continue
extra = freq - 1 w = windows.get(s)
span = (w1 - w0) / extra if extra > 1 else timedelta() if w is None or w == "" or w == "auto" or w == "overnight":
times += [w0 if extra == 1 else w0 + span * k for k in range(extra)] runs[s] = ["overnight"] + [None] * (f - 1)
elif freq > 1: auto_extra[s] = f - 1
span = (dw1 - dw0) / freq elif isinstance(w, list) or w in WINDOW_PRESETS:
times += [dw0 + span * (k + 1) for k in range(freq - 1)] runs[s] = normalize_runs(f, w)
result[s] = times else:
# 单次时段偏好源:唯一一次运行落在偏好时段开头 runs[s] = ["overnight"] + [None] * (f - 1)
for s in single_win: auto_extra[s] = f - 1
w0, _ = (hm(x) for x in WINDOW_PRESETS[windows[s]]) # 凌晨批次槽:全部标记为 overnight 的运行,逆序铺(deadline-safety 起往回)
result[s] = [w0] overnight = [(s, i) for s, rs in runs.items() for i, w in enumerate(rs) if w == "overnight"]
order = {s: k for k, s in enumerate(sorted(runs, key=lambda s: (-conf["frequencies"][s], s)))}
overnight.sort(key=lambda si: (order[si[0]], si[1]))
last = hm(conf["deadline"]) - timedelta(minutes=conf["safety_min"])
batch_slot = {si: (last - k * gap) % day for k, si in enumerate(reversed(overnight))}
# 时段内运行:同窗口多个则均布,单个放窗口头
by_window = {}
for s, rs in runs.items():
for i, w in enumerate(rs):
if w in WINDOW_PRESETS:
by_window.setdefault(w, []).append((s, i))
result = {s: [] for s in runs}
for w, items in by_window.items():
w0, w1 = (hm(x) for x in WINDOW_PRESETS[w])
span = (w1 - w0) / len(items) if len(items) > 1 else timedelta()
for k, (s, i) in enumerate(sorted(items, key=lambda si: (order[si[0]], si[1]))):
result[s].append(w0 + span * k if len(items) > 1 else w0)
# 自动源的附加跑:day_window 内均布
n_auto = sum(auto_extra.values())
for s, extra in auto_extra.items():
if extra <= 0:
continue
span = (dw1 - dw0) / (extra + 1)
result[s] += [dw0 + span * (k + 1) for k in range(extra)]
for (s, i), t in batch_slot.items():
result[s].append(t)
for s, ts in fixed.items(): for s, ts in fixed.items():
result[s] = [hm(t) for t in ts] result[s] = [hm(t) for t in ts]
avoid = [(hm(k) - timedelta(minutes=v), hm(k) + timedelta(minutes=v)) avoid = [(hm(k) - timedelta(minutes=v), hm(k) + timedelta(minutes=v))
......
...@@ -1326,10 +1326,23 @@ def schedule_board(): ...@@ -1326,10 +1326,23 @@ def schedule_board():
plan_conf = {} plan_conf = {}
freqs = plan_conf.get("frequencies", {}) freqs = plan_conf.get("frequencies", {})
wins = plan_conf.get("windows", {}) wins = plan_conf.get("windows", {})
def _runs_of(n):
f = freqs.get(n, 1)
w = wins.get(n)
if isinstance(w, list):
rs = [x for x in w if x in ("overnight", "morning", "afternoon", "evening")]
elif w in ("morning", "afternoon", "evening"):
rs = [w] if f == 1 else ["overnight"] + [w] * (f - 1)
else:
rs = []
while len(rs) < f:
rs.append("overnight")
return rs[:f]
for r in rows: for r in rows:
n = r["name"] n = r["name"]
r["intent"] = {"freq": freqs.get(n, len(r["times"]) or 1), r["intent"] = {"freq": freqs.get(n, len(r["times"]) or 1), "runs": _runs_of(n)}
"window": wins.get(n, "")}
return render_template("schedules.html", rows=rows, now=now, return render_template("schedules.html", rows=rows, now=now,
digest_next=_next_fire(digest_times, now), digest_next=_next_fire(digest_times, now),
digest_times=digest_times, digest_times=digest_times,
...@@ -1385,37 +1398,36 @@ def schedule_time_set(): ...@@ -1385,37 +1398,36 @@ def schedule_time_set():
@app.route("/schedule-intent", methods=["POST"]) @app.route("/schedule-intent", methods=["POST"])
@require_auth @require_auth
def schedule_intent(): def schedule_intent():
"""维护"意图"而非时间:每源 频率(每日次数)+ 时段偏好,保存后自动重排全部时间。""" """维护"意图"而非时间:每源 频率 + 逐次时段(第1次/第2次…各自可选
凌晨批次/上午/下午/晚间),保存后自动重排全部时间。"""
name = request.form.get("name", "").strip() name = request.form.get("name", "").strip()
try: try:
freq = int(request.form.get("freq", "1")) freq = int(request.form.get("freq", "1"))
except ValueError: except ValueError:
freq = 1 freq = 1
window = request.form.get("window", "").strip() if not re.match(r"^[a-zA-Z0-9_-]+$", name) or not 0 < freq <= 6:
if not re.match(r"^[a-zA-Z0-9_-]+$", name) or not 0 < freq <= 12: flash("参数不合法(频率 1-6 次)", "error")
flash("参数不合法", "error")
return redirect(url_for("schedule_board")) return redirect(url_for("schedule_board"))
if window and window not in ("morning", "afternoon", "evening"): valid = {"overnight", "morning", "afternoon", "evening"}
window = "" runs = []
for i in range(1, freq + 1):
w = request.form.get(f"window_{i}", "overnight").strip()
runs.append(w if w in valid else "overnight")
conf_path = Path(pl.default_conf_dir(pl.default_work_dir())) / "schedule-plan.json" conf_path = Path(pl.default_conf_dir(pl.default_work_dir())) / "schedule-plan.json"
try: try:
conf = json.loads(Path(conf_path).read_text(encoding="utf-8")) conf = json.loads(Path(conf_path).read_text(encoding="utf-8"))
except (OSError, ValueError): except (OSError, ValueError):
conf = {} conf = {}
conf.setdefault("frequencies", {})[name] = freq conf.setdefault("frequencies", {})[name] = freq
if window: conf.setdefault("windows", {})[name] = runs
conf.setdefault("windows", {})[name] = window
else:
conf.get("windows", {}).pop(name, None)
Path(conf_path).write_text(json.dumps(conf, ensure_ascii=False, indent=2) + "\n", Path(conf_path).write_text(json.dumps(conf, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8") encoding="utf-8")
# 保存意图后立即重排(时间计算全部交给规划器) zh = {"overnight": "凌晨批次", "morning": "上午", "afternoon": "下午", "evening": "晚间"}
r = subprocess.run( r = subprocess.run(
["python3", str(Path(__file__).resolve().parent.parent / "bin" / "plan_schedules.py")], ["python3", str(Path(__file__).resolve().parent.parent / "bin" / "plan_schedules.py")],
capture_output=True, text=True, timeout=300) capture_output=True, text=True, timeout=300)
flash(f"{name} 意图已保存(每日 {freq} 次" flash(f"{name} 意图已保存(每日 {freq} 次:{'+'.join(zh[w] for w in runs)})并重排全部时间\n"
+ (f",{ {'morning': '上午', 'afternoon': '下午', 'evening': '晚间'}.get(window, window)}时段" if window else "") + (r.stdout or "")[-2500:],
+ ")并重排全部时间\n" + (r.stdout or "")[-2500:],
"success" if r.returncode == 0 else "error") "success" if r.returncode == 0 else "error")
return redirect(url_for("schedule_board")) return redirect(url_for("schedule_board"))
......
...@@ -2,6 +2,14 @@ ...@@ -2,6 +2,14 @@
{% block title %}调度中心 — News Harvester{% endblock %} {% block title %}调度中心 — News Harvester{% endblock %}
{% block extra_head %} {% block extra_head %}
<meta http-equiv="refresh" content="60"> <meta http-equiv="refresh" content="60">
<script>
function toggleRuns(sel) {
var f = parseInt(sel.value, 10);
sel.form.querySelectorAll('.runwin').forEach(function (sp) {
sp.style.display = parseInt(sp.dataset.i, 10) <= f ? '' : 'none';
});
}
</script>
<style> <style>
.tl { position:relative; height:30px; background:linear-gradient(90deg,#181b24,#1e2230); border:1px solid #2a2d37; border-radius:5px; min-width:300px; } .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 .h { position:absolute; top:0; bottom:0; width:1px; background:#2a2d37; }
...@@ -93,19 +101,24 @@ ...@@ -93,19 +101,24 @@
{% else %}<span style="color:#666">从未</span>{% endif %} {% else %}<span style="color:#666">从未</span>{% endif %}
</td> </td>
<td> <td>
<form method="POST" action="{{ url_for('schedule_intent') }}" style="display:inline"> <form method="POST" action="{{ url_for('schedule_intent') }}" style="display:inline;white-space:nowrap">
<input type="hidden" name="name" value="{{ r.name }}"> <input type="hidden" name="name" value="{{ r.name }}">
<select name="freq" style="background:#1a1d27;border:1px solid #2a2d37;border-radius:4px;color:#d4d4d4;padding:3px 6px"> <select name="freq" class="intsel" onchange="toggleRuns(this)"
{% for f in [1,2,3,4,6] %} style="background:#1a1d27;border:1px solid #2a2d37;border-radius:4px;color:#d4d4d4;padding:3px 6px">
{% for f in [1,2,3,4,5,6] %}
<option value="{{ f }}" {% if r.intent.freq == f %}selected{% endif %}>每日 {{ f }} 次</option> <option value="{{ f }}" {% if r.intent.freq == f %}selected{% endif %}>每日 {{ f }} 次</option>
{% endfor %} {% endfor %}
</select> </select>
<select name="window" style="background:#1a1d27;border:1px solid #2a2d37;border-radius:4px;color:#d4d4d4;padding:3px 6px"> {% for i in range(1,7) %}
<option value="" {% if not r.intent.window %}selected{% endif %}>时段:自动(晨间批次)</option> <span class="runwin" data-i="{{ i }}" style="{% if i > r.intent.freq %}display:none;{% endif %}margin-left:4px">
<option value="morning" {% if r.intent.window == 'morning' %}selected{% endif %}>时段:上午</option> 第{{ i }}次
<option value="afternoon" {% if r.intent.window == 'afternoon' %}selected{% endif %}>时段:下午</option> <select name="window_{{ i }}" style="background:#1a1d27;border:1px solid #2a2d37;border-radius:4px;color:#d4d4d4;padding:3px 4px">
<option value="evening" {% if r.intent.window == 'evening' %}selected{% endif %}>时段:晚间</option> {% for v, lbl in [('overnight','凌晨批次'),('morning','上午'),('afternoon','下午'),('evening','晚间')] %}
</select> <option value="{{ v }}" {% if r.intent.runs[i-1] == v %}selected{% endif %}>{{ lbl }}</option>
{% endfor %}
</select>
</span>
{% endfor %}
<button type="submit" class="btn btn-sm">保存</button> <button type="submit" class="btn btn-sm">保存</button>
</form> </form>
{% if r.state != 'none' %} {% if r.state != 'none' %}
......
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