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
+2 -1
View File
@@ -1,6 +1,7 @@
import type { Metadata, Viewport } from "next";
import { Cormorant_Garamond, Inter } from "next/font/google";
import { Analytics } from "@/components/Analytics";
import { I18nProvider } from "@/lib/i18n/client";
import "./globals.css";
// Editorial fonts: drive tailwind `font-serif`/`font-sans` via
@@ -53,7 +54,7 @@ export default function RootLayout({
/>
</head>
<body className="bg-cream-50 text-clay-900 font-sans antialiased min-h-screen overflow-x-hidden">
{children}
<I18nProvider>{children}</I18nProvider>
<Analytics />
</body>
</html>
+186 -106
View File
@@ -20,6 +20,73 @@ import { AUTH_ENABLED } from "@/lib/supabase/config";
import { isAuthed, writeResumeSnapshot } from "@/lib/authResume";
import { AuthModal } from "@/components/AuthModal";
import { UserChip } from "@/components/UserChip";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { useI18n } from "@/lib/i18n/client";
// Option value → i18n key suffix maps. The Chinese strings from lib/options.ts
// stay as the underlying identifier (so analytics unions and STYLE_MAP keys
// stay byte-stable); we look up the display label per locale at render time.
const GENDER_KEYS: Record<Gender, "male" | "female" | "x"> = {
: "male",
: "female",
X: "x",
};
const ART_STYLE_KEYS: Record<string, string> = {
"自动": "auto",
"自定义风格": "custom",
"京阿尼": "kyoani",
"新海诚": "shinkai",
"吉卜力": "ghibli",
"黑白漫画": "manga",
"真实": "realistic",
"3D 动画": "3d",
"水墨": "ink",
"仙侠玄幻": "xianxia",
"浮世绘": "ukiyoe",
"敦煌壁画": "dunhuang",
"古典油画": "oil",
"莫奈": "monet",
"水彩": "watercolor",
"细密画": "miniature",
"镶嵌画": "mosaic",
"彩绘玻璃": "stainedGlass",
"赛博朋克": "cyberpunk",
"蒸汽朋克": "steampunk",
"哥特": "gothic",
"废土": "wasteland",
"暗黑童话": "darkFairytale",
"都市幻想": "urbanFantasy",
"像素风": "pixel",
"蒸汽波": "vaporwave",
"矢量插画": "vector",
"低多边形": "lowpoly",
"波普艺术": "popart",
"故障艺术": "glitch",
"彩铅": "pencil",
"手绘素描": "sketch",
"剪纸艺术": "papercut",
"儿童绘本": "children",
"儿童涂鸦": "crayon",
"黏土手工": "clay",
};
const PLOT_STYLE_KEYS: Record<string, string> = {
"平铺直叙": "straightforward",
"多线转折": "twist",
"悬疑烧脑": "suspense",
"治愈日常": "healing",
};
const PACING_KEYS: Record<string, string> = {
"慢热细腻": "slow",
"紧凑爽快": "fast",
};
const VOICE_KEYS: Record<string, string> = {
"关闭": "off",
"开启": "on",
};
/* ============================================================================
InfiPlot · 首页(编辑式视觉风格 · 居中构图,呼应低保真原型)
@@ -30,39 +97,26 @@ import { UserChip } from "@/components/UserChip";
========================================================================== */
const EXAMPLE_PHRASES: Record<Gender, string[]> = {
: [
"从小一起长大的青梅竹马,突然红着脸向我告白",
"一觉醒来,班上的女生好像都偷偷喜欢上了我",
"三年之期已到,原来我是富家公子,报仇时机已到",
"我带着无限 Token 穿越回了互联网诞生前夕……",
],
: [
"穿越成将军府的废物嫡女,冷面摄政王却独宠我一人",
"重生回到分手前夜,这一次换我先放手",
"一觉醒来成了乙游里的恶役千金,要躲开所有死亡结局",
],
X: [
"时空裂隙开启,多个平行世界的自己突然出现在眼前",
"记忆宫殿里,那些被遗忘的碎片正在重组为新的故事",
"一场无限流游戏开始,所有人都有唯一的通关机会",
"系统提示:你的选择将决定整个宇宙的命运走向",
],
};
// EXAMPLE_PHRASES is now sourced from i18n (home.examples.{male,female,x}).
// The Chinese values below are kept as gender identifiers only — they're the
// underlying session value and flow into analytics as a stable literal union.
type Opt = {
label: string;
items: string[];
defaultIndex?: number;
modal?: boolean;
// i18n key suffixes — used to render localized display labels for each item.
itemKey: string;
labelKey: string;
};
const OPTS: Opt[] = [
{ label: "性向", items: [...GENDERS] },
{ label: "绘画风格", modal: true, items: [...ART_STYLES] },
{ label: "剧情风格", items: [...PLOT_STYLES], defaultIndex: 1 },
{ label: "语音配音", items: ["关闭", "开启"], defaultIndex: 1 },
{ label: "内容节奏", items: [...PACINGS], defaultIndex: 1 },
{ label: "性向", items: [...GENDERS], labelKey: "home.options.gender", itemKey: "home.genders" },
{ label: "绘画风格", modal: true, items: [...ART_STYLES], labelKey: "home.options.artStyle", itemKey: "home.artStyles" },
{ label: "剧情风格", items: [...PLOT_STYLES], defaultIndex: 1, labelKey: "home.options.plotStyle", itemKey: "home.plotStyles" },
{ label: "语音配音", items: ["关闭", "开启"], defaultIndex: 1, labelKey: "home.options.voice", itemKey: "home.voiceOptions" },
{ label: "内容节奏", items: [...PACINGS], defaultIndex: 1, labelKey: "home.options.pacing", itemKey: "home.pacings" },
];
type StoryContent = { title: string; outline: string; style: string; tags: string[] };
@@ -822,6 +876,7 @@ function StoryCard({
function CategorySelect({
label,
items,
itemLabels,
value,
open,
onToggle,
@@ -829,6 +884,7 @@ function CategorySelect({
}: {
label: string;
items: string[];
itemLabels: string[];
value: number;
open: boolean;
onToggle: () => void;
@@ -843,7 +899,7 @@ function CategorySelect({
>
<span className="text-[10px] smallcaps text-clay-500">{label}</span>
<span className={"font-serif text-base md:text-lg " + (open ? "text-ember-500" : "text-clay-900")}>
{items[value]}
{itemLabels[value] ?? items[value]}
</span>
<i
className={
@@ -864,7 +920,7 @@ function CategorySelect({
(i === value ? "text-ember-500" : "text-clay-700")
}
>
{it}
{itemLabels[i] ?? it}
{i === value && <i className="fa-solid fa-check text-[10px]" />}
</button>
))}
@@ -914,6 +970,7 @@ async function extractStylePromptFromImage(resized: string): Promise<string> {
function StyleModal({
items,
itemLabels,
value,
onPick,
onClose,
@@ -924,6 +981,7 @@ function StyleModal({
onRequireAuth,
}: {
items: string[];
itemLabels: string[];
value: number;
onPick: (i: number) => void;
onClose: () => void;
@@ -933,6 +991,7 @@ function StyleModal({
setCustomStyleRefImage: (s: string) => void;
onRequireAuth: () => void;
}) {
const { t } = useI18n();
const [q, setQ] = useState("");
const [shown, setShown] = useState(false);
const [view, setView] = useState<"grid" | "custom">("grid");
@@ -1011,13 +1070,13 @@ function StyleModal({
const dataUrl = await new Promise<string>((resolve, reject) => {
const r = new FileReader();
r.onload = () => resolve(String(r.result));
r.onerror = () => reject(new Error("读取文件失败"));
r.onerror = () => reject(new Error(t("home.styleModal.fileReadError")));
r.readAsDataURL(file);
});
const img = await new Promise<HTMLImageElement>((resolve, reject) => {
const i = new Image();
i.onload = () => resolve(i);
i.onerror = () => reject(new Error("无法解码图片"));
i.onerror = () => reject(new Error(t("home.styleModal.imageDecodeError")));
i.src = dataUrl;
});
const MAX_DIM = 512;
@@ -1040,7 +1099,7 @@ function StyleModal({
const handleUploadStyleImage = async (file: File) => {
setParseError(null);
if (!file.type.startsWith("image/")) {
setParseError("只支持图片文件");
setParseError(t("home.styleModal.uploadError"));
return;
}
setParsing(true);
@@ -1058,12 +1117,12 @@ function StyleModal({
return;
}
const stylePrompt = await extractStylePromptFromImage(resized);
if (!stylePrompt) throw new Error("视觉模型返回了空的风格描述");
if (!stylePrompt) throw new Error(t("home.styleModal.visionError"));
setDraft(stylePrompt);
setCustomStyleRefImage(resized);
track("style_image_upload", { ok: true });
} catch (err) {
const msg = err instanceof Error ? err.message : "解析失败";
const msg = err instanceof Error ? err.message : t("home.styleModal.parseError");
setParseError(msg);
track("style_image_upload", { ok: false });
} finally {
@@ -1077,9 +1136,10 @@ function StyleModal({
};
const q2 = q.trim();
const list = items.map((name, i) => ({ name, i })).filter((x) => {
const list = items.map((name, i) => ({ name, label: itemLabels[i] ?? name, i })).filter((x) => {
if (!q2) return true;
return x.name.toLowerCase().includes(q2.toLowerCase());
const needle = q2.toLowerCase();
return x.name.toLowerCase().includes(needle) || x.label.toLowerCase().includes(needle);
});
return (
<div
@@ -1103,25 +1163,25 @@ function StyleModal({
type="button"
onClick={() => setView("grid")}
className="flex h-8 w-8 items-center justify-center rounded-sm text-clay-500 hover:bg-cream-100 hover:text-clay-900 transition-colors"
aria-label="返回"
aria-label={t("home.ui.back")}
>
<i className="fa-solid fa-arrow-left text-sm" />
</button>
<span className="font-serif text-xl md:text-2xl text-clay-900"></span>
<span className="font-serif text-xl md:text-2xl text-clay-900">{t("home.styleModal.customTitle")}</span>
</div>
) : (
<>
<div className="flex flex-1 flex-col">
<span className="font-serif text-xl md:text-2xl text-clay-900"></span>
<span className="font-serif text-xl md:text-2xl text-clay-900">{t("home.styleModal.title")}</span>
<span className="hidden md:block text-[11px] text-clay-500 mt-1 tracking-wide">
· AI
{t("home.styleModal.subtitle")}
</span>
</div>
<div className="relative w-[150px] max-w-[40vw] md:w-[280px] md:max-w-[46vw]">
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="搜索风格…"
placeholder={t("home.ui.searchPlaceholder")}
autoFocus
className="h-10 w-full rounded-sm border border-clay-900/15 bg-cream-100 pl-4 pr-10 font-sans text-sm text-clay-900 outline-none transition-colors focus:border-ember-500 placeholder:text-clay-400"
/>
@@ -1132,7 +1192,7 @@ function StyleModal({
<button
type="button"
onClick={close}
aria-label="关闭"
aria-label={t("home.ui.close")}
className="text-xl leading-none text-clay-500 hover:text-clay-900 transition-colors"
>
<i className="fa-solid fa-xmark" />
@@ -1157,7 +1217,7 @@ function StyleModal({
onChange={(e) => setDraft(e.target.value)}
autoFocus
rows={6}
placeholder={"描述你想要的画面风格,例如:\n梦幻水彩风格,柔和的色调,怀旧的氛围\n\n💡 提示:部分绘图模型对英文提示词效果更佳,建议先借助 AI 对话工具生成专业的英文风格描述,再粘贴到这里"}
placeholder={t("home.styleModal.customPlaceholder")}
className="w-full flex-1 resize-y rounded-sm border border-clay-900/15 bg-cream-50 px-3 py-2.5 font-sans text-[13px] leading-relaxed text-clay-900 outline-none transition-colors focus:border-ember-500 placeholder:text-clay-400"
/>
{parseError && (
@@ -1172,7 +1232,7 @@ function StyleModal({
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={customStyleRefImage}
alt="画风参考图"
alt={t("home.styleModal.refImageAlt")}
className="h-8 w-8 shrink-0 rounded-sm border border-clay-900/10 object-cover"
/>
<button
@@ -1181,14 +1241,14 @@ function StyleModal({
disabled={parsing}
className="font-sans text-[11px] text-clay-500 hover:text-ember-500 transition-colors disabled:opacity-50"
>
{t("home.styleModal.changeImage")}
</button>
<button
type="button"
onClick={() => removeStyleRefImage()}
className="font-sans text-[11px] text-clay-400 hover:text-clay-900 transition-colors"
>
{t("home.styleModal.remove")}
</button>
</div>
) : (
@@ -1206,12 +1266,12 @@ function StyleModal({
{parsing ? (
<>
<i className="fa-solid fa-circle-notch fa-spin text-[11px]" />
{t("home.styleModal.parsing")}
</>
) : (
<>
<i className="fa-regular fa-image text-[11px]" />
{t("home.styleModal.uploadImage")}
</>
)}
</button>
@@ -1224,7 +1284,7 @@ function StyleModal({
}}
className="h-8 w-36 md:w-44 rounded-sm border border-clay-900/15 bg-cream-50 px-2 font-sans text-[12px] text-clay-700 outline-none transition-colors focus:border-ember-500"
>
<option value=""></option>
<option value="">{t("home.styleModal.importFromPreset")}</option>
{Object.keys(STYLE_MAP).map((s) => (
<option key={s} value={s}>{s}</option>
))}
@@ -1235,7 +1295,7 @@ function StyleModal({
onClick={() => setView("grid")}
className="rounded-sm border border-clay-900/15 px-4 py-1.5 font-sans text-xs text-clay-700 hover:border-clay-900/30 hover:text-clay-900 transition-colors"
>
{t("home.ui.cancel")}
</button>
<button
type="button"
@@ -1248,13 +1308,13 @@ function StyleModal({
: "bg-clay-900/20 text-clay-500 cursor-not-allowed")
}
>
{t("home.ui.saveAndSelect")}
</button>
</div>
</div>
) : (
<div className="grid grid-cols-2 gap-3 overflow-y-auto px-6 py-6 md:grid-cols-4 md:gap-4 md:px-8">
{list.map(({ name, i }) => {
{list.map(({ name, label, i }) => {
const isCustom = name === "自定义风格";
const thumb = STYLE_THUMB[name];
return (
@@ -1288,20 +1348,20 @@ function StyleModal({
<div className="relative w-full overflow-hidden" style={{ paddingBottom: "100%" }}>
{thumb ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img src={thumb} alt={name} loading="lazy" className="absolute inset-0 h-full w-full object-cover" />
<img src={thumb} alt={label} loading="lazy" className="absolute inset-0 h-full w-full object-cover" />
) : (
<div className="absolute inset-0 bg-cream-100" />
)}
</div>
<span className={"block px-2 py-2 text-center font-serif text-sm " + (i === value ? "text-ember-500" : "text-clay-700")}>
{name}
{label}
</span>
</div>
);
})}
{list.length === 0 && (
<div className="col-span-full py-12 text-center font-serif text-sm text-clay-400">
{t("home.ui.noMatchingStyle")}
</div>
)}
</div>
@@ -1315,6 +1375,7 @@ function StyleModal({
export default function HomePage() {
const router = useRouter();
const { t, locale, tArray } = useI18n();
const [sel, setSel] = useState<number[]>(OPTS.map((o) => o.defaultIndex ?? 0));
const [open, setOpen] = useState<number>(-1);
@@ -1344,7 +1405,43 @@ export default function HomePage() {
const paceRow = OPTS.findIndex((o) => o.label === "内容节奏");
const genderIndex = sel[0] ?? 0;
const gender = (OPTS[0]!.items[genderIndex] as Gender) ?? "男性向";
const phrases = EXAMPLE_PHRASES[gender];
// Display labels for each option category — localized at render time. The
// underlying `items` are kept as Chinese literal identifiers because they
// flow into analytics unions and `STYLE_MAP` keys.
const optItemLabels = OPTS.map((o) => {
if (o.itemKey === "home.genders") {
return o.items.map((v) => t(`home.genders.${GENDER_KEYS[v as Gender] ?? "male"}`));
}
if (o.itemKey === "home.artStyles") {
return o.items.map((v) => {
const k = ART_STYLE_KEYS[v];
return k ? t(`home.artStyles.${k}`) : v;
});
}
if (o.itemKey === "home.plotStyles") {
return o.items.map((v) => {
const k = PLOT_STYLE_KEYS[v];
return k ? t(`home.plotStyles.${k}`) : v;
});
}
if (o.itemKey === "home.pacings") {
return o.items.map((v) => {
const k = PACING_KEYS[v];
return k ? t(`home.pacings.${k}`) : v;
});
}
if (o.itemKey === "home.voiceOptions") {
return o.items.map((v) => {
const k = VOICE_KEYS[v];
return k ? t(`home.voiceOptions.${k}`) : v;
});
}
return o.items;
});
const optLabels = OPTS.map((o) => t(o.labelKey));
const phrasesKey = GENDER_KEYS[gender] ?? "male";
const phrases = tArray(`home.examples.${phrasesKey}`);
void locale;
// 当前 Typewriter 闪动到第几句——start() 空输入时会拿它做默认故事种子,
// 实现「所见即所玩」。切性向时重置,否则索引可能越界。
const [phraseIdx, setPhraseIdx] = useState(0);
@@ -1590,13 +1687,13 @@ export default function HomePage() {
setStoryImportError(null);
if (!file) return;
if (file.size <= 0) {
setStoryImportError("这个剧情文件是空的。");
setStoryImportError(t("home.errors.emptyFile"));
return;
}
const isJson = file.name.toLowerCase().endsWith(".json") || file.type === "application/json";
const maxImportBytes = isJson ? 12_000_000 : 13_000_000;
if (file.size > maxImportBytes) {
setStoryImportError("剧情文件太大,无法载入。");
setStoryImportError(t("home.errors.fileTooLarge"));
return;
}
try {
@@ -1610,17 +1707,17 @@ export default function HomePage() {
});
if (!r.ok) {
const j = (await r.json().catch(() => ({}))) as { error?: string };
throw new Error(j.error ?? "剧情文件解包失败。");
throw new Error(j.error ?? t("home.errors.unpackFailed"));
}
const j = (await r.json()) as { docStr?: unknown };
if (typeof j.docStr !== "string") throw new Error("剧情文件解包失败。");
if (typeof j.docStr !== "string") throw new Error(t("home.errors.unpackFailed"));
text = j.docStr;
}
const doc = parseStoryShareDoc(JSON.parse(text));
window.sessionStorage.setItem(STORY_SHARE_STORAGE_KEY, JSON.stringify(doc));
router.push("/play?share=1");
} catch (e) {
setStoryImportError(e instanceof Error ? e.message : "剧情文件解析失败。");
setStoryImportError(e instanceof Error ? e.message : t("home.errors.parseFailed"));
} finally {
if (storyImportRef.current) storyImportRef.current.value = "";
}
@@ -1664,14 +1761,15 @@ export default function HomePage() {
Infi<em className="italic font-light text-ember-500">Plot</em>
</span>
<div className="flex items-center gap-4 md:gap-5">
<LanguageSwitcher variant="compact" />
<button
type="button"
onClick={() => {
setSettingsTab("general");
setSettingsOpen(true);
}}
aria-label="设置"
title="设置"
aria-label={t("home.ui.settings")}
title={t("home.ui.settings")}
className="text-base text-clay-500 hover:text-ember-500 transition-colors"
>
<i className="fa-solid fa-gear" />
@@ -1702,7 +1800,7 @@ export default function HomePage() {
<section className="px-6 md:px-16 pt-12 md:pt-24 pb-10 md:pb-14">
<div className="mx-auto max-w-[1100px] text-center">
<h1 className="font-serif font-light text-[32px] md:text-[56px] leading-[1.12] tracking-tight text-clay-900">
{t("home.hero.title")}
</h1>
{/* prompt 输入(居中) */}
@@ -1756,14 +1854,14 @@ export default function HomePage() {
>
<i className="fa-solid fa-file-import text-sm" />
<span className="pointer-events-none absolute -bottom-8 left-1/2 -translate-x-1/2 whitespace-nowrap rounded bg-clay-900 px-2 py-1 font-sans text-[11px] text-cream-50 opacity-0 transition-opacity group-hover:opacity-100">
{t("home.ui.loadStory")}
</span>
</button>
<button
type="submit"
className="inline-flex items-center gap-2 rounded-sm bg-clay-900 px-5 py-2 md:py-2.5 font-sans text-sm md:text-[15px] text-cream-50 transition-colors hover:bg-ember-500"
>
{t("home.ui.start")}
<i className="fa-solid fa-arrow-right text-xs" />
</button>
</div>
@@ -1775,7 +1873,7 @@ export default function HomePage() {
)}
{prompt && (
<p className="mt-2 text-right text-xs text-clay-400">
Enter · Shift+Enter
{t("home.hero.enterHint")}
</p>
)}
</form>
@@ -1785,8 +1883,9 @@ export default function HomePage() {
{OPTS.map((o, r) => (
<div data-cat key={r} className="text-left">
<CategorySelect
label={o.label}
label={optLabels[r] ?? o.label}
items={o.items}
itemLabels={optItemLabels[r] ?? o.items}
value={sel[r] ?? 0}
open={open === r}
onToggle={() => {
@@ -1810,16 +1909,14 @@ export default function HomePage() {
{/* 使用提示:可被用户永久关闭(localStorage:infiplot:hintClosed */}
{!hintClosed && (
<div className="relative mx-auto mt-10 md:mt-12 max-w-[640px] rounded-sm border border-clay-900/10 bg-cream-100/50 px-5 md:px-8 py-3.5">
<p className="font-serif text-[13px] md:text-sm leading-relaxed text-clay-500">
{AUTH_ENABLED && "(测试期间,登录即可免费畅玩)"}{" "}
<em className="not-italic text-ember-500">InfiPlot</em>
<span className="inline-flex items-center gap-1 text-ember-500"><i className="fa-solid fa-gear text-[10px]" /></span>
Key
</p>
<p
className="font-serif text-[13px] md:text-sm leading-relaxed text-clay-500"
dangerouslySetInnerHTML={{ __html: t("home.hint.text", { authEnabled: AUTH_ENABLED }) }}
/>
<button
type="button"
onClick={closeHint}
aria-label="不再显示此提示"
aria-label={t("home.hint.closeAriaLabel")}
className="absolute right-2 top-2 inline-flex h-6 w-6 items-center justify-center rounded-full text-clay-400 transition-colors hover:bg-clay-900/5 hover:text-clay-700"
>
<i className="fa-solid fa-xmark text-xs" />
@@ -1862,23 +1959,23 @@ export default function HomePage() {
<div className="mx-auto max-w-3xl text-center mb-14 md:mb-20">
<p className="font-serif text-clay-800 text-xl md:text-2xl leading-[1.7]">
<b className="font-medium text-clay-900">InfiPlot</b>{" "}
AI
{t("home.about.description")}
</p>
</div>
<div className="mx-auto grid max-w-4xl grid-cols-1 gap-y-10 text-center md:grid-cols-3 md:gap-x-10">
<div>
<p className="text-[10px] smallcaps text-clay-500 mb-3"> </p>
<p className="text-[10px] smallcaps text-clay-500 mb-3">{t("home.about.team")}</p>
<p className="font-serif italic text-clay-700 text-base leading-relaxed">
<span className="not-italic">oneshot</span>
{t("home.about.teamText")}
</p>
</div>
<div>
<p className="text-[10px] smallcaps text-clay-500 mb-3"> </p>
<p className="text-[10px] smallcaps text-clay-500 mb-3">{t("home.about.contact")}</p>
<p className="font-serif text-clay-700 text-base leading-relaxed">
<span className="block mb-2">
{" "}
{t("home.about.email")}{" "}
<a
href="mailto:hi@infiplot.com"
className="text-ember-500 hover:text-ember-400 transition-colors"
@@ -1896,7 +1993,7 @@ export default function HomePage() {
<span className="font-sans text-sm">@yzh_im</span>
</a>
</p>
<p className="text-[10px] smallcaps text-clay-500 mb-3 mt-7"> </p>
<p className="text-[10px] smallcaps text-clay-500 mb-3 mt-7">{t("home.about.openSource")}</p>
<a
href="https://github.com/zonghaoyuan/infiplot"
target="_blank"
@@ -1909,55 +2006,37 @@ export default function HomePage() {
</div>
<div>
<p className="text-[10px] smallcaps text-clay-500 mb-3"> </p>
<p className="text-[10px] smallcaps text-clay-500 mb-3">{t("home.about.betaUsers")}</p>
<img
src="/qq-group.webp"
alt="InfiPlot 公测交流群 QQ 群二维码(群号 575404333"
alt={t("home.about.qqGroupAlt")}
width={760}
height={760}
loading="lazy"
className="mx-auto mb-3 w-32 max-w-full rounded-sm border border-clay-900/10 shadow-sm shadow-clay-900/5"
/>
<p className="font-serif text-clay-700 text-base leading-relaxed">
QQ群号
{t("home.about.qqGroupLabel")}
<span className="font-sans text-sm text-clay-900">575404333</span>
</p>
</div>
</div>
<div className="hairline-full w-full mt-14 md:mt-20 mb-12 md:mb-16" />
<p className="mx-auto max-w-3xl text-center font-sans text-xs md:text-[13px] leading-[1.85] text-clay-500">
使
<br />
使
<br />
AI
{analyticsOn && (
<>
<br />
使{" "}
<a
href="https://umami.is/"
target="_blank"
rel="noopener noreferrer"
className="underline decoration-clay-900/20 underline-offset-2 transition-colors hover:text-clay-700"
>
Umami
</a>{" "}
访使 Cookie
</>
)}
</p>
<p
className="mx-auto max-w-3xl text-center font-sans text-xs md:text-[13px] leading-[1.85] text-clay-500"
dangerouslySetInnerHTML={{ __html: t("home.about.legalNotice", { analyticsOn }) }}
/>
</section>
<footer className="mx-auto w-full max-w-[1640px] px-6 md:px-16 pb-10 mt-auto">
<div className="hairline-full w-full mb-5" />
<div className="flex flex-col items-center gap-2 text-[10px] smallcaps text-clay-500">
<span>© 2026 InfiPlot. All rights reserved.</span>
<span>{t("home.about.copyright")}</span>
<span className="flex items-center gap-3 normal-case tracking-normal text-[11px]">
<a href="/privacy" className="hover:text-ember-500 transition-colors"></a>
<a href="/privacy" className="hover:text-ember-500 transition-colors">{t("home.about.privacyPolicy")}</a>
<span className="text-clay-300">·</span>
<a href="/terms" className="hover:text-ember-500 transition-colors"></a>
<a href="/terms" className="hover:text-ember-500 transition-colors">{t("home.about.terms")}</a>
</span>
</div>
</footer>
@@ -1965,6 +2044,7 @@ export default function HomePage() {
{styleOpen && styleRow >= 0 && (
<StyleModal
items={OPTS[styleRow]!.items}
itemLabels={optItemLabels[styleRow] ?? OPTS[styleRow]!.items}
value={sel[styleRow] ?? 0}
onPick={(i) => {
track("art_style_select", { style: ART_STYLES[i] ?? "自动" });
+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>
}
>