Spaces:
Sleeping
Sleeping
| const runtimeFieldIds = [ | |
| "business_start", "business_end", "turnaround_base", "golden_start", "golden_end", | |
| "efficiency_enabled", "efficiency_penalty_coef", "eff_daily_delta_cap", | |
| "rule1_enabled", "rule1_gap", | |
| "rule2_enabled", "rule2_threshold", "rule2_window_minutes", "rule2_penalty", "rule2_exempt_ranges", | |
| "rule3_enabled", "rule3_gap_minutes", "rule3_penalty", | |
| "rule4_enabled", "rule4_earliest", "rule4_latest", | |
| "rule9_enabled", "rule9_hot_top_n", "rule9_min_ratio", "rule9_penalty", | |
| "rule11_enabled", "rule11_after_time", "rule11_penalty", | |
| "rule12_enabled", "rule12_penalty_each", | |
| "rule13_enabled", "rule13_forbidden_halls", "tms_allowance", | |
| "iterations", "random_seed" | |
| ]; | |
| const checkIds = [ | |
| "efficiency_enabled", "rule1_enabled", "rule2_enabled", "rule3_enabled", | |
| "rule4_enabled", "rule9_enabled", "rule11_enabled", "rule12_enabled", "rule13_enabled" | |
| ]; | |
| const tuningColumns = [ | |
| "选中", "影片", "今日场次", "今日黄金场次", "今日全天效率", "今日黄金效率", | |
| "最少场次", "最多场次", "固定场次", "最少黄金场次", "最多黄金场次", "最低场次占比", "最高场次占比" | |
| ]; | |
| const readOnlyColumns = new Set(["影片", "今日场次", "今日黄金场次", "今日全天效率", "今日黄金效率"]); | |
| const boolColumns = new Set(["选中"]); | |
| const integerColumns = new Set(["最少场次", "最多场次", "固定场次", "最少黄金场次", "最多黄金场次"]); | |
| const floatColumns = new Set(["最低场次占比", "最高场次占比"]); | |
| const sessionConstraintColumns = ["最少场次", "最多场次", "固定场次", "最少黄金场次", "最多黄金场次"]; | |
| const ratioConstraintColumns = ["最低场次占比", "最高场次占比"]; | |
| const state = { | |
| runtimeCfg: null, | |
| bundle: null, | |
| tuningRows: [], | |
| jobState: null, | |
| results: null, | |
| activeCandidateIndex: 0, | |
| pollTimer: null, | |
| }; | |
| function deepClone(v) { | |
| if (typeof structuredClone === "function") { | |
| return structuredClone(v); | |
| } | |
| return JSON.parse(JSON.stringify(v)); | |
| } | |
| function $(id) { | |
| return document.getElementById(id); | |
| } | |
| function sleep(ms) { | |
| return new Promise((resolve) => setTimeout(resolve, ms)); | |
| } | |
| async function requestJSON(url, options = {}) { | |
| const init = { | |
| headers: { "Content-Type": "application/json" }, | |
| credentials: "same-origin", | |
| ...options, | |
| }; | |
| const resp = await fetch(url, init); | |
| let data = {}; | |
| try { | |
| data = await resp.json(); | |
| } catch { | |
| data = {}; | |
| } | |
| if (!resp.ok || data.success === false) { | |
| const msg = data.error || data.message || `请求失败: ${resp.status}`; | |
| throw new Error(msg); | |
| } | |
| return data; | |
| } | |
| function showMessage(text, type = "info", ttl = 2800) { | |
| const el = $("message"); | |
| el.className = `message ${type}`; | |
| el.textContent = text; | |
| el.classList.remove("hidden"); | |
| if (ttl > 0) { | |
| setTimeout(() => { | |
| el.classList.add("hidden"); | |
| }, ttl); | |
| } | |
| } | |
| function getDatePlusOne(dateText) { | |
| const d = new Date(`${dateText}T00:00:00`); | |
| if (Number.isNaN(d.getTime())) { | |
| return "-"; | |
| } | |
| d.setDate(d.getDate() + 1); | |
| return d.toISOString().slice(0, 10); | |
| } | |
| function updateTargetDateTip() { | |
| const base = $("base_date").value; | |
| $("target_date_tip").textContent = getDatePlusOne(base); | |
| } | |
| function toNumberOrNull(raw, parseFloatMode = false) { | |
| const val = String(raw ?? "").trim(); | |
| if (val === "") { | |
| return null; | |
| } | |
| const num = parseFloatMode ? Number.parseFloat(val) : Number.parseInt(val, 10); | |
| return Number.isFinite(num) ? num : null; | |
| } | |
| function collectMaintenanceBlocks() { | |
| const tbody = $("maintenance_table").querySelector("tbody"); | |
| const rows = Array.from(tbody.querySelectorAll("tr")); | |
| return rows | |
| .map((tr) => { | |
| const hall = tr.querySelector("input[data-key='hall']")?.value?.trim() || ""; | |
| const start = tr.querySelector("input[data-key='start']")?.value?.trim() || ""; | |
| const end = tr.querySelector("input[data-key='end']")?.value?.trim() || ""; | |
| return { hall, start, end }; | |
| }) | |
| .filter((x) => x.hall && x.start && x.end); | |
| } | |
| function collectRuntimeCfg() { | |
| const cfg = {}; | |
| for (const id of runtimeFieldIds) { | |
| const el = $(id); | |
| if (!el) continue; | |
| if (checkIds.includes(id)) { | |
| cfg[id] = !!el.checked; | |
| } else if (el.type === "number") { | |
| cfg[id] = el.step && String(el.step).includes(".") | |
| ? Number.parseFloat(el.value || "0") | |
| : Number.parseInt(el.value || "0", 10); | |
| } else { | |
| cfg[id] = el.value; | |
| } | |
| } | |
| cfg.maintenance_blocks = collectMaintenanceBlocks(); | |
| return cfg; | |
| } | |
| function setMaintenanceRows(rows = []) { | |
| const tbody = $("maintenance_table").querySelector("tbody"); | |
| tbody.innerHTML = ""; | |
| const list = rows.length ? rows : []; | |
| for (const row of list) { | |
| appendMaintenanceRow(row); | |
| } | |
| } | |
| function appendMaintenanceRow(row = { hall: "", start: "", end: "" }) { | |
| const tbody = $("maintenance_table").querySelector("tbody"); | |
| const tr = document.createElement("tr"); | |
| tr.innerHTML = ` | |
| <td><input type="text" data-key="hall" value="${escapeHtml(String(row.hall || ""))}" placeholder="2号厅 / 2"></td> | |
| <td><input type="text" data-key="start" value="${escapeHtml(String(row.start || ""))}" placeholder="14:00"></td> | |
| <td><input type="text" data-key="end" value="${escapeHtml(String(row.end || ""))}" placeholder="16:00"></td> | |
| <td><button type="button" class="btn secondary small">删除</button></td> | |
| `; | |
| tr.querySelector("button").addEventListener("click", () => tr.remove()); | |
| tbody.appendChild(tr); | |
| } | |
| function setRuntimeCfg(cfg) { | |
| state.runtimeCfg = deepClone(cfg); | |
| for (const id of runtimeFieldIds) { | |
| const el = $(id); | |
| if (!el) continue; | |
| const value = cfg[id]; | |
| if (checkIds.includes(id)) { | |
| el.checked = !!value; | |
| } else { | |
| el.value = value ?? ""; | |
| } | |
| } | |
| setMaintenanceRows(cfg.maintenance_blocks || []); | |
| refreshEnableStates(); | |
| } | |
| function refreshEnableStates() { | |
| $("efficiency_penalty_coef").disabled = !$("efficiency_enabled").checked; | |
| $("eff_daily_delta_cap").disabled = !$("efficiency_enabled").checked; | |
| $("rule1_gap").disabled = !$("rule1_enabled").checked; | |
| const rule2Enabled = $("rule2_enabled").checked; | |
| $("rule2_threshold").disabled = !rule2Enabled; | |
| $("rule2_window_minutes").disabled = !rule2Enabled; | |
| $("rule2_penalty").disabled = !rule2Enabled; | |
| const rule3Enabled = $("rule3_enabled").checked; | |
| $("rule3_gap_minutes").disabled = !rule3Enabled; | |
| $("rule3_penalty").disabled = !rule3Enabled; | |
| const rule4Enabled = $("rule4_enabled").checked; | |
| $("rule4_earliest").disabled = !rule4Enabled; | |
| $("rule4_latest").disabled = !rule4Enabled; | |
| const rule9Enabled = $("rule9_enabled").checked; | |
| $("rule9_hot_top_n").disabled = !rule9Enabled; | |
| $("rule9_min_ratio").disabled = !rule9Enabled; | |
| $("rule9_penalty").disabled = !rule9Enabled; | |
| const rule11Enabled = $("rule11_enabled").checked; | |
| $("rule11_after_time").disabled = !rule11Enabled; | |
| $("rule11_penalty").disabled = !rule11Enabled; | |
| $("rule12_penalty_each").disabled = !$("rule12_enabled").checked; | |
| $("rule13_forbidden_halls").disabled = !$("rule13_enabled").checked; | |
| } | |
| function renderSimpleTable(el, rows, columns = null) { | |
| el.innerHTML = ""; | |
| if (!rows || rows.length === 0) { | |
| el.innerHTML = "<thead><tr><th>提示</th></tr></thead><tbody><tr><td>无数据</td></tr></tbody>"; | |
| return; | |
| } | |
| const cols = columns || Object.keys(rows[0]); | |
| const thead = document.createElement("thead"); | |
| const htr = document.createElement("tr"); | |
| for (const c of cols) { | |
| const th = document.createElement("th"); | |
| th.textContent = c; | |
| htr.appendChild(th); | |
| } | |
| thead.appendChild(htr); | |
| const tbody = document.createElement("tbody"); | |
| for (const row of rows) { | |
| const tr = document.createElement("tr"); | |
| for (const c of cols) { | |
| const td = document.createElement("td"); | |
| const v = row[c]; | |
| td.textContent = v === null || v === undefined ? "" : String(v); | |
| tr.appendChild(td); | |
| } | |
| tbody.appendChild(tr); | |
| } | |
| el.appendChild(thead); | |
| el.appendChild(tbody); | |
| } | |
| function renderMapTop(tableId, mapObj, keyTitle, valTitle, limit = 20) { | |
| const entries = Object.entries(mapObj || {}).slice(0, limit); | |
| const rows = entries.map(([k, v]) => ({ [keyTitle]: k, [valTitle]: v })); | |
| renderSimpleTable($(tableId), rows, [keyTitle, valTitle]); | |
| } | |
| function renderMetrics(container, items) { | |
| container.innerHTML = ""; | |
| for (const item of items) { | |
| const card = document.createElement("div"); | |
| card.className = "metric"; | |
| card.innerHTML = `<div class="k">${escapeHtml(item.k)}</div><div class="v">${escapeHtml(String(item.v))}</div>`; | |
| container.appendChild(card); | |
| } | |
| } | |
| function getSelectedValues(selectId) { | |
| const el = $(selectId); | |
| if (!el) return []; | |
| return Array.from(el.selectedOptions).map((x) => x.value); | |
| } | |
| function setAllSelected(selectId, selected = true) { | |
| const el = $(selectId); | |
| if (!el) return; | |
| for (const opt of Array.from(el.options)) { | |
| opt.selected = !!selected; | |
| } | |
| } | |
| function renderExcludeEffect(effect) { | |
| const tip = $("exclude_effect_tip"); | |
| if (!tip) return; | |
| const e = effect || {}; | |
| const total = Number(e.total_count || 0); | |
| const removed = Number(e.removed_count || 0); | |
| const remaining = Number(e.remaining_count || Math.max(0, total - removed)); | |
| const affectedMovies = Number(e.affected_movies || 0); | |
| const reasons = Object.entries(e.reason_breakdown || {}) | |
| .map(([k, v]) => `${k}:${v}`) | |
| .join(";"); | |
| if (!total) { | |
| tip.textContent = "尚未应用剔除。"; | |
| return; | |
| } | |
| tip.textContent = `当前剔除效果:已剔除 ${removed}/${total} 场,剩余 ${remaining} 场,影响影片 ${affectedMovies} 部。${reasons ? ` 原因分布:${reasons}` : ""}`; | |
| } | |
| function renderBundle(bundle) { | |
| state.bundle = bundle; | |
| state.tuningRows = deepClone(bundle.tuning_rows || []); | |
| $("loaded_section").classList.remove("hidden"); | |
| $("load_summary").textContent = `目标日期 ${bundle.target_str},影片 ${bundle.movies_count} 部,已售锁定场 ${bundle.locked_count}。`; | |
| const exclude = $("exclude_select"); | |
| exclude.innerHTML = ""; | |
| const selectedSession = new Set(bundle.excluded_session_keys || []); | |
| for (const item of bundle.exclude_options || []) { | |
| const opt = document.createElement("option"); | |
| opt.value = String(item.key ?? ""); | |
| opt.textContent = String(item.label ?? ""); | |
| opt.selected = selectedSession.has(opt.value); | |
| opt.dataset.suspected = item.suspected ? "1" : "0"; | |
| exclude.appendChild(opt); | |
| } | |
| const movieSelect = $("exclude_movie_select"); | |
| movieSelect.innerHTML = ""; | |
| const selectedMovies = new Set(bundle.excluded_movies || []); | |
| for (const item of bundle.exclude_movie_options || []) { | |
| const opt = document.createElement("option"); | |
| opt.value = String(item.value ?? ""); | |
| opt.textContent = String(item.label ?? item.value ?? ""); | |
| opt.selected = selectedMovies.has(opt.value); | |
| movieSelect.appendChild(opt); | |
| } | |
| const hallSelect = $("exclude_hall_select"); | |
| hallSelect.innerHTML = ""; | |
| const selectedHalls = new Set(bundle.excluded_halls || []); | |
| for (const item of bundle.exclude_hall_options || []) { | |
| const opt = document.createElement("option"); | |
| opt.value = String(item.value ?? ""); | |
| opt.textContent = String(item.label ?? item.value ?? ""); | |
| opt.selected = selectedHalls.has(opt.value); | |
| hallSelect.appendChild(opt); | |
| } | |
| const rules = bundle.exclude_rules || {}; | |
| $("exclude_rule_zero_sales").checked = !!rules.zero_sales; | |
| $("exclude_rule_early_morning").checked = !!rules.early_morning; | |
| renderExcludeEffect(bundle.exclude_effect || {}); | |
| renderTuningTable(); | |
| } | |
| function renderTuningTable() { | |
| const table = $("tuning_table"); | |
| table.innerHTML = ""; | |
| const thead = document.createElement("thead"); | |
| const hr = document.createElement("tr"); | |
| for (const c of tuningColumns) { | |
| const th = document.createElement("th"); | |
| th.textContent = c; | |
| hr.appendChild(th); | |
| } | |
| thead.appendChild(hr); | |
| const tbody = document.createElement("tbody"); | |
| for (let i = 0; i < state.tuningRows.length; i += 1) { | |
| const row = state.tuningRows[i] || {}; | |
| const tr = document.createElement("tr"); | |
| for (const col of tuningColumns) { | |
| const td = document.createElement("td"); | |
| if (boolColumns.has(col)) { | |
| const input = document.createElement("input"); | |
| input.type = "checkbox"; | |
| input.checked = !!row[col]; | |
| input.addEventListener("change", () => { | |
| state.tuningRows[i][col] = !!input.checked; | |
| }); | |
| td.appendChild(input); | |
| } else if (readOnlyColumns.has(col)) { | |
| td.textContent = row[col] === null || row[col] === undefined ? "" : String(row[col]); | |
| } else if (integerColumns.has(col) || floatColumns.has(col)) { | |
| const input = document.createElement("input"); | |
| input.type = "number"; | |
| input.step = floatColumns.has(col) ? "0.1" : "1"; | |
| input.value = row[col] === null || row[col] === undefined ? "" : String(row[col]); | |
| const update = () => { | |
| state.tuningRows[i][col] = toNumberOrNull(input.value, floatColumns.has(col)); | |
| }; | |
| input.addEventListener("input", update); | |
| input.addEventListener("change", update); | |
| td.appendChild(input); | |
| } else { | |
| const input = document.createElement("input"); | |
| input.type = "text"; | |
| input.value = row[col] === null || row[col] === undefined ? "" : String(row[col]); | |
| input.addEventListener("input", () => { | |
| state.tuningRows[i][col] = input.value; | |
| }); | |
| input.addEventListener("change", () => { | |
| state.tuningRows[i][col] = input.value; | |
| }); | |
| td.appendChild(input); | |
| } | |
| tr.appendChild(td); | |
| } | |
| tbody.appendChild(tr); | |
| } | |
| table.appendChild(thead); | |
| table.appendChild(tbody); | |
| } | |
| function collectExclusionPayload() { | |
| return { | |
| excluded_session_keys: getSelectedValues("exclude_select"), | |
| excluded_movies: getSelectedValues("exclude_movie_select"), | |
| excluded_halls: getSelectedValues("exclude_hall_select"), | |
| exclude_rules: { | |
| zero_sales: !!$("exclude_rule_zero_sales").checked, | |
| early_morning: !!$("exclude_rule_early_morning").checked, | |
| }, | |
| }; | |
| } | |
| function applyTuningBatchMutation(mutator, successMsg) { | |
| if (!state.tuningRows || state.tuningRows.length === 0) { | |
| showMessage("当前没有可编辑约束行", "warn"); | |
| return; | |
| } | |
| const selectedIndexes = state.tuningRows | |
| .map((row, idx) => ({ idx, selected: !!row["选中"] })) | |
| .filter((x) => x.selected) | |
| .map((x) => x.idx); | |
| const targetIndexes = selectedIndexes.length | |
| ? selectedIndexes | |
| : state.tuningRows.map((_, idx) => idx); | |
| targetIndexes.forEach((idx) => mutator(state.tuningRows[idx], idx)); | |
| renderTuningTable(); | |
| showMessage(`${successMsg}(处理 ${targetIndexes.length} 行)`, "ok", 1800); | |
| } | |
| function setResultsHidden(hidden) { | |
| if (hidden) { | |
| $("results_section").classList.add("hidden"); | |
| } else { | |
| $("results_section").classList.remove("hidden"); | |
| } | |
| } | |
| function renderJobState(jobState) { | |
| state.jobState = jobState; | |
| if (!state.bundle && jobState.status !== "idle") { | |
| $("loaded_section").classList.remove("hidden"); | |
| } | |
| const metrics = [ | |
| { k: "任务状态", v: jobState.status || "idle" }, | |
| { k: "当前进度", v: `${((jobState.progress || 0) * 100).toFixed(1)}%` }, | |
| { k: "可行方案", v: jobState.feasible_count || 0 }, | |
| { k: "硬性淘汰", v: jobState.hard_reject || 0 }, | |
| { k: "已运行时长", v: `${(jobState.elapsed_seconds || 0).toFixed(1)}s` }, | |
| ]; | |
| renderMetrics($("job_metrics"), metrics); | |
| const progress = Math.max(0, Math.min(1, Number(jobState.progress || 0))); | |
| $("job_progress_bar").style.width = `${progress * 100}%`; | |
| if ((jobState.iterations || 0) > 0) { | |
| $("job_caption").textContent = `迭代进度:${jobState.iter_done || 0}/${jobState.iterations || 0};构造失败 ${jobState.build_reject || 0},硬规则淘汰 ${jobState.rule_reject || 0};${jobState.message || ""}`; | |
| } else { | |
| $("job_caption").textContent = jobState.message || ""; | |
| } | |
| renderMapTop("reject_reason_table", jobState.reject_reason_top || {}, "淘汰原因", "次数", 20); | |
| renderMapTop("reject_detail_table", jobState.reject_detail_top || {}, "详细原因", "次数", 20); | |
| $("run_btn").disabled = ["running", "paused"].includes(jobState.status); | |
| $("pause_btn").disabled = jobState.status !== "running"; | |
| $("resume_btn").disabled = jobState.status !== "paused"; | |
| $("stop_btn").disabled = !["running", "paused"].includes(jobState.status); | |
| } | |
| function renderRejectSummary(summary) { | |
| setResultsHidden(false); | |
| renderMetrics($("results_metrics"), [ | |
| { k: "最近一次状态", v: state.jobState?.status || "-" }, | |
| { k: "运行耗时", v: `${(summary.elapsed_seconds || 0).toFixed(1)}s` }, | |
| { k: "构造阶段失败", v: summary.build_reject || 0 }, | |
| { k: "硬性规则淘汰", v: summary.rule_reject || 0 }, | |
| ]); | |
| renderMapTop("reason_stats_table", summary.reject_reason_stats || {}, "淘汰原因", "次数", 200); | |
| renderMapTop("detail_stats_table", summary.reject_detail_stats || {}, "详细原因", "次数", 200); | |
| renderSimpleTable($("phase_stats_table"), [], ["淘汰阶段", "次数"]); | |
| $("reject_examples").textContent = ""; | |
| $("candidate_tabs").innerHTML = ""; | |
| $("candidate_content").innerHTML = ""; | |
| } | |
| function renderResults(data) { | |
| state.results = data; | |
| setResultsHidden(false); | |
| const s = data.summary || {}; | |
| renderMetrics($("results_metrics"), [ | |
| { k: "目标排片日期", v: data.target_str || "-" }, | |
| { k: "可行方案数", v: s.total_feasible || 0 }, | |
| { k: "硬性规则淘汰", v: s.hard_reject || 0 }, | |
| { k: "已售锁定场", v: s.locked_count || 0 }, | |
| { k: "生成耗时", v: `${(s.elapsed_seconds || 0).toFixed(1)}s` }, | |
| ]); | |
| renderMapTop("phase_stats_table", s.reject_phase_stats || {}, "淘汰阶段", "次数", 20); | |
| renderMapTop("reason_stats_table", s.reject_reason_stats || {}, "淘汰原因", "次数", 200); | |
| renderMapTop("detail_stats_table", s.reject_detail_stats || {}, "详细原因", "次数", 200); | |
| const exampleLines = []; | |
| const examples = s.reject_examples || {}; | |
| for (const [reason, arr] of Object.entries(examples)) { | |
| if (!arr || !arr.length) continue; | |
| exampleLines.push(`${reason}:`); | |
| for (const x of arr.slice(0, 3)) { | |
| exampleLines.push(`- ${x}`); | |
| } | |
| } | |
| $("reject_examples").textContent = exampleLines.join("\n"); | |
| $("movie_targets_json").textContent = JSON.stringify(s.movie_targets || {}, null, 2); | |
| renderSimpleTable($("today_eff_table"), s.today_eff_rows || []); | |
| const tabs = $("candidate_tabs"); | |
| tabs.innerHTML = ""; | |
| const candidates = data.candidates || []; | |
| candidates.forEach((cand, idx) => { | |
| const btn = document.createElement("button"); | |
| btn.type = "button"; | |
| btn.className = `tab-btn${idx === 0 ? " active" : ""}`; | |
| btn.textContent = cand.title; | |
| btn.addEventListener("click", () => { | |
| state.activeCandidateIndex = idx; | |
| renderCandidate(); | |
| }); | |
| tabs.appendChild(btn); | |
| }); | |
| state.activeCandidateIndex = 0; | |
| renderCandidate(); | |
| } | |
| function renderCandidate() { | |
| const payload = state.results; | |
| if (!payload || !payload.candidates || payload.candidates.length === 0) { | |
| $("candidate_content").innerHTML = ""; | |
| return; | |
| } | |
| const idx = Math.min(Math.max(0, state.activeCandidateIndex || 0), payload.candidates.length - 1); | |
| state.activeCandidateIndex = idx; | |
| const cand = payload.candidates[idx]; | |
| const tabButtons = Array.from($("candidate_tabs").querySelectorAll(".tab-btn")); | |
| tabButtons.forEach((btn, i) => { | |
| btn.classList.toggle("active", i === idx); | |
| }); | |
| const content = $("candidate_content"); | |
| content.innerHTML = ` | |
| <section class="subpanel"> | |
| <h3>${escapeHtml(cand.title)}</h3> | |
| <div>${cand.gantt_html || "<div class='muted'>无甘特图</div>"}</div> | |
| <h4>评分拆解</h4> | |
| <div class="table-wrap"><table id="score_breakdown_table" class="simple-table"></table></div> | |
| <h4>结果汇总</h4> | |
| <div class="table-wrap"><table id="summary_table" class="simple-table"></table></div> | |
| <h4>排片明细</h4> | |
| <div class="table-wrap"><table id="schedule_table" class="simple-table"></table></div> | |
| <h4>🔍 场次合理性检查日志</h4> | |
| <pre class="code-block">${escapeHtml(cand.log_text || "")}</pre> | |
| </section> | |
| `; | |
| renderSimpleTable($("score_breakdown_table"), cand.score_breakdown || [], ["规则", "分值", "说明"]); | |
| renderSimpleTable($("summary_table"), cand.summary_table || []); | |
| renderSimpleTable($("schedule_table"), cand.schedule_table || []); | |
| } | |
| function escapeHtml(text) { | |
| return String(text) | |
| .replace(/&/g, "&") | |
| .replace(/</g, "<") | |
| .replace(/>/g, ">") | |
| .replace(/"/g, """) | |
| .replace(/'/g, "'"); | |
| } | |
| async function saveConfig() { | |
| const runtime_cfg = collectRuntimeCfg(); | |
| const data = await requestJSON("/api/config/save", { | |
| method: "POST", | |
| body: JSON.stringify({ runtime_cfg }), | |
| }); | |
| setRuntimeCfg(data.runtime_cfg); | |
| showMessage("参数已保存", "ok"); | |
| } | |
| async function resetConfig() { | |
| const data = await requestJSON("/api/config/reset", { method: "POST", body: "{}" }); | |
| setRuntimeCfg(data.runtime_cfg); | |
| showMessage("已恢复默认参数", "ok"); | |
| } | |
| async function loadData() { | |
| showMessage("正在加载数据,请稍候...", "info", 1200); | |
| const runtime_cfg = collectRuntimeCfg(); | |
| const base_date = $("base_date").value; | |
| const data = await requestJSON("/api/load-data", { | |
| method: "POST", | |
| body: JSON.stringify({ base_date, runtime_cfg }), | |
| }); | |
| setRuntimeCfg(data.runtime_cfg); | |
| renderBundle(data.bundle); | |
| renderJobState(await fetchJobStateRaw()); | |
| setResultsHidden(true); | |
| showMessage(data.message || "数据加载完成", "ok"); | |
| } | |
| async function applyExclusions() { | |
| if (!state.bundle) { | |
| showMessage("请先加载数据", "warn"); | |
| return; | |
| } | |
| const payload = collectExclusionPayload(); | |
| const data = await requestJSON("/api/update-exclusions", { | |
| method: "POST", | |
| body: JSON.stringify(payload), | |
| }); | |
| renderBundle(data.bundle); | |
| showMessage(data.message || "剔除已应用并重算", "ok"); | |
| } | |
| async function startJob() { | |
| if (!state.bundle) { | |
| showMessage("请先加载数据", "warn"); | |
| return; | |
| } | |
| const data = await requestJSON("/api/job/start", { | |
| method: "POST", | |
| body: JSON.stringify({ tuning_rows: state.tuningRows }), | |
| }); | |
| renderJobState(data.job_state || state.jobState || {}); | |
| startPolling(); | |
| setResultsHidden(true); | |
| showMessage(data.message || "后台任务已启动", "ok"); | |
| } | |
| async function controlJob(action) { | |
| const data = await requestJSON("/api/job/control", { | |
| method: "POST", | |
| body: JSON.stringify({ action }), | |
| }); | |
| renderJobState(data.job_state || {}); | |
| showMessage(`已发送${action}指令`, "ok"); | |
| } | |
| async function fetchJobStateRaw() { | |
| const data = await requestJSON("/api/job/state", { method: "GET" }); | |
| return data.job_state || {}; | |
| } | |
| async function fetchJobState() { | |
| const jobState = await fetchJobStateRaw(); | |
| renderJobState(jobState); | |
| if (["running", "paused"].includes(jobState.status)) { | |
| startPolling(); | |
| } else { | |
| stopPolling(); | |
| if (["completed", "failed", "stopped"].includes(jobState.status)) { | |
| await sleep(200); | |
| await fetchResults(); | |
| } | |
| } | |
| } | |
| async function fetchResults() { | |
| const target = state.bundle?.target_str || getDatePlusOne($("base_date").value); | |
| const data = await requestJSON(`/api/results?target_str=${encodeURIComponent(target)}`, { method: "GET" }); | |
| if (data.has_results) { | |
| renderResults(data); | |
| showMessage("结果已更新", "ok", 1500); | |
| return; | |
| } | |
| if (data.reject_summary) { | |
| renderRejectSummary(data.reject_summary); | |
| showMessage("最近一次任务为失败/停止,已展示淘汰统计", "warn", 2400); | |
| return; | |
| } | |
| setResultsHidden(true); | |
| } | |
| function startPolling() { | |
| if (state.pollTimer) return; | |
| state.pollTimer = setInterval(() => { | |
| fetchJobState().catch((e) => { | |
| console.error(e); | |
| showMessage(e.message || "轮询失败", "err", 1800); | |
| stopPolling(); | |
| }); | |
| }, 900); | |
| } | |
| function stopPolling() { | |
| if (state.pollTimer) { | |
| clearInterval(state.pollTimer); | |
| state.pollTimer = null; | |
| } | |
| } | |
| async function init() { | |
| $("base_date").addEventListener("change", updateTargetDateTip); | |
| checkIds.forEach((id) => { | |
| $(id).addEventListener("change", refreshEnableStates); | |
| }); | |
| $("add_maintenance_btn").addEventListener("click", () => appendMaintenanceRow()); | |
| $("save_cfg_btn").addEventListener("click", async () => { | |
| try { | |
| await saveConfig(); | |
| } catch (e) { | |
| showMessage(e.message, "err"); | |
| } | |
| }); | |
| $("reset_cfg_btn").addEventListener("click", async () => { | |
| try { | |
| await resetConfig(); | |
| } catch (e) { | |
| showMessage(e.message, "err"); | |
| } | |
| }); | |
| $("load_data_btn").addEventListener("click", async () => { | |
| try { | |
| await loadData(); | |
| } catch (e) { | |
| showMessage(e.message, "err", 3600); | |
| } | |
| }); | |
| $("apply_exclude_btn").addEventListener("click", async () => { | |
| try { | |
| await applyExclusions(); | |
| } catch (e) { | |
| showMessage(e.message, "err", 3600); | |
| } | |
| }); | |
| $("exclude_select_all_btn").addEventListener("click", () => { | |
| setAllSelected("exclude_select", true); | |
| showMessage("已全选场次,点击“应用剔除”后生效", "ok", 1400); | |
| }); | |
| $("exclude_select_none_btn").addEventListener("click", () => { | |
| setAllSelected("exclude_select", false); | |
| showMessage("已取消全部场次选择", "ok", 1400); | |
| }); | |
| $("exclude_select_suspected_btn").addEventListener("click", () => { | |
| const el = $("exclude_select"); | |
| let count = 0; | |
| for (const opt of Array.from(el.options)) { | |
| const pick = opt.dataset.suspected === "1"; | |
| opt.selected = pick; | |
| if (pick) count += 1; | |
| } | |
| showMessage(`已选中 ${count} 条疑似特殊场次`, "ok", 1600); | |
| }); | |
| $("exclude_clear_btn").addEventListener("click", () => { | |
| setAllSelected("exclude_select", false); | |
| setAllSelected("exclude_movie_select", false); | |
| setAllSelected("exclude_hall_select", false); | |
| $("exclude_rule_zero_sales").checked = false; | |
| $("exclude_rule_early_morning").checked = false; | |
| showMessage("剔除条件已清空,点击“应用剔除”后恢复", "ok", 1800); | |
| }); | |
| $("tuning_select_all_btn").addEventListener("click", () => { | |
| if (!state.tuningRows.length) { | |
| showMessage("请先加载数据", "warn"); | |
| return; | |
| } | |
| state.tuningRows.forEach((row) => { | |
| row["选中"] = true; | |
| }); | |
| renderTuningTable(); | |
| showMessage("已全选所有影片行", "ok", 1500); | |
| }); | |
| $("tuning_select_none_btn").addEventListener("click", () => { | |
| if (!state.tuningRows.length) { | |
| showMessage("请先加载数据", "warn"); | |
| return; | |
| } | |
| state.tuningRows.forEach((row) => { | |
| row["选中"] = false; | |
| }); | |
| renderTuningTable(); | |
| showMessage("已取消所有影片勾选", "ok", 1500); | |
| }); | |
| $("tuning_clear_sessions_btn").addEventListener("click", () => { | |
| applyTuningBatchMutation((row) => { | |
| sessionConstraintColumns.forEach((col) => { | |
| row[col] = null; | |
| }); | |
| }, "场次约束已清空"); | |
| }); | |
| $("tuning_clear_ratio_btn").addEventListener("click", () => { | |
| applyTuningBatchMutation((row) => { | |
| ratioConstraintColumns.forEach((col) => { | |
| row[col] = null; | |
| }); | |
| }, "占比约束已清空"); | |
| }); | |
| $("run_btn").addEventListener("click", async () => { | |
| try { | |
| await startJob(); | |
| } catch (e) { | |
| showMessage(e.message, "err", 3200); | |
| } | |
| }); | |
| $("pause_btn").addEventListener("click", async () => { | |
| try { | |
| await controlJob("pause"); | |
| } catch (e) { | |
| showMessage(e.message, "err"); | |
| } | |
| }); | |
| $("resume_btn").addEventListener("click", async () => { | |
| try { | |
| await controlJob("resume"); | |
| } catch (e) { | |
| showMessage(e.message, "err"); | |
| } | |
| }); | |
| $("stop_btn").addEventListener("click", async () => { | |
| try { | |
| await controlJob("stop"); | |
| } catch (e) { | |
| showMessage(e.message, "err"); | |
| } | |
| }); | |
| try { | |
| const data = await requestJSON("/api/session", { method: "GET" }); | |
| $("base_date").value = data.base_date; | |
| updateTargetDateTip(); | |
| setRuntimeCfg(data.runtime_cfg || {}); | |
| if (data.bundle) { | |
| renderBundle(data.bundle); | |
| } | |
| renderJobState(data.job_state || {}); | |
| if (["running", "paused"].includes(data.job_state?.status)) { | |
| startPolling(); | |
| } | |
| if (["completed", "failed", "stopped"].includes(data.job_state?.status)) { | |
| await fetchResults(); | |
| } | |
| } catch (e) { | |
| showMessage(e.message || "初始化失败", "err", 5000); | |
| } | |
| } | |
| window.addEventListener("beforeunload", () => { | |
| stopPolling(); | |
| }); | |
| document.addEventListener("DOMContentLoaded", () => { | |
| init().catch((e) => { | |
| showMessage(e.message || "初始化失败", "err", 5000); | |
| }); | |
| }); | |