Ethscriptions commited on
Commit
028c113
·
verified ·
1 Parent(s): d8a00d2

Upload 2 files

Browse files
Files changed (2) hide show
  1. static/app.js +896 -0
  2. static/styles.css +362 -0
static/app.js ADDED
@@ -0,0 +1,896 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const runtimeFieldIds = [
2
+ "business_start", "business_end", "turnaround_base", "golden_start", "golden_end",
3
+ "efficiency_enabled", "efficiency_penalty_coef", "eff_daily_delta_cap",
4
+ "rule1_enabled", "rule1_gap",
5
+ "rule2_enabled", "rule2_threshold", "rule2_window_minutes", "rule2_penalty", "rule2_exempt_ranges",
6
+ "rule3_enabled", "rule3_gap_minutes", "rule3_penalty",
7
+ "rule4_enabled", "rule4_earliest", "rule4_latest",
8
+ "rule9_enabled", "rule9_hot_top_n", "rule9_min_ratio", "rule9_penalty",
9
+ "rule11_enabled", "rule11_after_time", "rule11_penalty",
10
+ "rule12_enabled", "rule12_penalty_each",
11
+ "rule13_enabled", "rule13_forbidden_halls", "tms_allowance",
12
+ "iterations", "random_seed"
13
+ ];
14
+
15
+ const checkIds = [
16
+ "efficiency_enabled", "rule1_enabled", "rule2_enabled", "rule3_enabled",
17
+ "rule4_enabled", "rule9_enabled", "rule11_enabled", "rule12_enabled", "rule13_enabled"
18
+ ];
19
+
20
+ const tuningColumns = [
21
+ "选中", "影片", "今日场次", "今日黄金场次", "今日全天效率", "今日黄金效率",
22
+ "最少场次", "最多场次", "固定场次", "最少黄金场次", "最多黄金场次", "最低场次占比", "最高场次占比"
23
+ ];
24
+
25
+ const readOnlyColumns = new Set(["影片", "今日场次", "今日黄金场次", "今日全天效率", "今日黄金效率"]);
26
+ const boolColumns = new Set(["选中"]);
27
+ const integerColumns = new Set(["最少场次", "最多场次", "固定场次", "最少黄金场次", "最多黄金场次"]);
28
+ const floatColumns = new Set(["最低场次占比", "最高场次占比"]);
29
+ const sessionConstraintColumns = ["最少场次", "最多场次", "固定场次", "最少黄金场次", "最多黄金场次"];
30
+ const ratioConstraintColumns = ["最低场次占比", "最高场次占比"];
31
+
32
+ const state = {
33
+ runtimeCfg: null,
34
+ bundle: null,
35
+ tuningRows: [],
36
+ jobState: null,
37
+ results: null,
38
+ activeCandidateIndex: 0,
39
+ pollTimer: null,
40
+ };
41
+
42
+ function deepClone(v) {
43
+ if (typeof structuredClone === "function") {
44
+ return structuredClone(v);
45
+ }
46
+ return JSON.parse(JSON.stringify(v));
47
+ }
48
+
49
+ function $(id) {
50
+ return document.getElementById(id);
51
+ }
52
+
53
+ function sleep(ms) {
54
+ return new Promise((resolve) => setTimeout(resolve, ms));
55
+ }
56
+
57
+ async function requestJSON(url, options = {}) {
58
+ const init = {
59
+ headers: { "Content-Type": "application/json" },
60
+ credentials: "same-origin",
61
+ ...options,
62
+ };
63
+ const resp = await fetch(url, init);
64
+ let data = {};
65
+ try {
66
+ data = await resp.json();
67
+ } catch {
68
+ data = {};
69
+ }
70
+ if (!resp.ok || data.success === false) {
71
+ const msg = data.error || data.message || `请求失败: ${resp.status}`;
72
+ throw new Error(msg);
73
+ }
74
+ return data;
75
+ }
76
+
77
+ function showMessage(text, type = "info", ttl = 2800) {
78
+ const el = $("message");
79
+ el.className = `message ${type}`;
80
+ el.textContent = text;
81
+ el.classList.remove("hidden");
82
+ if (ttl > 0) {
83
+ setTimeout(() => {
84
+ el.classList.add("hidden");
85
+ }, ttl);
86
+ }
87
+ }
88
+
89
+ function getDatePlusOne(dateText) {
90
+ const d = new Date(`${dateText}T00:00:00`);
91
+ if (Number.isNaN(d.getTime())) {
92
+ return "-";
93
+ }
94
+ d.setDate(d.getDate() + 1);
95
+ return d.toISOString().slice(0, 10);
96
+ }
97
+
98
+ function updateTargetDateTip() {
99
+ const base = $("base_date").value;
100
+ $("target_date_tip").textContent = getDatePlusOne(base);
101
+ }
102
+
103
+ function toNumberOrNull(raw, parseFloatMode = false) {
104
+ const val = String(raw ?? "").trim();
105
+ if (val === "") {
106
+ return null;
107
+ }
108
+ const num = parseFloatMode ? Number.parseFloat(val) : Number.parseInt(val, 10);
109
+ return Number.isFinite(num) ? num : null;
110
+ }
111
+
112
+ function collectMaintenanceBlocks() {
113
+ const tbody = $("maintenance_table").querySelector("tbody");
114
+ const rows = Array.from(tbody.querySelectorAll("tr"));
115
+ return rows
116
+ .map((tr) => {
117
+ const hall = tr.querySelector("input[data-key='hall']")?.value?.trim() || "";
118
+ const start = tr.querySelector("input[data-key='start']")?.value?.trim() || "";
119
+ const end = tr.querySelector("input[data-key='end']")?.value?.trim() || "";
120
+ return { hall, start, end };
121
+ })
122
+ .filter((x) => x.hall && x.start && x.end);
123
+ }
124
+
125
+ function collectRuntimeCfg() {
126
+ const cfg = {};
127
+ for (const id of runtimeFieldIds) {
128
+ const el = $(id);
129
+ if (!el) continue;
130
+ if (checkIds.includes(id)) {
131
+ cfg[id] = !!el.checked;
132
+ } else if (el.type === "number") {
133
+ cfg[id] = el.step && String(el.step).includes(".")
134
+ ? Number.parseFloat(el.value || "0")
135
+ : Number.parseInt(el.value || "0", 10);
136
+ } else {
137
+ cfg[id] = el.value;
138
+ }
139
+ }
140
+ cfg.maintenance_blocks = collectMaintenanceBlocks();
141
+ return cfg;
142
+ }
143
+
144
+ function setMaintenanceRows(rows = []) {
145
+ const tbody = $("maintenance_table").querySelector("tbody");
146
+ tbody.innerHTML = "";
147
+ const list = rows.length ? rows : [];
148
+ for (const row of list) {
149
+ appendMaintenanceRow(row);
150
+ }
151
+ }
152
+
153
+ function appendMaintenanceRow(row = { hall: "", start: "", end: "" }) {
154
+ const tbody = $("maintenance_table").querySelector("tbody");
155
+ const tr = document.createElement("tr");
156
+ tr.innerHTML = `
157
+ <td><input type="text" data-key="hall" value="${escapeHtml(String(row.hall || ""))}" placeholder="2号厅 / 2"></td>
158
+ <td><input type="text" data-key="start" value="${escapeHtml(String(row.start || ""))}" placeholder="14:00"></td>
159
+ <td><input type="text" data-key="end" value="${escapeHtml(String(row.end || ""))}" placeholder="16:00"></td>
160
+ <td><button type="button" class="btn secondary small">删除</button></td>
161
+ `;
162
+ tr.querySelector("button").addEventListener("click", () => tr.remove());
163
+ tbody.appendChild(tr);
164
+ }
165
+
166
+ function setRuntimeCfg(cfg) {
167
+ state.runtimeCfg = deepClone(cfg);
168
+ for (const id of runtimeFieldIds) {
169
+ const el = $(id);
170
+ if (!el) continue;
171
+ const value = cfg[id];
172
+ if (checkIds.includes(id)) {
173
+ el.checked = !!value;
174
+ } else {
175
+ el.value = value ?? "";
176
+ }
177
+ }
178
+ setMaintenanceRows(cfg.maintenance_blocks || []);
179
+ refreshEnableStates();
180
+ }
181
+
182
+ function refreshEnableStates() {
183
+ $("efficiency_penalty_coef").disabled = !$("efficiency_enabled").checked;
184
+ $("eff_daily_delta_cap").disabled = !$("efficiency_enabled").checked;
185
+
186
+ $("rule1_gap").disabled = !$("rule1_enabled").checked;
187
+
188
+ const rule2Enabled = $("rule2_enabled").checked;
189
+ $("rule2_threshold").disabled = !rule2Enabled;
190
+ $("rule2_window_minutes").disabled = !rule2Enabled;
191
+ $("rule2_penalty").disabled = !rule2Enabled;
192
+
193
+ const rule3Enabled = $("rule3_enabled").checked;
194
+ $("rule3_gap_minutes").disabled = !rule3Enabled;
195
+ $("rule3_penalty").disabled = !rule3Enabled;
196
+
197
+ const rule4Enabled = $("rule4_enabled").checked;
198
+ $("rule4_earliest").disabled = !rule4Enabled;
199
+ $("rule4_latest").disabled = !rule4Enabled;
200
+
201
+ const rule9Enabled = $("rule9_enabled").checked;
202
+ $("rule9_hot_top_n").disabled = !rule9Enabled;
203
+ $("rule9_min_ratio").disabled = !rule9Enabled;
204
+ $("rule9_penalty").disabled = !rule9Enabled;
205
+
206
+ const rule11Enabled = $("rule11_enabled").checked;
207
+ $("rule11_after_time").disabled = !rule11Enabled;
208
+ $("rule11_penalty").disabled = !rule11Enabled;
209
+
210
+ $("rule12_penalty_each").disabled = !$("rule12_enabled").checked;
211
+ $("rule13_forbidden_halls").disabled = !$("rule13_enabled").checked;
212
+ }
213
+
214
+ function renderSimpleTable(el, rows, columns = null) {
215
+ el.innerHTML = "";
216
+ if (!rows || rows.length === 0) {
217
+ el.innerHTML = "<thead><tr><th>提示</th></tr></thead><tbody><tr><td>无数据</td></tr></tbody>";
218
+ return;
219
+ }
220
+
221
+ const cols = columns || Object.keys(rows[0]);
222
+ const thead = document.createElement("thead");
223
+ const htr = document.createElement("tr");
224
+ for (const c of cols) {
225
+ const th = document.createElement("th");
226
+ th.textContent = c;
227
+ htr.appendChild(th);
228
+ }
229
+ thead.appendChild(htr);
230
+
231
+ const tbody = document.createElement("tbody");
232
+ for (const row of rows) {
233
+ const tr = document.createElement("tr");
234
+ for (const c of cols) {
235
+ const td = document.createElement("td");
236
+ const v = row[c];
237
+ td.textContent = v === null || v === undefined ? "" : String(v);
238
+ tr.appendChild(td);
239
+ }
240
+ tbody.appendChild(tr);
241
+ }
242
+
243
+ el.appendChild(thead);
244
+ el.appendChild(tbody);
245
+ }
246
+
247
+ function renderMapTop(tableId, mapObj, keyTitle, valTitle, limit = 20) {
248
+ const entries = Object.entries(mapObj || {}).slice(0, limit);
249
+ const rows = entries.map(([k, v]) => ({ [keyTitle]: k, [valTitle]: v }));
250
+ renderSimpleTable($(tableId), rows, [keyTitle, valTitle]);
251
+ }
252
+
253
+ function renderMetrics(container, items) {
254
+ container.innerHTML = "";
255
+ for (const item of items) {
256
+ const card = document.createElement("div");
257
+ card.className = "metric";
258
+ card.innerHTML = `<div class="k">${escapeHtml(item.k)}</div><div class="v">${escapeHtml(String(item.v))}</div>`;
259
+ container.appendChild(card);
260
+ }
261
+ }
262
+
263
+ function getSelectedValues(selectId) {
264
+ const el = $(selectId);
265
+ if (!el) return [];
266
+ return Array.from(el.selectedOptions).map((x) => x.value);
267
+ }
268
+
269
+ function setAllSelected(selectId, selected = true) {
270
+ const el = $(selectId);
271
+ if (!el) return;
272
+ for (const opt of Array.from(el.options)) {
273
+ opt.selected = !!selected;
274
+ }
275
+ }
276
+
277
+ function renderExcludeEffect(effect) {
278
+ const tip = $("exclude_effect_tip");
279
+ if (!tip) return;
280
+ const e = effect || {};
281
+ const total = Number(e.total_count || 0);
282
+ const removed = Number(e.removed_count || 0);
283
+ const remaining = Number(e.remaining_count || Math.max(0, total - removed));
284
+ const affectedMovies = Number(e.affected_movies || 0);
285
+ const reasons = Object.entries(e.reason_breakdown || {})
286
+ .map(([k, v]) => `${k}:${v}`)
287
+ .join(";");
288
+
289
+ if (!total) {
290
+ tip.textContent = "尚未应用剔除。";
291
+ return;
292
+ }
293
+ tip.textContent = `当前剔除效果:已剔除 ${removed}/${total} 场,剩余 ${remaining} 场,影响影片 ${affectedMovies} 部。${reasons ? ` 原因分布:${reasons}` : ""}`;
294
+ }
295
+
296
+ function renderBundle(bundle) {
297
+ state.bundle = bundle;
298
+ state.tuningRows = deepClone(bundle.tuning_rows || []);
299
+ $("loaded_section").classList.remove("hidden");
300
+
301
+ $("load_summary").textContent = `目标日期 ${bundle.target_str},影片 ${bundle.movies_count} 部,已售锁定场 ${bundle.locked_count}。`;
302
+
303
+ const exclude = $("exclude_select");
304
+ exclude.innerHTML = "";
305
+ const selectedSession = new Set(bundle.excluded_session_keys || []);
306
+ for (const item of bundle.exclude_options || []) {
307
+ const opt = document.createElement("option");
308
+ opt.value = String(item.key ?? "");
309
+ opt.textContent = String(item.label ?? "");
310
+ opt.selected = selectedSession.has(opt.value);
311
+ opt.dataset.suspected = item.suspected ? "1" : "0";
312
+ exclude.appendChild(opt);
313
+ }
314
+
315
+ const movieSelect = $("exclude_movie_select");
316
+ movieSelect.innerHTML = "";
317
+ const selectedMovies = new Set(bundle.excluded_movies || []);
318
+ for (const item of bundle.exclude_movie_options || []) {
319
+ const opt = document.createElement("option");
320
+ opt.value = String(item.value ?? "");
321
+ opt.textContent = String(item.label ?? item.value ?? "");
322
+ opt.selected = selectedMovies.has(opt.value);
323
+ movieSelect.appendChild(opt);
324
+ }
325
+
326
+ const hallSelect = $("exclude_hall_select");
327
+ hallSelect.innerHTML = "";
328
+ const selectedHalls = new Set(bundle.excluded_halls || []);
329
+ for (const item of bundle.exclude_hall_options || []) {
330
+ const opt = document.createElement("option");
331
+ opt.value = String(item.value ?? "");
332
+ opt.textContent = String(item.label ?? item.value ?? "");
333
+ opt.selected = selectedHalls.has(opt.value);
334
+ hallSelect.appendChild(opt);
335
+ }
336
+
337
+ const rules = bundle.exclude_rules || {};
338
+ $("exclude_rule_zero_sales").checked = !!rules.zero_sales;
339
+ $("exclude_rule_early_morning").checked = !!rules.early_morning;
340
+ renderExcludeEffect(bundle.exclude_effect || {});
341
+
342
+ renderTuningTable();
343
+ }
344
+
345
+ function renderTuningTable() {
346
+ const table = $("tuning_table");
347
+ table.innerHTML = "";
348
+
349
+ const thead = document.createElement("thead");
350
+ const hr = document.createElement("tr");
351
+ for (const c of tuningColumns) {
352
+ const th = document.createElement("th");
353
+ th.textContent = c;
354
+ hr.appendChild(th);
355
+ }
356
+ thead.appendChild(hr);
357
+
358
+ const tbody = document.createElement("tbody");
359
+ for (let i = 0; i < state.tuningRows.length; i += 1) {
360
+ const row = state.tuningRows[i] || {};
361
+ const tr = document.createElement("tr");
362
+
363
+ for (const col of tuningColumns) {
364
+ const td = document.createElement("td");
365
+
366
+ if (boolColumns.has(col)) {
367
+ const input = document.createElement("input");
368
+ input.type = "checkbox";
369
+ input.checked = !!row[col];
370
+ input.addEventListener("change", () => {
371
+ state.tuningRows[i][col] = !!input.checked;
372
+ });
373
+ td.appendChild(input);
374
+ } else if (readOnlyColumns.has(col)) {
375
+ td.textContent = row[col] === null || row[col] === undefined ? "" : String(row[col]);
376
+ } else if (integerColumns.has(col) || floatColumns.has(col)) {
377
+ const input = document.createElement("input");
378
+ input.type = "number";
379
+ input.step = floatColumns.has(col) ? "0.1" : "1";
380
+ input.value = row[col] === null || row[col] === undefined ? "" : String(row[col]);
381
+ const update = () => {
382
+ state.tuningRows[i][col] = toNumberOrNull(input.value, floatColumns.has(col));
383
+ };
384
+ input.addEventListener("input", update);
385
+ input.addEventListener("change", update);
386
+ td.appendChild(input);
387
+ } else {
388
+ const input = document.createElement("input");
389
+ input.type = "text";
390
+ input.value = row[col] === null || row[col] === undefined ? "" : String(row[col]);
391
+ input.addEventListener("input", () => {
392
+ state.tuningRows[i][col] = input.value;
393
+ });
394
+ input.addEventListener("change", () => {
395
+ state.tuningRows[i][col] = input.value;
396
+ });
397
+ td.appendChild(input);
398
+ }
399
+
400
+ tr.appendChild(td);
401
+ }
402
+
403
+ tbody.appendChild(tr);
404
+ }
405
+
406
+ table.appendChild(thead);
407
+ table.appendChild(tbody);
408
+ }
409
+
410
+ function collectExclusionPayload() {
411
+ return {
412
+ excluded_session_keys: getSelectedValues("exclude_select"),
413
+ excluded_movies: getSelectedValues("exclude_movie_select"),
414
+ excluded_halls: getSelectedValues("exclude_hall_select"),
415
+ exclude_rules: {
416
+ zero_sales: !!$("exclude_rule_zero_sales").checked,
417
+ early_morning: !!$("exclude_rule_early_morning").checked,
418
+ },
419
+ };
420
+ }
421
+
422
+ function applyTuningBatchMutation(mutator, successMsg) {
423
+ if (!state.tuningRows || state.tuningRows.length === 0) {
424
+ showMessage("当前没有可编辑约束行", "warn");
425
+ return;
426
+ }
427
+ const selectedIndexes = state.tuningRows
428
+ .map((row, idx) => ({ idx, selected: !!row["选中"] }))
429
+ .filter((x) => x.selected)
430
+ .map((x) => x.idx);
431
+ const targetIndexes = selectedIndexes.length
432
+ ? selectedIndexes
433
+ : state.tuningRows.map((_, idx) => idx);
434
+ targetIndexes.forEach((idx) => mutator(state.tuningRows[idx], idx));
435
+ renderTuningTable();
436
+ showMessage(`${successMsg}(处理 ${targetIndexes.length} 行)`, "ok", 1800);
437
+ }
438
+
439
+ function setResultsHidden(hidden) {
440
+ if (hidden) {
441
+ $("results_section").classList.add("hidden");
442
+ } else {
443
+ $("results_section").classList.remove("hidden");
444
+ }
445
+ }
446
+
447
+ function renderJobState(jobState) {
448
+ state.jobState = jobState;
449
+
450
+ if (!state.bundle && jobState.status !== "idle") {
451
+ $("loaded_section").classList.remove("hidden");
452
+ }
453
+
454
+ const metrics = [
455
+ { k: "任务状态", v: jobState.status || "idle" },
456
+ { k: "当前进度", v: `${((jobState.progress || 0) * 100).toFixed(1)}%` },
457
+ { k: "可行方案", v: jobState.feasible_count || 0 },
458
+ { k: "硬性淘汰", v: jobState.hard_reject || 0 },
459
+ { k: "已运行时长", v: `${(jobState.elapsed_seconds || 0).toFixed(1)}s` },
460
+ ];
461
+ renderMetrics($("job_metrics"), metrics);
462
+
463
+ const progress = Math.max(0, Math.min(1, Number(jobState.progress || 0)));
464
+ $("job_progress_bar").style.width = `${progress * 100}%`;
465
+
466
+ if ((jobState.iterations || 0) > 0) {
467
+ $("job_caption").textContent = `迭代进度:${jobState.iter_done || 0}/${jobState.iterations || 0};构造失败 ${jobState.build_reject || 0},硬规则淘汰 ${jobState.rule_reject || 0};${jobState.message || ""}`;
468
+ } else {
469
+ $("job_caption").textContent = jobState.message || "";
470
+ }
471
+
472
+ renderMapTop("reject_reason_table", jobState.reject_reason_top || {}, "淘汰原因", "次数", 20);
473
+ renderMapTop("reject_detail_table", jobState.reject_detail_top || {}, "详细原因", "次数", 20);
474
+
475
+ $("run_btn").disabled = ["running", "paused"].includes(jobState.status);
476
+ $("pause_btn").disabled = jobState.status !== "running";
477
+ $("resume_btn").disabled = jobState.status !== "paused";
478
+ $("stop_btn").disabled = !["running", "paused"].includes(jobState.status);
479
+ }
480
+
481
+ function renderRejectSummary(summary) {
482
+ setResultsHidden(false);
483
+ renderMetrics($("results_metrics"), [
484
+ { k: "最近一次状态", v: state.jobState?.status || "-" },
485
+ { k: "运行耗时", v: `${(summary.elapsed_seconds || 0).toFixed(1)}s` },
486
+ { k: "构造阶段失败", v: summary.build_reject || 0 },
487
+ { k: "硬性规则淘汰", v: summary.rule_reject || 0 },
488
+ ]);
489
+ renderMapTop("reason_stats_table", summary.reject_reason_stats || {}, "淘汰原因", "次数", 200);
490
+ renderMapTop("detail_stats_table", summary.reject_detail_stats || {}, "详细原因", "次数", 200);
491
+ renderSimpleTable($("phase_stats_table"), [], ["淘汰阶段", "次数"]);
492
+ $("reject_examples").textContent = "";
493
+
494
+ $("candidate_tabs").innerHTML = "";
495
+ $("candidate_content").innerHTML = "";
496
+ }
497
+
498
+ function renderResults(data) {
499
+ state.results = data;
500
+ setResultsHidden(false);
501
+
502
+ const s = data.summary || {};
503
+ renderMetrics($("results_metrics"), [
504
+ { k: "目标排片日期", v: data.target_str || "-" },
505
+ { k: "可行方案数", v: s.total_feasible || 0 },
506
+ { k: "硬性规则淘汰", v: s.hard_reject || 0 },
507
+ { k: "已售锁定场", v: s.locked_count || 0 },
508
+ { k: "生成耗时", v: `${(s.elapsed_seconds || 0).toFixed(1)}s` },
509
+ ]);
510
+
511
+ renderMapTop("phase_stats_table", s.reject_phase_stats || {}, "淘汰阶段", "次数", 20);
512
+ renderMapTop("reason_stats_table", s.reject_reason_stats || {}, "淘汰原因", "次数", 200);
513
+ renderMapTop("detail_stats_table", s.reject_detail_stats || {}, "详细原因", "次数", 200);
514
+
515
+ const exampleLines = [];
516
+ const examples = s.reject_examples || {};
517
+ for (const [reason, arr] of Object.entries(examples)) {
518
+ if (!arr || !arr.length) continue;
519
+ exampleLines.push(`${reason}:`);
520
+ for (const x of arr.slice(0, 3)) {
521
+ exampleLines.push(`- ${x}`);
522
+ }
523
+ }
524
+ $("reject_examples").textContent = exampleLines.join("\n");
525
+ $("movie_targets_json").textContent = JSON.stringify(s.movie_targets || {}, null, 2);
526
+
527
+ renderSimpleTable($("today_eff_table"), s.today_eff_rows || []);
528
+
529
+ const tabs = $("candidate_tabs");
530
+ tabs.innerHTML = "";
531
+ const candidates = data.candidates || [];
532
+ candidates.forEach((cand, idx) => {
533
+ const btn = document.createElement("button");
534
+ btn.type = "button";
535
+ btn.className = `tab-btn${idx === 0 ? " active" : ""}`;
536
+ btn.textContent = cand.title;
537
+ btn.addEventListener("click", () => {
538
+ state.activeCandidateIndex = idx;
539
+ renderCandidate();
540
+ });
541
+ tabs.appendChild(btn);
542
+ });
543
+
544
+ state.activeCandidateIndex = 0;
545
+ renderCandidate();
546
+ }
547
+
548
+ function renderCandidate() {
549
+ const payload = state.results;
550
+ if (!payload || !payload.candidates || payload.candidates.length === 0) {
551
+ $("candidate_content").innerHTML = "";
552
+ return;
553
+ }
554
+
555
+ const idx = Math.min(Math.max(0, state.activeCandidateIndex || 0), payload.candidates.length - 1);
556
+ state.activeCandidateIndex = idx;
557
+ const cand = payload.candidates[idx];
558
+
559
+ const tabButtons = Array.from($("candidate_tabs").querySelectorAll(".tab-btn"));
560
+ tabButtons.forEach((btn, i) => {
561
+ btn.classList.toggle("active", i === idx);
562
+ });
563
+
564
+ const content = $("candidate_content");
565
+ content.innerHTML = `
566
+ <section class="subpanel">
567
+ <h3>${escapeHtml(cand.title)}</h3>
568
+ <div>${cand.gantt_html || "<div class='muted'>无甘特图</div>"}</div>
569
+ <h4>评分拆解</h4>
570
+ <div class="table-wrap"><table id="score_breakdown_table" class="simple-table"></table></div>
571
+ <h4>结果汇总</h4>
572
+ <div class="table-wrap"><table id="summary_table" class="simple-table"></table></div>
573
+ <h4>排片明细</h4>
574
+ <div class="table-wrap"><table id="schedule_table" class="simple-table"></table></div>
575
+ <h4>🔍 场次合理性检查日志</h4>
576
+ <pre class="code-block">${escapeHtml(cand.log_text || "")}</pre>
577
+ </section>
578
+ `;
579
+
580
+ renderSimpleTable($("score_breakdown_table"), cand.score_breakdown || [], ["规则", "分值", "说明"]);
581
+ renderSimpleTable($("summary_table"), cand.summary_table || []);
582
+ renderSimpleTable($("schedule_table"), cand.schedule_table || []);
583
+ }
584
+
585
+ function escapeHtml(text) {
586
+ return String(text)
587
+ .replace(/&/g, "&amp;")
588
+ .replace(/</g, "&lt;")
589
+ .replace(/>/g, "&gt;")
590
+ .replace(/"/g, "&quot;")
591
+ .replace(/'/g, "&#039;");
592
+ }
593
+
594
+ async function saveConfig() {
595
+ const runtime_cfg = collectRuntimeCfg();
596
+ const data = await requestJSON("/api/config/save", {
597
+ method: "POST",
598
+ body: JSON.stringify({ runtime_cfg }),
599
+ });
600
+ setRuntimeCfg(data.runtime_cfg);
601
+ showMessage("参数已保存", "ok");
602
+ }
603
+
604
+ async function resetConfig() {
605
+ const data = await requestJSON("/api/config/reset", { method: "POST", body: "{}" });
606
+ setRuntimeCfg(data.runtime_cfg);
607
+ showMessage("已恢复默认参数", "ok");
608
+ }
609
+
610
+ async function loadData() {
611
+ showMessage("正在加载数据,请稍候...", "info", 1200);
612
+ const runtime_cfg = collectRuntimeCfg();
613
+ const base_date = $("base_date").value;
614
+ const data = await requestJSON("/api/load-data", {
615
+ method: "POST",
616
+ body: JSON.stringify({ base_date, runtime_cfg }),
617
+ });
618
+ setRuntimeCfg(data.runtime_cfg);
619
+ renderBundle(data.bundle);
620
+ renderJobState(await fetchJobStateRaw());
621
+ setResultsHidden(true);
622
+ showMessage(data.message || "数据加载完成", "ok");
623
+ }
624
+
625
+ async function applyExclusions() {
626
+ if (!state.bundle) {
627
+ showMessage("请先加载数据", "warn");
628
+ return;
629
+ }
630
+ const payload = collectExclusionPayload();
631
+ const data = await requestJSON("/api/update-exclusions", {
632
+ method: "POST",
633
+ body: JSON.stringify(payload),
634
+ });
635
+ renderBundle(data.bundle);
636
+ showMessage(data.message || "剔除已应用并重算", "ok");
637
+ }
638
+
639
+ async function startJob() {
640
+ if (!state.bundle) {
641
+ showMessage("请先加载数据", "warn");
642
+ return;
643
+ }
644
+ const data = await requestJSON("/api/job/start", {
645
+ method: "POST",
646
+ body: JSON.stringify({ tuning_rows: state.tuningRows }),
647
+ });
648
+ renderJobState(data.job_state || state.jobState || {});
649
+ startPolling();
650
+ setResultsHidden(true);
651
+ showMessage(data.message || "后台任务已启动", "ok");
652
+ }
653
+
654
+ async function controlJob(action) {
655
+ const data = await requestJSON("/api/job/control", {
656
+ method: "POST",
657
+ body: JSON.stringify({ action }),
658
+ });
659
+ renderJobState(data.job_state || {});
660
+ showMessage(`已发送${action}指令`, "ok");
661
+ }
662
+
663
+ async function fetchJobStateRaw() {
664
+ const data = await requestJSON("/api/job/state", { method: "GET" });
665
+ return data.job_state || {};
666
+ }
667
+
668
+ async function fetchJobState() {
669
+ const jobState = await fetchJobStateRaw();
670
+ renderJobState(jobState);
671
+
672
+ if (["running", "paused"].includes(jobState.status)) {
673
+ startPolling();
674
+ } else {
675
+ stopPolling();
676
+ if (["completed", "failed", "stopped"].includes(jobState.status)) {
677
+ await sleep(200);
678
+ await fetchResults();
679
+ }
680
+ }
681
+ }
682
+
683
+ async function fetchResults() {
684
+ const target = state.bundle?.target_str || getDatePlusOne($("base_date").value);
685
+ const data = await requestJSON(`/api/results?target_str=${encodeURIComponent(target)}`, { method: "GET" });
686
+
687
+ if (data.has_results) {
688
+ renderResults(data);
689
+ showMessage("结果已更新", "ok", 1500);
690
+ return;
691
+ }
692
+
693
+ if (data.reject_summary) {
694
+ renderRejectSummary(data.reject_summary);
695
+ showMessage("最近一次任务为失败/停止,已展示淘汰统计", "warn", 2400);
696
+ return;
697
+ }
698
+
699
+ setResultsHidden(true);
700
+ }
701
+
702
+ function startPolling() {
703
+ if (state.pollTimer) return;
704
+ state.pollTimer = setInterval(() => {
705
+ fetchJobState().catch((e) => {
706
+ console.error(e);
707
+ showMessage(e.message || "轮询失败", "err", 1800);
708
+ stopPolling();
709
+ });
710
+ }, 900);
711
+ }
712
+
713
+ function stopPolling() {
714
+ if (state.pollTimer) {
715
+ clearInterval(state.pollTimer);
716
+ state.pollTimer = null;
717
+ }
718
+ }
719
+
720
+ async function init() {
721
+ $("base_date").addEventListener("change", updateTargetDateTip);
722
+
723
+ checkIds.forEach((id) => {
724
+ $(id).addEventListener("change", refreshEnableStates);
725
+ });
726
+
727
+ $("add_maintenance_btn").addEventListener("click", () => appendMaintenanceRow());
728
+
729
+ $("save_cfg_btn").addEventListener("click", async () => {
730
+ try {
731
+ await saveConfig();
732
+ } catch (e) {
733
+ showMessage(e.message, "err");
734
+ }
735
+ });
736
+
737
+ $("reset_cfg_btn").addEventListener("click", async () => {
738
+ try {
739
+ await resetConfig();
740
+ } catch (e) {
741
+ showMessage(e.message, "err");
742
+ }
743
+ });
744
+
745
+ $("load_data_btn").addEventListener("click", async () => {
746
+ try {
747
+ await loadData();
748
+ } catch (e) {
749
+ showMessage(e.message, "err", 3600);
750
+ }
751
+ });
752
+
753
+ $("apply_exclude_btn").addEventListener("click", async () => {
754
+ try {
755
+ await applyExclusions();
756
+ } catch (e) {
757
+ showMessage(e.message, "err", 3600);
758
+ }
759
+ });
760
+
761
+ $("exclude_select_all_btn").addEventListener("click", () => {
762
+ setAllSelected("exclude_select", true);
763
+ showMessage("已全选场次,点击“应用剔除”后生效", "ok", 1400);
764
+ });
765
+
766
+ $("exclude_select_none_btn").addEventListener("click", () => {
767
+ setAllSelected("exclude_select", false);
768
+ showMessage("已取消全部场次选择", "ok", 1400);
769
+ });
770
+
771
+ $("exclude_select_suspected_btn").addEventListener("click", () => {
772
+ const el = $("exclude_select");
773
+ let count = 0;
774
+ for (const opt of Array.from(el.options)) {
775
+ const pick = opt.dataset.suspected === "1";
776
+ opt.selected = pick;
777
+ if (pick) count += 1;
778
+ }
779
+ showMessage(`已选中 ${count} 条疑似特殊场次`, "ok", 1600);
780
+ });
781
+
782
+ $("exclude_clear_btn").addEventListener("click", () => {
783
+ setAllSelected("exclude_select", false);
784
+ setAllSelected("exclude_movie_select", false);
785
+ setAllSelected("exclude_hall_select", false);
786
+ $("exclude_rule_zero_sales").checked = false;
787
+ $("exclude_rule_early_morning").checked = false;
788
+ showMessage("剔除条件已清空,点击“应用剔除”后恢复", "ok", 1800);
789
+ });
790
+
791
+ $("tuning_select_all_btn").addEventListener("click", () => {
792
+ if (!state.tuningRows.length) {
793
+ showMessage("请先加载数据", "warn");
794
+ return;
795
+ }
796
+ state.tuningRows.forEach((row) => {
797
+ row["选中"] = true;
798
+ });
799
+ renderTuningTable();
800
+ showMessage("已全选所有影片行", "ok", 1500);
801
+ });
802
+
803
+ $("tuning_select_none_btn").addEventListener("click", () => {
804
+ if (!state.tuningRows.length) {
805
+ showMessage("请先加载数据", "warn");
806
+ return;
807
+ }
808
+ state.tuningRows.forEach((row) => {
809
+ row["选中"] = false;
810
+ });
811
+ renderTuningTable();
812
+ showMessage("已取消所有影片勾选", "ok", 1500);
813
+ });
814
+
815
+ $("tuning_clear_sessions_btn").addEventListener("click", () => {
816
+ applyTuningBatchMutation((row) => {
817
+ sessionConstraintColumns.forEach((col) => {
818
+ row[col] = null;
819
+ });
820
+ }, "场次约束已清空");
821
+ });
822
+
823
+ $("tuning_clear_ratio_btn").addEventListener("click", () => {
824
+ applyTuningBatchMutation((row) => {
825
+ ratioConstraintColumns.forEach((col) => {
826
+ row[col] = null;
827
+ });
828
+ }, "占比约束已清空");
829
+ });
830
+
831
+ $("run_btn").addEventListener("click", async () => {
832
+ try {
833
+ await startJob();
834
+ } catch (e) {
835
+ showMessage(e.message, "err", 3200);
836
+ }
837
+ });
838
+
839
+ $("pause_btn").addEventListener("click", async () => {
840
+ try {
841
+ await controlJob("pause");
842
+ } catch (e) {
843
+ showMessage(e.message, "err");
844
+ }
845
+ });
846
+
847
+ $("resume_btn").addEventListener("click", async () => {
848
+ try {
849
+ await controlJob("resume");
850
+ } catch (e) {
851
+ showMessage(e.message, "err");
852
+ }
853
+ });
854
+
855
+ $("stop_btn").addEventListener("click", async () => {
856
+ try {
857
+ await controlJob("stop");
858
+ } catch (e) {
859
+ showMessage(e.message, "err");
860
+ }
861
+ });
862
+
863
+ try {
864
+ const data = await requestJSON("/api/session", { method: "GET" });
865
+ $("base_date").value = data.base_date;
866
+ updateTargetDateTip();
867
+
868
+ setRuntimeCfg(data.runtime_cfg || {});
869
+
870
+ if (data.bundle) {
871
+ renderBundle(data.bundle);
872
+ }
873
+
874
+ renderJobState(data.job_state || {});
875
+
876
+ if (["running", "paused"].includes(data.job_state?.status)) {
877
+ startPolling();
878
+ }
879
+
880
+ if (["completed", "failed", "stopped"].includes(data.job_state?.status)) {
881
+ await fetchResults();
882
+ }
883
+ } catch (e) {
884
+ showMessage(e.message || "初始化失败", "err", 5000);
885
+ }
886
+ }
887
+
888
+ window.addEventListener("beforeunload", () => {
889
+ stopPolling();
890
+ });
891
+
892
+ document.addEventListener("DOMContentLoaded", () => {
893
+ init().catch((e) => {
894
+ showMessage(e.message || "初始化失败", "err", 5000);
895
+ });
896
+ });
static/styles.css ADDED
@@ -0,0 +1,362 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --bg: #f4f7fb;
3
+ --panel: #ffffff;
4
+ --line: #d9e0ea;
5
+ --text: #10233c;
6
+ --muted: #5f738d;
7
+ --primary: #1f6feb;
8
+ --primary-strong: #0f4cb5;
9
+ --danger: #c0392b;
10
+ --ok: #1f8f4a;
11
+ }
12
+
13
+ * {
14
+ box-sizing: border-box;
15
+ }
16
+
17
+ body {
18
+ margin: 0;
19
+ font-family: "PingFang SC", "Noto Sans CJK SC", "Microsoft YaHei", sans-serif;
20
+ color: var(--text);
21
+ background: radial-gradient(circle at top right, #e6eefc, #f4f7fb 45%, #eef3fb);
22
+ }
23
+
24
+ .container {
25
+ max-width: 1560px;
26
+ margin: 0 auto;
27
+ padding: 24px 20px 48px;
28
+ }
29
+
30
+ .page-head h1 {
31
+ margin: 0;
32
+ font-size: 26px;
33
+ }
34
+
35
+ .page-head p {
36
+ margin: 8px 0 0;
37
+ color: var(--muted);
38
+ }
39
+
40
+ .panel {
41
+ background: var(--panel);
42
+ border: 1px solid var(--line);
43
+ border-radius: 12px;
44
+ margin-top: 16px;
45
+ padding: 16px;
46
+ box-shadow: 0 8px 24px rgba(16, 35, 60, 0.06);
47
+ }
48
+
49
+ .panel h2,
50
+ .panel h3,
51
+ .panel h4 {
52
+ margin-top: 0;
53
+ }
54
+
55
+ .grid-3 {
56
+ display: grid;
57
+ grid-template-columns: repeat(3, minmax(280px, 1fr));
58
+ gap: 12px;
59
+ }
60
+
61
+ .row {
62
+ display: grid;
63
+ gap: 12px;
64
+ }
65
+
66
+ .row-2 {
67
+ grid-template-columns: repeat(2, minmax(280px, 1fr));
68
+ }
69
+
70
+ .row-3 {
71
+ grid-template-columns: repeat(3, minmax(240px, 1fr));
72
+ }
73
+
74
+ .subpanel {
75
+ border: 1px solid var(--line);
76
+ border-radius: 10px;
77
+ padding: 12px;
78
+ background: linear-gradient(180deg, #fff, #fbfdff);
79
+ }
80
+
81
+ .field {
82
+ display: flex;
83
+ flex-direction: column;
84
+ gap: 6px;
85
+ margin-bottom: 10px;
86
+ font-size: 13px;
87
+ }
88
+
89
+ .field input,
90
+ .field textarea,
91
+ .field select {
92
+ width: 100%;
93
+ border: 1px solid #c9d4e1;
94
+ border-radius: 6px;
95
+ padding: 8px;
96
+ font-size: 14px;
97
+ background: #fff;
98
+ }
99
+
100
+ .field.readonly {
101
+ justify-content: center;
102
+ }
103
+
104
+ .field.checkbox {
105
+ flex-direction: row;
106
+ align-items: center;
107
+ gap: 8px;
108
+ }
109
+
110
+ .field.checkbox input {
111
+ width: auto;
112
+ }
113
+
114
+ .small {
115
+ font-size: 12px;
116
+ }
117
+
118
+ .tip,
119
+ .muted {
120
+ color: var(--muted);
121
+ }
122
+
123
+ .actions {
124
+ display: flex;
125
+ align-items: center;
126
+ gap: 8px;
127
+ flex-wrap: wrap;
128
+ }
129
+
130
+ .inline-end {
131
+ justify-content: flex-end;
132
+ }
133
+
134
+ .inline-start {
135
+ justify-content: flex-start;
136
+ align-items: flex-end;
137
+ }
138
+
139
+ .select-actions {
140
+ margin: 8px 0 10px;
141
+ }
142
+
143
+ .align-end {
144
+ align-items: end;
145
+ }
146
+
147
+ .subpanel.compact {
148
+ padding: 10px 12px;
149
+ }
150
+
151
+ .subpanel.compact h4 {
152
+ margin: 0 0 8px;
153
+ }
154
+
155
+ #exclude_effect_tip {
156
+ font-size: 13px;
157
+ line-height: 1.45;
158
+ }
159
+
160
+ .btn {
161
+ border: 1px solid transparent;
162
+ border-radius: 8px;
163
+ padding: 9px 13px;
164
+ cursor: pointer;
165
+ font-size: 14px;
166
+ transition: all .15s ease;
167
+ }
168
+
169
+ .btn.primary {
170
+ background: var(--primary);
171
+ color: #fff;
172
+ }
173
+
174
+ .btn.primary:hover {
175
+ background: var(--primary-strong);
176
+ }
177
+
178
+ .btn.secondary {
179
+ background: #eef3fc;
180
+ border-color: #cad7ec;
181
+ color: #173b72;
182
+ }
183
+
184
+ .btn.secondary:hover {
185
+ background: #e4ecfb;
186
+ }
187
+
188
+ .btn.danger {
189
+ background: #fbe9e7;
190
+ border-color: #f1c6c1;
191
+ color: var(--danger);
192
+ }
193
+
194
+ .btn.small {
195
+ padding: 6px 10px;
196
+ font-size: 13px;
197
+ }
198
+
199
+ .btn:disabled {
200
+ opacity: .45;
201
+ cursor: not-allowed;
202
+ }
203
+
204
+ .table-wrap {
205
+ overflow: auto;
206
+ border: 1px solid var(--line);
207
+ border-radius: 8px;
208
+ background: #fff;
209
+ }
210
+
211
+ .tuning-wrap {
212
+ max-height: 460px;
213
+ }
214
+
215
+ .simple-table {
216
+ width: 100%;
217
+ border-collapse: collapse;
218
+ min-width: 760px;
219
+ }
220
+
221
+ .simple-table th,
222
+ .simple-table td {
223
+ border-bottom: 1px solid #e8edf4;
224
+ border-right: 1px solid #edf1f7;
225
+ padding: 7px 8px;
226
+ font-size: 13px;
227
+ vertical-align: top;
228
+ }
229
+
230
+ .simple-table th {
231
+ background: #f7faff;
232
+ position: sticky;
233
+ top: 0;
234
+ z-index: 1;
235
+ text-align: left;
236
+ }
237
+
238
+ .simple-table td input[type="number"],
239
+ .simple-table td input[type="text"] {
240
+ width: 100%;
241
+ border: 1px solid #cfd9e6;
242
+ border-radius: 5px;
243
+ padding: 6px;
244
+ }
245
+
246
+ .simple-table td input[type="checkbox"] {
247
+ transform: scale(1.05);
248
+ }
249
+
250
+ #tuning_table th:first-child,
251
+ #tuning_table td:first-child {
252
+ text-align: center;
253
+ min-width: 74px;
254
+ }
255
+
256
+ .metrics {
257
+ display: grid;
258
+ grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
259
+ gap: 8px;
260
+ margin: 10px 0;
261
+ }
262
+
263
+ .metric {
264
+ border: 1px solid var(--line);
265
+ border-radius: 8px;
266
+ padding: 10px;
267
+ background: #fbfdff;
268
+ }
269
+
270
+ .metric .k {
271
+ font-size: 12px;
272
+ color: var(--muted);
273
+ }
274
+
275
+ .metric .v {
276
+ margin-top: 3px;
277
+ font-size: 20px;
278
+ font-weight: 700;
279
+ }
280
+
281
+ .progress-wrap {
282
+ width: 100%;
283
+ height: 12px;
284
+ border: 1px solid #d3dceb;
285
+ border-radius: 999px;
286
+ overflow: hidden;
287
+ background: #edf3ff;
288
+ margin: 8px 0 6px;
289
+ }
290
+
291
+ #job_progress_bar {
292
+ height: 100%;
293
+ width: 0;
294
+ background: linear-gradient(90deg, #1f6feb, #5b9bff);
295
+ }
296
+
297
+ .tab-bar {
298
+ display: flex;
299
+ flex-wrap: wrap;
300
+ gap: 8px;
301
+ margin: 12px 0;
302
+ }
303
+
304
+ .tab-btn {
305
+ border: 1px solid #c9d6ea;
306
+ border-radius: 999px;
307
+ padding: 6px 10px;
308
+ background: #f3f7ff;
309
+ color: #164488;
310
+ cursor: pointer;
311
+ }
312
+
313
+ .tab-btn.active {
314
+ background: #1f6feb;
315
+ color: #fff;
316
+ border-color: #1f6feb;
317
+ }
318
+
319
+ .code-block {
320
+ white-space: pre-wrap;
321
+ word-break: break-word;
322
+ margin: 8px 0;
323
+ padding: 10px;
324
+ border: 1px solid var(--line);
325
+ border-radius: 8px;
326
+ background: #f6f8fb;
327
+ font-family: "SFMono-Regular", "Consolas", monospace;
328
+ font-size: 12px;
329
+ }
330
+
331
+ .message {
332
+ position: fixed;
333
+ right: 14px;
334
+ bottom: 14px;
335
+ z-index: 1000;
336
+ max-width: 440px;
337
+ padding: 12px 14px;
338
+ border-radius: 8px;
339
+ color: #fff;
340
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
341
+ }
342
+
343
+ .message.info { background: #3f6db5; }
344
+ .message.ok { background: var(--ok); }
345
+ .message.warn { background: #cc8b21; }
346
+ .message.err { background: var(--danger); }
347
+
348
+ .hidden {
349
+ display: none !important;
350
+ }
351
+
352
+ @media (max-width: 1100px) {
353
+ .grid-3,
354
+ .row-3,
355
+ .row-2 {
356
+ grid-template-columns: 1fr;
357
+ }
358
+
359
+ .actions.inline-end {
360
+ justify-content: flex-start;
361
+ }
362
+ }