mirror of
https://github.com/Narcooo/inkos.git
synced 2026-08-28 23:02:03 +08:00
feat(studio): bilingual film wizard and service management pages
影游向导/分析面板/故事树/流程图残留、服务商列表/详情/配置来源卡/ 快捷链接/服务分组全部双语
This commit is contained in:
@@ -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 运行时不会使用它;CLI、daemon 和部署环境仍可按 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 (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user