feat(i18n): add language switcher with en/ja translations

- New client-side i18n via React Context (useI18n, tArray, I18nProvider)
- Catalog ships 21 locale stubs; only zh-CN/en/ja have reviewed translations
- Header language switcher (globe icon + short label) before settings gear
- All hardcoded Chinese UI text migrated to keys: typewriter, options,
  hints (with embedded gear icon via dangerouslySetInnerHTML), settings
  panel, footer/about, play page hints
- AI output language follows user-selected locale via trailing one-liner
  directive appended to Architect/Writer/CharacterDesigner/InsertBeat
  user messages (preserves system-prompt cacheability)
- Per-locale separator rule: zh uses middot between every glyph; en/ja
  use plain spaces
- Option value → i18n key suffix maps preserve Chinese as the underlying
  identifier so analytics unions and STYLE_MAP keys stay byte-stable

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
DESKTOP-I1T6TF3\Q
2026-06-18 16:54:35 +08:00
parent f1fe7964a2
commit 2d35c1d9de
52 changed files with 6411 additions and 261 deletions
+50 -43
View File
@@ -57,6 +57,7 @@ import { AUTH_ENABLED } from "@/lib/supabase/config";
import { writeResumeSnapshot, consumeResumeSnapshot } from "@/lib/authResume";
import { AuthModal } from "@/components/AuthModal";
import { UserChip } from "@/components/UserChip";
import { useI18n } from "@/lib/i18n/client";
const MUTED_STORAGE_KEY = "infiplot:muted";
// One-shot snapshot of in-progress game state, written just before an OAuth
@@ -602,6 +603,7 @@ function getConnectionType(): "4g" | "3g" | "2g" | "slow-2g" | "unknown" {
function PlayInner() {
const router = useRouter();
const params = useSearchParams();
const { t, locale } = useI18n();
const [phase, setPhase] = useState<Phase>("loading-first");
const [session, setSession] = useState<Session | null>(null);
@@ -1362,7 +1364,7 @@ function PlayInner() {
let audioByBeatId: Record<string, string> = {};
try {
setExportProgress({ done: 0, total: 0, label: "正在准备配音" });
setExportProgress({ done: 0, total: 0, label: t("play.exportProgress.preparingVoice") });
audioByBeatId = await collectBeatAudioForExport({
session: s,
beatAudioMap,
@@ -1371,7 +1373,7 @@ function PlayInner() {
byoVoiceCache: provisionedVoicesRef.current,
prebakedAudio: prebakedAudioRef.current,
onProgress: (done, total) =>
setExportProgress({ done, total, label: "正在准备配音" }),
setExportProgress({ done, total, label: t("play.exportProgress.preparingVoice") }),
});
} catch {
// best-effort — even if the collector throws, the gallery without audio
@@ -1425,7 +1427,7 @@ function PlayInner() {
let audioByBeatId: Record<string, string> = {};
try {
setExportProgress({ done: 0, total: 0, label: "正在准备配音" });
setExportProgress({ done: 0, total: 0, label: t("play.exportProgress.preparingVoice") });
audioByBeatId = await collectBeatAudioForExport({
session: s,
beatAudioMap,
@@ -1434,7 +1436,7 @@ function PlayInner() {
byoVoiceCache: provisionedVoicesRef.current,
prebakedAudio: prebakedAudioRef.current,
onProgress: (done, total) =>
setExportProgress({ done, total, label: "正在准备配音" }),
setExportProgress({ done, total, label: t("play.exportProgress.preparingVoice") }),
});
} catch {
// best-effort — share the doc silent if collecting audio failed
@@ -1459,7 +1461,7 @@ function PlayInner() {
});
if (!r.ok) {
const j = (await r.json().catch(() => ({}))) as { error?: string };
window.alert(j.error ?? "剧情分享打包失败");
window.alert(j.error ?? t("play.shareErrors.packFailed"));
return;
}
const blob = await r.blob();
@@ -1473,11 +1475,11 @@ function PlayInner() {
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 2000);
} catch {
window.alert("剧情分享打包失败");
window.alert(t("play.shareErrors.packFailed"));
} finally {
exportingStoryRef.current = false;
}
}, [beatAudioMap]);
}, [beatAudioMap, t]);
// ── Presentation mode toggle ─────────────────────────────────────────
const togglePresentation = useCallback(async () => {
@@ -1595,12 +1597,12 @@ function PlayInner() {
const t0 = Date.now();
try {
const raw = sessionStorage.getItem(STORY_SHARE_STORAGE_KEY);
if (!raw) throw new Error("没有找到要载入的剧情文件。");
if (!raw) throw new Error(t("play.shareErrors.notFound"));
const doc = parseStoryShareDoc(JSON.parse(raw));
const imported = doc.session;
const first = imported.history[0];
if (!first) throw new Error("剧情分享文件没有可载入的剧情。");
if (!first.scene.imageUrl) throw new Error("剧情分享文件缺少第一幕图片。");
if (!first) throw new Error(t("play.shareErrors.invalid"));
if (!first.scene.imageUrl) throw new Error(t("play.shareErrors.noImage"));
const sessionOrientation =
first.scene.orientation ?? imported.orientation ?? detectOrientation();
@@ -1609,7 +1611,7 @@ function PlayInner() {
lastImageOriginalUrlRef.current = first.scene.imageUrl;
const initialStoryState = first.storyStateAfter ?? imported.storyState;
if (!initialStoryState) throw new Error("剧情分享文件缺少初始剧情记忆,无法载入。");
if (!initialStoryState) throw new Error(t("play.shareErrors.noMemory"));
const initial: Session = {
...imported,
@@ -1666,11 +1668,12 @@ function PlayInner() {
styleReferenceImage?: string;
orientation?: Orientation;
playerName?: string;
language?: string;
} | null = null;
if (!cardName) {
if (presetId) {
const p = PRESETS.find((x) => x.id === presetId);
if (p) livePayload = { worldSetting: p.worldSetting, styleGuide: p.styleGuide, playerName: readStoredPlayerName() || undefined };
if (p) livePayload = { worldSetting: p.worldSetting, styleGuide: p.styleGuide, playerName: readStoredPlayerName() || undefined, language: locale };
} else if (isCustom) {
const stored = sessionStorage.getItem("infiplot:custom");
if (stored) {
@@ -1687,6 +1690,7 @@ function PlayInner() {
styleGuide: parsed.styleGuide,
styleReferenceImage: parsed.styleReferenceImage || undefined,
playerName: parsed.playerName || undefined,
language: locale,
};
// audioEnabled 已在 useState 初始化时反向投射到 muted;这里无需再额外存。
} catch {
@@ -1701,6 +1705,10 @@ function PlayInner() {
// firstact-portrait/ and firstscene-portrait/.
const sessionOrientation: Orientation = detectOrientation();
if (livePayload) livePayload.orientation = sessionOrientation;
// sessionLanguage flows into Session.language regardless of which start
// path was taken (prebaked card skips /api/start, so the language has to
// be tagged onto the local Session build for /api/scene calls).
const sessionLanguage: string = locale;
if (!cardName && !livePayload) {
router.replace("/");
@@ -1737,7 +1745,7 @@ function PlayInner() {
return { ...fallback, scene: { ...fallback.scene, orientation: "landscape" as const } };
}
}
throw new Error(`找不到精选剧情:${cardName}`);
throw new Error(t("home.errors.cardNotFound", { cardName }));
},
)
: (async () => {
@@ -1781,6 +1789,7 @@ function PlayInner() {
styleReferenceImage: data.styleReferenceImage,
orientation: data.scene.orientation ?? sessionOrientation,
playerName: livePayload?.playerName || readStoredPlayerName() || undefined,
language: sessionLanguage,
};
visitedBeatsRef.current = [data.scene.entryBeatId];
setSession(initial);
@@ -1985,7 +1994,7 @@ function PlayInner() {
setPhase("transitioning");
setPendingClick(null);
try {
if (!next.scene.imageUrl) throw new Error("剧情分享文件缺少下一幕图片。");
if (!next.scene.imageUrl) throw new Error(t("play.shareErrors.noNextImage"));
const blobUrl = await getOrCreateBlobUrl(next.scene.imageUrl);
const priorOriginal = lastImageOriginalUrlRef.current;
if (priorOriginal && priorOriginal !== next.scene.imageUrl) {
@@ -2429,7 +2438,7 @@ function PlayInner() {
<div className="min-h-screen flex flex-col items-center justify-center px-8">
<div className="max-w-md text-center animate-fade-in">
<p className="text-[10px] smallcaps text-clay-500 mb-6">
· · · ·
{t("play.error.title")}
</p>
<p className="font-serif italic text-clay-900 text-lg leading-[1.7] mb-6">
{error}
@@ -2439,7 +2448,7 @@ function PlayInner() {
className="mt-4 text-[10px] smallcaps text-clay-700 hover:text-ember-500 transition-colors inline-flex items-center gap-3"
>
<i className="fa-solid fa-arrow-left text-[9px]" />
{t("play.error.back")}
</Link>
</div>
</div>
@@ -2484,7 +2493,7 @@ function PlayInner() {
<Link
href="/"
className="pointer-events-auto flex h-9 w-9 items-center justify-center rounded-full bg-black/40 text-white/80 backdrop-blur-sm transition-colors hover:text-white"
aria-label="返回"
aria-label={t("play.tooltips.back")}
>
<i className="fa-solid fa-arrow-left text-[13px]" />
</Link>
@@ -2492,7 +2501,7 @@ function PlayInner() {
type="button"
onClick={toggleMuted}
className="pointer-events-auto flex h-9 w-9 items-center justify-center rounded-full bg-black/40 text-white/80 backdrop-blur-sm transition-colors hover:text-white"
aria-label={muted ? "取消静音" : "静音"}
aria-label={muted ? t("play.tooltips.unmute") : t("play.tooltips.mute")}
>
<i
className={`fa-solid ${muted ? "fa-volume-xmark" : "fa-volume-high"} text-[13px]`}
@@ -2505,7 +2514,7 @@ function PlayInner() {
initialVisionClickEnabled={visionClickEnabled}
onClose={() => setSettingsOpen(false)}
onSaved={handleSettingsSaved}
footerNote="保存后配音 Key 会立即生效,用你自己的额度合成当前这一幕的配音。"
footerNote={t("play.settingsFooter")}
/>
)}
{authModalOpen && (
@@ -2570,9 +2579,9 @@ function PlayInner() {
</Link>
<div className="flex items-center gap-3">
<div className="text-[10px] smallcaps text-clay-500 num flex items-center gap-3">
<span> · {String(sceneCount).padStart(3, "0")} · </span>
<span>{t("play.counter.scene", { n: String(sceneCount).padStart(3, "0") })}</span>
<span className="text-clay-300">·</span>
<span>{String(beatCount).padStart(3, "0")} · </span>
<span>{t("play.counter.beat", { n: String(beatCount).padStart(3, "0") })}</span>
</div>
<UserChip />
</div>
@@ -2603,11 +2612,11 @@ function PlayInner() {
type="button"
onClick={() => void togglePresentation()}
className="text-[10px] smallcaps text-clay-500 hover:text-ember-500 transition-colors flex items-center gap-2"
aria-label="进入全屏"
title="全屏 (F)"
aria-label={t("play.tooltips.enterFullscreen")}
title={t("play.tooltips.fullscreen")}
>
<i className="fa-solid fa-expand text-[10px]" />
F · · ·
{t("play.buttons.fullscreen")}
</button>
}
belowCanvas={
@@ -2618,22 +2627,22 @@ function PlayInner() {
onClick={() => void handleExportGallery()}
disabled={!!exportProgress}
className="text-[10px] smallcaps text-clay-500 hover:text-ember-500 transition-colors flex items-center gap-2 disabled:opacity-50"
aria-label="导出可交互图集"
title="导出本局为可交互图集链接(含配音;只会保留最近两次的可交互图集链接)"
aria-label={t("play.tooltips.exportGalleryLabel")}
title={t("play.tooltips.exportGallery")}
>
<i className="fa-solid fa-link text-[10px]" />
· · ·
{t("play.buttons.exportGallery")}
</button>
<button
type="button"
onClick={() => void handleExportStory()}
disabled={!!exportProgress}
className="text-[10px] smallcaps text-clay-500 hover:text-ember-500 transition-colors flex items-center gap-2 disabled:opacity-50"
aria-label="分享当前剧情"
title="导出本局为可继续游玩的剧情 .infiplot(含配音)"
aria-label={t("play.tooltips.shareStoryLabel")}
title={t("play.tooltips.shareStory")}
>
<i className="fa-solid fa-share-nodes text-[10px]" />
· · ·
{t("play.buttons.shareStory")}
</button>
</>
) : null
@@ -2644,13 +2653,13 @@ function PlayInner() {
type="button"
onClick={toggleMuted}
className="text-[10px] smallcaps text-clay-500 hover:text-ember-500 transition-colors flex items-center gap-2"
aria-label={muted ? "取消静音" : "静音"}
title={muted ? "取消静音" : "静音"}
aria-label={muted ? t("play.tooltips.unmute") : t("play.tooltips.mute")}
title={muted ? t("play.tooltips.unmute") : t("play.tooltips.mute")}
>
<i
className={`fa-solid ${muted ? "fa-volume-xmark" : "fa-volume-high"} text-[10px]`}
/>
{muted ? "静 · 音" : "有 · 声"}
{muted ? t("play.buttons.muted") : t("play.buttons.sound")}
</button>
{/* Silence nudge — a compact pill right beside the mute toggle.
@@ -2665,16 +2674,16 @@ function PlayInner() {
type="button"
onClick={() => setSettingsOpen(true)}
className="inline-flex items-center gap-1.5 rounded-full border border-ember-500/40 bg-ember-500/10 px-2.5 py-1 text-[10px] text-ember-500 hover:bg-ember-500/20 transition-colors"
title="效果不满意/经常没声音?填入自己的 API Key 试试"
title={t("play.tooltips.silenceNudge")}
>
<i className="fa-solid fa-volume-xmark text-[9px]" />
/ API Key
{t("play.tooltips.silenceNudge")}
</button>
<button
type="button"
onClick={() => setNudgeDismissed(true)}
aria-label="关闭提示"
title="关闭"
aria-label={t("play.tooltips.closeNudge")}
title={t("play.tooltips.closeNudge")}
className="text-clay-400 hover:text-clay-700 transition-colors"
>
<i className="fa-solid fa-xmark text-[10px]" />
@@ -2688,12 +2697,12 @@ function PlayInner() {
<div className="mt-4 max-w-md w-full text-center min-h-[28px] flex items-center justify-center">
{phase === "loading-first" && (
<p className="text-[10px] smallcaps text-clay-500 animate-slow-pulse">
· · · · · ·
{t("play.loading.loadingFirst")}
</p>
)}
{phase === "ready" && lastExitLabel && (
<p className="text-[9px] smallcaps text-clay-400 animate-fade-in">
<span className="mr-2"> · · ·</span>
<span className="mr-2">{t("play.previousStep")}</span>
<span className="text-clay-600">{lastExitLabel}</span>
</p>
)}
@@ -2706,7 +2715,7 @@ function PlayInner() {
initialVisionClickEnabled={visionClickEnabled}
onClose={() => setSettingsOpen(false)}
onSaved={handleSettingsSaved}
footerNote="保存后配音 Key 会立即生效,用你自己的额度合成当前这一幕的配音。"
footerNote={t("play.settingsFooter")}
/>
)}
{authModalOpen && (
@@ -2736,9 +2745,7 @@ export default function PlayPage() {
<Suspense
fallback={
<div className="min-h-screen flex items-center justify-center">
<span className="text-[10px] smallcaps text-clay-500 animate-slow-pulse">
</span>
<i className="fa-solid fa-circle-notch fa-spin text-clay-500 text-xl" />
</div>
}
>