Files
SpineViewer/release/UNIVERSAL_SpineThumbnailGenerateServer/progress.html
T
2026-10-30 16:31:27 +08:00

235 lines
9.3 KiB
HTML

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title data-i18n="progress.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 data-i18n="progress.generating">正在生成动态缩略图……</h1>
<div class="bar"><i id="bar"></i></div>
<div id="count" data-i18n="progress.waiting">等待开始…</div>
<div id="name"></div>
<div id="confirm">
<div id="confirmText" data-i18n="progress.confirmPrompt">是否确认取消?</div>
<div id="confirmButtons">
<button id="resume" type="button" data-i18n="progress.resume">继续</button>
<button id="cancel" type="button" data-i18n="progress.cancel">确认取消</button>
</div>
</div>
<button id="action" type="button" data-i18n="progress.stop">停止</button>
<script>
(function () {
try {
const pathname = decodeURIComponent(new URL('.', location.href).pathname).replace(/\\/g, '/');
window.SpinePluginRoot = pathname.replace(/^\/+([A-Za-z]:)/, '$1');
} catch (error) {
window.SpinePluginRoot = '';
}
})();
</script>
<script src="js/i18n.js"></script>
<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 t = SpineThumbI18n.t;
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; // 上次由我们设置的位置,用来判断用户是不是手动拖过
let allowClose = false;
/** 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() {
// setSize 设置的是包含系统标题栏的外部高度,内容高度要补回窗口边框占用。
const height = Math.ceil(document.body.scrollHeight + Math.max(0, window.outerHeight - window.innerHeight));
if (Math.abs(height - lastHeight) < 2) return;
lastHeight = height;
eagle.window.setSize(WIDTH, height)
.then(() => reCenter(WIDTH, height))
.catch(() => { /* 调整失败不影响显示 */ });
}
/** 三种状态:生成中显示「停止」,暂停询问时显示「继续 / 取消」,跑完显示「确认」。 */
function syncButtons() {
actionEl.textContent = finished ? t('progress.confirm') : t('progress.stop');
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 requestStop() {
if (paused) return;
paused = true;
writeControl('pause');
syncButtons();
resumeEl.focus();
}
// 系统关闭按钮与页面里的「停止」走同一条暂停确认流程。
window.addEventListener('beforeunload', (event) => {
if (allowClose || finished) return;
event.preventDefault();
event.returnValue = false;
requestStop();
});
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 = t('progress.generatedConfirm', { done: doneCount });
if (finished) {
bar.style.width = '100%';
countEl.textContent = state.text || t('progress.complete');
nameEl.textContent = '';
} else {
bar.style.width = Math.min(100, done / total * 100) + '%';
countEl.textContent = t('progress.completed', {
done,
total: state.total || 0,
percent: Math.round(done / total * 100),
});
nameEl.textContent = state.name || '';
}
syncButtons();
}
actionEl.addEventListener('click', () => {
if (finished) {
allowClose = true;
window.close();
return;
}
requestStop();
});
resumeEl.addEventListener('click', () => {
paused = false;
writeControl('resume');
syncButtons();
});
document.getElementById('cancel').addEventListener('click', () => {
// 同步写控制文件再关窗:窗口没了宿主也能读到 cancel,把剩余队列丢掉。
writeControl('cancel');
allowClose = true;
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>