feedback5 WI: UI strings through t() with an English switch on the title screen and in 设置; pilot screens converted

public/i18n/en.json: Chinese msgid -> English, seeded from PR #70 (@YuriRestia, credited) for every Chinese
literal of the current code (≈830 entries; 966 of the 1 036 literals left for phase 2 already translated),
completed by hand in the official EN terms. public/js/ui/lang.js: boot language (?lang=en|zh, then
localStorage sp.pref.lang, else Chinese; the browser language is not consulted), switchLang, useLang,
LangToggle; main.js renders after initLang and re-renders in place on a switch, and translates server
texts (m.toast / m.ticker frames, room.closed reasons, error codes via ui/toasts.js describeError).
Converted: title screen (English title, wordmark hidden), lobby, room, settings (语言 row, On/Off,
quality, hotkeys), the shell (crash screen, toasts) and DifficultyTag.

tools/i18n.mjs: extract (msgids + untranslated literals per file), check (msgids missing from en.json),
codemod (html`` text / attributes / strings / template literals -> t(), nested strings, N_ for module
level, i18n-ignore lines), seed (fill en.json from a msgid map).
This commit is contained in:
sganggs committed 2026-10-05 20:19:58 +08:00
1 parent e596478bef
commit a12a539e4d
13 files changed
+2102 -171

No files matched your search

+57 -55
View File
@@ -9,6 +9,7 @@
// Spectator seats (community report #26, a remake feature): a co-op room with spectators shows the 观战席 strip under
// the seats — names, offline marks, the host's ✕ (room.removeSpectator) — and a spectator's own view swaps the ready
// button for 观战中 and offers 入座 (room.join of the room) while a player seat is free.
// Texts go through t() (docs/I18N.md).
import { useEffect, useRef, useState } from '../../vendor/hooks.module.js';
import { DIFFICULTIES, DIFFICULTY_NAMES, DIFFICULTY_COLORS, MAX_SEATS, MAX_SPECTATORS } from '../../../shared/constants.js';
@@ -22,6 +23,7 @@ import { LoadoutButton } from './loadout.js';
import { net } from '../net.js';
import { store, useStore, shallowEqual, emptyMatch, isSpectating } from '../store.js';
import { difficultyInfo } from './lobby.js';
import { t, tc } from '../../../shared/i18n.js';
/**
* Seats padded to the room's capacity (co-op 4, solo 1), each null or a seat record.
@@ -90,13 +92,13 @@ function SeatCard({ seat, index, room, facts, myId, busy, onAddBot, onRemoveBot,
<header class="seat__head"><span class="seat__no num">P${index + 1}</span><${MicroLabel}>SEAT ${String(index + 1).padStart(2, '0')}<//></header>
<div class="seat__art seat__art--empty">
<div class="seat__radar" aria-hidden="true"></div>
<span class="seat__wait">等待博士加入</span>
<span class="seat__wait">${t('等待博士加入')}</span>
<${MicroLabel}>AWAITING DOCTOR<//>
</div>
<footer class="seat__foot">
${canAdd
? html`<${Button} variant="secondary" size="sm" icon="robot" block=${true} loading=${busy === `add`} onClick=${onAddBot}>添加 AI 队友<//>`
: html`<span class="seat__state t-dim">空位</span>`}
? html`<${Button} variant="secondary" size="sm" icon="robot" block=${true} loading=${busy === `add`} onClick=${onAddBot}>${t('添加 AI 队友')}<//>`
: html`<span class="seat__state t-dim">${t('空位')}</span>`}
</footer>
</article>`;
}
@@ -110,30 +112,30 @@ function SeatCard({ seat, index, room, facts, myId, busy, onAddBot, onRemoveBot,
<header class="seat__head">
<span class="seat__no num">P${index + 1}</span>
<${MicroLabel}>SEAT ${String(index + 1).padStart(2, '0')}<//>
${isHostSeat ? html`<span class="seat__host"><${Icon} name="crown" />创建者</span>` : null}
${isHostSeat ? html`<span class="seat__host"><${Icon} name="crown" />${t('创建者')}</span>` : null}
</header>
<div class="seat__art">
<div class="seat__stripes" aria-hidden="true"></div>
<${AvatarFrame} size="xl" name=${seat.name} seat=${index} bot=${seat.isBot} self=${isMe} ready=${state === 'ready'} offline=${offline} />
${seat.isBot ? html`<span class="seat__bot-label"><${Icon} name="robot" />AI 队友</span>` : null}
${seat.isBot ? html`<span class="seat__bot-label"><${Icon} name="robot" />${t('AI 队友')}</span>` : null}
</div>
<div class="seat__who">
<span class="seat__name">${seat.name || '博士'}</span>
${isMe ? html`<span class="seat__you">你</span>` : null}
<span class="seat__name">${seat.name || t('博士')}</span>
${isMe ? html`<span class="seat__you">${t('你')}</span>` : null}
</div>
<${MicroLabel}>${seat.isBot ? 'AUTONOMOUS UNIT' : `DOCTOR #${doctorNo(seat.playerId)}`}<//>
<footer class="seat__foot">
<span class=${`seat__state seat__state--${state}`}>
${state === 'ready' ? html`<${Icon} name="check" />已就绪`
: state === 'offline' ? html`<${Icon} name="wifiOff" />连接中断`
: state === 'host' ? html`<${Icon} name="crown" />待命中`
: html`<${Icon} name="hourglass" />准备中`}
${state === 'ready' ? html`<${Icon} name="check" />${t('已就绪')}`
: state === 'offline' ? html`<${Icon} name="wifiOff" />${t('连接中断')}`
: state === 'host' ? html`<${Icon} name="crown" />${t('待命中')}`
: html`<${Icon} name="hourglass" />${t('准备中')}`}
</span>
${seat.isBot && facts.isHost ? html`<${Tooltip} text="移除该 AI 队友">
<${Button} variant="ghost" size="sm" square=${true} icon="close" loading=${busy === `rm${index}`} onClick=${() => onRemoveBot(index)} aria-label="移除 AI 队友" />
${seat.isBot && facts.isHost ? html`<${Tooltip} text=${t('移除该 AI 队友')}>
<${Button} variant="ghost" size="sm" square=${true} icon="close" loading=${busy === `rm${index}`} onClick=${() => onRemoveBot(index)} aria-label=${t('移除 AI 队友')} />
<//>` : null}
${!seat.isBot && !isMe && facts.isHost ? html`<${Tooltip} text="将该博士移出同盟">
<${Button} variant="ghost" size="sm" square=${true} icon="close" loading=${busy === `kick${index}`} onClick=${() => onKick(index, seat.name, seat.playerId)} aria-label="移出该博士" />
${!seat.isBot && !isMe && facts.isHost ? html`<${Tooltip} text=${t('将该博士移出同盟')}>
<${Button} variant="ghost" size="sm" square=${true} icon="close" loading=${busy === `kick${index}`} onClick=${() => onKick(index, seat.name, seat.playerId)} aria-label=${t('移出该博士')} />
<//>` : null}
</footer>
</article>`;
@@ -142,29 +144,29 @@ function SeatCard({ seat, index, room, facts, myId, busy, onAddBot, onRemoveBot,
/** 观战席: the room's spectators (host: ✕ frees a seat), and 入座 for a spectator while a player seat is free. */
function SpectatorBar({ facts, myId, busy, onRemove, onSit }) {
if (!facts.spectators.length) return null;
return html`<section class="specbar" aria-label="观战席">
<span class="specbar__label"><${Icon} name="eye" />观战席<b class="num">${facts.spectators.length}</b><span class="num t-dim">/${MAX_SPECTATORS}</span></span>
return html`<section class="specbar" aria-label=${t('观战席')}>
<span class="specbar__label"><${Icon} name="eye" />${t('观战席')}<b class="num">${facts.spectators.length}</b><span class="num t-dim">/${MAX_SPECTATORS}</span></span>
${facts.spectators.map((s) => html`<span key=${s.playerId} class=${`specbar__who${s.playerId === myId ? ' is-me' : ''}${s.connected === false ? ' is-offline' : ''}`}>
${s.connected === false ? html`<${Icon} name="wifiOff" />` : null}${s.name || '博士'}${s.playerId === myId ? html`<span class="seat__you">你</span>` : null}
${s.connected === false ? html`<${Icon} name="wifiOff" />` : null}${s.name || t('博士')}${s.playerId === myId ? html`<span class="seat__you">${t('你')}</span>` : null}
${facts.isHost ? html`<${Button} variant="ghost" size="sm" square=${true} icon="close" loading=${busy === `rs${s.playerId}`}
onClick=${() => onRemove(s.playerId)} aria-label=${`移出观战者 ${s.name || ''}`} title="移出该观战者" />` : null}
onClick=${() => onRemove(s.playerId)} aria-label=${t('移出观战者 {name}', { name: s.name || '' })} title=${t('移出该观战者')} />` : null}
</span>`)}
${facts.spectating && facts.emptySeats > 0 ? html`<${Button} size="sm" icon="user" loading=${busy === 'sit'} onClick=${onSit}>入座<//>` : null}
${facts.spectating && facts.emptySeats > 0 ? html`<${Button} size="sm" icon="user" loading=${busy === 'sit'} onClick=${onSit}>${t('入座')}<//>` : null}
</section>`;
}
function InviteBox({ code }) {
const copy = async (what) => {
const ok = await copyText(what === 'code' ? code : inviteLink(code));
if (ok) toast(what === 'code' ? `已复制同盟密钥 ${code}` : '已复制邀请链接', 'success');
else toast('复制失败,请手动复制', 'warn');
if (ok) toast(what === 'code' ? t('已复制同盟密钥 {code}', { code }) : t('已复制邀请链接'), 'success');
else toast(t('复制失败,请手动复制'), 'warn');
};
return html`<div class="invite brackets">
<div class="invite__label"><${Icon} name="key" /><span>同盟密钥</span><${MicroLabel}>ALLIANCE KEY<//></div>
<div class="invite__code num selectable" aria-label=${`同盟密钥 ${code}`}>${[...String(code)].map((ch, i) => html`<span key=${i}>${ch}</span>`)}</div>
<div class="invite__label"><${Icon} name="key" /><span>${t('同盟密钥')}</span><${MicroLabel}>ALLIANCE KEY<//></div>
<div class="invite__code num selectable" aria-label=${t('同盟密钥 {code}', { code })}>${[...String(code)].map((ch, i) => html`<span key=${i}>${ch}</span>`)}</div>
<div class="invite__btns">
<${Button} size="sm" icon="copy" onClick=${() => copy('code')}>复制密钥<//>
<${Button} size="sm" icon="link" onClick=${() => copy('link')}>复制链接<//>
<${Button} size="sm" icon="copy" onClick=${() => copy('code')}>${t('复制密钥')}<//>
<${Button} size="sm" icon="link" onClick=${() => copy('link')}>${t('复制链接')}<//>
</div>
</div>`;
}
@@ -173,14 +175,14 @@ function DifficultyPicker({ room, isHost, busy, onPick }) {
if (!isHost) {
return html`<div class="dpick dpick--ro">
<${DifficultyTag} difficulty=${room.difficulty} size="lg" code=${difficultyInfo(room.mode, room.difficulty).code} />
<span class="t-dim">由创建者选择</span>
<span class="t-dim">${t('由创建者选择')}</span>
</div>`;
}
return html`<div class="dpick" role="radiogroup" aria-label="模拟难度">
return html`<div class="dpick" role="radiogroup" aria-label=${t('模拟难度')}>
${DIFFICULTIES.map((d) => html`<button key=${d} type="button" role="radio" aria-checked=${room.difficulty === d ? 'true' : 'false'}
class=${`dpick__opt${room.difficulty === d ? ' is-active' : ''}`} style=${`--d-color:${DIFFICULTY_COLORS[d]}`}
disabled=${!!busy} onClick=${() => room.difficulty !== d && onPick(d)}>
<${DifficultyIcon} difficulty=${d} />${DIFFICULTY_NAMES[d].replace('模拟', '')}
<${DifficultyIcon} difficulty=${d} />${t(DIFFICULTY_NAMES[d].replace('模拟', ''))}
</button>`)}
</div>`;
}
@@ -204,7 +206,7 @@ export function RoomScreen() {
const run = async (kind, fn) => {
if (inFlight.current) return;
if (!online) { toast('连接中断,请稍候重试', 'warn'); return; }
if (!online) { toast(t('连接中断,请稍候重试'), 'warn'); return; }
inFlight.current = true;
setBusy(kind);
try { await fn(); } catch (err) { toastError(err); } finally {
@@ -221,7 +223,7 @@ export function RoomScreen() {
// confirmed player's id goes along: if they left and someone else took the seat meanwhile, the server refuses it.
const kick = async (seat, name, playerId) => {
if (inFlight.current) return;
const ok = await confirmDialog({ title: '移出同盟', text: `确定将「${name || '博士'}」移出同盟吗?对方可以凭同盟密钥重新加入。`, okText: '移出', danger: true });
const ok = await confirmDialog({ title: t('移出同盟'), text: t('确定将「{name}」移出同盟吗?对方可以凭同盟密钥重新加入。', { name: name || t('博士') }), okText: t('移出'), danger: true });
if (ok) run(`kick${seat}`, () => net.request('room.kick', { seat, playerId }));
};
const setDifficulty = (difficulty) => run('diff', () => net.request('room.setDifficulty', { difficulty }));
@@ -232,7 +234,7 @@ export function RoomScreen() {
if (inFlight.current) return;
const othersHere = facts.humans.some((s) => s.playerId !== me.playerId);
if (facts.isHost && othersHere) {
const ok = await confirmDialog({ title: '离开同盟', text: '你是同盟的创建者,离开后创建者身份将移交或同盟解散。确定离开吗?', okText: '离开', danger: true });
const ok = await confirmDialog({ title: t('离开同盟'), text: t('你是同盟的创建者,离开后创建者身份将移交或同盟解散。确定离开吗?'), okText: t('离开'), danger: true });
if (!ok) return;
}
inFlight.current = true;
@@ -250,37 +252,37 @@ export function RoomScreen() {
};
const statusLine = !online
? html`<span class="t-orange"><${Icon} name="wifiOff" />连接中断,正在重连…</span>`
? html`<span class="t-orange"><${Icon} name="wifiOff" />${t('连接中断,正在重连…')}</span>`
: facts.spectating
? html`<span class="t-lo"><${Icon} name="eye" />观战中 · 不占博士席位,模拟开始后可切换观看各位博士</span>`
? html`<span class="t-lo"><${Icon} name="eye" />${t('观战中 · 不占博士席位,模拟开始后可切换观看各位博士')}</span>`
: !coop
? html`<span class="t-mint">*模拟协议已就绪,准许进入模拟</span>`
? html`<span class="t-mint">${t('*模拟协议已就绪,准许进入模拟')}</span>`
: facts.isHost
? facts.canStart
? html`<span class="t-mint">*同盟人数达标,准许进入模拟</span>`
: html`<span class="t-lo">等待所有博士准备就绪</span>`
? html`<span class="t-mint">${t('*同盟人数达标,准许进入模拟')}</span>`
: html`<span class="t-lo">${t('等待所有博士准备就绪')}</span>`
: myReady
? html`<span class="t-mint">已就绪 · 等待创建者开始模拟</span>`
: html`<span class="t-lo">准备就绪后,创建者即可开始模拟</span>`;
? html`<span class="t-mint">${t('已就绪 · 等待创建者开始模拟')}</span>`
: html`<span class="t-lo">${t('准备就绪后,创建者即可开始模拟')}</span>`;
return html`<div class="screen room-screen">
<header class="topbar">
<div class="topbar__left">
<${Tooltip} text="离开同盟" placement="bottom">
<${Button} variant="danger" size="lg" square=${true} icon="exit" loading=${busy === 'leave'} onClick=${leave} aria-label="离开同盟" />
<${Tooltip} text=${t('离开同盟')} placement="bottom">
<${Button} variant="danger" size="lg" square=${true} icon="exit" loading=${busy === 'leave'} onClick=${leave} aria-label=${t('离开同盟')} />
<//>
<div class="room-ping">
<${PingPill} ms=${conn.ping} online=${online} />
<${MicroLabel}>当前延迟<//>
<${MicroLabel}>${t('当前延迟')}<//>
</div>
<${GuideButton} class="room-guide" variant="secondary" />
<${GuideButton} class="room-guide" variant="secondary" label=${t('玩法说明')} />
</div>
<div class="topbar__center">
<${MicroLabel} tone="mint">${coop ? 'ALLIANCE LOBBY' : 'SOLO SIMULATION'}<//>
<h1 class="topbar__title">${coop ? '同盟模拟' : '独立模拟'}<span class="topbar__sep"></span><${DifficultyTag} difficulty=${room.difficulty} size="lg" /></h1>
<h1 class="topbar__title">${coop ? t('同盟模拟') : t('独立模拟')}<span class="topbar__sep"></span><${DifficultyTag} difficulty=${room.difficulty} size="lg" /></h1>
</div>
<div class="topbar__right">
${coop ? html`<${InviteBox} code=${room.code} />` : html`<div class="solo-note"><${MicroLabel}>SINGLE OPERATOR<//><span>仅限 1 名博士</span></div>`}
${coop ? html`<${InviteBox} code=${room.code} />` : html`<div class="solo-note"><${MicroLabel}>SINGLE OPERATOR<//><span>${t('仅限 1 名博士')}</span></div>`}
</div>
</header>
@@ -289,12 +291,12 @@ export function RoomScreen() {
myId=${me.playerId} busy=${busy} onAddBot=${addBot} onRemoveBot=${removeBot} onKick=${kick} />`)}
${coop ? null : html`<aside class="solo-brief brackets">
<${MicroLabel} tone="mint">BRIEFING<//>
<h2>${DIFFICULTY_NAMES[room.difficulty] || ''}<span class="num t-dim"> ${info.code}</span></h2>
<h2>${DIFFICULTY_NAMES[room.difficulty] ? t(DIFFICULTY_NAMES[room.difficulty]) : ''}<span class="num t-dim"> ${info.code}</span></h2>
<p>${info.desc}</p>
<ul>
${info.effects.map((e) => html`<li key=${e}>${e}</li>`)}
<li>共 <b class="num">${info.rounds}</b> 回合${info.hidden ? ',满足条件时进入隐秘核心' : ''}</li>
<li>独立模拟中休整期与机变阶段不限时</li>
<li>${t('共')} <b class="num">${info.rounds}</b> ${tc('rounds', '回合')}${info.hidden ? t(',满足条件时进入隐秘核心') : ''}</li>
<li>${t('独立模拟中休整期与机变阶段不限时')}</li>
</ul>
</aside>`}
</main>
@@ -302,12 +304,12 @@ export function RoomScreen() {
<footer class="room-bar">
<div class="room-bar__left">
<span class="room-bar__label">模拟难度<${MicroLabel}>DIFFICULTY<//></span>
<span class="room-bar__label">${t('模拟难度')}<${MicroLabel}>DIFFICULTY<//></span>
<${DifficultyPicker} room=${room} isHost=${facts.isHost} busy=${busy} onPick=${setDifficulty} />
</div>
<div class="room-bar__center">
<div class="ready-count" hidden=${!coop}>
<span class="t-lo">已就绪</span>
<span class="t-lo">${t('已就绪')}</span>
<b class="num">${facts.readyHumans}</b><span class="num t-dim">/${facts.humans.length}</span>
<span class="ready-count__icons" aria-hidden="true">
${facts.humans.map((s) => html`<${Icon} key=${s.playerId} name="user" class=${facts.isReady(s) ? 'is-on' : ''} />`)}
@@ -316,15 +318,15 @@ export function RoomScreen() {
<div class="room-bar__status">${statusLine}</div>
</div>
<div class="room-bar__right">
<${LoadoutButton} from="room" size="lg" class="room-loadout" />
<${LoadoutButton} from="room" size="lg" class="room-loadout" label=${t('干员调配')} />
${facts.isHost
? html`<${Tooltip} text=${facts.canStart ? null : '仍有博士未准备就绪'}>
<${Button} variant="primary" size="xl" icon="play" loading=${busy === 'start'} disabled=${!facts.canStart || !online} onClick=${start}>开始模拟<//>
? html`<${Tooltip} text=${facts.canStart ? null : t('仍有博士未准备就绪')}>
<${Button} variant="primary" size="xl" icon="play" loading=${busy === 'start'} disabled=${!facts.canStart || !online} onClick=${start}>${t('开始模拟')}<//>
<//>`
: facts.spectating
? html`<${Button} variant="secondary" size="xl" icon="eye" disabled=${true}>观战中<//>`
? html`<${Button} variant="secondary" size="xl" icon="eye" disabled=${true}>${t('观战中')}<//>`
: html`<${Button} variant=${myReady ? 'primary' : 'secondary'} size="xl" icon=${myReady ? 'check' : 'hourglass'} active=${myReady}
loading=${busy === 'ready'} disabled=${!online || !facts.mine} onClick=${toggleReady}>${myReady ? '已就绪' : '准备就绪'}<//>`}
loading=${busy === 'ready'} disabled=${!online || !facts.mine} onClick=${toggleReady}>${myReady ? t('已就绪') : t('准备就绪')}<//>`}
</div>
</footer>
</div>`;