Files
Anisong-Organizer/app/web_ui.html
T

535 lines
25 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Anisong Organizer</title>
<style>
:root {
--bg: #f6f7f9; --card: #ffffff; --line: #e3e6ea; --text: #26303b; --muted: #7a8694;
--accent: #3b82f6; --green: #16a34a; --red: #dc2626; --yellow: #d97706;
}
@media (prefers-color-scheme: dark) {
:root { --bg: #171a1f; --card: #20252c; --line: #333a44; --text: #e6eaf0; --muted: #8b95a1;
--accent: #60a5fa; --green: #4ade80; --red: #f87171; --yellow: #fbbf24; }
}
* { box-sizing: border-box; }
body { margin: 0; font-family: "Segoe UI", "Microsoft YaHei", sans-serif; background: var(--bg); color: var(--text); }
.wrap { max-width: 920px; margin: 0 auto; padding: 24px 16px 80px; }
h1 { font-size: 20px; margin: 0 0 4px; }
.sub { color: var(--muted); font-size: 13px; margin-bottom: 20px; }
.toolbar { display: flex; flex-direction: column; gap: 12px; margin-bottom: 20px; }
.trow { display: flex; gap: 12px; align-items: center; }
.spacer { flex: 1; }
input[type=text] { flex: 1; min-width: 240px; padding: 11px 14px; border: 1px solid var(--line); border-radius: 10px; background: var(--card); color: var(--text); font-size: 14px; }
button { padding: 10px 20px; border: none; border-radius: 10px; font-size: 14px; cursor: pointer; background: var(--accent); color: #fff; }
button:hover { filter: brightness(1.08); }
button.primary { padding: 11px 28px; font-weight: 600; }
button.ghost { background: var(--card); color: var(--text); border: 1px solid var(--line); }
button.danger { background: var(--red); }
button:disabled { opacity: .45; cursor: not-allowed; }
select.series-sel { background: color-mix(in srgb, var(--accent) 10%, var(--card)); border-color: var(--line);
color: var(--accent); font-size: 12.5px; padding: 5px 22px 5px 10px; max-width: 200px; }
/* 无效占位项不显示在下拉列表里(只用于按钮封面显示「标注系列」占位) */
option:disabled { display: none; }
label.chk { display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--muted); cursor: pointer; }
.progress { display: none; margin: 12px 0; }
.bar { height: 8px; background: var(--line); border-radius: 4px; overflow: hidden; }
.bar > div { height: 100%; width: 0; background: var(--accent); transition: width .3s; }
.ptext { font-size: 13px; color: var(--muted); margin-top: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.found { background: var(--card); border: 1px solid var(--line); border-radius: 10px;
padding: 10px 16px; margin: 12px 0; display: none; max-height: 300px; overflow-y: auto; }
.found h3 { font-size: 13px; color: var(--muted); margin: 0 0 8px; font-weight: normal; }
.found .frow { display: flex; gap: 8px; align-items: baseline; font-size: 13px; padding: 3px 0; }
.found .fnum { color: var(--muted); font-variant-numeric: tabular-nums; min-width: 40px; }
.found .fname { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.found .fres { white-space: nowrap; color: var(--accent); }
.found .fanno { white-space: nowrap; color: var(--muted); font-size: 12px; }
.sources { display: flex; gap: 8px; flex-wrap: wrap; margin: 12px 0; }
.src-chip { display: flex; align-items: center; gap: 6px; background: var(--card); border: 1px solid var(--line);
border-radius: 8px; padding: 6px 12px; font-size: 13px; }
.dot { width: 8px; height: 8px; border-radius: 50%; }
.dot.ok { background: var(--green); } .dot.bad { background: var(--red); } .dot.warn { background: var(--yellow); }
.src-chip .hint { color: var(--muted); font-size: 12px; }
.batch { display: none; align-items: center; gap: 10px; margin: 10px 0; padding: 10px 14px;
background: var(--card); border: 1px solid var(--line); border-radius: 10px; font-size: 13px; }
.batch select { padding: 6px 8px; border: 1px solid var(--line); border-radius: 8px; background: var(--card); color: var(--text); }
.btn-mini { padding: 6px 14px; font-size: 13px; border-radius: 6px; }
.cat { background: var(--card); border: 1px solid var(--line); border-radius: 10px; margin: 14px 0; overflow: hidden; }
.cat.drop-target { outline: 2px dashed var(--accent); outline-offset: -2px; }
.cat h2 { font-size: 15px; margin: 0; padding: 12px 16px; border-bottom: 1px solid var(--line); display: flex; justify-content: space-between; align-items: center; }
.cat h2 .cnt { color: var(--muted); font-weight: normal; font-size: 13px; }
.row { display: flex; align-items: center; gap: 8px; padding: 8px 16px; border-bottom: 1px solid var(--line); font-size: 14px; cursor: default; }
.row:last-child { border-bottom: none; }
.row.dragging { opacity: .4; }
.row.drop-above { box-shadow: 0 -2px 0 0 var(--accent); }
.row.sel-on { background: color-mix(in srgb, var(--accent) 8%, transparent); }
.sel { width: 15px; height: 15px; cursor: pointer; accent-color: var(--accent); flex: none; }
.num { min-width: 34px; text-align: center; background: var(--bg); border-radius: 6px; padding: 3px 6px; font-variant-numeric: tabular-nums; font-size: 13px; color: var(--muted); }
.info { flex: 1; min-width: 0; }
.info .t { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.info .m { color: var(--muted); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.src-badge { font-size: 11px; color: var(--muted); background: var(--bg); border-radius: 6px; padding: 2px 8px; white-space: nowrap; }
select { padding: 6px 8px; border: 1px solid var(--line); border-radius: 8px; background: var(--card); color: var(--text); font-size: 13px; }
.mini { padding: 4px 9px; font-size: 13px; border-radius: 6px; }
.assign-edit { display: flex; gap: 6px; align-items: center; flex: 1; min-width: 0; }
.assign-edit select { padding: 5px 8px; border: 1px solid var(--accent); border-radius: 6px;
background: var(--card); color: var(--text); font-size: 13px; max-width: 220px; }
.assign-edit input { flex: 1; min-width: 140px; padding: 5px 8px; border: 1px solid var(--accent);
border-radius: 6px; background: var(--card); color: var(--text); font-size: 13px; }
.foot { display: flex; gap: 8px; margin: 20px 0; align-items: center; }
.report { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; font-size: 14px; display: none; }
.report .ok { color: var(--green); } .report .bad { color: var(--red); } .report .warn { color: var(--yellow); }
.warnings { color: var(--yellow); font-size: 13px; margin-top: 10px; }
.empty { color: var(--muted); text-align: center; padding: 40px 0; font-size: 14px; }
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.45); display: none;
align-items: center; justify-content: center; z-index: 100; padding: 20px; }
.modal { background: var(--card); border: 1px solid var(--line); border-radius: 12px;
max-width: 860px; width: 100%; max-height: 82vh; display: flex; flex-direction: column; }
.modal h3 { margin: 0; padding: 14px 18px; font-size: 15px; border-bottom: 1px solid var(--line);
display: flex; justify-content: space-between; align-items: center; }
.modal .mbody { padding: 14px 18px; overflow-y: auto; font-family: Consolas, "Microsoft YaHei", monospace;
font-size: 12.5px; white-space: pre-wrap; word-break: break-all; line-height: 1.6; }
.modal .mfoot { padding: 12px 18px; border-top: 1px solid var(--line); display: flex;
gap: 8px; justify-content: flex-end; }
</style>
</head>
<body>
<div class="wrap">
<h1>Anisong Organizer</h1>
<div class="sub">扫描 → 预览分类计划 → 勾选/拖动调整 → 应用。可多次应用;点击歌曲旁的系列标签可直接更换或标注所属动漫。</div>
<div class="toolbar">
<div class="trow">
<input type="text" id="folder" placeholder="来源文件夹(如网易云下载目录)">
<input type="text" id="output" placeholder="输出目录(留空=来源文件夹)">
</div>
<div class="trow actions">
<label class="chk"><input type="checkbox" id="recursive">递归整理子目录(下载目录勾选)</label>
<span class="spacer"></span>
<button id="guideBtn" class="ghost">📖 导览</button>
<button id="stopBtn" class="danger" disabled>停止</button>
<button id="scanBtn" class="primary">扫描</button>
</div>
</div>
<div class="progress" id="progress">
<div class="bar"><div id="pbar"></div></div>
<div class="ptext" id="ptext"></div>
</div>
<div class="found" id="found">
<h3>已识别的歌曲</h3>
<div id="foundList"></div>
</div>
<div class="batch" id="batch">
<span>已选 <b id="batchN">0</b> 首 → 移到</span>
<select id="batchCat"></select>
<button class="btn-mini" onclick="batchMove()">移动</button>
<button class="btn-mini ghost" onclick="clearSel()">取消选择</button>
</div>
<div id="plans"></div>
<div class="foot" id="foot" style="display:none">
<button id="applyBtn">应用全部移动</button>
<span id="planNote" style="color:var(--muted);font-size:13px"></span>
</div>
<div class="report" id="report"></div>
<div class="modal-overlay" id="guideModal">
<div class="modal">
<h3>整理导览(移动对照 · 撤销日志)<button class="mini ghost" data-closeguide=""></button></h3>
<div class="mbody" id="guideBody">加载中…</div>
<div class="mfoot">
<button class="btn-mini ghost" id="guideCopy">复制</button>
<button class="btn-mini ghost" id="guideDownload">下载 导览.txt</button>
<button class="btn-mini" data-closeguide="">关闭</button>
</div>
</div>
</div>
</div>
<script>
const $ = (id) => document.getElementById(id);
let planData = null, catMeta = [];
let selSet = new Set(); // 批量选择(src 集合)
let applyShownSig = 'null'; // 已展示过的应用统计签名
let newSeriesEdit = null; // 正在新建系列的编辑状态 {src, value}(外置保存,重渲染不丢失)
let pollTimer = null;
async function api(path, body) {
const r = await fetch(path, body === undefined ? {} : {
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body),
});
return r.json();
}
function esc(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
function srcLabel(s) {
return {'本地': '本地', 'animethemes': '网络', 'anison': '网络', 'wikipedia': '网络',
'moegirl': '网络', 'musicbrainz': 'MB', '手动': '手动', '': ''}[s] || s;
}
function renderSources(sources) {
const el = document.createElement('div');
el.className = 'sources';
for (const s of sources) {
let cls = 'ok', hint = '';
if (s.disabled) { cls = 'bad'; hint = `已停用(连续失败)`; }
else if (s.fail) { cls = 'warn'; hint = `失败 ${s.fail} 次`; }
else if (s.ok || s.cache) { hint = s.cache !== undefined ? `联网 ${s.ok}·缓存 ${s.cache}` : `正常`; }
else { cls = 'warn'; hint = '未使用'; }
if (s.needs_proxy) hint += '·需代理';
el.innerHTML += `<span class="src-chip" title="${esc(s.err || '')}">
<span class="dot ${cls}"></span>${esc(s.name)}<span class="hint">${esc(hint)}</span></span>`;
}
return el;
}
// 系列控件(蓝色下拉按钮): 封面显示该行所属动漫系列名;
// 无系列时封面显示「+ 标注系列…」占位(禁用项,不显示在下拉列表里);
// 下拉列表只有两个有效项: 第一行「+ 新建系列…」(选中进输入态) + 现存系列(选中即生效)。
function seriesControl(it, catKey) {
if (newSeriesEdit && newSeriesEdit.src === it.src) {
return `<span class="assign-edit">
<input type="text" data-atext placeholder="输入新系列名(如 犬夜叉)" value="${esc(newSeriesEdit.value)}">
<button class="mini" data-confirm="${esc(it.src)}">✓</button>
<button class="mini ghost" data-cancel="">✕</button></span>`;
}
// 注意: 计划 JSON 的分类条目没有 category 字段(分类由外层键表示),
// 必须用渲染循环传入的 catKey 判断——曾误用 it.category,导致封面永远显示占位符
const cur = catKey === 'anisong' ? (it.group || '') : '';
const names = (planData.series || []).slice();
if (cur && !names.includes(cur)) names.unshift(cur); // 本次新建的系列也要可选中
return `<select class="series-sel" data-series="${esc(it.src)}" title="所属动漫系列(第一行可新建)">
<option value="__new__"> 新建系列…</option>
${cur ? '' : '<option value="__none__" selected disabled> 标注系列…</option>'}
${names.map(n => `<option value="${esc(n)}"${n === cur ? ' selected' : ''}>${esc(n)}</option>`).join('')}
</select>`;
}
function renderPlan() {
const box = $('plans');
box.innerHTML = '';
if (!planData) return;
catMeta = planData.categories;
if (planData.sources) box.appendChild(renderSources(planData.sources));
let any = false;
for (const cat of catMeta) {
const items = planData.cats[cat.key] || [];
if (!items.length) continue;
any = true;
const el = document.createElement('div');
el.className = 'cat';
el.dataset.cat = cat.key;
el.innerHTML = `<h2>${esc(cat.name)}<span class="cnt">${items.length} 首 · 可拖动到此移动</span></h2>` +
items.map((it) => `
<div class="row${selSet.has(it.src) ? ' sel-on' : ''}" draggable="true"
data-src="${esc(it.src)}" data-cat="${cat.key}">
<input type="checkbox" class="sel"${selSet.has(it.src) ? ' checked' : ''}>
<span class="num">${String(it.number).padStart(2, '0')}</span>
<div class="info">
<div class="t">${esc(it.title)}</div>
<div class="m">${esc(it.artist || '')} → ${esc(it.final_name)}</div>
</div>
${seriesControl(it, cat.key)}
<span class="src-badge">${srcLabel(it.source)}</span>
<select data-selcat="${esc(it.src)}">
${catMeta.map(c => `<option value="${c.key}"${c.key === cat.key ? ' selected' : ''}>${esc(c.name)}</option>`).join('')}
</select>
<button class="mini ghost" title="上移" data-up="${esc(it.src)}">↑</button>
<button class="mini ghost" title="下移" data-down="${esc(it.src)}">↓</button>
</div>`).join('');
box.appendChild(el);
}
if (!any) {
box.innerHTML = '<div class="empty">暂无计划。扫描一个包含音乐文件的文件夹开始。</div>';
}
$('foot').style.display = any ? 'flex' : 'none';
const total = Object.values(planData.stats || {}).reduce((s, c) => s + (c.moves || 0), 0);
$('planNote').textContent = `共 ${total} 个移动。勾选多行后拖动可批量移动/排序,✎ 手动标注动漫。`;
if (planData.warnings && planData.warnings.length) {
const w = document.createElement('div');
w.className = 'warnings';
w.innerHTML = planData.warnings.map(esc).join('<br>');
box.appendChild(w);
}
updateBatch();
}
function updateBatch() {
const b = $('batch');
const n = selSet.size;
b.style.display = n ? 'flex' : 'none';
$('batchN').textContent = n;
if (n && !$('batchCat').options.length) {
$('batchCat').innerHTML = catMeta.map(c => `<option value="${c.key}">${esc(c.name)}</option>`).join('');
}
}
function clearSel() { selSet.clear(); if (planData) renderPlan(); }
// ---- 交互(事件委托) ----
$('plans').addEventListener('click', async (e) => {
const row = e.target.closest('.row');
if (!row) return;
const src = row.dataset.src;
if (e.target.classList.contains('sel')) {
if (e.target.checked) selSet.add(src); else selSet.delete(src);
renderPlan();
return;
}
// 注意: 系列下拉的点击不做任何拦截——点击自然打开原生下拉供浏览;
// 输入态只由 change 事件触发(选中第一行「新建系列」),见下方 change 监听。
if (e.target.dataset.confirm) {
// 确认新建系列(状态存于 newSeriesEdit,不依赖可能被重建的 DOM)
const val = newSeriesEdit ? newSeriesEdit.value.trim() : '';
const tgt = newSeriesEdit ? newSeriesEdit.src : '';
if (!val) { alert('请输入新系列名'); return; }
newSeriesEdit = null;
const r = await api('/api/assign', {src: tgt, anime: val});
if (r.ok && r.plan) { planData = r.plan; renderPlan(); }
else { alert(r.error || '标注失败'); renderPlan(); }
return;
}
if (e.target.dataset.cancel !== undefined) { newSeriesEdit = null; renderPlan(); return; }
if (e.target.dataset.up || e.target.dataset.down) {
const dir = e.target.dataset.up ? -1 : 1;
const r = await api('/api/reorder', {src, dir});
if (r.ok && r.plan) { planData = r.plan; renderPlan(); }
return;
}
// 注意: select[data-selcat] 只在 change 事件里处理(点击打开下拉时 click 不应触发移动,
// 否则"没改动也会把歌曲挪到末尾")。
});
$('plans').addEventListener('change', async (e) => {
// 分类下拉: 换分类
const sel = e.target.closest('select[data-selcat]');
if (sel) {
const row = sel.closest('.row');
const r = await api('/api/override', {src: row.dataset.src, category: sel.value});
if (r.ok && r.plan) { planData = r.plan; renderPlan(); }
return;
}
// 系列下拉: 选现存系列直接标注;第一行「新建系列」进入输入态
const ssel = e.target.closest('select[data-series]');
if (!ssel) return;
const src = ssel.dataset.series;
if (ssel.value === '__new__') {
newSeriesEdit = {src, value: ''};
renderPlan();
const inp = document.querySelector('input[data-atext]');
if (inp) inp.focus();
} else {
const r = await api('/api/assign', {src, anime: ssel.value});
if (r.ok && r.plan) { planData = r.plan; renderPlan(); }
else alert(r.error || '标注失败');
}
});
// 新建系列输入: 输入内容同步到外部状态,任何重渲染都不丢失
$('plans').addEventListener('input', (e) => {
if (e.target.dataset.atext !== undefined && newSeriesEdit) {
newSeriesEdit.value = e.target.value;
}
});
// ---- 拖拽(勾选多行时整组拖动) ----
let dragSrcs = [];
$('plans').addEventListener('dragstart', (e) => {
// 表单控件(下拉/输入/按钮)内不触发行拖拽,避免打断操作
if (e.target.closest('select, input, button, .assign-edit')) return;
const row = e.target.closest('.row');
if (!row) return;
const src = row.dataset.src;
if (!selSet.has(src)) { selSet.clear(); selSet.add(src); renderPlan(); }
dragSrcs = [...selSet];
e.dataTransfer.setData('text/plain', JSON.stringify(dragSrcs));
e.dataTransfer.effectAllowed = 'move';
document.querySelectorAll('.row').forEach(r => {
if (dragSrcs.includes(r.dataset.src)) r.classList.add('dragging');
});
});
$('plans').addEventListener('dragend', () => {
document.querySelectorAll('.row').forEach(r => r.classList.remove('dragging', 'drop-above'));
document.querySelectorAll('.cat').forEach(c => c.classList.remove('drop-target'));
});
$('plans').addEventListener('dragover', (e) => {
if (!dragSrcs.length) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
document.querySelectorAll('.row').forEach(r => r.classList.remove('drop-above'));
document.querySelectorAll('.cat').forEach(c => c.classList.remove('drop-target'));
const row = e.target.closest('.row');
if (row && !dragSrcs.includes(row.dataset.src)) row.classList.add('drop-above');
else if (!row) {
const cat = e.target.closest('.cat');
if (cat) cat.classList.add('drop-target');
}
});
$('plans').addEventListener('drop', async (e) => {
if (!dragSrcs.length) return;
e.preventDefault();
const row = e.target.closest('.row');
const cat = e.target.closest('.cat');
let payload;
if (row && !dragSrcs.includes(row.dataset.src)) {
payload = {srcs: dragSrcs, category: row.dataset.cat, before: row.dataset.src};
} else if (cat) {
payload = {srcs: dragSrcs, category: cat.dataset.cat, before: null};
}
dragSrcs = [];
selSet.clear(); // 拖动完成即取消勾选
document.querySelectorAll('.row').forEach(r => r.classList.remove('dragging', 'drop-above'));
document.querySelectorAll('.cat').forEach(c => c.classList.remove('drop-target'));
if (!payload) return;
const r = await api('/api/move', payload);
if (r.ok && r.plan) { planData = r.plan; renderPlan(); }
else alert(r.error || '移动失败');
});
async function batchMove() {
const r = await api('/api/move', {srcs: [...selSet], category: $('batchCat').value, before: null});
if (r.ok && r.plan) { planData = r.plan; selSet.clear(); renderPlan(); }
else alert(r.error || '移动失败');
}
// ---- 顶部操作 ----
async function scan() {
const folder = $('folder').value.trim();
const r = await api('/api/scan', {
folder,
output: $('output').value.trim(),
recursive: $('recursive').checked,
});
if (!r.ok) { alert(r.error || '扫描失败'); return; }
planData = null; selSet.clear(); applyShownSig = 'null';
$('report').style.display = 'none';
$('plans').innerHTML = '<div class="empty">扫描中…</div>';
$('found').style.display = 'none';
$('foundList').innerHTML = '';
}
async function apply() {
if (!confirm('确认执行全部移动?此操作可通过「导览」中的移动对照回退。')) return;
await api('/api/apply', {});
}
async function stop() { await api('/api/stop', {}); }
// ---- 导览弹窗 ----
let guideText = '';
async function openGuide() {
const r = await api('/api/guide');
if (!r.ok) { alert(r.error || '暂无导览(请先扫描)'); return; }
guideText = r.text || '';
$('guideBody').textContent = guideText || '无需处理。';
$('guideModal').style.display = 'flex';
}
function closeGuide() { $('guideModal').style.display = 'none'; }
$('guideBtn').onclick = openGuide;
$('guideModal').addEventListener('click', (e) => {
if (e.target.dataset.closeguide !== undefined) closeGuide();
if (e.target === $('guideModal')) closeGuide();
});
$('guideCopy').onclick = async () => {
try {
await navigator.clipboard.writeText(guideText);
alert('已复制到剪贴板');
} catch (e) {
const ta = document.createElement('textarea');
ta.value = guideText;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
ta.remove();
alert('已复制到剪贴板');
}
};
$('guideDownload').onclick = () => {
const blob = new Blob(['' + guideText], {type: 'text/plain;charset=utf-8'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = '导览.txt';
a.click();
URL.revokeObjectURL(a.href);
};
// 已识别歌曲的注解: Anisong 标注所属动漫系列(结果即系列名,附首播年份);其他类标注乐队组建年份
function foundAnno(it) {
const y = (it.date || '').slice(0, 4);
if (it.category === 'anisong') return y ? `${y}年` : '';
return y ? `${y}年组建` : '组建时间未知';
}
function renderFound(list) {
const box = $('found');
if (!list || !list.length) { box.style.display = 'none'; return; }
box.style.display = 'block';
$('foundList').innerHTML = list.map(it => `
<div class="frow">
<span class="fnum">${String(it.i).padStart(2, '0')}/${it.n}</span>
<span class="fname">${esc(it.label)}</span>
<span class="fres">→ ${esc(it.result)}</span>
<span class="fanno">${esc(foundAnno(it))}</span>
</div>`).join('');
box.scrollTop = box.scrollHeight; // 扫描中自动滚到最新
}
async function poll() {
try {
const st = await api('/api/status');
if (st.found) renderFound(st.found);
const busy = ['scanning', 'applying'].includes(st.phase);
$('scanBtn').disabled = busy;
$('applyBtn').disabled = busy || st.phase !== 'planned';
$('stopBtn').disabled = !busy;
$('progress').style.display = busy ? 'block' : 'none';
if (busy && st.progress) {
const p = st.progress;
if (p.n > 0) $('pbar').style.width = Math.round(p.i / p.n * 100) + '%';
else $('pbar').style.width = '3%';
$('ptext').textContent = p.i && p.n ? `[${p.i}/${p.n}] ${p.label}${p.result}` : (p.label || '处理中…');
}
if (st.phase === 'planned' && !planData) {
const r = await api('/api/plan');
if (r.ok) { planData = r.plan; renderPlan(); }
}
// 应用完成的统计报告(应用后计划自动回到可编辑态,可继续调整再次应用)
const sig = JSON.stringify(st.last || null);
if (sig !== applyShownSig) {
applyShownSig = sig;
if (st.last) {
const l = st.last;
const rep = $('report');
rep.style.display = 'block';
rep.innerHTML = `<span class="ok">✓ 应用完成</span>: 移动 ${l.moved},跳过 ${l.skipped},失败 ${l.failed}` +
(l.stopped ? ' <span class="warn">(已停止)</span>' : ' <span class="warn">· 可继续调整并再次应用</span>');
planData = null; // 强制重新加载计划(文件位置已更新)
selSet.clear();
}
} else if (st.phase === 'error') {
const rep = $('report');
rep.style.display = 'block';
rep.innerHTML = `<span class="bad">✗ 出错</span>: ${esc(st.error)}`;
}
} catch (e) { /* 服务未就绪 */ }
}
$('scanBtn').onclick = scan;
$('applyBtn').onclick = apply;
$('stopBtn').onclick = stop;
pollTimer = setInterval(poll, 800);
poll();
// 关闭网页即退出服务进程(用户要求: 页面关闭后不留后台进程)
window.addEventListener('pagehide', () => {
try { navigator.sendBeacon('/api/exit'); } catch (e) { /* 忽略 */ }
});
</script>
</body>
</html>