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:
+18
-16
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 && (
|
||||
|
||||
@@ -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 端点支持浏览器跨域请求(CORS)。大多数主流提供商(OpenAI、Anthropic、Gemini、Runware 等)已默认支持。
|
||||
{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>
|
||||
|
||||
Reference in New Issue
Block a user