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
+18 -16
View File
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useState } from "react";
import { createClient } from "@/lib/supabase/client";
import { track } from "@/lib/analytics";
import { useI18n } from "@/lib/i18n/client";
type AuthStep = "pick" | "email-input" | "otp-verify";
@@ -25,6 +26,7 @@ export function AuthModal({
const [otp, setOtp] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const { t } = useI18n();
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
@@ -120,21 +122,21 @@ export function AuthModal({
}}
role="dialog"
aria-modal="true"
aria-label="登录"
aria-label={t("auth.ariaLabel")}
>
{/* header */}
<div className="flex items-center justify-between border-b border-cream-50/10 px-5 py-3.5">
<div className="flex items-center gap-2 text-[11px] smallcaps text-cream-50/70">
<i className="fa-solid fa-right-to-bracket text-[11px]" />
{step === "pick" && "登录以继续"}
{step === "email-input" && "邮箱登录"}
{step === "otp-verify" && "验证码"}
{step === "pick" && t("auth.steps.pick")}
{step === "email-input" && t("auth.steps.email")}
{step === "otp-verify" && t("auth.steps.otp")}
</div>
<button
type="button"
onClick={onClose}
className="flex h-7 w-7 items-center justify-center text-cream-50/60 transition-colors hover:text-cream-50"
aria-label="关闭"
aria-label={t("auth.close")}
>
<i className="fa-solid fa-xmark text-[12px]" />
</button>
@@ -154,7 +156,7 @@ export function AuthModal({
className="flex w-full items-center justify-center gap-2.5 rounded-md border border-cream-50/15 bg-cream-50/[0.06] px-4 py-2.5 text-[13px] text-cream-50/90 transition-colors hover:bg-cream-50/[0.12] disabled:opacity-50"
>
<i className="fa-brands fa-google text-[14px]" />
Google
{t("auth.googleLogin")}
</button>
<button
type="button"
@@ -163,11 +165,11 @@ export function AuthModal({
className="flex w-full items-center justify-center gap-2.5 rounded-md border border-cream-50/15 bg-cream-50/[0.06] px-4 py-2.5 text-[13px] text-cream-50/90 transition-colors hover:bg-cream-50/[0.12] disabled:opacity-50"
>
<i className="fa-brands fa-github text-[14px]" />
GitHub
{t("auth.githubLogin")}
</button>
<div className="flex items-center gap-3 py-1">
<div className="h-px flex-1 bg-cream-50/10" />
<span className="text-[10px] text-cream-50/40"></span>
<span className="text-[10px] text-cream-50/40">{t("auth.or")}</span>
<div className="h-px flex-1 bg-cream-50/10" />
</div>
<button
@@ -176,7 +178,7 @@ export function AuthModal({
className="flex w-full items-center justify-center gap-2.5 rounded-md border border-cream-50/15 bg-cream-50/[0.06] px-4 py-2.5 text-[13px] text-cream-50/90 transition-colors hover:bg-cream-50/[0.12]"
>
<i className="fa-solid fa-envelope text-[13px]" />
{t("auth.emailLogin")}
</button>
</>
)}
@@ -188,7 +190,7 @@ export function AuthModal({
value={email}
onChange={(e) => setEmail(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSendOtp()}
placeholder="your@email.com"
placeholder={t("auth.emailPlaceholder")}
autoFocus
className="w-full rounded-md border border-cream-50/15 bg-cream-50/[0.06] px-3.5 py-2.5 text-[13px] text-cream-50/90 placeholder:text-cream-50/30 outline-none focus:border-[rgba(175,138,72,0.6)]"
/>
@@ -198,7 +200,7 @@ export function AuthModal({
onClick={handleSendOtp}
className="w-full rounded-md bg-[rgba(175,138,72,0.85)] px-4 py-2.5 text-[13px] font-medium text-cream-50 transition-colors hover:bg-[rgba(175,138,72,1)] disabled:opacity-50"
>
{loading ? "发送中..." : "发送验证码"}
{loading ? t("auth.sending") : t("auth.sendCode")}
</button>
<button
type="button"
@@ -208,7 +210,7 @@ export function AuthModal({
}}
className="w-full text-center text-[12px] text-cream-50/50 transition-colors hover:text-cream-50/80"
>
{t("auth.back")}
</button>
</>
)}
@@ -216,7 +218,7 @@ export function AuthModal({
{step === "otp-verify" && (
<>
<p className="text-[12px] text-cream-50/60 leading-snug">
<span className="text-cream-50/90">{email.trim()}</span>
{t("auth.codeSent", { email: email.trim() })}
</p>
<input
type="text"
@@ -225,7 +227,7 @@ export function AuthModal({
value={otp}
onChange={(e) => setOtp(e.target.value.replace(/\D/g, ""))}
onKeyDown={(e) => e.key === "Enter" && handleVerifyOtp()}
placeholder="6 位验证码"
placeholder={t("auth.codePlaceholder")}
autoFocus
className="w-full rounded-md border border-cream-50/15 bg-cream-50/[0.06] px-3.5 py-2.5 text-center text-[16px] tracking-[0.35em] text-cream-50/90 placeholder:text-cream-50/30 placeholder:tracking-normal outline-none focus:border-[rgba(175,138,72,0.6)]"
/>
@@ -235,7 +237,7 @@ export function AuthModal({
onClick={handleVerifyOtp}
className="w-full rounded-md bg-[rgba(175,138,72,0.85)] px-4 py-2.5 text-[13px] font-medium text-cream-50 transition-colors hover:bg-[rgba(175,138,72,1)] disabled:opacity-50"
>
{loading ? "验证中..." : "确认"}
{loading ? t("auth.verifying") : t("auth.verify")}
</button>
<button
type="button"
@@ -246,7 +248,7 @@ export function AuthModal({
}}
className="w-full text-center text-[12px] text-cream-50/50 transition-colors hover:text-cream-50/80"
>
{t("auth.resend")}
</button>
</>
)}
+10 -8
View File
@@ -3,9 +3,11 @@
import { useRouter } from "next/navigation";
import { useState } from "react";
import { track } from "@/lib/analytics";
import { useI18n } from "@/lib/i18n/client";
export function CustomForm() {
const router = useRouter();
const { t } = useI18n();
const [worldSetting, setWorldSetting] = useState("");
const [styleGuide, setStyleGuide] = useState("");
const [submitting, setSubmitting] = useState(false);
@@ -35,7 +37,7 @@ export function CustomForm() {
<span className="text-clay-400 mr-2 font-serif italic not-italic font-normal">
</span>
World ·
{t("customForm.world")}
</span>
<span className="text-[10px] text-clay-400 num">
{worldSetting.length}
@@ -45,7 +47,7 @@ export function CustomForm() {
value={worldSetting}
onChange={(e) => setWorldSetting(e.target.value)}
rows={6}
placeholder="例:1990 年代末的中国南方县城。主角是高三转学生,在多雨的六月遇到一个总在天台读诗的同学。剧情慢热、含蓄、带点伤感⋯"
placeholder={t("customForm.worldPlaceholder")}
className="w-full bg-transparent border-0 border-b border-clay-900/20 px-0 py-3 text-clay-900 font-serif text-lg leading-[1.7] focus:outline-none focus:border-clay-700 transition-colors resize-none placeholder:font-serif placeholder:italic placeholder:text-base placeholder:leading-[1.7]"
/>
</div>
@@ -56,7 +58,7 @@ export function CustomForm() {
<span className="text-clay-400 mr-2 font-serif italic not-italic font-normal">
</span>
Style ·
{t("customForm.style")}
</span>
<span className="text-[10px] text-clay-400 num">
{styleGuide.length}
@@ -66,7 +68,7 @@ export function CustomForm() {
value={styleGuide}
onChange={(e) => setStyleGuide(e.target.value)}
rows={4}
placeholder="例:水彩柔光,午后暖意,动漫视觉小说画风,传统对话面板⋯"
placeholder={t("customForm.stylePlaceholder")}
className="w-full bg-transparent border-0 border-b border-clay-900/20 px-0 py-3 text-clay-900 font-serif text-lg leading-[1.7] focus:outline-none focus:border-clay-700 transition-colors resize-none placeholder:font-serif placeholder:italic placeholder:text-base placeholder:leading-[1.7]"
/>
</div>
@@ -74,17 +76,17 @@ export function CustomForm() {
<div className="pt-6 flex items-center justify-between">
<span className="text-[10px] smallcaps text-clay-500">
{submitting
? "正在唤起第一帧…"
? t("customForm.status.starting")
: canSubmit
? "准 · 备 · 就 · 绪"
: "两 · 段 · 即 · 可 · 开 · 场"}
? t("customForm.status.ready")
: t("customForm.status.needMore")}
</span>
<button
type="submit"
disabled={!canSubmit}
className="group flex items-center gap-3 text-[10px] smallcaps text-clay-900 disabled:text-clay-300 disabled:cursor-not-allowed enabled:hover:text-ember-500 transition-colors duration-300"
>
{t("customForm.start")}
<span className="w-10 h-px bg-current transition-all duration-300 group-enabled:group-hover:w-16" />
<i className="fa-solid fa-arrow-right text-[9px]" />
</button>
+10 -8
View File
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useRef } from "react";
import { useI18n } from "@/lib/i18n/client";
export type DialogueHistoryItem = {
id: string;
@@ -23,6 +24,7 @@ export function DialogueHistoryModal({
onClose: () => void;
playerName?: string;
}) {
const { t } = useI18n();
const displaySpeaker = (s: string | undefined) =>
s === "你" && playerName ? playerName : s;
const listRef = useRef<HTMLDivElement>(null);
@@ -63,19 +65,19 @@ export function DialogueHistoryModal({
}}
role="dialog"
aria-modal="true"
aria-label="剧情回溯"
aria-label={t("history.ariaLabel")}
>
<div className="flex items-center justify-between border-b border-cream-50/10 px-4 py-3">
<div className="flex items-center gap-2 text-[10px] smallcaps text-cream-50/70">
<i className="fa-solid fa-clock-rotate-left text-[10px]" />
· · ·
{t("history.title")}
</div>
<button
type="button"
onClick={onClose}
className="flex h-7 w-7 items-center justify-center text-cream-50/60 transition-colors hover:text-cream-50"
aria-label="关闭剧情回溯"
title="关闭"
aria-label={t("history.closeAriaLabel")}
title={t("history.close")}
>
<i className="fa-solid fa-xmark text-[12px]" />
</button>
@@ -89,7 +91,7 @@ export function DialogueHistoryModal({
>
{items.length === 0 ? (
<p className="py-8 text-center font-serif text-[13px] text-cream-50/55">
{t("history.noHistory")}
</p>
) : (
<div className="space-y-3">
@@ -97,7 +99,7 @@ export function DialogueHistoryModal({
<div key={item.id} className="text-left">
<div className="mb-1 flex items-baseline gap-2">
<span className="text-[9px] smallcaps text-cream-50/35">
{String(item.sceneIndex).padStart(3, "0")}
{t("history.scene", { n: String(item.sceneIndex).padStart(3, "0") })}
</span>
{item.speaker && (
<span className="font-serif text-[12px] text-[rgba(205,165,90,0.92)]">
@@ -128,7 +130,7 @@ export function DialogueHistoryModal({
{item.selectedChoice && (
<p className="mt-2 inline-flex max-w-full items-start gap-2 rounded-[5px] border border-[rgba(180,140,80,0.35)] bg-[rgba(180,140,60,0.10)] px-2.5 py-1.5 font-serif text-[12px] leading-snug text-cream-50/85">
<span className="shrink-0 text-[rgba(195,155,75,0.9)]">
{t("history.choice")}
</span>
<span>{item.selectedChoice}</span>
</p>
@@ -136,7 +138,7 @@ export function DialogueHistoryModal({
{item.freeformAction && (
<p className="mt-2 inline-flex max-w-full items-start gap-2 rounded-[5px] border border-ember-500/30 bg-ember-500/10 px-2.5 py-1.5 font-serif text-[12px] leading-snug text-cream-50/85">
<span className="shrink-0 text-ember-300/90">
{t("history.action")}
</span>
<span>{item.freeformAction}</span>
</p>
+111
View File
@@ -0,0 +1,111 @@
"use client";
import { useState } from "react";
import { useI18n } from "@/lib/i18n/client";
import { LOCALES, LOCALE_NAMES, type Locale } from "@/lib/i18n/config";
interface LanguageSwitcherProps {
className?: string;
/** "compact" = icon + short label, fits a header next to other icons.
* "full" = icon + full label + chevron, for a settings panel row. */
variant?: "compact" | "full";
}
// Locales with actual filled-in translations. The catalog ships stub files
// for the other 18 locales (so the loader doesn't 404), but only these
// three have been reviewed. Hide the rest until they're translated.
const TRANSLATED_LOCALES: Locale[] = ["zh-CN", "en", "ja"];
// Short labels for the compact header button — keeps the row tidy next to
// the gear/github/x icons where every other item is 1-2 glyphs.
const SHORT_LOCALE_NAMES: Record<Locale, string> = {
"zh-CN": "中文",
"zh-TW": "繁中",
"zh-HK": "繁中",
en: "EN",
ja: "日本語",
ko: "한국어",
es: "ES",
fr: "FR",
de: "DE",
"pt-BR": "PT",
pt: "PT",
ru: "RU",
it: "IT",
vi: "VI",
th: "TH",
id: "ID",
tr: "TR",
pl: "PL",
nl: "NL",
uk: "UK",
hi: "हिन्दी",
cs: "CZ",
};
export function LanguageSwitcher({ className = "", variant = "full" }: LanguageSwitcherProps) {
const { locale, setLocale, t } = useI18n();
const [isOpen, setIsOpen] = useState(false);
const currentLocaleName = LOCALE_NAMES[locale] || locale;
const currentShortName = SHORT_LOCALE_NAMES[locale] || locale;
const availableLocales = LOCALES.filter((l) => TRANSLATED_LOCALES.includes(l));
return (
<div className={`relative ${className}`}>
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className={
variant === "compact"
? "inline-flex items-center gap-1.5 text-base text-clay-500 hover:text-ember-500 transition-colors"
: "flex items-center gap-2 px-3 py-1.5 rounded-lg hover:bg-clay-100 transition-colors text-clay-700"
}
aria-label={t("language.select")}
title={t("language.select")}
aria-expanded={isOpen}
>
<i className="fa-solid fa-globe" />
<span className={variant === "compact" ? "text-[12px] font-sans" : "text-sm"}>
{variant === "compact" ? currentShortName : currentLocaleName}
</span>
{variant === "full" && (
<i
className={`fa-solid fa-chevron-down text-[9px] transition-transform ${isOpen ? "rotate-180" : ""}`}
/>
)}
</button>
{isOpen && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setIsOpen(false)}
aria-hidden="true"
/>
<div className="absolute right-0 top-full mt-1 w-44 overflow-hidden rounded-sm border border-clay-900/15 bg-cream-50 shadow-xl shadow-clay-900/10 z-20">
<div className="py-1">
{availableLocales.map((loc) => (
<button
key={loc}
type="button"
onClick={() => {
setLocale(loc);
setIsOpen(false);
}}
className={`flex w-full items-center justify-between px-3 py-1.5 text-sm font-serif transition-colors hover:bg-cream-100 ${
locale === loc ? "text-ember-500" : "text-clay-700"
}`}
>
{LOCALE_NAMES[loc]}
{locale === loc && <i className="fa-solid fa-check text-[10px]" />}
</button>
))}
</div>
</div>
</>
)}
</div>
);
}
+14 -12
View File
@@ -6,6 +6,7 @@ import {
type DialogueHistoryItem,
} from "@/components/DialogueHistoryModal";
import type { Beat, BeatChoice, Orientation } from "@infiplot/types";
import { useI18n } from "@/lib/i18n/client";
export type Phase =
| "loading-first" // first scene not yet rendered
@@ -216,6 +217,7 @@ export function PlayCanvas({
disabledChoiceIds?: readonly string[];
freeformDisabled?: boolean;
}) {
const { t } = useI18n();
const imgRef = useRef<HTMLImageElement>(null);
const audioRef = useRef<HTMLAudioElement>(null);
const [historyOpen, setHistoryOpen] = useState(false);
@@ -401,7 +403,7 @@ export function PlayCanvas({
src={imageUrl}
width={intrinsicW}
height={intrinsicH}
alt="Generated scene"
alt={t("play.imageAlt")}
onClick={handleImageClick}
draggable={false}
onLoad={() => {
@@ -492,7 +494,7 @@ export function PlayCanvas({
setFreeformText("");
}
}}
placeholder="输入你想说的或想做的..."
placeholder={t("play.freeform.placeholder")}
maxLength={50}
autoFocus
className="flex-1 min-w-0 bg-transparent border-none outline-none font-serif text-[14px] placeholder:text-[rgba(200,185,155,0.50)]"
@@ -531,7 +533,7 @@ export function PlayCanvas({
index={i}
label={choice.label}
disabled={phase !== "ready" || disabledChoices.has(choice.id)}
disabledTitle={disabledChoices.has(choice.id) ? "分享剧情未包含这条分支" : undefined}
disabledTitle={disabledChoices.has(choice.id) ? t("play.choiceDisabled") : undefined}
vertical={portrait}
onClick={() => onSelectChoice(choice)}
/>
@@ -554,7 +556,7 @@ export function PlayCanvas({
width: portrait ? "100%" : "42px",
padding: portrait ? "10px 16px" : "0",
}}
title="自由输入"
title={t("play.freeform.title")}
>
<span
className="opacity-0 group-hover:opacity-100 absolute inset-0 rounded-[5px] transition-opacity duration-200 pointer-events-none"
@@ -573,7 +575,7 @@ export function PlayCanvas({
className="font-serif text-[13px]"
style={{ color: "rgba(200,185,155,0.70)" }}
>
{t("play.freeform.title")}
</span>
</span>
) : (
@@ -667,8 +669,8 @@ export function PlayCanvas({
onOpenSettings();
}}
className="absolute bottom-[6px] right-[8px] flex h-7 w-7 items-center justify-center text-[rgba(195,155,75,0.78)] transition-colors hover:text-[rgba(245,235,210,0.96)]"
aria-label="打开设置"
title="设置"
aria-label={t("play.tooltips.openSettings")}
title={t("home.ui.settings")}
>
<i className="fa-solid fa-gear text-[12px]" />
</button>
@@ -683,8 +685,8 @@ export function PlayCanvas({
className={`absolute bottom-[6px] ${
onOpenSettings ? "right-[40px]" : "right-[8px]"
} flex h-7 w-7 items-center justify-center text-[rgba(195,155,75,0.78)] transition-colors hover:text-[rgba(245,235,210,0.96)]`}
aria-label="打开剧情回溯"
title="剧情回溯"
aria-label={t("play.tooltips.openHistory")}
title={t("play.tooltips.openHistory")}
>
<i className="fa-solid fa-clock-rotate-left text-[12px]" />
</button>
@@ -697,8 +699,8 @@ export function PlayCanvas({
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<p className="text-[10px] smallcaps text-cream-50/70 animate-slow-pulse">
{phase === "transitioning"
? "AI · 正 · 在 · 描 · 画 · 下 · 一 · 幕"
: "AI · 正 · 在 · 想 · 你 · 看 · 到 · 了 · 什 · 么"}
? t("play.loading.transitioning")
: t("play.loading.visionThinking")}
</p>
</div>
)}
@@ -742,7 +744,7 @@ export function PlayCanvas({
>
<div className="w-1.5 h-1.5 bg-clay-500 rounded-full animate-slow-pulse" />
<p className="text-[9px] smallcaps text-clay-500 animate-slow-pulse">
· · · · · ·
{t("play.loading.firstFrame")}
</p>
{/* 加载占位也挂同一对 slot,让右上 / 左上的操作按钮在第一帧就出现 */}
{!fullViewport && aboveCanvas && (
+70 -67
View File
@@ -18,6 +18,7 @@ import {
TTS_KEY_DOC_URL,
TTS_REGION_PRESETS,
} from "@/lib/ttsPresets";
import { useI18n } from "@/lib/i18n/client";
const PLAYER_NAME_STORAGE_KEY = "infiplot:playerName";
const VISION_CLICK_STORAGE_KEY = "infiplot:visionClick";
@@ -50,10 +51,10 @@ export function readStoredVisionClick(): boolean {
}
}
const PROVIDER_OPTIONS: { value: ProviderProtocol | ""; label: string }[] = [
{ value: "", label: "自动推断(推荐)" },
{ value: "openai_compatible", label: "OpenAI Compatible" },
{ value: "runware", label: "Runware" },
const PROVIDER_OPTIONS: { value: ProviderProtocol | ""; labelKey: string; fallback: string }[] = [
{ value: "", labelKey: "settings.models.providerAuto", fallback: "Auto-detect" },
{ value: "openai_compatible", labelKey: "", fallback: "OpenAI Compatible" },
{ value: "runware", labelKey: "", fallback: "Runware" },
];
type ModelGroup = {
@@ -85,6 +86,7 @@ export function SettingsModal({
}) => void;
footerNote?: ReactNode;
}) {
const { t } = useI18n();
const [activeTab, setActiveTab] = useState<TabKey>(initialTab);
// ── General tab state ──
@@ -96,7 +98,7 @@ export function SettingsModal({
const [groups, setGroups] = useState<ModelGroup[]>([
{
key: "text",
label: "文本模型",
label: "text",
icon: "fa-solid fa-pen-nib",
baseUrl: initial?.textBaseUrl ?? "",
apiKey: initial?.textApiKey ?? "",
@@ -105,7 +107,7 @@ export function SettingsModal({
},
{
key: "image",
label: "绘图模型",
label: "image",
icon: "fa-solid fa-palette",
baseUrl: initial?.imageBaseUrl ?? "",
apiKey: initial?.imageApiKey ?? "",
@@ -114,7 +116,7 @@ export function SettingsModal({
},
{
key: "vision",
label: "识图模型",
label: "vision",
icon: "fa-solid fa-eye",
baseUrl: initial?.visionBaseUrl ?? "",
apiKey: initial?.visionApiKey ?? "",
@@ -254,10 +256,17 @@ export function SettingsModal({
const hasAnySetting = hasGeneralSetting || hasModelSetting;
const tabs: { key: TabKey; label: string; icon: string }[] = [
{ key: "general", label: "通用", icon: "fa-solid fa-sliders" },
{ key: "models", label: "模型", icon: "fa-solid fa-microchip" },
{ key: "general", label: t("settings.tabs.general"), icon: "fa-solid fa-sliders" },
{ key: "models", label: t("settings.tabs.models"), icon: "fa-solid fa-microchip" },
];
const groupLabel = (k: string) =>
k === "text"
? t("settings.models.textModel")
: k === "image"
? t("settings.models.imageModel")
: t("settings.models.visionModel");
return (
<div
onMouseDown={close}
@@ -279,16 +288,16 @@ export function SettingsModal({
<div className="flex items-center gap-5 px-6 md:px-8 py-5 border-b border-clay-900/10">
<div className="flex flex-col">
<span className="font-serif text-xl md:text-2xl text-clay-900">
{t("settings.title")}
</span>
<span className="text-[11px] text-clay-500 mt-1 tracking-wide">
·
{t("settings.subtitle")}
</span>
</div>
<button
type="button"
onClick={close}
aria-label="关闭"
aria-label={t("home.ui.close")}
className="ml-auto text-xl leading-none text-clay-500 hover:text-clay-900 transition-colors"
>
<i className="fa-solid fa-xmark" />
@@ -329,7 +338,7 @@ export function SettingsModal({
<i className="fa-solid fa-user-pen text-[11px]" />
</span>
<span className="font-serif text-base text-clay-900">
{t("settings.general.playerName")}
</span>
</div>
<input
@@ -339,11 +348,11 @@ export function SettingsModal({
maxLength={20}
autoComplete="off"
spellCheck={false}
placeholder="不填则使用「你」"
placeholder={t("settings.general.playerNamePlaceholder")}
className="h-11 w-full rounded-sm border border-clay-900/15 bg-cream-100 px-4 font-sans text-sm text-clay-900 outline-none transition-colors focus:border-ember-500 placeholder:text-clay-400"
/>
<span className="text-[11px] text-clay-400">
NPC
{t("settings.general.playerNameHint")}
</span>
</div>
@@ -356,22 +365,22 @@ export function SettingsModal({
<i className="fa-solid fa-eye text-[11px]" />
</span>
<span className="font-serif text-base text-clay-900">
{t("settings.general.visionClick")}
</span>
</div>
<div className="grid grid-cols-2 gap-2">
{(
[
{ on: true, label: "开启", icon: "fa-solid fa-wand-magic-sparkles" },
{ on: false, label: "关闭", icon: "fa-solid fa-ban" },
{ on: true, labelKey: "settings.general.visionOn", icon: "fa-solid fa-wand-magic-sparkles" },
{ on: false, labelKey: "settings.general.visionOff", icon: "fa-solid fa-ban" },
] as const
).map((t) => {
const active = visionClick === t.on;
).map((opt) => {
const active = visionClick === opt.on;
return (
<button
key={String(t.on)}
key={String(opt.on)}
type="button"
onClick={() => setVisionClick(t.on)}
onClick={() => setVisionClick(opt.on)}
className={
"flex items-center justify-center gap-2 rounded-sm border px-3 py-2.5 text-[13px] transition-all " +
(active
@@ -379,14 +388,14 @@ export function SettingsModal({
: "border-clay-900/12 text-clay-600 hover:border-clay-900/35 hover:bg-cream-100")
}
>
<i className={t.icon + " text-[11px]"} />
{t.label}
<i className={opt.icon + " text-[11px]"} />
{t(opt.labelKey)}
</button>
);
})}
</div>
<span className="text-[11px] text-clay-400">
AI
{t("settings.general.visionHint")}
</span>
</div>
@@ -405,7 +414,7 @@ export function SettingsModal({
<div className="px-6 md:px-8 py-4">
<p className="text-[11px] leading-relaxed text-clay-400">
<i className="fa-solid fa-circle-info mr-1.5" />
API CORSOpenAIAnthropicGeminiRunware
{t("settings.models.corsNotice")}
</p>
</div>
@@ -422,13 +431,13 @@ export function SettingsModal({
<i className={`${g.icon} text-[11px]`} />
</span>
<span className="font-serif text-base text-clay-900">
{g.label}
{groupLabel(g.key)}
</span>
</div>
<div className="flex flex-col gap-2">
<span className="text-[10px] smallcaps text-clay-500">
BASE URL
{t("settings.models.baseUrl")}
</span>
<input
value={g.baseUrl}
@@ -443,7 +452,7 @@ export function SettingsModal({
<div className="flex flex-col gap-2">
<span className="text-[10px] smallcaps text-clay-500">
API Key
{t("settings.models.apiKey")}
</span>
<div className="relative">
<input
@@ -463,7 +472,7 @@ export function SettingsModal({
[g.key]: !prev[g.key],
}))
}
aria-label={showKeys[g.key] ? "隐藏" : "显示"}
aria-label={showKeys[g.key] ? t("settings.models.hide") : t("settings.models.show")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-clay-400 hover:text-clay-700 transition-colors"
>
<i
@@ -475,7 +484,7 @@ export function SettingsModal({
<div className="flex flex-col gap-2">
<span className="text-[10px] smallcaps text-clay-500">
Model
{t("settings.models.model")}
</span>
<input
value={g.model}
@@ -490,7 +499,7 @@ export function SettingsModal({
<div className="flex flex-col gap-2">
<span className="text-[10px] smallcaps text-clay-500">
Provider
{t("settings.models.provider")}
</span>
<select
value={g.provider}
@@ -499,12 +508,12 @@ export function SettingsModal({
>
{PROVIDER_OPTIONS.map((opt) => (
<option key={opt.value || "auto"} value={opt.value}>
{opt.label}
{opt.labelKey ? t(opt.labelKey) : opt.fallback}
</option>
))}
</select>
<span className="text-[11px] text-clay-400">
Base URL
{t("settings.models.providerHint")}
</span>
</div>
</div>
@@ -520,43 +529,39 @@ export function SettingsModal({
<i className="fa-solid fa-volume-high text-[11px]" />
</span>
<span className="font-serif text-base text-clay-900">
{t("settings.tts.title")}
</span>
</div>
<p className="text-[12px] leading-relaxed text-clay-500">
<span className="text-clay-800"> MiMo API Key</span>
Key MiMo
TTS
<span className="text-clay-800"></span>
使
</p>
<p
className="text-[12px] leading-relaxed text-clay-500"
dangerouslySetInnerHTML={{ __html: t("settings.tts.description") }}
/>
<div className="flex flex-col gap-2">
<span className="text-[10px] smallcaps text-clay-500">
Key
{t("settings.tts.keyType")}
</span>
<div className="grid grid-cols-2 gap-2">
{(
[
{
kind: "payg",
label: "按量付费 Pay-as-you-go",
sub: "sk- 开头",
labelKey: "settings.tts.payg",
subKey: "settings.tts.paygSub",
},
{
kind: "token-plan",
label: "套餐 Token Plan",
sub: "tp- 开头",
labelKey: "settings.tts.tokenPlan",
subKey: "settings.tts.tokenPlanSub",
},
] as const
).map((t) => {
const active = keyType === t.kind;
).map((opt) => {
const active = keyType === opt.kind;
return (
<button
key={t.kind}
key={opt.kind}
type="button"
onClick={() => setKeyType(t.kind)}
onClick={() => setKeyType(opt.kind)}
className={
"flex flex-col gap-0.5 rounded-sm border px-3 py-2.5 text-left transition-all " +
(active
@@ -564,9 +569,9 @@ export function SettingsModal({
: "border-clay-900/12 text-clay-600 hover:border-clay-900/35 hover:bg-cream-100")
}
>
<span className="text-[13px]">{t.label}</span>
<span className="text-[13px]">{t(opt.labelKey)}</span>
<span className="text-[10px] text-clay-400">
{t.sub}
{t(opt.subKey)}
</span>
</button>
);
@@ -577,7 +582,7 @@ export function SettingsModal({
{keyType === "token-plan" && (
<div className="flex flex-col gap-2">
<span className="text-[10px] smallcaps text-clay-500">
{t("settings.tts.region")}
</span>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
{TTS_REGION_PRESETS.map((p) => {
@@ -600,14 +605,14 @@ export function SettingsModal({
})}
</div>
<span className="text-[11px] text-clay-400">
{t("settings.tts.regionHint")}
</span>
</div>
)}
<div className="flex flex-col gap-2">
<span className="text-[10px] smallcaps text-clay-500">
API Key
{t("settings.models.apiKey")}
</span>
<div className="relative">
<input
@@ -618,15 +623,15 @@ export function SettingsModal({
spellCheck={false}
placeholder={
keyType === "payg"
? "粘贴 sk- 开头的按量 Key"
: "粘贴 tp- 开头的套餐 Key"
? t("settings.tts.apiKeyPlaceholderPayg")
: t("settings.tts.apiKeyPlaceholderToken")
}
className="h-11 w-full rounded-sm border border-clay-900/15 bg-cream-100 pl-4 pr-11 font-sans text-sm text-clay-900 outline-none transition-colors focus:border-ember-500 placeholder:text-clay-400"
/>
<button
type="button"
onClick={() => setShowTtsKey((v) => !v)}
aria-label={showTtsKey ? "隐藏" : "显示"}
aria-label={showTtsKey ? t("settings.models.hide") : t("settings.models.show")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-clay-400 hover:text-clay-700 transition-colors"
>
<i
@@ -637,11 +642,9 @@ export function SettingsModal({
{prefixMismatch && (
<span className="flex items-start gap-1.5 text-[11px] leading-relaxed text-ember-500">
<i className="fa-solid fa-triangle-exclamation mt-0.5 text-[10px]" />
Key {expectedPrefix}
{keyType === "payg"
? "按量付费 Pay-as-you-go"
: "套餐 Token Plan"}
? t("settings.tts.keyMismatchPayg")
: t("settings.tts.keyMismatchToken")}
</span>
)}
<a
@@ -651,7 +654,7 @@ export function SettingsModal({
className="inline-flex items-center gap-1.5 text-[11px] text-ember-500 hover:text-ember-400 transition-colors"
>
<i className="fa-brands fa-github text-[11px]" />
Key
{t("settings.tts.tutorialLink")}
</a>
</div>
</div>
@@ -668,7 +671,7 @@ export function SettingsModal({
className="inline-flex items-center gap-2 rounded-sm border border-clay-900/15 px-4 py-2 font-sans text-sm text-clay-600 transition-colors hover:border-clay-900/35 hover:text-clay-900"
>
<i className="fa-solid fa-rotate-left text-xs" />
{t("settings.actions.clearAll")}
</button>
)}
<button
@@ -677,7 +680,7 @@ export function SettingsModal({
className="ml-auto inline-flex items-center gap-2 rounded-sm bg-clay-900 px-5 py-2.5 font-sans text-sm text-cream-50 transition-colors hover:bg-ember-500"
>
<i className="fa-solid fa-check text-xs" />
{t("settings.actions.save")}
</button>
</div>
</div>