feat(studio): bilingual film wizard and service management pages

影游向导/分析面板/故事树/流程图残留、服务商列表/详情/配置来源卡/
快捷链接/服务分组全部双语
This commit is contained in:
Ma
2026-07-07 17:23:03 +08:00
parent 81abb7a39d
commit e895739dd3
10 changed files with 257 additions and 158 deletions
@@ -0,0 +1,37 @@
import { describe, it, expect, afterEach } from "vitest";
import { setAppLanguage } from "../lib/app-language";
import { getGroupDescription, getGroupLabel, getGroupShortLabel } from "../constants/service-groups";
import { getServiceQuickLinks } from "../components/ServiceQuickLinks";
// 每条用例结束后恢复默认语言,避免污染其他测试。
afterEach(() => {
setAppLanguage("zh");
});
describe("service-groups i18n", () => {
it("默认(zh)返回中文标签", () => {
expect(getGroupLabel("overseas")).toBe("海外原厂");
expect(getGroupShortLabel("aggregator")).toBe("聚合");
expect(getGroupDescription("aggregator")).toContain("聚合国内外主流模型");
expect(getGroupDescription("overseas")).toBeNull();
});
it("切换到 en 后返回英文标签", () => {
setAppLanguage("en");
expect(getGroupLabel("overseas")).toBe("International providers");
expect(getGroupShortLabel("aggregator")).toBe("Aggregator");
expect(getGroupDescription("aggregator")).toContain("one API key");
});
});
describe("service quick links i18n", () => {
it("默认(zh)返回中文标签,en 分支返回英文标签,href 不变", () => {
const zhLinks = getServiceQuickLinks("kkaiapi");
expect(zhLinks.map((l) => l.label)).toEqual(["官网", "API 文档", "模型/价格"]);
setAppLanguage("en");
const enLinks = getServiceQuickLinks("kkaiapi");
expect(enLinks.map((l) => l.label)).toEqual(["Website", "API docs", "Models & pricing"]);
expect(enLinks.map((l) => l.href)).toEqual(zhLinks.map((l) => l.href));
});
});
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import { fetchJson } from "../hooks/use-api";
import { tr } from "../lib/app-language";
type ConfigSource = "env" | "studio";
type EnvScope = "project" | "global" | null;
@@ -37,7 +38,7 @@ export function ServiceConfigSourceCard({ onChange }: { onChange?: () => void })
setData(payload);
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : "读取配置来源失败");
setError(e instanceof Error ? e.message : tr("读取配置来源失败", "Failed to load config source"));
}
};
@@ -56,7 +57,7 @@ export function ServiceConfigSourceCard({ onChange }: { onChange?: () => void })
await load();
onChange?.();
} catch (e) {
setError(e instanceof Error ? e.message : "切换配置来源失败");
setError(e instanceof Error ? e.message : tr("切换配置来源失败", "Failed to switch config source"));
} finally {
setSaving(null);
}
@@ -71,7 +72,7 @@ export function ServiceConfigSourceCard({ onChange }: { onChange?: () => void })
await load();
onChange?.();
} catch (e) {
setError(e instanceof Error ? e.message : "导入环境变量配置失败");
setError(e instanceof Error ? e.message : tr("导入环境变量配置失败", "Failed to import env config"));
} finally {
setImporting(false);
}
@@ -80,7 +81,7 @@ export function ServiceConfigSourceCard({ onChange }: { onChange?: () => void })
if (!data && !error) {
return (
<div className="rounded-xl border border-border/40 bg-card/70 p-4 text-sm text-muted-foreground/70">
{tr("正在读取配置来源…", "Loading config source…")}
</div>
);
}
@@ -88,7 +89,7 @@ export function ServiceConfigSourceCard({ onChange }: { onChange?: () => void })
if (!data) {
return (
<div className="rounded-xl border border-amber-500/30 bg-amber-500/[0.04] p-4 text-sm text-amber-600">
{error ?? "读取配置来源失败"}
{error ?? tr("读取配置来源失败", "Failed to load config source")}
</div>
);
}
@@ -96,17 +97,21 @@ export function ServiceConfigSourceCard({ onChange }: { onChange?: () => void })
const { configSource, envConfig } = data;
const storedConfigSource = data.storedConfigSource ?? configSource;
const activeEnvSummary = envConfig.effectiveSource === "project" ? envConfig.project : envConfig.global;
const envLabel = envConfig.effectiveSource === "project" ? "项目 .env" : envConfig.effectiveSource === "global" ? "全局 ~/.inkos/.env" : null;
const envLabel = envConfig.effectiveSource === "project"
? tr("项目 .env", "Project .env")
: envConfig.effectiveSource === "global"
? tr("全局 ~/.inkos/.env", "Global ~/.inkos/.env")
: null;
const envDetected = envConfig.project.detected || envConfig.global.detected;
return (
<div className="rounded-xl border border-border/40 bg-card/70 p-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">LLM </div>
<div className="text-sm font-medium">{tr("LLM 配置来源", "LLM config source")}</div>
<div className="text-xs text-muted-foreground/70 mt-1">
Studio
<span className="text-foreground"> 使 Studio </span>
{tr("Studio 运行时:", "Studio runtime:")}
<span className="text-foreground"> {tr("使用服务页配置和 Studio 密钥", "uses service page config and Studio keys")}</span>
</div>
</div>
<div className="flex items-center gap-2">
@@ -116,7 +121,7 @@ export function ServiceConfigSourceCard({ onChange }: { onChange?: () => void })
disabled={saving !== null || importing || configSource === "studio"}
className="rounded-lg border border-border/50 px-3 py-1.5 text-xs hover:bg-secondary/50 disabled:opacity-50"
>
{saving === "studio" ? "切换中…" : "使用 Studio 配置"}
{saving === "studio" ? tr("切换中…", "Switching…") : tr("使用 Studio 配置", "Use Studio config")}
</button>
{envDetected && activeEnvSummary.hasApiKey ? (
<button
@@ -125,7 +130,7 @@ export function ServiceConfigSourceCard({ onChange }: { onChange?: () => void })
disabled={saving !== null || importing}
className="rounded-lg border border-border/50 bg-secondary/40 px-3 py-1.5 text-xs hover:bg-secondary/70 disabled:opacity-50"
>
{importing ? "导入中…" : "导入检测到的配置"}
{importing ? tr("导入中…", "Importing…") : tr("导入检测到的配置", "Import detected config")}
</button>
) : null}
</div>
@@ -133,27 +138,36 @@ export function ServiceConfigSourceCard({ onChange }: { onChange?: () => void })
{storedConfigSource === "env" ? (
<div className="rounded-lg border border-amber-500/25 bg-amber-500/[0.04] p-3 text-xs text-muted-foreground/80">
`.env` Studio 使CLIdaemon env 使
{tr(
"检测到旧配置标记为 `.env` 优先。Studio 运行时不会使用它;CLI、daemon 和部署环境仍可按 env 覆盖层使用。",
"A legacy setting marks `.env` as preferred. The Studio runtime ignores it; CLI, daemon, and deployment environments may still use the env override layer.",
)}
</div>
) : null}
{envDetected ? (
<div className="rounded-lg border border-amber-500/25 bg-amber-500/[0.04] p-3 text-xs text-muted-foreground/80 space-y-1.5">
<div className="text-foreground">
LLM
<span className="font-medium"> {envLabel ?? "已检测到但未定位来源"}</span>
{tr("检测到 LLM 环境变量覆盖:", "Detected LLM environment variable override:")}
<span className="font-medium"> {envLabel ?? tr("已检测到但未定位来源", "detected but source not located")}</span>
</div>
{activeEnvSummary.baseUrl ? <div>Base URL: <span className="font-mono text-foreground">{activeEnvSummary.baseUrl}</span></div> : null}
{activeEnvSummary.model ? <div>Model: <span className="font-mono text-foreground">{activeEnvSummary.model}</span></div> : null}
{activeEnvSummary.provider ? <div>Provider: <span className="font-mono text-foreground">{activeEnvSummary.provider}</span></div> : null}
<div>API Key: <span className="text-foreground">{activeEnvSummary.hasApiKey ? "已设置" : "未设置"}</span></div>
<div>API Key: <span className="text-foreground">{activeEnvSummary.hasApiKey ? tr("已设置", "set") : tr("未设置", "not set")}</span></div>
<div className="text-muted-foreground/70 pt-1">
.env Studio Agent 使 Studio
{tr(
"当前虽然检测到 .env,但 Studio 和 Agent 请求不会直接使用这套覆盖;点击“导入检测到的配置”后,会把它保存为 Studio 服务配置。",
"A .env override was detected, but Studio and agent requests do not use it directly. Click “Import detected config” to save it as Studio service config.",
)}
</div>
</div>
) : (
<div className="rounded-lg border border-border/30 bg-secondary/20 p-3 text-xs text-muted-foreground/75">
`.env` LLM 使 Studio
{tr(
"未检测到目录或全局 `.env` 里的 LLM 覆盖变量。当前会直接使用项目配置和 Studio 服务配置。",
"No LLM override variables detected in the project or global `.env`. Project config and Studio service config are used directly.",
)}
</div>
)}
@@ -1,34 +1,39 @@
import { ExternalLink } from "lucide-react";
import { tr } from "../lib/app-language";
interface ServiceQuickLink {
readonly label: string;
readonly href: string;
}
const SERVICE_QUICK_LINKS: Record<string, ReadonlyArray<ServiceQuickLink>> = {
// 标签在调用时通过 tr() 解析语言,所以这里存 zh/en 对而不是最终字符串。
const SERVICE_QUICK_LINKS: Record<string, ReadonlyArray<{ zh: string; en: string; href: string }>> = {
kimicode: [
{ label: "官网", href: "https://www.kimi.com?aff=inkos" },
{ zh: "官网", en: "Website", href: "https://www.kimi.com?aff=inkos" },
],
kimiCodingPlan: [
{ label: "官网", href: "https://www.kimi.com?aff=inkos" },
{ zh: "官网", en: "Website", href: "https://www.kimi.com?aff=inkos" },
],
kkaiapi: [
{ label: "官网", href: "https://kkaiapi.com/" },
{ label: "API 文档", href: "https://kkaiapi.com/docs" },
{ label: "模型/价格", href: "https://kkaiapi.com/models" },
{ zh: "官网", en: "Website", href: "https://kkaiapi.com/" },
{ zh: "API 文档", en: "API docs", href: "https://kkaiapi.com/docs" },
{ zh: "模型/价格", en: "Models & pricing", href: "https://kkaiapi.com/models" },
],
moonshot: [
{ label: "开放平台", href: "https://platform.kimi.com?aff=inkos" },
{ zh: "开放平台", en: "Developer platform", href: "https://platform.kimi.com?aff=inkos" },
],
openrouter: [
{ label: "API Keys", href: "https://openrouter.ai/keys" },
{ label: "模型", href: "https://openrouter.ai/models" },
{ label: "文档", href: "https://openrouter.ai/docs/api-reference/overview" },
{ zh: "API Keys", en: "API Keys", href: "https://openrouter.ai/keys" },
{ zh: "模型", en: "Models", href: "https://openrouter.ai/models" },
{ zh: "文档", en: "Docs", href: "https://openrouter.ai/docs/api-reference/overview" },
],
};
export function getServiceQuickLinks(serviceId: string): ReadonlyArray<ServiceQuickLink> {
return SERVICE_QUICK_LINKS[serviceId] ?? [];
return (SERVICE_QUICK_LINKS[serviceId] ?? []).map((link) => ({
label: tr(link.zh, link.en),
href: link.href,
}));
}
export function ServiceQuickLinks({
@@ -52,7 +57,7 @@ export function ServiceQuickLinks({
className,
].filter(Boolean).join(" ")}
>
{!compact && <span className="mr-0.5"></span>}
{!compact && <span className="mr-0.5">{tr("配置入口", "Quick links")}</span>}
{links.map((link) => (
<a
key={link.href}
@@ -1,5 +1,6 @@
import { useApi } from "../../hooks/use-api";
import { useColors } from "../../hooks/use-colors";
import { tr } from "../../lib/app-language";
import type { Theme } from "../../hooks/use-theme";
// ---------------------------------------------------------------------------
@@ -106,10 +107,10 @@ function IssuesList({ report, c }: { report: AnalysisReport; c: Colors }) {
return (
<div className="border border-border rounded p-3" data-testid="validation-panel">
<div className={`text-sm font-medium ${c.muted}`}>
{report.ok ? "" : "(有阻断问题)"}
{tr("校验", "Validation")}{report.ok ? "" : tr("(有阻断问题)", " (blocking issues)")}
</div>
{report.issues.length === 0 ? (
<div className={`text-sm mt-1 ${c.muted}`}></div>
<div className={`text-sm mt-1 ${c.muted}`}>{tr("无问题", "No issues")}</div>
) : (
<ul className="mt-1 space-y-1">
{report.issues.map((issue, i) => (
@@ -135,15 +136,15 @@ function EmotionArcChart({ arcs, c }: { arcs: EmotionArcs; c: Colors }) {
return (
<div data-testid="emotion-arc" className="border border-border rounded p-3">
<div className={`text-sm font-medium mb-2 ${c.muted}`}>线</div>
<div className={`text-sm font-medium mb-2 ${c.muted}`}>{tr("情感曲线", "Emotion arcs")}</div>
{displayArcs.length === 0 ? (
<div className={`text-sm ${c.muted}`}></div>
<div className={`text-sm ${c.muted}`}>{tr("暂无可分析路径", "No paths to analyze")}</div>
) : (
<>
<svg
width="100%"
viewBox={`0 0 ${SVG_W} ${SVG_H}`}
aria-label="情感曲线图"
aria-label={tr("情感曲线图", "Emotion arc chart")}
className="rounded bg-muted/10"
style={{ maxHeight: SVG_H }}
>
@@ -182,14 +183,14 @@ function EmotionArcChart({ arcs, c }: { arcs: EmotionArcs; c: Colors }) {
className="inline-block w-4 h-0.5 rounded-full"
style={{ background: ARC_STROKE_COLORS[idx % ARC_STROKE_COLORS.length] }}
/>
<span className={c.muted}>{arc.endingId ?? "无结局"}</span>
<span className={c.muted}>{arc.endingId ?? tr("无结局", "No ending")}</span>
</span>
))}
</div>
{(overLimit || arcs.truncated) && (
<div className={`text-xs mt-1 ${c.muted}`}>
{overLimit && `仅显示前 ${MAX_ARC_DISPLAY} 条路径`}
{arcs.truncated && "(路径总数已超过枚举上限)"}
{overLimit && tr(`仅显示前 ${MAX_ARC_DISPLAY} 条路径`, `Showing first ${MAX_ARC_DISPLAY} paths only`)}
{arcs.truncated && tr("(路径总数已超过枚举上限)", " (total paths exceed the enumeration limit)")}
</div>
)}
</>
@@ -213,16 +214,16 @@ function PathDistributionPanel({
return (
<div data-testid="path-distribution" className="border border-border rounded p-3">
<div className={`text-sm font-medium mb-2 ${c.muted}`}></div>
<div className={`text-sm font-medium mb-2 ${c.muted}`}>{tr("路径分布", "Path distribution")}</div>
{distribution.truncated && (
<div className={`text-xs mb-2 ${c.muted}`}>
{distribution.total}
{tr(`路径过多,仅统计前 ${distribution.total}`, `Too many paths; only the first ${distribution.total} are counted`)}
</div>
)}
{endingEntries.length === 0 ? (
<div className={`text-sm ${c.muted}`}></div>
<div className={`text-sm ${c.muted}`}>{tr("暂无路径数据", "No path data")}</div>
) : (
<div className="space-y-1.5 mb-4">
{endingEntries.map(([endingId, count]) => {
@@ -252,7 +253,7 @@ function PathDistributionPanel({
{histEntries.length > 0 && (
<div>
<div className={`text-xs font-medium mb-2 ${c.muted}`}></div>
<div className={`text-xs font-medium mb-2 ${c.muted}`}>{tr("路径长度分布", "Path length distribution")}</div>
<div className="flex items-end gap-1 h-12">
{histEntries.map(({ len, count }) => {
const heightPct = (count / maxHistCount) * 100;
@@ -264,7 +265,7 @@ function PathDistributionPanel({
<div
className="w-full bg-primary/50 rounded-t"
style={{ height: `${heightPct}%` }}
title={`长度 ${len}: ${count}`}
title={tr(`长度 ${len}: ${count}`, `Length ${len}: ${count} paths`)}
/>
<span className={`text-xs leading-none ${c.muted}`}>{len}</span>
</div>
@@ -294,15 +295,15 @@ export function AnalysisPanel({
);
if (loading) {
return <div className={`p-4 text-sm ${c.muted}`}></div>;
return <div className={`p-4 text-sm ${c.muted}`}>{tr("正在加载分析结果…", "Loading analysis…")}</div>;
}
if (error) {
return <div className="p-4 text-sm text-destructive">{error}</div>;
return <div className="p-4 text-sm text-destructive">{tr("加载失败:", "Load failed: ")}{error}</div>;
}
if (!data) {
return <div className={`p-4 text-sm ${c.muted}`}></div>;
return <div className={`p-4 text-sm ${c.muted}`}>{tr("暂无分析数据", "No analysis data")}</div>;
}
return (
+35 -14
View File
@@ -1,4 +1,5 @@
import type { EndpointGroup } from "../store/service/types";
import { tr } from "../lib/app-language";
export const GROUP_ORDER: ReadonlyArray<EndpointGroup> = [
"aggregator",
@@ -8,22 +9,42 @@ export const GROUP_ORDER: ReadonlyArray<EndpointGroup> = [
"codingPlan",
] as const;
export const GROUP_LABELS: Record<EndpointGroup, string> = {
overseas: "海外原厂",
china: "国产原厂",
aggregator: "聚合 API",
local: "本地 / 订阅",
codingPlan: "CodingPlan",
// 标签在渲染时通过 tr() 取值,不能在模块加载时固化成单一语言字符串,
// 所以这里存 zh/en 对,由下方 getGroupLabel 等函数在调用时解析。
const GROUP_LABELS: Record<EndpointGroup, { zh: string; en: string }> = {
overseas: { zh: "海外原厂", en: "International providers" },
china: { zh: "国产原厂", en: "China providers" },
aggregator: { zh: "聚合 API", en: "Aggregator APIs" },
local: { zh: "本地 / 订阅", en: "Local / Subscription" },
codingPlan: { zh: "CodingPlan", en: "CodingPlan" },
};
export const GROUP_DESCRIPTIONS: Partial<Record<EndpointGroup, string>> = {
aggregator: "聚合国内外主流模型,适合用一个 API Key 接入多模型的场景。",
const GROUP_DESCRIPTIONS: Partial<Record<EndpointGroup, { zh: string; en: string }>> = {
aggregator: {
zh: "聚合国内外主流模型,适合用一个 API Key 接入多模型的场景。",
en: "Aggregates mainstream models from multiple vendors — access many models with one API key.",
},
};
export const GROUP_SHORT_LABELS: Record<EndpointGroup, string> = {
overseas: "海外",
china: "国产",
aggregator: "聚合",
local: "本地",
codingPlan: "CodingPlan",
const GROUP_SHORT_LABELS: Record<EndpointGroup, { zh: string; en: string }> = {
overseas: { zh: "海外", en: "Intl" },
china: { zh: "国产", en: "China" },
aggregator: { zh: "聚合", en: "Aggregator" },
local: { zh: "本地", en: "Local" },
codingPlan: { zh: "CodingPlan", en: "CodingPlan" },
};
export function getGroupLabel(group: EndpointGroup): string {
const label = GROUP_LABELS[group];
return tr(label.zh, label.en);
}
export function getGroupDescription(group: EndpointGroup): string | null {
const desc = GROUP_DESCRIPTIONS[group];
return desc ? tr(desc.zh, desc.en) : null;
}
export function getGroupShortLabel(group: EndpointGroup): string {
const label = GROUP_SHORT_LABELS[group];
return tr(label.zh, label.en);
}
+35 -26
View File
@@ -4,6 +4,7 @@ import type { TFunction } from "../hooks/use-i18n";
import type { SSEMessage } from "../hooks/use-sse";
import { useNewSSEMessages } from "../hooks/use-sse";
import { useColors } from "../hooks/use-colors";
import { tr } from "../lib/app-language";
import { useApi } from "../hooks/use-api";
import { AnalysisPanel } from "../components/film/AnalysisPanel";
import { ExportBar } from "../components/film/ExportBar";
@@ -51,12 +52,14 @@ type Colors = ReturnType<typeof useColors>;
// Constants
// ---------------------------------------------------------------------------
const PHASE_LABELS: Record<Phase, string> = {
world: "世界",
scale: "规模",
structure: "结构",
workshop: "逐节点",
validate: "校验",
// 标签在渲染时通过 tr() 解析语言,模块加载时不能固化成单一语言字符串,
// 所以这里存 zh/en 对。
const PHASE_LABELS: Record<Phase, { zh: string; en: string }> = {
world: { zh: "世界", en: "World" },
scale: { zh: "规模", en: "Scale" },
structure: { zh: "结构", en: "Structure" },
workshop: { zh: "逐节点", en: "Nodes" },
validate: { zh: "校验", en: "Validate" },
};
@@ -68,19 +71,19 @@ const DEFAULT_SUBVIEW: Record<Phase, string> = {
validate: "validate",
};
const PHASE_SUBVIEWS: Record<Phase, ReadonlyArray<{ key: string; label: string }>> = {
const PHASE_SUBVIEWS: Record<Phase, ReadonlyArray<{ key: string; zh: string; en: string }>> = {
world: [
{ key: "chat", label: "对话" },
{ key: "anchor", label: "世界锚点" },
{ key: "chat", zh: "对话", en: "Chat" },
{ key: "anchor", zh: "世界锚点", en: "World anchor" },
],
scale: [],
structure: [
{ key: "flow", label: "流程图" },
{ key: "tree", label: "树" },
{ key: "flow", zh: "流程图", en: "Flow" },
{ key: "tree", zh: "树", en: "Tree" },
],
workshop: [
{ key: "tree", label: "树" },
{ key: "chat", label: "对话" },
{ key: "tree", zh: "树", en: "Tree" },
{ key: "chat", zh: "对话", en: "Chat" },
],
validate: [],
};
@@ -115,7 +118,10 @@ function WorldAnchorView({
if (!graph?.worldAnchor) {
return (
<div className={`p-6 text-sm ${c.muted}`}>
AI
{tr(
"暂无世界锚点。请先切换到「对话」,请 AI 帮您设定世界观和角色。",
"No world anchor yet. Switch to “Chat” and ask the AI to set up the world and characters.",
)}
</div>
);
}
@@ -124,34 +130,34 @@ function WorldAnchorView({
return (
<div className="p-4 space-y-3 text-sm" data-testid="film-world">
<div>
<div className={`text-xs font-medium mb-1 ${c.muted}`}></div>
<div className={`text-xs font-medium mb-1 ${c.muted}`}>{tr("故事核心", "Story core")}</div>
<div className="text-foreground">{worldAnchor.storyCore || "—"}</div>
</div>
<div className="flex gap-6">
<div>
<div className={`text-xs font-medium mb-1 ${c.muted}`}></div>
<div className={`text-xs font-medium mb-1 ${c.muted}`}>{tr("主题", "Theme")}</div>
<div>{worldAnchor.theme || "—"}</div>
</div>
<div>
<div className={`text-xs font-medium mb-1 ${c.muted}`}></div>
<div className={`text-xs font-medium mb-1 ${c.muted}`}>{tr("题材", "Genre")}</div>
<div>{worldAnchor.genre || "—"}</div>
</div>
{worldAnchor.durationMinutes > 0 && (
<div>
<div className={`text-xs font-medium mb-1 ${c.muted}`}></div>
<div>{worldAnchor.durationMinutes} </div>
<div className={`text-xs font-medium mb-1 ${c.muted}`}>{tr("时长", "Duration")}</div>
<div>{worldAnchor.durationMinutes} {tr("分钟", "min")}</div>
</div>
)}
</div>
{worldAnchor.worldRules && (
<div>
<div className={`text-xs font-medium mb-1 ${c.muted}`}></div>
<div className={`text-xs font-medium mb-1 ${c.muted}`}>{tr("世界规则", "World rules")}</div>
<div className="whitespace-pre-wrap">{worldAnchor.worldRules}</div>
</div>
)}
{graph.characters.length > 0 && (
<div>
<div className={`text-xs font-medium mb-2 ${c.muted}`}></div>
<div className={`text-xs font-medium mb-2 ${c.muted}`}>{tr("主要角色", "Main characters")}</div>
<ul className="space-y-2">
{graph.characters.map((ch) => (
<li key={ch.id} className="flex items-start gap-2">
@@ -176,7 +182,10 @@ function WorldAnchorView({
function ScalePlaceholderView({ c }: { c: Colors }) {
return (
<div className={`p-6 text-sm ${c.muted}`} data-testid="film-scale-placeholder">
P2
{tr(
"规模配置(P2 功能)— 在此设定节点数量目标、分支深度、多结局数量等参数。",
"Scale settings (P2) — set node count targets, branch depth, number of endings, and other parameters here.",
)}
</div>
);
}
@@ -250,7 +259,7 @@ export default function FilmWizard({
onClick={nav.toDashboard}
className={c.link}
>
{tr("互动影游", "Interactive films")}
</button>
<div className="flex items-center gap-1 flex-wrap">
{WIZARD_PHASES.map((p, i) => {
@@ -282,7 +291,7 @@ export default function FilmWizard({
>
{i + 1}
</span>
<span>{PHASE_LABELS[p]}</span>
<span>{tr(PHASE_LABELS[p].zh, PHASE_LABELS[p].en)}</span>
</button>
);
})}
@@ -298,7 +307,7 @@ export default function FilmWizard({
showPreview ? c.btnPrimary : c.btnSecondary,
].join(" ")}
>
{tr("试玩", "Play")}
</button>
</div>
@@ -316,7 +325,7 @@ export default function FilmWizard({
currentSubView === sv.key ? c.btnPrimary : c.btnSecondary,
].join(" ")}
>
{sv.label}
{tr(sv.zh, sv.en)}
</button>
))}
</div>
+12 -11
View File
@@ -16,6 +16,7 @@ import {
import "@xyflow/react/dist/style.css";
import { useApi, fetchJson } from "../hooks/use-api";
import { useColors } from "../hooks/use-colors";
import { tr } from "../lib/app-language";
import type { Theme } from "../hooks/use-theme";
import type { TFunction } from "../hooks/use-i18n";
import { layoutStoryGraph } from "../lib/story-flow-layout";
@@ -215,7 +216,7 @@ export default function FlowView({
if (conn.source === conn.target) return;
const src = graph.nodes.find((g) => g.id === conn.source);
if (!src) return;
await post(addChoiceDelta(src, { id: genChoiceId(), text: "新选项", targetNodeId: conn.target }));
await post(addChoiceDelta(src, { id: genChoiceId(), text: tr("新选项", "New choice"), targetNodeId: conn.target }));
};
const onNodesDelete = async (deleted: Array<{ id: string }>) => {
@@ -245,7 +246,7 @@ export default function FlowView({
addNodeDelta({
id: genNodeId(),
type: "normal",
title: "新节点",
title: tr("新节点", "New node"),
choices: [],
position: { x: 80, y: 80 },
} as never),
@@ -279,7 +280,7 @@ export default function FlowView({
onClick={() => setEditing((v) => !v)}
className={`ml-auto px-3 py-1 rounded text-xs ${c.btnSecondary}`}
>
{editing ? "完成编辑" : "编辑"}
{editing ? tr("完成编辑", "Done editing") : tr("编辑", "Edit")}
</button>
{editing && (
<button
@@ -287,7 +288,7 @@ export default function FlowView({
onClick={onAddNode}
className={`px-3 py-1 rounded text-xs ${c.btnSecondary}`}
>
{tr("加节点", "Add node")}
</button>
)}
</div>
@@ -301,22 +302,22 @@ export default function FlowView({
data-testid="flow-stats"
className="flex items-center gap-4 text-xs text-muted-foreground border border-border rounded px-3 py-1.5 bg-card shrink-0"
>
<span> {stats.total}</span>
<span> {stats.branch}</span>
<span> {stats.ending}</span>
<span> {stats.deadEnd}</span>
<span>{tr("总节点", "Nodes")} {stats.total}</span>
<span>{tr("分支", "Branches")} {stats.branch}</span>
<span>{tr("结局", "Endings")} {stats.ending}</span>
<span>{tr("死路", "Dead ends")} {stats.deadEnd}</span>
<span className="ml-auto flex items-center gap-3">
<span className="flex items-center gap-1">
<span style={{ display: "inline-block", width: 20, height: 2, background: "#9ca3af", borderRadius: 1 }} />
{tr("默认", "Default")}
</span>
<span className="flex items-center gap-1">
<span style={{ display: "inline-block", width: 20, height: 2, background: "#f59e0b", borderRadius: 1 }} />
{tr("结局边", "Ending edge")}
</span>
<span className="flex items-center gap-1">
<span style={{ display: "inline-block", width: 20, height: 2, background: "#8b5cf6", borderRadius: 1 }} />
{tr("悬停路径", "Hover path")}
</span>
</span>
</div>
+31 -25
View File
@@ -3,6 +3,7 @@ import { fetchJson } from "../hooks/use-api";
import { useServiceStore } from "../store/service";
import { Eye, EyeOff, Loader2, ArrowLeft, Trash2 } from "lucide-react";
import { ServiceQuickLinks } from "../components/ServiceQuickLinks";
import { tr } from "../lib/app-language";
import {
deleteServiceConfig,
matchServiceConfigEntryForDetail,
@@ -81,7 +82,7 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
const resolvedCustomName = persistedCustomName || customName.trim() || "Custom";
const effectiveServiceId = isCustom ? `custom:${resolvedCustomName}` : serviceId;
const label = isCustom ? (customName || persistedCustomName || "自定义服务") : (svc?.label ?? serviceId);
const label = isCustom ? (customName || persistedCustomName || tr("自定义服务", "Custom service")) : (svc?.label ?? serviceId);
const storeModels = useServiceStore((s) => s.modelsByService[effectiveServiceId]);
useEffect(() => {
@@ -130,11 +131,11 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
const handleTest = async () => {
const trimmedKey = apiKey.trim();
if (!trimmedKey && !isCustom) {
setStatus({ state: "error", message: "请先输入 API Key" });
setStatus({ state: "error", message: tr("请先输入 API Key", "Enter an API key first") });
return;
}
if (isCustom && !baseUrl.trim()) {
setStatus({ state: "error", message: "请先填写 Base URL" });
setStatus({ state: "error", message: tr("请先填写 Base URL", "Enter a base URL first") });
return;
}
setApiKey(trimmedKey);
@@ -169,17 +170,17 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
setStoreModels(effectiveServiceId, models); // Write to global store
} else {
setVerifiedProbe(null);
setStatus({ state: "error", message: result.error ?? "连接失败" });
setStatus({ state: "error", message: result.error ?? tr("连接失败", "Connection failed") });
clearStoreModels(effectiveServiceId);
}
} catch (e) {
setVerifiedProbe(null);
setStatus({ state: "error", message: e instanceof Error ? e.message : "连接失败" });
setStatus({ state: "error", message: e instanceof Error ? e.message : tr("连接失败", "Connection failed") });
}
};
const handleDelete = async () => {
if (!window.confirm(`删除“${label}”的配置和密钥?`)) return;
if (!window.confirm(tr(`删除“${label}”的配置和密钥?`, `Delete the config and key for “${label}”?`))) return;
setStatus({ state: "saving" });
try {
await deleteServiceConfig(effectiveServiceId);
@@ -187,7 +188,7 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
await refreshServices();
nav.toServices();
} catch (e) {
setStatus({ state: "error", message: e instanceof Error ? e.message : "删除失败" });
setStatus({ state: "error", message: e instanceof Error ? e.message : tr("删除失败", "Delete failed") });
}
};
@@ -195,7 +196,7 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
const trimmedKey = apiKey.trim();
setApiKey(trimmedKey);
if (isCustom && !baseUrl.trim()) {
setStatus({ state: "error", message: "请先填写 Base URL" });
setStatus({ state: "error", message: tr("请先填写 Base URL", "Enter a base URL first") });
return;
}
setStatus({ state: "saving" });
@@ -228,7 +229,7 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
await refreshServices();
nav.toServices();
} catch (e) {
setStatus({ state: "error", message: e instanceof Error ? e.message : "保存失败" });
setStatus({ state: "error", message: e instanceof Error ? e.message : tr("保存失败", "Save failed") });
}
};
@@ -240,7 +241,7 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
className="inline-flex items-center gap-2 rounded-lg border border-border/50 bg-card/60 px-3 py-2 text-sm font-medium text-foreground hover:bg-secondary/50 transition-colors"
>
<ArrowLeft size={14} />
{tr("返回服务商管理", "Back to providers")}
</button>
{/* Title + status */}
@@ -248,7 +249,7 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
<h1 className="font-serif text-2xl">{label}</h1>
{isConnected && (
<span className="text-[10px] px-2 py-0.5 rounded-full bg-emerald-500/10 text-emerald-500 font-medium">
{tr("已连接", "Connected")}
</span>
)}
</div>
@@ -258,9 +259,9 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
{/* Custom fields */}
{isCustom && (
<div className="grid grid-cols-2 gap-4">
<Field label="服务名称">
<Field label={tr("服务名称", "Service name")}>
<input type="text" value={customName} onChange={(e) => setCustomName(e.target.value)}
placeholder="例如:本地 Ollama" className="w-full rounded-lg border border-border/60 bg-background px-3 py-2 text-sm" />
placeholder={tr("例如:本地 Ollama", "e.g. local Ollama")} className="w-full rounded-lg border border-border/60 bg-background px-3 py-2 text-sm" />
</Field>
<Field label="Base URL">
<input type="text" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)}
@@ -289,37 +290,42 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
<button onClick={handleTest} disabled={isBusy}
className="flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-lg border border-border/60 hover:bg-secondary/50 transition-colors disabled:opacity-50">
{status.state === "testing" && <Loader2 size={12} className="animate-spin" />}
{tr("测试连接", "Test connection")}
</button>
<button onClick={handleSave} disabled={isBusy}
className="flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50">
{status.state === "saving" && <Loader2 size={12} className="animate-spin" />}
{tr("保存", "Save")}
</button>
{(isConnected || isCustom) && (
<button onClick={handleDelete} disabled={isBusy}
className="flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-lg border border-destructive/30 text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-50">
<Trash2 size={12} />
{tr("删除配置", "Delete config")}
</button>
)}
{/* Status feedback */}
{status.state === "connected" && (
<span className="text-xs text-emerald-500">
{models.length}
{detectedModel ? `,已自动匹配 ${detectedModel}${detectedConfig ? ` / ${detectedConfig.apiFormat === "responses" ? "Responses" : "Chat"} / ${detectedConfig.stream ? "流式" : "非流式"}` : ""}` : ""}
{tr(`连接成功,${models.length} 个模型`, `Connected, ${models.length} models`)}
{detectedModel
? tr(
`,已自动匹配 ${detectedModel}${detectedConfig ? ` / ${detectedConfig.apiFormat === "responses" ? "Responses" : "Chat"} / ${detectedConfig.stream ? "流式" : "非流式"}` : ""}`,
`, auto-matched ${detectedModel}${detectedConfig ? ` / ${detectedConfig.apiFormat === "responses" ? "Responses" : "Chat"} / ${detectedConfig.stream ? "streaming" : "non-streaming"}` : ""}`,
)
: ""}
</span>
)}
{status.state === "error" && (
<span className="text-xs text-destructive">{status.message}</span>
)}
{status.state === "saved" && (
<span className="text-xs text-emerald-500"></span>
<span className="text-xs text-emerald-500">{tr("已保存", "Saved")}</span>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<Field label="协议类型">
<Field label={tr("协议类型", "Protocol")}>
<select
value={apiFormat}
onChange={(e) => setApiFormat(e.target.value as "chat" | "responses")}
@@ -330,14 +336,14 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
</select>
</Field>
<Field label="流式响应">
<Field label={tr("流式响应", "Streaming")}>
<label className="flex h-10 items-center gap-2 rounded-lg border border-border/60 bg-background px-3 text-sm">
<input
type="checkbox"
checked={stream}
onChange={(e) => setStream(e.target.checked)}
/>
<span>{stream ? "开启" : "关闭"}</span>
<span>{stream ? tr("开启", "On") : tr("关闭", "Off")}</span>
</label>
</Field>
</div>
@@ -346,7 +352,7 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
{isConnected && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground/70 font-medium uppercase tracking-wider">
{models.length}
{tr(`可用模型(${models.length}`, `Available models (${models.length})`)}
</p>
{models.length > 0 ? (
<div className="flex gap-1.5 flex-wrap">
@@ -357,7 +363,7 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
))}
</div>
) : (
<p className="text-xs text-muted-foreground/60"></p>
<p className="text-xs text-muted-foreground/60">{tr("点击“测试连接”查看可用模型", "Click “Test connection” to list available models")}</p>
)}
</div>
)}
@@ -365,7 +371,7 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
{/* Advanced params */}
<details className="group pt-2 border-t border-border/20">
<summary className="text-xs text-muted-foreground/60 cursor-pointer select-none hover:text-muted-foreground transition-colors py-2">
{tr("高级参数", "Advanced")}
</summary>
<div className="space-y-4 pt-2">
<Field label="temperature">
+30 -26
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import { Check, Eye, EyeOff, Loader2, Plus, Search, X } from "lucide-react";
import { GROUP_DESCRIPTIONS, GROUP_LABELS, GROUP_ORDER, GROUP_SHORT_LABELS } from "../constants/service-groups";
import { GROUP_ORDER, getGroupDescription, getGroupLabel, getGroupShortLabel } from "../constants/service-groups";
import { tr } from "../lib/app-language";
import { fetchJson } from "../hooks/use-api";
import { useServiceStore } from "../store/service";
import type { EndpointGroup, ServiceInfo } from "../store/service";
@@ -41,7 +42,7 @@ function ServiceCard({ svc, onClick }: { svc: ServiceInfo; onClick: () => void }
<span className={`h-1.5 w-1.5 rounded-full shrink-0 ${svc.connected ? "bg-emerald-500" : "bg-muted-foreground/30"}`} />
</div>
<span className="text-xs text-muted-foreground/60">
{svc.connected ? "已连接" : "未配置"}
{svc.connected ? tr("已连接", "Connected") : tr("未配置", "Not configured")}
</span>
</button>
{quickLinks.length > 0 && (
@@ -92,7 +93,7 @@ function CoverConfigCard() {
.catch((error) => {
if (cancelled) return;
setStatus("error");
setMessage(error instanceof Error ? error.message : "读取封面配置失败");
setMessage(error instanceof Error ? error.message : tr("读取封面配置失败", "Failed to load cover config"));
});
return () => { cancelled = true; };
}, []);
@@ -136,10 +137,10 @@ function CoverConfigCard() {
body: JSON.stringify({ service: provider.service, model }),
});
setStatus("saved");
setMessage("封面配置已保存");
setMessage(tr("封面配置已保存", "Cover config saved"));
} catch (error) {
setStatus("error");
setMessage(error instanceof Error ? error.message : "保存封面配置失败");
setMessage(error instanceof Error ? error.message : tr("保存封面配置失败", "Failed to save cover config"));
}
};
@@ -149,21 +150,24 @@ function CoverConfigCard() {
<section className="rounded-xl border border-border/50 bg-card/50 p-4 space-y-3">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-sm font-medium text-foreground"></h2>
<h2 className="text-sm font-medium text-foreground">{tr("封面生成", "Cover generation")}</h2>
<p className="mt-1 text-xs text-muted-foreground/70">
{tr(
"只配置封面通道和模型;封面尺寸由短篇封面提示词和内部默认处理。",
"Only configures the cover provider and model; cover size is handled by the short-story cover prompt and internal defaults.",
)}
</p>
</div>
{selected?.connected && (
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-500">
{tr("已有密钥", "Key saved")}
</span>
)}
</div>
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1.5">
<span className="block text-xs font-medium text-muted-foreground/70"></span>
<span className="block text-xs font-medium text-muted-foreground/70">{tr("服务", "Service")}</span>
<select
value={service}
onChange={(event) => handleServiceChange(event.target.value)}
@@ -175,7 +179,7 @@ function CoverConfigCard() {
</select>
</label>
<label className="space-y-1.5">
<span className="block text-xs font-medium text-muted-foreground/70"></span>
<span className="block text-xs font-medium text-muted-foreground/70">{tr("封面模型", "Cover model")}</span>
<select
value={model}
onChange={(event) => setModel(event.target.value)}
@@ -215,7 +219,7 @@ function CoverConfigCard() {
className="inline-flex items-center gap-1.5 rounded-lg bg-primary px-3.5 py-2 text-xs text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
>
{status === "saving" && <Loader2 size={12} className="animate-spin" />}
{tr("保存封面配置", "Save cover config")}
</button>
{selected?.baseUrl && (
<span className="text-xs text-muted-foreground/60">
@@ -314,13 +318,13 @@ export function ServiceListPage({ nav }: { nav: Nav }) {
onClick={nav.toDashboard}
className="inline-flex items-center rounded-lg border border-border/50 bg-card/60 px-3 py-1.5 font-medium text-foreground hover:bg-secondary/50 transition-colors"
>
{tr("首页", "Home")}
</button>
<span className="text-border">/</span>
<span className="text-foreground"></span>
<span className="text-foreground">{tr("服务商管理", "Providers")}</span>
</div>
<h1 className="font-serif text-2xl"></h1>
<h1 className="font-serif text-2xl">{tr("服务商管理", "Providers")}</h1>
<ServiceConfigSourceCard onChange={() => { void refreshServices(); }} />
@@ -332,14 +336,14 @@ export function ServiceListPage({ nav }: { nav: Nav }) {
type="text"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索服务商"
placeholder={tr("搜索服务商", "Search providers")}
className="w-full rounded-lg border border-border/60 bg-background py-2 pl-9 pr-9 text-sm outline-none focus:border-primary/50"
/>
{query && (
<button
onClick={() => setQuery("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground/50 hover:text-muted-foreground"
aria-label="清空搜索"
aria-label={tr("清空搜索", "Clear search")}
>
<X size={14} />
</button>
@@ -356,7 +360,7 @@ export function ServiceListPage({ nav }: { nav: Nav }) {
: "border-border/60 text-muted-foreground hover:bg-secondary/50",
].join(" ")}
>
{bankServices.length}
{tr("全部", "All")} {bankServices.length}
</button>
{GROUP_ORDER.map((group) => {
const selected = selectedGroups.has(group);
@@ -372,7 +376,7 @@ export function ServiceListPage({ nav }: { nav: Nav }) {
].join(" ")}
>
{selected && <Check size={12} />}
{GROUP_SHORT_LABELS[group]} {groupCounts[group]}
{getGroupShortLabel(group)} {groupCounts[group]}
</button>
);
})}
@@ -381,7 +385,7 @@ export function ServiceListPage({ nav }: { nav: Nav }) {
onClick={() => setSelectedGroups(new Set())}
className="inline-flex items-center rounded-full px-3 py-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
>
{tr("清除筛选", "Clear filters")}
</button>
)}
</div>
@@ -392,7 +396,7 @@ export function ServiceListPage({ nav }: { nav: Nav }) {
checked={onlyConnected}
onChange={(event) => setOnlyConnected(event.target.checked)}
/>
<span> ({connectedCount})</span>
<span>{tr("只看已连接", "Connected only")} ({connectedCount})</span>
</label>
<div className="h-px bg-border/30" />
@@ -410,11 +414,11 @@ export function ServiceListPage({ nav }: { nav: Nav }) {
<section key={group} className="space-y-3">
<div className="space-y-1">
<h2 className="text-xs font-medium uppercase tracking-wider text-muted-foreground/70">
{GROUP_LABELS[group]}
{getGroupLabel(group)}
</h2>
{GROUP_DESCRIPTIONS[group] && (
{getGroupDescription(group) && (
<p className="text-xs text-muted-foreground/60">
{GROUP_DESCRIPTIONS[group]}
{getGroupDescription(group)}
</p>
)}
</div>
@@ -434,7 +438,7 @@ export function ServiceListPage({ nav }: { nav: Nav }) {
{showCustomSection && (
<section className="space-y-3">
<h2 className="text-xs font-medium uppercase tracking-wider text-muted-foreground/70">
{tr("自定义服务", "Custom services")}
</h2>
<div className="grid grid-cols-2 gap-3">
{filteredCustom.map((svc) => (
@@ -450,7 +454,7 @@ export function ServiceListPage({ nav }: { nav: Nav }) {
className="flex min-h-[92px] flex-col items-center justify-center gap-1.5 rounded-lg border border-dashed border-border/40 p-5 text-muted-foreground/60 transition-all hover:border-primary/30 hover:text-muted-foreground"
>
<Plus size={18} />
<span className="text-xs"></span>
<span className="text-xs">{tr("自定义服务", "Custom service")}</span>
</button>
)}
</div>
@@ -459,7 +463,7 @@ export function ServiceListPage({ nav }: { nav: Nav }) {
{!loading && filtered.length === 0 && filteredCustom.length === 0 && !canCreateCustom && (
<div className="rounded-lg border border-dashed border-border/40 p-8 text-center text-sm text-muted-foreground">
{tr("没有匹配的服务商", "No matching providers")}
</div>
)}
</div>
+12 -11
View File
@@ -1,6 +1,7 @@
import { useState } from "react";
import { useApi, fetchJson, buildApiUrl } from "../hooks/use-api";
import { useColors } from "../hooks/use-colors";
import { tr } from "../lib/app-language";
import type { Theme } from "../hooks/use-theme";
import type { TFunction } from "../hooks/use-i18n";
import type { StoryGraph, StoryNode } from "@actalk/inkos-core/interactive-film/graph-schema";
@@ -85,21 +86,21 @@ export function StoryGraphTree({
className={`ml-auto px-3 py-1 rounded ${c.btnPrimary}`}
data-testid="film-play"
>
{tr("试玩", "Play")}
</button>
<button
onClick={() => nav.toFlow(projectId)}
className={`px-3 py-1 rounded ${c.btnSecondary}`}
data-testid="open-flow"
>
{tr("流程图", "Flow")}
</button>
<button
onClick={() => nav.toFilmAuthor(projectId)}
className={`px-3 py-1 rounded ${c.btnSecondary}`}
data-testid="open-authoring"
>
AI
{tr("AI 对话创作", "AI chat authoring")}
</button>
{exportUrl && (
<a
@@ -108,7 +109,7 @@ export function StoryGraphTree({
className={`px-3 py-1 rounded ${c.btnSecondary}`}
data-testid="film-export-package"
>
{tr("导出整包", "Export package")}
</a>
)}
</div>
@@ -118,15 +119,15 @@ export function StoryGraphTree({
{saveError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive" data-testid="film-save-error">
{saveError}
{tr("保存失败:", "Save failed: ")}{saveError}
</div>
)}
{graph.worldAnchor && (
<div className="border rounded p-3 text-sm" data-testid="film-world">
<div className={c.muted}></div>
<div>{graph.worldAnchor.storyCore}</div>
<div>{graph.worldAnchor.theme} · {graph.worldAnchor.genre}</div>
<div className={c.muted}>{tr("世界锚点", "World anchor")}</div>
<div>{tr("核心:", "Core: ")}{graph.worldAnchor.storyCore}</div>
<div>{tr("主题:", "Theme: ")}{graph.worldAnchor.theme} · {tr("题材:", "Genre: ")}{graph.worldAnchor.genre}</div>
</div>
)}
@@ -190,7 +191,7 @@ function NodeEditor({
<div className="mt-2 space-y-1">
{node.dialogue.map((l, i) => (
<div key={i} className="text-xs">
<span className={colors.accent}>{l.speaker}</span>
<span className={colors.accent}>{l.speaker}{tr("", ": ")}</span>
{l.text}
</div>
))}
@@ -203,7 +204,7 @@ function NodeEditor({
onClick={() => onSave({ ...node, sceneDesc: scene })}
className={`px-3 py-1 text-xs rounded ${colors.btnPrimary} disabled:opacity-40`}
>
{saving ? "保存中…" : "保存"}
{saving ? tr("保存中…", "Saving…") : tr("保存", "Save")}
</button>
<button
data-testid={`gen-image-${node.id}`}
@@ -211,7 +212,7 @@ function NodeEditor({
onClick={() => onGenerateImage(node.id)}
className={`px-3 py-1 text-xs rounded ${colors.btnSecondary} disabled:opacity-40`}
>
{generating ? "生成中…" : "生成配图"}
{generating ? tr("生成中…", "Generating…") : tr("生成配图", "Generate image")}
</button>
</div>
</div>