Commit fa73eb4c authored by 郭阔's avatar 郭阔

feat(panel): intent-based schedule maintenance — no more hand-written times

调度中心 rows now maintain 每日次数 + 时段偏好(自动晨间批次/上午/下午/
晚间) via dropdowns; POST /schedule-intent persists to schedule-plan.json
and immediately re-plans all times. Planner gains windows support: a
source with a preferred window gets all its runs evenly placed inside
06:30-11:30 / 12:00-17:30 / 18:00-21:30, outside the morning batch;
global gap/deadline math stays automatic. Applied: reuters-technology →
morning (06:30), freeing an overnight slot (everything shifts 40min
later, more slack before the 06:00 deadline).
parent cbb0b159
......@@ -49,15 +49,29 @@ def load_conf(path):
return default
WINDOW_PRESETS = {
"morning": ["06:30", "11:30"],
"afternoon": ["12:00", "17:30"],
"evening": ["18:00", "21:30"],
}
def plan(conf):
gap = timedelta(minutes=conf["min_gap_min"])
day = timedelta(days=1)
sources = sorted(conf["frequencies"], key=lambda s: -conf["frequencies"][s])
fixed = conf.get("fixed_times", {})
windows = conf.get("windows", {}) # {"<source>": "morning|afternoon|evening"}
# 分组:固定时刻 / 时段偏好(在偏好时段内均布)/ 常规(晨间批次+白天均布)
fixed_src = [s for s in conf["frequencies"] if s in fixed]
win_src = [s for s in conf["frequencies"]
if s not in fixed and windows.get(s) in WINDOW_PRESETS]
sources = sorted((s for s in conf["frequencies"]
if s not in fixed and s not in win_src),
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)}
# 白天附加槽:day_window 内均布剩余次数
dw0, dw1 = hm(conf["day_window"][0]), hm(conf["day_window"][1])
result = {}
for s in sources:
......@@ -67,6 +81,14 @@ def plan(conf):
span = (dw1 - dw0) / freq
times += [dw0 + span * (k + 1) for k in range(freq - 1)]
result[s] = times
# 时段偏好源:全部运行落在其偏好时段内均布(不占晨间批次,交付线由偏好方自行权衡)
for s in win_src:
freq = conf["frequencies"][s]
w0, w1 = (hm(x) for x in WINDOW_PRESETS[windows[s]])
span = (w1 - w0) / freq if freq > 1 else timedelta()
result[s] = [w0 if freq == 1 else w0 + span * k for k in range(freq)]
for s, ts in fixed.items():
result[s] = [hm(t) for t in ts]
avoid = [(hm(k) - timedelta(minutes=v), hm(k) + timedelta(minutes=v))
for k, v in conf.get("avoid", {}).items()]
# 全局去冲突+避让(按时刻排序,冲突/避让者顺延 gap;迭代收敛)
......
......@@ -1317,6 +1317,19 @@ def schedule_board():
})
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()]
# 排程意图(频率/时段偏好)供维护界面回显
plan_conf = {}
plan_path = Path(pl.default_conf_dir(pl.default_work_dir())) / "schedule-plan.json"
try:
plan_conf = json.loads(plan_path.read_text(encoding="utf-8"))
except (OSError, ValueError):
plan_conf = {}
freqs = plan_conf.get("frequencies", {})
wins = plan_conf.get("windows", {})
for r in rows:
n = r["name"]
r["intent"] = {"freq": freqs.get(n, len(r["times"]) or 1),
"window": wins.get(n, "")}
return render_template("schedules.html", rows=rows, now=now,
digest_next=_next_fire(digest_times, now),
digest_times=digest_times)
......@@ -1368,6 +1381,44 @@ def schedule_time_set():
return redirect(url_for("schedule_board"))
@app.route("/schedule-intent", methods=["POST"])
@require_auth
def schedule_intent():
"""维护"意图"而非时间:每源 频率(每日次数)+ 时段偏好,保存后自动重排全部时间。"""
name = request.form.get("name", "").strip()
try:
freq = int(request.form.get("freq", "1"))
except ValueError:
freq = 1
window = request.form.get("window", "").strip()
if not re.match(r"^[a-zA-Z0-9_-]+$", name) or not 0 < freq <= 12:
flash("参数不合法", "error")
return redirect(url_for("schedule_board"))
if window and window not in ("morning", "afternoon", "evening"):
window = ""
conf_path = pl.default_conf_dir(pl.default_work_dir()) / "schedule-plan.json"
try:
conf = json.loads(Path(conf_path).read_text(encoding="utf-8"))
except (OSError, ValueError):
conf = {}
conf.setdefault("frequencies", {})[name] = freq
if window:
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",
encoding="utf-8")
# 保存意图后立即重排(时间计算全部交给规划器)
r = subprocess.run(
["python3", str(Path(__file__).resolve().parent.parent / "bin" / "plan_schedules.py")],
capture_output=True, text=True, timeout=300)
flash(f"{name} 意图已保存(每日 {freq} 次"
+ (f",{ {'morning': '上午', 'afternoon': '下午', 'evening': '晚间'}.get(window, window)}时段" if window else "")
+ ")并重排全部时间\n" + (r.stdout or "")[-2500:],
"success" if r.returncode == 0 else "error")
return redirect(url_for("schedule_board"))
@app.route("/schedule-plan-apply", methods=["POST"])
@require_auth
def schedule_plan_apply():
......
......@@ -85,11 +85,20 @@
{% else %}<span style="color:#666">从未</span>{% endif %}
</td>
<td>
<form method="POST" action="{{ url_for('schedule_time_set') }}" style="display:inline">
<form method="POST" action="{{ url_for('schedule_intent') }}" 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>
<select name="freq" style="background:#1a1d27;border:1px solid #2a2d37;border-radius:4px;color:#d4d4d4;padding:3px 6px">
{% for f in [1,2,3,4,6] %}
<option value="{{ f }}" {% if r.intent.freq == f %}selected{% endif %}>每日 {{ f }} 次</option>
{% endfor %}
</select>
<select name="window" style="background:#1a1d27;border:1px solid #2a2d37;border-radius:4px;color:#d4d4d4;padding:3px 6px">
<option value="" {% if not r.intent.window %}selected{% endif %}>时段:自动(晨间批次)</option>
<option value="morning" {% if r.intent.window == 'morning' %}selected{% endif %}>时段:上午</option>
<option value="afternoon" {% if r.intent.window == 'afternoon' %}selected{% endif %}>时段:下午</option>
<option value="evening" {% if r.intent.window == 'evening' %}selected{% endif %}>时段:晚间</option>
</select>
<button type="submit" class="btn btn-sm">保存</button>
</form>
{% if r.state != 'none' %}
<form method="POST" action="{{ url_for('schedule_delete', name=r.name) }}" style="display:inline"
......
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