多国语言

This commit is contained in:
Yeomai committed 2026-09-11 22:34:08 +08:00
1 parent 63cd0b3246
commit f0694431e3
30 files changed
+2343 -189

No files matched your search

+137 -62
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Spine 预览</title>
<title data-i18n="viewer.title">Spine 预览</title>
<style>
:root {
--bg: #1e1e1e;
@@ -197,30 +197,30 @@
<body>
<div id="app">
<div id="toolbar">
<label>皮肤
<label><span data-i18n="viewer.skin">皮肤</span>
<select id="skinSelect" disabled></select>
</label>
<button id="playBtn" title="播放/暂停 (空格)">⏸ 暂停</button>
<label><input type="checkbox" id="loopCheck" checked> 循环</label>
<label>速度
<button id="playBtn" data-i18n-title="viewer.playPauseTitle" title="播放/暂停 (空格)">⏸ 暂停</button>
<label><input type="checkbox" id="loopCheck" checked> <span data-i18n="viewer.loop">循环</span></label>
<label><span data-i18n="viewer.speed">速度</span>
<input type="range" id="speedRange" min="0" max="3" step="0.05" value="1">
<span id="speedVal">1.0x</span>
</label>
<label title="用于以 Premultiplied Alpha(PMA)方式导出的贴图">
<input type="checkbox" id="pmaCheck" disabled> 预乘
<label data-i18n-title="viewer.pmaTitle" title="用于以 Premultiplied Alpha(PMA)方式导出的贴图">
<input type="checkbox" id="pmaCheck" disabled> <span data-i18n="viewer.pma">预乘</span>
</label>
<label title="50×50 方格背景,随画布移动和缩放,并参与混合模式">
<input type="checkbox" id="backgroundCheck"> 背景
<label data-i18n-title="viewer.backgroundTitle" title="50×50 方格背景,随画布移动和缩放,并参与混合模式">
<input type="checkbox" id="backgroundCheck"> <span data-i18n="viewer.background">背景</span>
</label>
<div class="spacer"></div>
<button id="thumbBtn" title="使用当前帧生成 400×400 缩略图">🖼 生成缩略图</button>
<label id="verLabel" style="color: var(--text-dim);" title="Spine 版本"></label>
<button id="reloadBtn" title="重新加载目录">↻</button>
<button id="thumbBtn" data-i18n="viewer.thumbnail" data-i18n-title="viewer.thumbnailTitle" title="使用当前帧生成 400×400 缩略图">🖼 生成缩略图</button>
<label id="verLabel" style="color: var(--text-dim);" data-i18n-title="viewer.versionTitle" title="Spine 版本"></label>
<button id="reloadBtn" title="重新加载目录" data-i18n-title="viewer.reloadTitle">↻</button>
</div>
<div id="stage">
<aside id="animationPanel" aria-label="动画列表">
<div id="animationPanelTitle">动画</div>
<div id="animationList" role="listbox" aria-label="动画"></div>
<aside id="animationPanel" data-i18n-aria-label="viewer.animationList" aria-label="动画列表">
<div id="animationPanelTitle" data-i18n="viewer.animations">动画</div>
<div id="animationList" role="listbox" data-i18n-aria-label="viewer.animations" aria-label="动画"></div>
</aside>
<div id="viewport">
<canvas id="canvas"></canvas>
@@ -228,7 +228,7 @@
<div id="loading"><div class="spinner"></div></div>
<div id="overlay" class="hidden">
<div class="box">
<div class="title" id="errorTitle">无法预览</div>
<div class="title" id="errorTitle" data-i18n="viewer.errorTitle">无法预览</div>
<div class="msg" id="errorMsg"></div>
</div>
</div>
@@ -236,15 +236,15 @@
</div>
<div id="timelineBar">
<div id="timelineLabels">
<span class="tl-label" id="durationLabel">时长 --</span>
<span id="keyframeLegend" title="蓝色短线表示动画中至少一个骨骼、插槽或约束属性在该时刻有关键帧">蓝色短线:关键帧</span>
<span class="tl-label" id="durationLabel" data-i18n="viewer.durationPlaceholder">时长 --</span>
<span id="keyframeLegend" data-i18n="viewer.keyframesLegend" data-i18n-title="viewer.keyframesLegendTitle" title="蓝色短线表示动画中至少一个骨骼、插槽或约束属性在该时刻有关键帧">蓝色短线:关键帧</span>
</div>
<div class="tl-wrap">
<div id="timeRuler"></div>
<input type="range" id="timeline" min="0" max="1" step="0.001" value="0" disabled>
<div id="keyframes"></div>
</div>
<span id="timeLabel">0.00 / 0.00 秒 · 0 / 0 帧</span>
<span id="timeLabel" data-i18n="viewer.timePlaceholder">0.00 / 0.00 秒 · 0 / 0 帧</span>
</div>
</div>
@@ -263,7 +263,7 @@
try {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error('Spine badge 加载失败'));
image.onerror = () => reject(new Error(t('viewer.badgeFailed')));
image.src = spineBadge.dataUrl();
} catch (e) {
reject(e);
@@ -300,6 +300,73 @@
const lang = urlParams.get('lang');
if (theme === 'light') document.documentElement.setAttribute('data-theme', 'light');
// ---------- 多国语系 ----------
// 文案与 manifest.json 的 languages / fallbackLanguage 共用同一份 _locales/<语言>.json。
// 格式扩展插件的查看器页不会注入 Eagle Plugin API,因此这里直接读取语言文件。
const I18N_LANGUAGES = ['en', 'zh_CN', 'zh_TW', 'ja_JP', 'ko_KR', 'ru_RU', 'es_ES', 'de_DE'];
const I18N_FALLBACK = 'zh_CN';
function normalizeLocale(raw) {
if (!raw) return null;
const tag = String(raw).trim().toLowerCase().replace(/-/g, '_');
if (tag === 'zh_tw' || tag === 'zh_hk' || tag === 'zh_hant' || tag === 'zh_mo') return 'zh_TW';
for (const code of I18N_LANGUAGES) {
if (code.toLowerCase() === tag) return code;
}
const base = tag.split('_')[0];
if (base === 'zh') return 'zh_CN';
for (const code of I18N_LANGUAGES) {
if (code.toLowerCase().split('_')[0] === base) return code;
}
return null;
}
function readLocaleFile(code) {
try {
const file = path.join(__dirname, '..', '_locales', code + '.json');
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
return (data && typeof data === 'object') ? data : {};
} catch (e) {
return {};
}
}
// 按 "viewer.skin" 这样的点号路径取字符串,与官方 i18next 的用法一致
function lookupMessage(table, key) {
let node = table;
for (const part of String(key).split('.')) {
if (!node || typeof node !== 'object') return undefined;
node = node[part];
}
return typeof node === 'string' ? node : undefined;
}
const fallbackMessages = readLocaleFile(I18N_FALLBACK);
const locale = normalizeLocale(lang) || I18N_FALLBACK;
const messages = locale === I18N_FALLBACK ? fallbackMessages : readLocaleFile(locale);
document.documentElement.lang = locale.replace('_', '-');
log('界面语言: ' + locale + ' (lang=' + (lang || '未提供') + ')');
// t('viewer.skin', { name: '...' }):缺失的键回退到 fallbackLanguage
function t(key, vars) {
let text = lookupMessage(messages, key);
if (text === undefined) text = lookupMessage(fallbackMessages, key);
if (text === undefined) return key;
if (vars) {
text = text.replace(/\{(\w+)\}/g, (whole, name) => (Object.prototype.hasOwnProperty.call(vars, name) ? String(vars[name]) : whole));
}
return text;
}
// 应用静态文案:data-i18n / data-i18n-title / data-i18n-aria-label
function applyStaticI18n(root) {
const scope = root || document;
for (const el of scope.querySelectorAll('[data-i18n]')) el.textContent = t(el.dataset.i18n);
for (const el of scope.querySelectorAll('[data-i18n-title]')) el.title = t(el.dataset.i18nTitle);
for (const el of scope.querySelectorAll('[data-i18n-aria-label]')) el.setAttribute('aria-label', t(el.dataset.i18nAriaLabel));
}
applyStaticI18n();
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const overlay = document.getElementById('overlay');
@@ -312,7 +379,7 @@
const durationLabel = document.getElementById('durationLabel');
const timeRuler = document.getElementById('timeRuler');
const keyframesEl = document.getElementById('keyframes');
keyframesEl.title = '蓝色短线表示动画关键帧;悬停短线可查看时间和帧号';
keyframesEl.title = t('viewer.keyframesHint');
const appEl = document.getElementById('app');
const toolbarEl = document.getElementById('toolbar');
const timelineBarEl = document.getElementById('timelineBar');
@@ -381,7 +448,7 @@
try { require('../js/spine-legacy-binary.js').install(spine, dir); resolve(); }
catch (e) { reject(e); }
};
s.onerror = () => reject(new Error('无法加载 Spine 运行时:lib/' + dir + '/spine-canvas.js'));
s.onerror = () => reject(new Error(t('viewer.runtimeFailed', { file: 'lib/' + dir + '/spine-canvas.js' })));
document.head.appendChild(s);
});
}
@@ -529,8 +596,8 @@
const response = await fetch('http://localhost:41595/api/item/info?id=' + encodeURIComponent(itemId));
const info = await response.json();
if (!response.ok || info.status !== 'success' || info.data.name + '.' + info.data.ext !== path.basename(filePath))
throw new Error('Eagle 尚未同步复合后缀名称,请重新打开素材库');
if (info.data.ext === 'txt') throw new Error('此条目仍按 TXT 登记,请在 Eagle 重新生成缩略图并重新打开素材库');
throw new Error(t('viewer.compoundName'));
if (info.data.ext === 'txt') throw new Error(t('viewer.txtRegistered'));
}
const response = await fetch('http://localhost:41595/api/item/setCustomThumbnail', {
method: 'POST',
@@ -541,16 +608,16 @@
try { result = await response.json(); } catch (e) { /* use HTTP status below */ }
if (!response.ok || (result && result.status && result.status !== 'success')) {
const reason = result && (result.message || result.error);
throw new Error(reason || ('Eagle API 返回 HTTP ' + response.status));
throw new Error(reason || t('viewer.apiHttp', { status: response.status }));
}
return result;
}
async function generateThumbnail() {
if (!skeleton || !renderer) { setStatus('尚未加载骨骼,无法生成缩略图'); return; }
if (!skeleton || !renderer) { setStatus(t('viewer.noSkeletonForThumb')); return; }
const thumbBtn = document.getElementById('thumbBtn');
thumbBtn.disabled = true;
setStatus('正在生成并更新 Eagle 缩略图…', Infinity);
setStatus(t('viewer.generating'), Infinity);
try {
// 暂停动画,冻结当前帧:先确保当前动画时间已被应用到骨骼
playing = false;
@@ -599,25 +666,25 @@
const outPath = path.join(path.dirname(filePath), stem + '_thumbnail.png');
const tempPath = path.join(path.dirname(filePath), '.' + stem + '-thumbnail-' + Date.now() + (isWebp ? '.webp' : '.png'));
try {
if (!itemId) throw new Error('无法从素材路径识别 Eagle 项目 ID');
if (!itemId) throw new Error(t('viewer.noItemId'));
fs.writeFileSync(tempPath, buf);
await setEagleCustomThumbnail(itemId, tempPath);
setStatus('Eagle 缩略图已更新 · ' + (isWebp ? 'WebP' : 'PNG') + ' ' + (buf.length / 1024).toFixed(1) + 'KB', 3500);
setStatus(t('viewer.thumbUpdated', { format: isWebp ? 'WebP' : 'PNG', size: (buf.length / 1024).toFixed(1) }), 3500);
log('通过 Eagle API 更新缩略图: item=' + itemId + ' (' + buf.length + 'B)');
} catch (e) {
// 兼容旧版 Eagle:保留原先的磁盘写入方案,但明确提示可能需要手动刷新列表。
try {
fs.writeFileSync(outPath, buf);
setStatus('缩略图已写入;自动刷新失败:' + ((e && e.message) || String(e)), 6000);
setStatus(t('viewer.thumbWrittenNoRefresh', { error: (e && e.message) || String(e) }), 6000);
log('Eagle API 更新失败,已回退写入: ' + ((e && e.message) || String(e)));
} catch (e2) {
setStatus('生成缩略图失败: ' + ((e2 && e2.message) || String(e2)), 6000);
setStatus(t('viewer.thumbnailFailed', { error: (e2 && e2.message) || String(e2) }), 6000);
}
} finally {
try { if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch (e) { /* ignore */ }
}
} catch (e) {
setStatus('生成缩略图失败: ' + ((e && e.message) || String(e)), 6000);
setStatus(t('viewer.thumbnailFailed', { error: (e && e.message) || String(e) }), 6000);
log('生成当前帧缩略图失败: ' + ((e && e.stack) || e));
} finally {
thumbBtn.disabled = false;
@@ -642,7 +709,7 @@
if (entry) {
const currentTime = getTrackAnimationTime(entry);
if (now >= statusHoldUntil) {
setStatus(animName(entry.animation ? entry.animation.name : '') + ' · ' + (isStaticAnimation ? '静帧' : formatSeconds(currentTime)));
setStatus(animName(entry.animation ? entry.animation.name : '') + ' · ' + (isStaticAnimation ? t('viewer.staticLabel') : formatSeconds(currentTime)));
}
// 同步时间轴(若用户未在拖动)。兼容 3.x 的 time 与 4.x 的 trackTime/getAnimationTime。
if (!seeking) {
@@ -667,8 +734,8 @@
function formatSeconds(seconds) {
const value = Math.max(0, Number(seconds) || 0);
if (value < 10) return value.toFixed(2) + ' 秒';
if (value < 60) return value.toFixed(1) + ' 秒';
if (value < 10) return t('viewer.seconds', { value: value.toFixed(2) });
if (value < 60) return t('viewer.seconds', { value: value.toFixed(1) });
const minutes = Math.floor(value / 60);
return minutes + ':' + (value % 60).toFixed(1).padStart(4, '0');
}
@@ -726,11 +793,16 @@
function updateTimeReadout(time) {
if (isStaticAnimation) {
timeLabel.textContent = '静帧 · 1 / 1 帧';
timeLabel.textContent = t('viewer.readoutStatic');
return;
}
const current = Math.max(0, Math.min(Number(time) || 0, duration));
timeLabel.textContent = formatSeconds(current) + ' / ' + formatSeconds(duration) + ' · ' + frameOf(current) + ' / ' + frameOf(duration) + ' 帧';
timeLabel.textContent = t('viewer.readout', {
time: formatSeconds(current),
total: formatSeconds(duration),
frame: frameOf(current),
totalFrames: frameOf(duration)
});
}
function niceTickInterval(totalSeconds) {
@@ -746,7 +818,7 @@
const label = document.createElement('span');
label.className = 'tick-label first';
label.style.left = '0%';
label.textContent = '0 秒(静帧)';
label.textContent = t('viewer.rulerStatic');
timeRuler.appendChild(label);
return;
}
@@ -859,7 +931,7 @@
timeline.step = String(isStaticAnimation ? 1 : Math.min(1 / FPS, Math.max(duration / 1000, 0.001)));
timeline.value = '0';
timeline.disabled = !anim || isStaticAnimation;
durationLabel.textContent = isStaticAnimation ? '静帧 · 1 帧' : ('时长 ' + formatSeconds(duration) + ' · ' + frameOf(duration) + ' 帧');
durationLabel.textContent = isStaticAnimation ? t('viewer.timelineStatic') : t('viewer.timelineDuration', { time: formatSeconds(duration), frames: frameOf(duration) });
updateTimeReadout(0);
renderTimeRuler();
renderKeyframeMarkers(anim);
@@ -873,7 +945,7 @@
if (isStaticAnimation) {
const span = document.createElement('span');
span.style.left = '0%';
span.title = '静帧姿势 · 第 1 帧';
span.title = t('viewer.staticPoseTitle');
span.setAttribute('aria-label', span.title);
keyframesEl.appendChild(span);
return;
@@ -886,7 +958,7 @@
const t = sorted[i];
const span = document.createElement('span');
span.style.left = (duration > 0 ? (t / duration) * 100 : 0) + '%';
span.title = '关键帧 · ' + formatSeconds(t) + ' · 第 ' + frameOf(t) + ' 帧';
span.title = t('viewer.keyframeTitle', { time: formatSeconds(t), frame: frameOf(t) });
span.setAttribute('aria-label', span.title);
keyframesEl.appendChild(span);
}
@@ -939,12 +1011,12 @@
const scale = Math.min(cw / img.width, ch / img.height);
const w = img.width * scale, h = img.height * scale;
ctx.drawImage(img, (cw - w) / 2, (ch - h) / 2, w, h);
setStatus('图片预览(未检测到配套 Spine 资源)');
setStatus(t('viewer.imagePreview'));
};
img.onerror = () => showError('图片加载失败', file);
img.onerror = () => showError(t('viewer.imageLoadFailed'), file);
img.src = 'data:' + mime + ';base64,' + data.toString('base64');
} catch (e) {
showError('图片加载失败', (e && e.message) || String(e));
showError(t('viewer.imageLoadFailed'), (e && e.message) || String(e));
}
}
@@ -953,14 +1025,14 @@
if (!utils.extOf(path.basename(filePath))) {
// Registering txt/bytes is required by Eagle's extension dispatch.
// Ordinary text remains readable and never starts Spine discovery.
let message = '此文件不是 .atlas.txt 或 .skel.bytes 格式的 Spine 资源。';
let message = t('viewer.notSpineText');
if (/\.txt$/i.test(filePath)) {
const fd = fs.openSync(filePath, 'r');
try {
const buffer = Buffer.alloc(256 * 1024);
const count = fs.readSync(fd, buffer, 0, buffer.length, 0);
message = buffer.subarray(0, count).toString('utf8');
if (fs.fstatSync(fd).size > count) message += '\n\n(仅显示前 256 KB)';
if (fs.fstatSync(fd).size > count) message += t('viewer.truncated256');
} finally { fs.closeSync(fd); }
}
showTextPreview(path.basename(filePath), message);
@@ -978,7 +1050,7 @@
let text = buffer.subarray(0, count).toString('utf8');
if (stat.size <= maxPreviewBytes) {
try { text = JSON.stringify(JSON.parse(text), null, 2); } catch (e) { /* 保留 JSONC/原始文本 */ }
} else text += '\n\n(文件较大,仅显示前 1 MB)';
} else text += t('viewer.truncated1m');
showTextPreview(path.basename(filePath), text);
return;
}
@@ -986,9 +1058,9 @@
if (!assets.skeletonPath || !assets.atlasPath) {
if (!assets.skeletonPath) {
showError('未找到骨骼文件', '未能定位到与所选文件配套的 Spine 骨骼文件(.skel / .skel.bytes / .json)。\n\n请将同一套 Spine 导出文件导入 Eagle。');
showError(t('viewer.noSkeletonFile'), t('viewer.noSkeletonFileMsg'));
} else {
showError('未找到 Atlas', '未能定位到配套的 .atlas / .atlas.txt 文件。');
showError(t('viewer.noAtlas'), t('viewer.noAtlasMsg'));
}
return;
}
@@ -1005,7 +1077,10 @@
activeVersion = version;
const runtimeDir = utils.chooseRuntimeDir(version, assets.isBinary);
if (!runtimeDir) {
showError('版本不支持', '无法识别的 Spine 版本:' + (version || '未知') + '\n支持的版本:3.4 / 3.5 / 3.6 / 3.7 / 3.8 / 4.0 / 4.1 / 4.2 / 4.3');
showError(t('viewer.unsupportedVersion'), t('viewer.unsupportedVersionMsg', {
version: version || t('viewer.unknownVersion'),
list: '3.4 / 3.5 / 3.6 / 3.7 / 3.8 / 4.0 / 4.1 / 4.2 / 4.3'
}));
return;
}
activeRuntimeDir = runtimeDir;
@@ -1014,7 +1089,7 @@
try {
await loadRuntime(runtimeDir);
} catch (e) {
showError('运行时加载失败', e.message);
showError(t('viewer.runtimeLoadFailed'), e.message);
return;
}
@@ -1036,7 +1111,7 @@
document.getElementById('pmaCheck').checked = premultipliedAlpha;
atlas = await buildBrowserAtlas(spine, atlasText, assets);
} catch (e) {
showError('Atlas 加载失败', (e && e.message) || String(e));
showError(t('viewer.atlasLoadFailed'), (e && e.message) || String(e));
return;
}
@@ -1053,7 +1128,7 @@
}
if (!skeletonData2 || !skeletonData2.bones || skeletonData2.bones.length === 0) {
showError('骨骼数据为空', '未能读取到有效的骨骼数据。');
showError(t('viewer.emptySkeleton'), t('viewer.emptySkeletonMsg'));
return;
}
@@ -1101,11 +1176,11 @@
const btn = document.getElementById('playBtn');
btn.disabled = isStaticAnimation;
if (isStaticAnimation) {
btn.textContent = '▮ 静帧';
btn.textContent = t('viewer.static');
btn.classList.remove('active');
return;
}
btn.textContent = playing ? '⏸ 暂停' : '▶ 播放';
btn.textContent = playing ? t('viewer.pause') : t('viewer.play');
btn.classList.toggle('active', !playing);
}
@@ -1140,11 +1215,11 @@
if (renderer) renderer.premultipliedAlpha = premultipliedAlpha;
draw();
try {
if (!previewSettings) throw new Error('素材设置尚未就绪');
if (!previewSettings) throw new Error(t('viewer.settingsNotReady'));
previewSettings.write(premultipliedAlpha);
setStatus((premultipliedAlpha ? '预乘已开启' : '预乘已关闭') + ' · 已记住此素材的设置', 1800);
setStatus(t('viewer.pmaRemembered', { state: premultipliedAlpha ? t('viewer.pmaOn') : t('viewer.pmaOff') }), 1800);
} catch (err) {
setStatus('预乘已切换,但设置保存失败:' + err.message, 5000);
setStatus(t('viewer.pmaSaveFailed', { error: err.message }), 5000);
}
});
document.getElementById('reloadBtn').addEventListener('click', () => {
@@ -1195,7 +1270,7 @@
panX = 0; panY = 0; zoom = 1;
fitToCanvas();
draw();
setStatus('已回到画布中心');
setStatus(t('viewer.recentered'));
}
});
canvas.addEventListener('pointermove', (e) => {
@@ -1229,12 +1304,12 @@
// ---------- 启动 ----------
if (!filePath) {
showError('缺少文件', '未传入文件路径参数。');
showError(t('viewer.missingFile'), t('viewer.missingFileMsg'));
} else if (!fs.existsSync(filePath)) {
showError('文件不存在', filePath);
showError(t('viewer.fileNotExist'), filePath);
} else {
loadAssets().catch((err) => {
showError('加载失败', (err && err.message) ? err.message : String(err));
showError(t('viewer.loadFailed'), (err && err.message) ? err.message : String(err));
});
}
</script>