Files
SpineViewer/SpineThumbnailGenerateServer/progress.html
T
2026-09-20 23:33:36 +08:00

204 lines
8.3 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
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">
<title>Spine缩略图</title>
<style>
:root { --bg: #1e1e1e; --panel: #252526; --text: #e6e6e6; --dim: #9d9d9d; --accent: #4fc1ff; --border: #3c3c3c; }
html, body { margin: 0; background: var(--bg); color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif; font-size: 13px; }
body { display: flex; flex-direction: column; gap: 8px; padding: 14px; box-sizing: border-box; }
h1 { font-size: 13px; margin: 0; font-weight: 600; text-align: center; }
.bar { height: 10px; background: var(--panel); border: 1px solid var(--border); border-radius: 999px; overflow: hidden; }
/* 不加过渡:窗口被遮挡时 CSS 过渡会停在中途,进度条就不跟着走了。 */
.bar > i { display: block; height: 100%; width: 0; background: var(--accent); }
#count { color: var(--text); }
#name { color: var(--dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
button { background: var(--panel); color: var(--text); border: 1px solid var(--border);
border-radius: 6px; padding: 5px 24px; font: inherit; cursor: pointer; }
button:hover { border-color: var(--accent); color: var(--accent); }
#action { align-self: center; }
#confirm { display: none; flex-direction: column; gap: 8px; }
#confirmText { text-align: center; color: var(--text); }
#confirmButtons { display: flex; gap: 10px; justify-content: center; }
/* 继续 = 默认项(绿色、自动获得焦点);确认取消 = 危险操作(红色)。 */
#resume { background: #2ea043; border-color: #2ea043; color: #fff; }
#resume:hover { background: #3fb950; border-color: #3fb950; color: #fff; }
#cancel { background: #d64545; border-color: #d64545; color: #fff; }
#cancel:hover { background: #e5534b; border-color: #e5534b; color: #fff; }
</style>
</head>
<body>
<h1>正在生成动态缩略图……</h1>
<div class="bar"><i id="bar"></i></div>
<div id="count">等待开始…</div>
<div id="name"></div>
<div id="confirm">
<div id="confirmText">是否确认取消?</div>
<div id="confirmButtons">
<button id="resume" type="button">继续</button>
<button id="cancel" type="button">确认取消</button>
</div>
</div>
<button id="action" type="button">停止</button>
<script>
// 这个页面是宿主用 Eagle 的 create-plugin-window 打开的插件窗口:
// 无标题栏、置顶、挂在 Eagle 主窗口上;进度从临时目录的状态文件轮询。
// 按钮往回写控制文件:生成中 = 停止(暂停并就地询问),生成完 = 确认(关窗)。
const fs = require('fs');
const os = require('os');
const path = require('path');
const STATE_FILE = path.join(os.tmpdir(), 'spine-thumbnail-progress.json');
const ALIVE_FILE = path.join(os.tmpdir(), 'spine-thumbnail-progress-alive');
const CONTROL_FILE = path.join(os.tmpdir(), 'spine-thumbnail-progress-control.json');
const WIDTH = 380;
const bar = document.getElementById('bar');
const countEl = document.getElementById('count');
const nameEl = document.getElementById('name');
const actionEl = document.getElementById('action');
const confirmEl = document.getElementById('confirm');
const confirmTextEl = document.getElementById('confirmText');
const resumeEl = document.getElementById('resume');
let finished = false;
let paused = false;
let doneCount = 0;
let lastHeight = 0;
let placedAt = null; // 上次由我们设置的位置,用来判断用户是不是手动拖过
/** Eagle 的 userData 目录;eagle.app.userDataPath 注入得比较晚,所以留一个兜底。 */
function userDataPath() {
if (eagle.app && eagle.app.userDataPath) return eagle.app.userDataPath;
if (process.platform === 'darwin') return path.join(os.homedir(), 'Library', 'Application Support', 'Eagle');
return path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'Eagle');
}
/** 算出"Eagle 主窗口正中心"对应的弹窗位置;读不到就返回 null。 */
function centerTarget(width, height) {
let state = null;
try {
state = JSON.parse(fs.readFileSync(path.join(userDataPath(), 'window-state.json'), 'utf8'));
} catch (error) { /* 读不到就用不了 */ }
if (!state || !Number.isFinite(state.x)) return null;
// 主窗口最大化/全屏时记录的是还原后的位置,拿它当基准会偏,改用所在显示器居中。
const area = (state.isMaximized || state.isFullScreen) && state.displayBounds ? state.displayBounds : state;
return [
Math.round(area.x + (area.width - width) / 2),
Math.round(area.y + (area.height - height) / 2),
];
}
/** 跟着 Eagle 主窗口居中;一旦用户手动拖过窗口,就只改尺寸、不再动位置。 */
async function reCenter(width, height) {
try {
const current = await eagle.window.getPosition();
const movedByUser = placedAt && (Math.abs(current[0] - placedAt[0]) > 4 || Math.abs(current[1] - placedAt[1]) > 4);
if (movedByUser) return;
} catch (error) { /* 拿不到位置就照常居中 */ }
const target = centerTarget(width, height);
if (!target) {
await eagle.window.center();
return;
}
placedAt = target;
await eagle.window.setPosition(target[0], target[1]);
}
/** 窗口高度跟着内容走。 */
function fitHeight() {
const height = Math.ceil(document.body.scrollHeight);
if (Math.abs(height - lastHeight) < 2) return;
lastHeight = height;
eagle.window.setSize(WIDTH, height)
.then(() => reCenter(WIDTH, height))
.catch(() => { /* 调整失败不影响显示 */ });
}
/** 三种状态:生成中显示「停止」,暂停询问时显示「继续 / 取消」,跑完显示「确认」。 */
function syncButtons() {
actionEl.textContent = finished ? '确认' : '停止';
actionEl.style.display = paused ? 'none' : '';
confirmEl.style.display = paused ? 'flex' : 'none';
fitHeight();
}
function writeControl(action) {
try {
fs.writeFileSync(CONTROL_FILE, JSON.stringify({ action, at: Date.now() }), 'utf8');
} catch (error) { /* 写不进去就等于没按 */ }
}
function render(state) {
const total = Math.max(1, state.total || 0);
const done = state.done || 0;
finished = state.finished === true;
if (finished) paused = false;
doneCount = done;
confirmTextEl.textContent = '已生成 ' + doneCount + ' 张缩略图,是否确认取消?';
if (finished) {
bar.style.width = '100%';
countEl.textContent = state.text || '已完成';
nameEl.textContent = '';
} else {
bar.style.width = Math.min(100, done / total * 100) + '%';
countEl.textContent = '已完成 ' + done + ' / ' + (state.total || 0) +
'(' + Math.round(done / total * 100) + '%)';
nameEl.textContent = state.name || '';
}
syncButtons();
}
actionEl.addEventListener('click', () => {
if (finished) {
window.close();
return;
}
// 先让宿主暂停(它会在当前素材做完后停在原地),再就地问要不要继续。
paused = true;
writeControl('pause');
syncButtons();
resumeEl.focus(); // 默认选中「继续」
});
resumeEl.addEventListener('click', () => {
paused = false;
writeControl('resume');
syncButtons();
});
document.getElementById('cancel').addEventListener('click', () => {
// 同步写控制文件再关窗:窗口没了宿主也能读到 cancel,把剩余队列丢掉。
writeControl('cancel');
window.close();
});
// 窗口尺寸/置顶/居中:Eagle 的 window-state.json 会覆盖 manifest 里的位置和宽高,这里再对齐一次。
(async () => {
try {
await eagle.window.setResizable(false);
} catch (error) { /* ignore */ }
syncButtons();
})();
// 心跳:让宿主知道这个窗口还开着;窗口关掉后心跳就停了,下次需要时会重新开。
const beat = () => {
try { fs.writeFileSync(ALIVE_FILE, String(Date.now()), 'utf8'); } catch (error) { /* ignore */ }
};
beat();
setInterval(beat, 1000);
let lastStamp = 0;
setInterval(() => {
let state;
try {
state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
} catch (error) {
return; // 状态文件还没写出来
}
if (state.updatedAt === lastStamp) return;
lastStamp = state.updatedAt;
render(state);
}, 400);
</script>
</body>
</html>