feat(play): integrate vision click with unified settings modal
Merge vision-click toggle into the shared SettingsModal alongside player name and TTS key configuration. Remove standalone TtsKeyModal. Add settings gear button to PlayCanvas dialogue card and header. Fix fullscreen settings modal not rendering in immersive mode. Voice toggle uses standard CategorySelect dropdown matching other tab bar options. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+28
-28
@@ -11,7 +11,7 @@ import {
|
||||
type Gender,
|
||||
} from "@/lib/options";
|
||||
import { readStoredTtsConfig } from "@/lib/clientTtsConfig";
|
||||
import { SettingsModal, readStoredPlayerName } from "@/components/SettingsModal";
|
||||
import { SettingsModal, readStoredPlayerName, readStoredVisionClick } from "@/components/SettingsModal";
|
||||
|
||||
/* ============================================================================
|
||||
InfiPlot · 首页(编辑式视觉风格 · 居中构图,呼应低保真原型)
|
||||
@@ -47,6 +47,7 @@ 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 },
|
||||
];
|
||||
|
||||
@@ -1252,13 +1253,14 @@ export default function HomePage() {
|
||||
// 顶部使用提示:默认展示,用户可点 × 永久关闭(localStorage:infiplot:hintClosed)。
|
||||
const [hintClosed, setHintClosed] = useState(false);
|
||||
|
||||
// 统一设置弹窗(名字 + 配音 + TTS Key):可选增强,数据只存浏览器。
|
||||
// 统一设置弹窗(名字 + 识图 + TTS Key):可选增强,数据只存浏览器。
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [ttsConfigured, setTtsConfigured] = useState(false);
|
||||
const [playerName, setPlayerName] = useState("");
|
||||
const [audioEnabled, setAudioEnabled] = useState(true);
|
||||
const [visionClickEnabled, setVisionClickEnabled] = useState(true);
|
||||
|
||||
const styleRow = OPTS.findIndex((o) => o.modal);
|
||||
const voiceRow = OPTS.findIndex((o) => o.label === "语音配音");
|
||||
const genderIndex = sel[0] ?? 0;
|
||||
const gender = (OPTS[0]!.items[genderIndex] as Gender) ?? "男性向";
|
||||
const phrases = EXAMPLE_PHRASES[gender];
|
||||
@@ -1300,14 +1302,11 @@ export default function HomePage() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 启动时回填配置状态——读 localStorage 判断用户是否已存过 Key / 名字 / 配音偏好。
|
||||
// 启动时回填配置状态——读 localStorage 判断用户是否已存过 Key / 名字。
|
||||
useEffect(() => {
|
||||
setTtsConfigured(readStoredTtsConfig() != null);
|
||||
setPlayerName(readStoredPlayerName());
|
||||
try {
|
||||
const stored = localStorage.getItem("infiplot:muted");
|
||||
if (stored === "1") setAudioEnabled(false);
|
||||
} catch { /* ignore */ }
|
||||
setVisionClickEnabled(readStoredVisionClick());
|
||||
}, []);
|
||||
|
||||
// 输入框随内容自动增高:长文本整段可见(打字与点卡片填入都覆盖)。
|
||||
@@ -1334,7 +1333,9 @@ export default function HomePage() {
|
||||
prompt.trim() || (phrases[phraseIdx] ?? "").trim();
|
||||
const artStyle = ART_STYLES[sel[1] ?? 0] ?? "自动";
|
||||
const plotStyle = PLOT_STYLES[sel[2] ?? 1] ?? "多线转折";
|
||||
const pace = PACINGS[sel[3] ?? 1] ?? "紧凑爽快";
|
||||
const voice = OPTS[voiceRow]!.items[sel[voiceRow] ?? 1]!;
|
||||
const audioEnabled = voice === "开启";
|
||||
const pace = PACINGS[sel[4] ?? 1] ?? "紧凑爽快";
|
||||
|
||||
// worldSetting 顺序很重要:玩家输入若存在,必须放在最前面、单独成段、
|
||||
// 用强指令包住,否则模型会把它当成夹在风格说明里的背景参考、扩写出
|
||||
@@ -1407,6 +1408,8 @@ export default function HomePage() {
|
||||
// 其余选项(剧情风格 / 内容节奏)在预烘焙时已锁成「多线转折 / 紧凑爽快」
|
||||
// 的红果默认基调,对精选卡不再生效。
|
||||
const onCardClick = (idx: number, _card: StoryContent) => {
|
||||
const voice = OPTS[voiceRow]!.items[sel[voiceRow] ?? 1]!;
|
||||
const audioEnabled = voice === "开启";
|
||||
sessionStorage.setItem(
|
||||
"infiplot:custom",
|
||||
JSON.stringify({ worldSetting: "", styleGuide: "", audioEnabled, playerName }),
|
||||
@@ -1428,6 +1431,15 @@ export default function HomePage() {
|
||||
Infi<em className="italic font-light text-ember-500">Plot</em>
|
||||
</span>
|
||||
<div className="flex items-center gap-5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
aria-label="设置"
|
||||
title="设置"
|
||||
className="text-base text-clay-500 hover:text-ember-500 transition-colors"
|
||||
>
|
||||
<i className="fa-solid fa-gear" />
|
||||
</button>
|
||||
<a
|
||||
href="https://github.com/zonghaoyuan/infiplot"
|
||||
target="_blank"
|
||||
@@ -1528,23 +1540,6 @@ export default function HomePage() {
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{/* 设置入口:与 CategorySelect 视觉一致,点击打开 modal */}
|
||||
<div className="text-left">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
className="group flex items-center gap-2.5 pb-1.5 border-b border-clay-900/20 hover:border-clay-900/45 transition-colors"
|
||||
>
|
||||
<span className="text-[10px] smallcaps text-clay-500">设置</span>
|
||||
<span className={
|
||||
"font-serif text-base md:text-lg " +
|
||||
(ttsConfigured || playerName ? "text-ember-500" : "text-clay-900")
|
||||
}>
|
||||
{playerName || (ttsConfigured ? "已配置" : "未配置")}
|
||||
</span>
|
||||
<i className="fa-solid fa-gear text-[9px] text-clay-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 使用提示:可被用户永久关闭(localStorage:infiplot:hintClosed) */}
|
||||
@@ -1714,12 +1709,17 @@ export default function HomePage() {
|
||||
)}
|
||||
{settingsOpen && (
|
||||
<SettingsModal
|
||||
initialAudioEnabled={audioEnabled}
|
||||
initialVisionClickEnabled={visionClickEnabled}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
onSaved={(settings) => {
|
||||
setTtsConfigured(settings.ttsConfigured);
|
||||
setPlayerName(settings.playerName);
|
||||
setAudioEnabled(settings.audioEnabled);
|
||||
setVisionClickEnabled(settings.visionClickEnabled);
|
||||
if (settings.ttsConfigured && voiceRow >= 0) {
|
||||
const onIdx = OPTS[voiceRow]!.items.indexOf("开启");
|
||||
if (onIdx >= 0)
|
||||
setSel((s) => s.map((v, j) => (j === voiceRow ? onIdx : v)));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
+29
-20
@@ -17,8 +17,7 @@ import {
|
||||
} from "@/components/PlayCanvas";
|
||||
import type { DialogueHistoryItem } from "@/components/DialogueHistoryModal";
|
||||
import type { GalleryDoc, GalleryScene } from "@/app/gallery/page";
|
||||
import { TtsKeyModal } from "@/components/TtsKeyModal";
|
||||
import { readStoredPlayerName } from "@/components/SettingsModal";
|
||||
import { SettingsModal, readStoredPlayerName, readStoredVisionClick } from "@/components/SettingsModal";
|
||||
import { annotateClick } from "@/lib/annotateClient";
|
||||
import { loadClientTtsConfig } from "@/lib/clientTtsConfig";
|
||||
import { PRESETS } from "@/lib/presets";
|
||||
@@ -580,9 +579,11 @@ function PlayInner() {
|
||||
const [silenceStrikes, setSilenceStrikes] = useState(0);
|
||||
// Once the player dismisses the silence nudge, keep it gone for this session.
|
||||
const [nudgeDismissed, setNudgeDismissed] = useState(false);
|
||||
// The in-place BYO-key modal, opened from the silence nudge so the player can
|
||||
// add a key without leaving the play page.
|
||||
const [ttsModalOpen, setTtsModalOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [visionClickEnabled, setVisionClickEnabled] = useState<boolean>(() => {
|
||||
if (typeof window === "undefined") return true;
|
||||
return readStoredVisionClick();
|
||||
});
|
||||
|
||||
const startedRef = useRef(false);
|
||||
const poolRef = useRef<Map<string, PrefetchEntry>>(new Map());
|
||||
@@ -852,15 +853,10 @@ function PlayInner() {
|
||||
prefetchSceneAudio();
|
||||
}, [muted, prefetchSceneAudio]);
|
||||
|
||||
// ── BYO key enabled/disabled from the play page (silence nudge → modal) ─
|
||||
// On enable: point the synth path at the user's key and immediately
|
||||
// re-synthesize the current scene in-browser, so the voices the player just
|
||||
// missed come back without a reload (their characters already carry
|
||||
// server-provisioned `voice`, which resolveByoVoice reuses with the new key).
|
||||
// On disable: just stop using it; later scenes fall back to the server.
|
||||
const handleByoSaved = useCallback(
|
||||
(configured: boolean) => {
|
||||
const cfg = configured ? loadClientTtsConfig() : null;
|
||||
const handleSettingsSaved = useCallback(
|
||||
(settings: { ttsConfigured: boolean; playerName: string; visionClickEnabled: boolean }) => {
|
||||
setVisionClickEnabled(settings.visionClickEnabled);
|
||||
const cfg = settings.ttsConfigured ? loadClientTtsConfig() : null;
|
||||
byoTtsRef.current = cfg;
|
||||
setByoTtsConfig(cfg);
|
||||
if (cfg) {
|
||||
@@ -1761,6 +1757,8 @@ function PlayInner() {
|
||||
onFreeformInput={onFreeformInput}
|
||||
orientation={orientation}
|
||||
playerName={session?.playerName}
|
||||
visionClickEnabled={visionClickEnabled}
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
fullViewport
|
||||
dialogueHistory={dialogueHistory}
|
||||
/>
|
||||
@@ -1788,6 +1786,14 @@ function PlayInner() {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{settingsOpen && (
|
||||
<SettingsModal
|
||||
initialVisionClickEnabled={visionClickEnabled}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
onSaved={handleSettingsSaved}
|
||||
footerNote="保存后配音 Key 会立即生效,用你自己的额度合成当前这一幕的配音。"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1838,6 +1844,8 @@ function PlayInner() {
|
||||
onFreeformInput={onFreeformInput}
|
||||
orientation={orientation}
|
||||
playerName={session?.playerName}
|
||||
visionClickEnabled={visionClickEnabled}
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
dialogueHistory={dialogueHistory}
|
||||
aboveCanvas={
|
||||
<button
|
||||
@@ -1887,7 +1895,7 @@ function PlayInner() {
|
||||
<span className="flex items-center gap-1 animate-fade-in">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTtsModalOpen(true)}
|
||||
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="经常没声音?填入你自己的小米 MiMo Key(免费),配音更稳定"
|
||||
>
|
||||
@@ -1925,11 +1933,12 @@ function PlayInner() {
|
||||
|
||||
</main>
|
||||
|
||||
{ttsModalOpen && (
|
||||
<TtsKeyModal
|
||||
onClose={() => setTtsModalOpen(false)}
|
||||
onSaved={handleByoSaved}
|
||||
footerNote="保存后会立即用这把 Key 在你的浏览器里合成当前这一幕的配音;本设备后续游玩也会自动使用此 Key。"
|
||||
{settingsOpen && (
|
||||
<SettingsModal
|
||||
initialVisionClickEnabled={visionClickEnabled}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
onSaved={handleSettingsSaved}
|
||||
footerNote="保存后配音 Key 会立即生效,用你自己的额度合成当前这一幕的配音。"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+38
-21
@@ -178,6 +178,8 @@ export function PlayCanvas({
|
||||
fullViewport = false,
|
||||
orientation = "landscape",
|
||||
playerName,
|
||||
visionClickEnabled = true,
|
||||
onOpenSettings,
|
||||
aboveCanvas,
|
||||
aboveCanvasLeft,
|
||||
belowCanvas,
|
||||
@@ -197,6 +199,9 @@ export function PlayCanvas({
|
||||
// 会话锁定的图片朝向。"portrait" 时整图铺满视口(object-fit:cover)、选项竖排、字号放大。
|
||||
orientation?: Orientation;
|
||||
playerName?: string;
|
||||
// 选择节点点击背景是否触发识图。关闭时背景点击保持静默,用户只能点选项。
|
||||
visionClickEnabled?: boolean;
|
||||
onOpenSettings?: () => void;
|
||||
// 渲染在图片正上方、右对齐的 slot(画面外、紧贴右上角)。
|
||||
aboveCanvas?: ReactNode;
|
||||
// 渲染在图片正上方、左对齐的 slot(画面外、紧贴左上角),与 aboveCanvas 水平镜像。
|
||||
@@ -276,14 +281,18 @@ export function PlayCanvas({
|
||||
}
|
||||
|
||||
function handleImageClick(e: React.MouseEvent<HTMLImageElement>) {
|
||||
if (phase !== "ready" || !imgRef.current || !beat) return;
|
||||
if (phase !== "ready" || !beat) return;
|
||||
if (!typingDone) {
|
||||
skipTypewriter();
|
||||
return;
|
||||
}
|
||||
if (beat.next.type === "continue") {
|
||||
onAdvance();
|
||||
return;
|
||||
}
|
||||
if (!visionClickEnabled || !imgRef.current) return;
|
||||
const el = imgRef.current;
|
||||
const rect = el.getBoundingClientRect();
|
||||
// Portrait renders with object-fit:cover, which scales the 9:16 image to
|
||||
// FILL the box and crops the overflow — so the rendered box ≠ the full
|
||||
// image. Map the click from box-space back into full-image-space via the
|
||||
// cover geometry so the marker lands where the user tapped. Landscape's box
|
||||
// matches the image aspect (no crop), so it keeps simple normalization.
|
||||
let x: number;
|
||||
let y: number;
|
||||
if (orientation === "portrait") {
|
||||
@@ -298,18 +307,6 @@ export function PlayCanvas({
|
||||
x = (e.clientX - rect.left) / rect.width;
|
||||
y = (e.clientY - rect.top) / rect.height;
|
||||
}
|
||||
// If the typewriter is still printing, a click completes it instantly
|
||||
// (standard VN affordance) — the page never sees this click.
|
||||
if (!typingDone) {
|
||||
skipTypewriter();
|
||||
return;
|
||||
}
|
||||
// For continue-type beats, image click advances; for choice beats,
|
||||
// image click goes through vision (treat as freeform action).
|
||||
if (beat.next.type === "continue") {
|
||||
onAdvance();
|
||||
return;
|
||||
}
|
||||
onBackgroundClick({
|
||||
x: Math.max(0, Math.min(1, x)),
|
||||
y: Math.max(0, Math.min(1, y)),
|
||||
@@ -329,6 +326,9 @@ export function PlayCanvas({
|
||||
}
|
||||
|
||||
const interactive = phase === "ready" && !!imageUrl;
|
||||
const imageClickable =
|
||||
interactive &&
|
||||
(!typingDone || beat?.next.type === "continue" || visionClickEnabled);
|
||||
const dimmed = phase === "transitioning";
|
||||
|
||||
const portrait = orientation === "portrait";
|
||||
@@ -393,7 +393,7 @@ export function PlayCanvas({
|
||||
onClick={handleImageClick}
|
||||
draggable={false}
|
||||
className={`block ${portrait ? "" : "w-auto h-auto"} select-none animate-fade-in transition-opacity duration-700 ease-out ${
|
||||
interactive ? "cursor-pointer" : "cursor-wait"
|
||||
imageClickable ? "cursor-pointer" : interactive ? "cursor-default" : "cursor-wait"
|
||||
} ${dimmed ? "opacity-40" : "opacity-100"}`}
|
||||
style={sizeStyle}
|
||||
/>
|
||||
@@ -631,7 +631,7 @@ export function PlayCanvas({
|
||||
|
||||
{typingDone && beat.next.type === "continue" && (
|
||||
<span
|
||||
className="absolute bottom-[6px] right-[42px] text-[10px] animate-slow-pulse"
|
||||
className={`absolute bottom-[6px] ${onOpenSettings ? "right-[74px]" : "right-[42px]"} text-[10px] animate-slow-pulse`}
|
||||
style={{ color: "rgba(195,155,75,0.7)" }}
|
||||
aria-hidden
|
||||
>
|
||||
@@ -639,13 +639,30 @@ export function PlayCanvas({
|
||||
</span>
|
||||
)}
|
||||
|
||||
{onOpenSettings && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
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="设置"
|
||||
>
|
||||
<i className="fa-solid fa-gear text-[12px]" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setHistoryOpen(true);
|
||||
}}
|
||||
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)]"
|
||||
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="剧情回溯"
|
||||
>
|
||||
|
||||
+171
-160
@@ -14,6 +14,7 @@ import {
|
||||
} from "@/lib/ttsPresets";
|
||||
|
||||
const PLAYER_NAME_STORAGE_KEY = "infiplot:playerName";
|
||||
const VISION_CLICK_STORAGE_KEY = "infiplot:visionClick";
|
||||
|
||||
export function readStoredPlayerName(): string {
|
||||
try {
|
||||
@@ -35,15 +36,23 @@ export function writeStoredPlayerName(name: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function readStoredVisionClick(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(VISION_CLICK_STORAGE_KEY) !== "0";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function SettingsModal({
|
||||
initialAudioEnabled = true,
|
||||
initialVisionClickEnabled = true,
|
||||
onClose,
|
||||
onSaved,
|
||||
footerNote,
|
||||
}: {
|
||||
initialAudioEnabled?: boolean;
|
||||
initialVisionClickEnabled?: boolean;
|
||||
onClose: () => void;
|
||||
onSaved: (settings: { ttsConfigured: boolean; playerName: string; audioEnabled: boolean }) => void;
|
||||
onSaved: (settings: { ttsConfigured: boolean; playerName: string; visionClickEnabled: boolean }) => void;
|
||||
footerNote?: ReactNode;
|
||||
}) {
|
||||
const [initialTts] = useState(() => readStoredTtsConfig());
|
||||
@@ -59,7 +68,7 @@ export function SettingsModal({
|
||||
const ttsAlreadyConfigured = initialTts != null;
|
||||
|
||||
const [playerName, setPlayerName] = useState(() => readStoredPlayerName());
|
||||
const [voiceOn, setVoiceOn] = useState(initialAudioEnabled);
|
||||
const [visionClick, setVisionClick] = useState(initialVisionClickEnabled);
|
||||
|
||||
const [shown, setShown] = useState(false);
|
||||
|
||||
@@ -82,7 +91,7 @@ export function SettingsModal({
|
||||
writeStoredPlayerName(name);
|
||||
|
||||
try {
|
||||
localStorage.setItem("infiplot:muted", voiceOn ? "0" : "1");
|
||||
localStorage.setItem(VISION_CLICK_STORAGE_KEY, visionClick ? "1" : "0");
|
||||
} catch { /* ignore */ }
|
||||
|
||||
const key = apiKey.trim();
|
||||
@@ -97,18 +106,15 @@ export function SettingsModal({
|
||||
ttsConfigured = true;
|
||||
}
|
||||
|
||||
if (ttsConfigured && !voiceOn) setVoiceOn(true);
|
||||
const finalVoiceOn = ttsConfigured ? true : voiceOn;
|
||||
|
||||
onSaved({ ttsConfigured, playerName: name, audioEnabled: finalVoiceOn });
|
||||
onSaved({ ttsConfigured, playerName: name, visionClickEnabled: visionClick });
|
||||
close();
|
||||
};
|
||||
|
||||
const clearAll = () => {
|
||||
clearStoredTtsConfig();
|
||||
writeStoredPlayerName("");
|
||||
try { localStorage.removeItem("infiplot:muted"); } catch { /* ignore */ }
|
||||
onSaved({ ttsConfigured: false, playerName: "", audioEnabled: true });
|
||||
try { localStorage.removeItem(VISION_CLICK_STORAGE_KEY); } catch { /* ignore */ }
|
||||
onSaved({ ttsConfigured: false, playerName: "", visionClickEnabled: true });
|
||||
close();
|
||||
};
|
||||
|
||||
@@ -179,29 +185,29 @@ export function SettingsModal({
|
||||
|
||||
<div className="border-t border-clay-900/8 mx-6 md:mx-8" />
|
||||
|
||||
{/* ── Voice Section (toggle + key as child) ── */}
|
||||
<div className="flex flex-col gap-3 px-6 md:px-8 pt-5 pb-5">
|
||||
{/* ── Vision Click Section ── */}
|
||||
<div className="flex flex-col gap-3 px-6 md:px-8 py-5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-sm border border-clay-900/10 bg-cream-100 text-clay-400">
|
||||
<i className="fa-solid fa-volume-high text-[11px]" />
|
||||
<i className="fa-solid fa-eye text-[11px]" />
|
||||
</span>
|
||||
<span className="font-serif text-base text-clay-900">
|
||||
语音配音
|
||||
点击画面识别
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(
|
||||
[
|
||||
{ on: true, label: "开启", icon: "fa-solid fa-volume-high" },
|
||||
{ on: false, label: "关闭", icon: "fa-solid fa-volume-xmark" },
|
||||
{ on: true, label: "开启", icon: "fa-solid fa-wand-magic-sparkles" },
|
||||
{ on: false, label: "关闭", icon: "fa-solid fa-ban" },
|
||||
] as const
|
||||
).map((t) => {
|
||||
const active = voiceOn === t.on;
|
||||
const active = visionClick === t.on;
|
||||
return (
|
||||
<button
|
||||
key={String(t.on)}
|
||||
type="button"
|
||||
onClick={() => setVoiceOn(t.on)}
|
||||
onClick={() => setVisionClick(t.on)}
|
||||
className={
|
||||
"flex items-center justify-center gap-2 rounded-sm border px-3 py-2.5 text-[13px] transition-all " +
|
||||
(active
|
||||
@@ -215,155 +221,160 @@ export function SettingsModal({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span className="text-[11px] text-clay-400">
|
||||
开启后,在选择节点点击画面会触发 AI 识图并生成新的剧情分支。
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ── TTS Key (sub-section, only when voice is on) ── */}
|
||||
{voiceOn && (
|
||||
<div className="mt-3 flex flex-col gap-4 rounded-sm border border-clay-900/8 bg-cream-100/40 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<i className="fa-solid fa-key text-[10px] text-clay-400" />
|
||||
<span className="text-[13px] text-clay-800">
|
||||
自带配音 Key
|
||||
</span>
|
||||
<span className="text-[10px] text-clay-400">可选</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>
|
||||
<div className="border-t border-clay-900/8 mx-6 md:mx-8" />
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-[10px] smallcaps text-clay-500">
|
||||
K e y · 类 型
|
||||
</span>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(
|
||||
[
|
||||
{
|
||||
kind: "payg",
|
||||
label: "按量付费 Pay-as-you-go",
|
||||
sub: "sk- 开头",
|
||||
},
|
||||
{
|
||||
kind: "token-plan",
|
||||
label: "套餐 Token Plan",
|
||||
sub: "tp- 开头",
|
||||
},
|
||||
] as const
|
||||
).map((t) => {
|
||||
const active = keyType === t.kind;
|
||||
return (
|
||||
<button
|
||||
key={t.kind}
|
||||
type="button"
|
||||
onClick={() => setKeyType(t.kind)}
|
||||
className={
|
||||
"flex flex-col gap-0.5 rounded-sm border px-3 py-2.5 text-left transition-all " +
|
||||
(active
|
||||
? "border-ember-500 bg-ember-500/5 text-clay-900"
|
||||
: "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-[10px] text-clay-400">
|
||||
{t.sub}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* ── TTS Key Section ── */}
|
||||
<div className="flex flex-col gap-3 px-6 md:px-8 pt-5 pb-5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-sm border border-clay-900/10 bg-cream-100 text-clay-400">
|
||||
<i className="fa-solid fa-key text-[11px]" />
|
||||
</span>
|
||||
<span className="font-serif text-base text-clay-900">
|
||||
自带配音 Key
|
||||
</span>
|
||||
<span className="text-[10px] text-clay-400">可选</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>
|
||||
|
||||
{keyType === "token-plan" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-[10px] smallcaps text-clay-500">
|
||||
区 域 节 点
|
||||
</span>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
{TTS_REGION_PRESETS.map((p) => {
|
||||
const active = p.id === regionId;
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => setRegionId(p.id)}
|
||||
className={
|
||||
"rounded-sm border px-3 py-2.5 text-left text-[13px] transition-all " +
|
||||
(active
|
||||
? "border-ember-500 bg-ember-500/5 text-clay-900"
|
||||
: "border-clay-900/12 text-clay-600 hover:border-clay-900/35 hover:bg-cream-100")
|
||||
}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span className="text-[11px] text-clay-400">
|
||||
选择与你的套餐订阅地区一致的节点(通常也是延迟最低的那个)。
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-[10px] smallcaps text-clay-500">
|
||||
A P I · K e y
|
||||
</span>
|
||||
<div className="relative">
|
||||
<input
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
type={showKey ? "text" : "password"}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder={
|
||||
keyType === "payg"
|
||||
? "粘贴 sk- 开头的按量 Key"
|
||||
: "粘贴 tp- 开头的套餐 Key"
|
||||
}
|
||||
className="h-11 w-full rounded-sm border border-clay-900/15 bg-cream-50 pl-4 pr-11 font-sans text-sm text-clay-900 outline-none transition-colors focus:border-ember-500 placeholder:text-clay-400"
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-[10px] smallcaps text-clay-500">
|
||||
K e y · 类 型
|
||||
</span>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(
|
||||
[
|
||||
{
|
||||
kind: "payg",
|
||||
label: "按量付费 Pay-as-you-go",
|
||||
sub: "sk- 开头",
|
||||
},
|
||||
{
|
||||
kind: "token-plan",
|
||||
label: "套餐 Token Plan",
|
||||
sub: "tp- 开头",
|
||||
},
|
||||
] as const
|
||||
).map((t) => {
|
||||
const active = keyType === t.kind;
|
||||
return (
|
||||
<button
|
||||
key={t.kind}
|
||||
type="button"
|
||||
onClick={() => setShowKey((v) => !v)}
|
||||
aria-label={showKey ? "隐藏" : "显示"}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-clay-400 hover:text-clay-700 transition-colors"
|
||||
onClick={() => setKeyType(t.kind)}
|
||||
className={
|
||||
"flex flex-col gap-0.5 rounded-sm border px-3 py-2.5 text-left transition-all " +
|
||||
(active
|
||||
? "border-ember-500 bg-ember-500/5 text-clay-900"
|
||||
: "border-clay-900/12 text-clay-600 hover:border-clay-900/35 hover:bg-cream-100")
|
||||
}
|
||||
>
|
||||
<i
|
||||
className={`fa-solid ${showKey ? "fa-eye-slash" : "fa-eye"} text-sm`}
|
||||
/>
|
||||
<span className="text-[13px]">{t.label}</span>
|
||||
<span className="text-[10px] text-clay-400">
|
||||
{t.sub}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{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"}
|
||||
」类型不符,请确认是否填错。
|
||||
</span>
|
||||
)}
|
||||
<a
|
||||
href={TTS_KEY_DOC_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
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?查看图文教程
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{footerNote && (
|
||||
<p className="text-[11px] leading-relaxed text-clay-400">
|
||||
{footerNote}
|
||||
</p>
|
||||
)}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{keyType === "token-plan" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-[10px] smallcaps text-clay-500">
|
||||
区 域 节 点
|
||||
</span>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
{TTS_REGION_PRESETS.map((p) => {
|
||||
const active = p.id === regionId;
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => setRegionId(p.id)}
|
||||
className={
|
||||
"rounded-sm border px-3 py-2.5 text-left text-[13px] transition-all " +
|
||||
(active
|
||||
? "border-ember-500 bg-ember-500/5 text-clay-900"
|
||||
: "border-clay-900/12 text-clay-600 hover:border-clay-900/35 hover:bg-cream-100")
|
||||
}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span className="text-[11px] text-clay-400">
|
||||
选择与你的套餐订阅地区一致的节点(通常也是延迟最低的那个)。
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-[10px] smallcaps text-clay-500">
|
||||
A P I · K e y
|
||||
</span>
|
||||
<div className="relative">
|
||||
<input
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
type={showKey ? "text" : "password"}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder={
|
||||
keyType === "payg"
|
||||
? "粘贴 sk- 开头的按量 Key"
|
||||
: "粘贴 tp- 开头的套餐 Key"
|
||||
}
|
||||
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={() => setShowKey((v) => !v)}
|
||||
aria-label={showKey ? "隐藏" : "显示"}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-clay-400 hover:text-clay-700 transition-colors"
|
||||
>
|
||||
<i
|
||||
className={`fa-solid ${showKey ? "fa-eye-slash" : "fa-eye"} text-sm`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{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"}
|
||||
」类型不符,请确认是否填错。
|
||||
</span>
|
||||
)}
|
||||
<a
|
||||
href={TTS_KEY_DOC_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
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?查看图文教程
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{footerNote && (
|
||||
<p className="text-[11px] leading-relaxed text-clay-400">
|
||||
{footerNote}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,271 +0,0 @@
|
||||
"use client";
|
||||
|
||||
// Bring-your-own Xiaomi MiMo TTS key modal — shared by the homepage and the
|
||||
// play page. Two-step picker (key family → region for Token Plan only), key
|
||||
// stored CLIENT-SIDE ONLY (see lib/clientTtsConfig). `onSaved(configured)`
|
||||
// fires after a save/disable so each host can react (homepage flips the
|
||||
// 语音配音 toggle; the play page re-synthesizes the current scene in-browser).
|
||||
// `footerNote` lets the host tailor the closing hint to its own context.
|
||||
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import {
|
||||
clearStoredTtsConfig,
|
||||
readStoredTtsConfig,
|
||||
writeStoredTtsConfig,
|
||||
} from "@/lib/clientTtsConfig";
|
||||
import {
|
||||
findTtsPreset,
|
||||
PAYG_PRESET_ID,
|
||||
TTS_KEY_DOC_URL,
|
||||
TTS_REGION_PRESETS,
|
||||
} from "@/lib/ttsPresets";
|
||||
|
||||
const DEFAULT_FOOTER_NOTE: ReactNode =
|
||||
"提示:需将上方「语音配音」设为「开启」配音才会生效。保存后本设备后续游玩会自动使用此 Key。";
|
||||
|
||||
export function TtsKeyModal({
|
||||
onClose,
|
||||
onSaved,
|
||||
footerNote = DEFAULT_FOOTER_NOTE,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onSaved: (configured: boolean) => void;
|
||||
footerNote?: ReactNode;
|
||||
}) {
|
||||
// Read storage once; useState initializers ignore later renders, so local
|
||||
// edits aren't clobbered and we don't re-hit localStorage every render.
|
||||
const [initial] = useState(() => readStoredTtsConfig());
|
||||
// Two-step picker: choose key family first, then — only for Token Plan — a
|
||||
// region. Pay-as-you-go (`sk-`) keys hit one fixed endpoint, so no region.
|
||||
const initialKind = findTtsPreset(initial?.presetId)?.kind ?? "token-plan";
|
||||
const [keyType, setKeyType] = useState<"token-plan" | "payg">(initialKind);
|
||||
const [regionId, setRegionId] = useState<string>(
|
||||
initialKind === "token-plan"
|
||||
? (initial?.presetId ?? TTS_REGION_PRESETS[0]!.id)
|
||||
: TTS_REGION_PRESETS[0]!.id,
|
||||
);
|
||||
const [apiKey, setApiKey] = useState<string>(initial?.apiKey ?? "");
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [shown, setShown] = useState(false);
|
||||
const alreadyConfigured = initial != null;
|
||||
// Soft guard: tp- keys belong to Token Plan, sk- to pay-as-you-go. A
|
||||
// mismatched pairing hits the wrong endpoint → guaranteed auth failure →
|
||||
// silent playback (the very symptom BYO exists to kill). Warn, but never
|
||||
// block: prefix conventions could change and a hard gate would lock out an
|
||||
// otherwise-valid key.
|
||||
const expectedPrefix = keyType === "payg" ? "sk-" : "tp-";
|
||||
const prefixMismatch =
|
||||
apiKey.trim().length > 0 && !apiKey.trim().startsWith(expectedPrefix);
|
||||
|
||||
useEffect(() => {
|
||||
const id = requestAnimationFrame(() => setShown(true));
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, []);
|
||||
|
||||
const close = () => {
|
||||
setShown(false);
|
||||
setTimeout(onClose, 280);
|
||||
};
|
||||
const save = () => {
|
||||
const key = apiKey.trim();
|
||||
if (!key) return;
|
||||
const presetId = keyType === "payg" ? PAYG_PRESET_ID : regionId;
|
||||
writeStoredTtsConfig({ presetId, apiKey: key });
|
||||
onSaved(true);
|
||||
close();
|
||||
};
|
||||
const disable = () => {
|
||||
clearStoredTtsConfig();
|
||||
onSaved(false);
|
||||
close();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onMouseDown={close}
|
||||
className={
|
||||
"fixed inset-0 z-[60] flex items-center justify-center p-6 md:p-10 transition-all duration-300 " +
|
||||
(shown
|
||||
? "bg-clay-900/30 backdrop-blur-md"
|
||||
: "bg-clay-900/0 backdrop-blur-0")
|
||||
}
|
||||
>
|
||||
<div
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className={
|
||||
"flex w-[560px] max-w-[94vw] max-h-[88vh] flex-col overflow-hidden rounded-sm border border-clay-900/15 bg-cream-50 shadow-2xl shadow-clay-900/25 transition-all duration-300 " +
|
||||
(shown ? "opacity-100 scale-100" : "opacity-0 scale-95")
|
||||
}
|
||||
>
|
||||
<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">
|
||||
自带配音 Key
|
||||
</span>
|
||||
<span className="text-[11px] text-clay-500 mt-1 tracking-wide">
|
||||
可选 · 用你自己的小米 MiMo 免费额度,配音更稳定、延迟更低
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={close}
|
||||
aria-label="关闭"
|
||||
className="ml-auto text-xl leading-none text-clay-500 hover:text-clay-900 transition-colors"
|
||||
>
|
||||
<i className="fa-solid fa-xmark" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6 overflow-y-auto px-6 md:px-8 py-6">
|
||||
<p className="text-[13px] leading-relaxed text-clay-600">
|
||||
经常没有声音?公共语音模型有调用频率限额(RPM / TPM),同时游玩的人多时很容易撞到限额而静音。填入你自己的小米 MiMo API Key 后,配音将
|
||||
<span className="text-clay-900">直接在你的浏览器里合成</span>
|
||||
、使用你自己的免费额度 ——{" "}
|
||||
<span className="text-clay-900">Key 只保存在本地浏览器、绝不经过我们的服务器</span>
|
||||
。
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-[10px] smallcaps text-clay-500">K e y · 类 型</span>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(
|
||||
[
|
||||
{ kind: "token-plan", label: "套餐 Token Plan", sub: "tp- 开头" },
|
||||
{ kind: "payg", label: "按量付费 Pay-as-you-go", sub: "sk- 开头" },
|
||||
] as const
|
||||
).map((t) => {
|
||||
const active = keyType === t.kind;
|
||||
return (
|
||||
<button
|
||||
key={t.kind}
|
||||
type="button"
|
||||
onClick={() => setKeyType(t.kind)}
|
||||
className={
|
||||
"flex flex-col gap-0.5 rounded-sm border px-3 py-2.5 text-left transition-all " +
|
||||
(active
|
||||
? "border-ember-500 bg-ember-500/5 text-clay-900"
|
||||
: "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-[10px] text-clay-400">{t.sub}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{keyType === "token-plan" ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-[10px] smallcaps text-clay-500">区 域 节 点</span>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
{TTS_REGION_PRESETS.map((p) => {
|
||||
const active = p.id === regionId;
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => setRegionId(p.id)}
|
||||
className={
|
||||
"rounded-sm border px-3 py-2.5 text-left text-[13px] transition-all " +
|
||||
(active
|
||||
? "border-ember-500 bg-ember-500/5 text-clay-900"
|
||||
: "border-clay-900/12 text-clay-600 hover:border-clay-900/35 hover:bg-cream-100")
|
||||
}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span className="text-[11px] text-clay-400">
|
||||
选择与你的套餐订阅地区一致的节点(通常也是延迟最低的那个)。
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start gap-2 rounded-sm border border-clay-900/10 bg-cream-100/60 px-3.5 py-2.5">
|
||||
<i className="fa-solid fa-circle-info mt-0.5 text-[11px] text-clay-400" />
|
||||
<span className="text-[11px] leading-relaxed text-clay-500">
|
||||
按量付费使用统一端点{" "}
|
||||
<span className="text-clay-700">api.xiaomimimo.com</span>
|
||||
,无需选择区域。
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-[10px] smallcaps text-clay-500">
|
||||
A P I · K e y
|
||||
</span>
|
||||
<div className="relative">
|
||||
<input
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
type={showKey ? "text" : "password"}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder={
|
||||
keyType === "payg"
|
||||
? "粘贴 sk- 开头的按量 Key"
|
||||
: "粘贴 tp- 开头的套餐 Key"
|
||||
}
|
||||
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={() => setShowKey((v) => !v)}
|
||||
aria-label={showKey ? "隐藏" : "显示"}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-clay-400 hover:text-clay-700 transition-colors"
|
||||
>
|
||||
<i
|
||||
className={`fa-solid ${showKey ? "fa-eye-slash" : "fa-eye"} text-sm`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{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"}
|
||||
」类型不符,请确认是否填错。
|
||||
</span>
|
||||
)}
|
||||
<a
|
||||
href={TTS_KEY_DOC_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
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?查看图文教程
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] leading-relaxed text-clay-400">{footerNote}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 border-t border-clay-900/10 px-6 md:px-8 py-4">
|
||||
{alreadyConfigured && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={disable}
|
||||
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" />
|
||||
停用并清除
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={save}
|
||||
disabled={!apiKey.trim()}
|
||||
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 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<i className="fa-solid fa-check text-xs" />
|
||||
保存并启用
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user