添加程序源码 app/ 与数据 data/,更新 README、.gitignore、.gitattributes

This commit is contained in:
UMR
2026-08-31 00:06:15 +08:00
parent cbf1001162
commit 4fd28965e9
9 changed files with 3571 additions and 162 deletions
+534
View File
@@ -0,0 +1,534 @@
<!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>