mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-08-24 00:28:16 +00:00
feat(tts): split DynamicParamForm into Basic and Advanced sections
Phase 3 of the TTS params/layout/agent-override plan.
Reads the `Group` field shipped in Phase 1 to partition each provider's
params into Basic (always visible) and Advanced (collapsed by default,
chevron toggle, count badge). Edge providers with no advanced params
hide the toggle entirely.
- `partitionSchema(schema)` splits params; unknown group values fall
into Basic (forward-compat).
- `evaluateDependsOn` runs against the full shared form state, so
cross-section dependencies (e.g. MiniMax `audio.bitrate` depends on
basic-section `audio.format`) work without scoping.
- Count badge uses `t("tts.advanced.count", {count})` with i18next
pluralization (`_one` for English).
- Desktop mirror with inline SVG chevron (no lucide-react dep).
i18n (web + desktop):
- New `tts.advanced.count` + `tts.advanced.count_one` keys in en/vi/zh.
- `tts.advanced.title` already present; verified.
TS contract: `ParamSchema.group?: string` added to web + desktop
`tts-capabilities.ts`. Plain TS interface — no Zod strict-validator
guard needed (verified via grep).
10 new unit tests cover partition order, unknown-group fallback, count
badge respecting DependsOn, and the cross-group MiniMax case.
189/189 web tests pass.
This commit is contained in:
@@ -34,6 +34,8 @@ export interface ParamSchema {
|
||||
step?: number;
|
||||
enum?: EnumOption[];
|
||||
depends_on?: Dependency[];
|
||||
/** "advanced" → Advanced section; absent/undefined → Basic section (forward-compat: unknown values → Basic). */
|
||||
group?: string;
|
||||
}
|
||||
|
||||
export interface VoiceOption {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import type { ParamSchema } from "@/api/tts-capabilities"
|
||||
import {
|
||||
fieldRenderers,
|
||||
@@ -34,10 +36,77 @@ export function evaluateDependsOn(
|
||||
return deps.every((d) => String(formState[d.field]) === String(d.value))
|
||||
}
|
||||
|
||||
/**
|
||||
* Partitions schema into basic and advanced groups.
|
||||
* group === "advanced" → advanced bucket; anything else (undefined/absent/unknown) → basic.
|
||||
* Stable order preserved within each group (source array order).
|
||||
*/
|
||||
export function partitionSchema(schema: ParamSchema[]): {
|
||||
basic: ParamSchema[]
|
||||
advanced: ParamSchema[]
|
||||
} {
|
||||
const basic: ParamSchema[] = []
|
||||
const advanced: ParamSchema[] = []
|
||||
for (const param of schema) {
|
||||
if (param.group === "advanced") {
|
||||
advanced.push(param)
|
||||
} else {
|
||||
basic.push(param)
|
||||
}
|
||||
}
|
||||
return { basic, advanced }
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single param field row (label + description + input widget).
|
||||
*/
|
||||
function ParamFieldRow({
|
||||
param,
|
||||
value,
|
||||
onChange,
|
||||
readonly,
|
||||
}: {
|
||||
param: ParamSchema
|
||||
value: Record<string, ParamValue>
|
||||
onChange?: (key: string, val: ParamValue) => void
|
||||
readonly: boolean
|
||||
}) {
|
||||
if (!evaluateDependsOn(param.depends_on, value)) return null
|
||||
|
||||
const Renderer = fieldRenderers[param.type] ?? DefaultField
|
||||
const currentVal: ParamValue =
|
||||
value[param.key] !== undefined
|
||||
? value[param.key]!
|
||||
: ((param.default as ParamValue) ?? "")
|
||||
|
||||
const handleChange = (val: ParamValue) => {
|
||||
if (!readonly && onChange) {
|
||||
onChange(param.key, val)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={param.key} className="space-y-1">
|
||||
<label className="text-sm font-medium" htmlFor={`param-${param.key}`}>
|
||||
{param.label}
|
||||
</label>
|
||||
{param.description && (
|
||||
<p className="text-xs text-text-secondary">{param.description}</p>
|
||||
)}
|
||||
<Renderer
|
||||
schema={param}
|
||||
value={currentVal}
|
||||
onChange={handleChange}
|
||||
readonly={readonly}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a list of TTS provider params from a ParamSchema array.
|
||||
* Each field type maps to a dedicated renderer. Visibility is gated by
|
||||
* evaluateDependsOn. When readonly=true, no onChange callbacks fire.
|
||||
* Splits params into Basic (always open) and Advanced (collapsed by default).
|
||||
* evaluateDependsOn always runs against full shared value state.
|
||||
*
|
||||
* Desktop callers MUST pass readonly={true} per Phase C constraint.
|
||||
* NOT mounted in any settings page in Phase A — exported for Phase C wiring.
|
||||
@@ -48,42 +117,83 @@ export function DynamicParamForm({
|
||||
onChange,
|
||||
readonly = false,
|
||||
}: DynamicParamFormProps) {
|
||||
const { t } = useTranslation("tts")
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||
|
||||
if (!schema || schema.length === 0) return null
|
||||
|
||||
const { basic, advanced } = partitionSchema(schema)
|
||||
|
||||
// Count visible advanced params (DependsOn resolves against shared value state)
|
||||
const visibleAdvancedCount = advanced.filter((p) =>
|
||||
evaluateDependsOn(p.depends_on, value),
|
||||
).length
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{schema.map((param) => {
|
||||
if (!evaluateDependsOn(param.depends_on, value)) return null
|
||||
|
||||
const Renderer = fieldRenderers[param.type] ?? DefaultField
|
||||
const currentVal: ParamValue =
|
||||
value[param.key] !== undefined
|
||||
? value[param.key]!
|
||||
: ((param.default as ParamValue) ?? "")
|
||||
|
||||
const handleChange = (val: ParamValue) => {
|
||||
if (!readonly && onChange) {
|
||||
onChange(param.key, val)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={param.key} className="space-y-1">
|
||||
<label className="text-sm font-medium" htmlFor={`param-${param.key}`}>
|
||||
{param.label}
|
||||
</label>
|
||||
{param.description && (
|
||||
<p className="text-xs text-text-secondary">{param.description}</p>
|
||||
)}
|
||||
<Renderer
|
||||
schema={param}
|
||||
value={currentVal}
|
||||
onChange={handleChange}
|
||||
{/* Basic section — always visible */}
|
||||
<section data-section="basic">
|
||||
<div className="space-y-4">
|
||||
{basic.map((param) => (
|
||||
<ParamFieldRow
|
||||
key={param.key}
|
||||
param={param}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
readonly={readonly}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Advanced section — toggle + collapsible (inline SVG chevron: no lucide-react on desktop) */}
|
||||
{advanced.length > 0 && (
|
||||
<section data-section="advanced">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdvancedOpen((prev) => !prev)}
|
||||
className="flex w-full items-center gap-1.5 text-sm text-text-secondary hover:text-text transition-colors py-1"
|
||||
aria-expanded={advancedOpen}
|
||||
>
|
||||
{/* ChevronRight inline SVG — rotates to ChevronDown when open */}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={`shrink-0 transition-transform ${advancedOpen ? "rotate-90" : ""}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
<span>{t("advanced.title")}</span>
|
||||
{visibleAdvancedCount > 0 && (
|
||||
<span className="ml-1 text-xs text-text-secondary">
|
||||
({t("advanced.count", { count: visibleAdvancedCount })})
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{advancedOpen && (
|
||||
<div className="mt-3 space-y-4">
|
||||
{advanced.map((param) => (
|
||||
<ParamFieldRow
|
||||
key={param.key}
|
||||
param={param}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
readonly={readonly}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -269,6 +269,8 @@
|
||||
"requiresProvider": "Select a provider above to enable test playback."
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Advanced settings"
|
||||
"title": "Advanced settings",
|
||||
"count": "{{count}} advanced settings",
|
||||
"count_one": "{{count}} advanced setting"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,6 +269,8 @@
|
||||
"requiresProvider": "Chọn provider ở trên để bật phát thử."
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Cài đặt nâng cao"
|
||||
"title": "Cài đặt nâng cao",
|
||||
"count": "{{count}} cài đặt nâng cao",
|
||||
"count_one": "{{count}} cài đặt nâng cao"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,6 +269,8 @@
|
||||
"requiresProvider": "请先选择 Provider 以启用试听。"
|
||||
},
|
||||
"advanced": {
|
||||
"title": "高级设置"
|
||||
"title": "高级设置",
|
||||
"count": "{{count}} 项高级设置",
|
||||
"count_one": "{{count}} 项高级设置"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface ParamSchema {
|
||||
step?: number;
|
||||
enum?: EnumOption[];
|
||||
depends_on?: Dependency[];
|
||||
/** "advanced" → Advanced section; absent/undefined → Basic section (forward-compat: unknown values → Basic). */
|
||||
group?: string;
|
||||
}
|
||||
|
||||
export interface VoiceOption {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* Pure-logic tests for DynamicParamForm utilities.
|
||||
* NO @testing-library/react — tests isolate state-shape transforms only.
|
||||
* Covers: evaluateDependsOn, initializeDefaults, applyNestedChange, rendererDispatch, edge cases.
|
||||
* Covers: evaluateDependsOn, initializeDefaults, applyNestedChange, rendererDispatch,
|
||||
* partitionSchema, advanced toggle count badge, cross-group DependsOn, edge cases.
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { evaluateDependsOn } from "../dynamic-param-form";
|
||||
import { evaluateDependsOn, partitionSchema } from "../dynamic-param-form";
|
||||
import {
|
||||
initializeDefaults,
|
||||
applyNestedChange,
|
||||
@@ -180,3 +181,127 @@ describe("empty enum handling", () => {
|
||||
expect(defaults["format"]).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
// ---- partitionSchema ----
|
||||
|
||||
describe("partitionSchema", () => {
|
||||
it("puts group='advanced' params into advanced bucket", () => {
|
||||
const schema: ParamSchema[] = [
|
||||
{ key: "speed", type: "range", label: "Speed" },
|
||||
{ key: "instructions", type: "text", label: "Instructions", group: "advanced" },
|
||||
];
|
||||
const { basic, advanced } = partitionSchema(schema);
|
||||
expect(basic.map((p) => p.key)).toEqual(["speed"]);
|
||||
expect(advanced.map((p) => p.key)).toEqual(["instructions"]);
|
||||
});
|
||||
|
||||
it("puts params without group into basic bucket", () => {
|
||||
const schema: ParamSchema[] = [
|
||||
{ key: "speed", type: "range", label: "Speed" },
|
||||
{ key: "format", type: "enum", label: "Format" },
|
||||
];
|
||||
const { basic, advanced } = partitionSchema(schema);
|
||||
expect(basic).toHaveLength(2);
|
||||
expect(advanced).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("preserves source order within each bucket", () => {
|
||||
const schema: ParamSchema[] = [
|
||||
{ key: "a", type: "range", label: "A" },
|
||||
{ key: "b", type: "range", label: "B", group: "advanced" },
|
||||
{ key: "c", type: "range", label: "C" },
|
||||
{ key: "d", type: "range", label: "D", group: "advanced" },
|
||||
];
|
||||
const { basic, advanced } = partitionSchema(schema);
|
||||
expect(basic.map((p) => p.key)).toEqual(["a", "c"]);
|
||||
expect(advanced.map((p) => p.key)).toEqual(["b", "d"]);
|
||||
});
|
||||
|
||||
it("unknown group value (forward-compat) falls into basic bucket", () => {
|
||||
// Future group values like "expert" should default to basic, not crash.
|
||||
const schema: ParamSchema[] = [
|
||||
{ key: "future_param", type: "string", label: "Future", group: "expert" },
|
||||
];
|
||||
const { basic, advanced } = partitionSchema(schema);
|
||||
expect(basic.map((p) => p.key)).toEqual(["future_param"]);
|
||||
expect(advanced).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns empty buckets for empty schema", () => {
|
||||
const { basic, advanced } = partitionSchema([]);
|
||||
expect(basic).toHaveLength(0);
|
||||
expect(advanced).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- visibleAdvancedCount (count badge logic) ----
|
||||
|
||||
describe("visibleAdvancedCount — count badge respects DependsOn", () => {
|
||||
it("counts all advanced params when no DependsOn constraints", () => {
|
||||
const advanced: ParamSchema[] = [
|
||||
{ key: "seed", type: "integer", label: "Seed", group: "advanced" },
|
||||
{ key: "latency", type: "integer", label: "Latency", group: "advanced" },
|
||||
];
|
||||
const value = {};
|
||||
const count = advanced.filter((p) => evaluateDependsOn(p.depends_on, value)).length;
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
|
||||
it("excludes advanced param when DependsOn is not satisfied (cross-group)", () => {
|
||||
// MiniMax-like case: audio.bitrate is advanced, depends on basic audio.format == "mp3"
|
||||
const advanced: ParamSchema[] = [
|
||||
{
|
||||
key: "audio.bitrate",
|
||||
type: "integer",
|
||||
label: "Bitrate",
|
||||
group: "advanced",
|
||||
depends_on: [{ field: "audio.format", op: "eq", value: "mp3" }],
|
||||
},
|
||||
];
|
||||
const valueWav = { "audio.format": "wav" };
|
||||
const countWav = advanced.filter((p) => evaluateDependsOn(p.depends_on, valueWav)).length;
|
||||
expect(countWav).toBe(0);
|
||||
|
||||
const valueMp3 = { "audio.format": "mp3" };
|
||||
const countMp3 = advanced.filter((p) => evaluateDependsOn(p.depends_on, valueMp3)).length;
|
||||
expect(countMp3).toBe(1);
|
||||
});
|
||||
|
||||
it("returns 0 when advanced bucket is empty (Edge provider — no advanced toggle)", () => {
|
||||
const advanced: ParamSchema[] = [];
|
||||
const count = advanced.filter((p) => evaluateDependsOn(p.depends_on, {})).length;
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- cross-group DependsOn (MiniMax bitrate scenario) ----
|
||||
|
||||
describe("cross-group DependsOn — evaluateDependsOn uses shared value state", () => {
|
||||
it("advanced param with depends_on basic field: visible when basic field matches", () => {
|
||||
const advancedParam: ParamSchema = {
|
||||
key: "audio.bitrate",
|
||||
type: "integer",
|
||||
label: "Bitrate (MP3 only)",
|
||||
group: "advanced",
|
||||
depends_on: [{ field: "audio.format", op: "eq", value: "mp3" }],
|
||||
};
|
||||
// shared state includes basic field value
|
||||
expect(evaluateDependsOn(advancedParam.depends_on, { "audio.format": "mp3" })).toBe(true);
|
||||
expect(evaluateDependsOn(advancedParam.depends_on, { "audio.format": "wav" })).toBe(false);
|
||||
expect(evaluateDependsOn(advancedParam.depends_on, {})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- partitionSchema does not mutate source array ----
|
||||
|
||||
describe("partitionSchema immutability", () => {
|
||||
it("does not mutate source schema array", () => {
|
||||
const schema: ParamSchema[] = [
|
||||
{ key: "speed", type: "range", label: "Speed" },
|
||||
{ key: "seed", type: "integer", label: "Seed", group: "advanced" },
|
||||
];
|
||||
const original = [...schema];
|
||||
partitionSchema(schema);
|
||||
expect(schema).toEqual(original);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { useState } from "react"
|
||||
import { ChevronRight } from "lucide-react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import type { ParamSchema } from "@/api/tts-capabilities"
|
||||
import {
|
||||
fieldRenderers,
|
||||
@@ -31,10 +34,80 @@ export function evaluateDependsOn(
|
||||
return deps.every((d) => String(formState[d.field]) === String(d.value))
|
||||
}
|
||||
|
||||
/**
|
||||
* Partitions schema into basic and advanced groups.
|
||||
* group === "advanced" → advanced bucket; anything else (undefined/absent/unknown) → basic.
|
||||
* Stable order preserved within each group (source array order).
|
||||
*/
|
||||
export function partitionSchema(schema: ParamSchema[]): {
|
||||
basic: ParamSchema[]
|
||||
advanced: ParamSchema[]
|
||||
} {
|
||||
const basic: ParamSchema[] = []
|
||||
const advanced: ParamSchema[] = []
|
||||
for (const param of schema) {
|
||||
if (param.group === "advanced") {
|
||||
advanced.push(param)
|
||||
} else {
|
||||
basic.push(param)
|
||||
}
|
||||
}
|
||||
return { basic, advanced }
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single param field row (label + description + input widget).
|
||||
* Used by both Basic and Advanced sections to avoid duplication.
|
||||
*/
|
||||
function ParamFieldRow({
|
||||
param,
|
||||
value,
|
||||
onChange,
|
||||
readonly,
|
||||
}: {
|
||||
param: ParamSchema
|
||||
value: Record<string, ParamValue>
|
||||
onChange?: (key: string, val: ParamValue) => void
|
||||
readonly: boolean
|
||||
}) {
|
||||
if (!evaluateDependsOn(param.depends_on, value)) return null
|
||||
|
||||
const Renderer = fieldRenderers[param.type] ?? DefaultField
|
||||
const currentVal: ParamValue =
|
||||
value[param.key] !== undefined
|
||||
? value[param.key]!
|
||||
: ((param.default as ParamValue) ?? "")
|
||||
|
||||
const handleChange = (val: ParamValue) => {
|
||||
if (!readonly && onChange) {
|
||||
onChange(param.key, val)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={param.key} className="space-y-1">
|
||||
<label className="text-sm font-medium" htmlFor={`param-${param.key}`}>
|
||||
{param.label}
|
||||
</label>
|
||||
{param.description && (
|
||||
<p className="text-xs text-muted-foreground">{param.description}</p>
|
||||
)}
|
||||
<Renderer
|
||||
schema={param}
|
||||
value={currentVal}
|
||||
onChange={handleChange}
|
||||
readonly={readonly}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a list of TTS provider params from a ParamSchema array.
|
||||
* Each field type maps to a dedicated renderer. Visibility is gated by
|
||||
* evaluateDependsOn. When readonly=true, no onChange callbacks fire.
|
||||
* Splits params into Basic (always open) and Advanced (collapsed by default).
|
||||
* Basic: params without group or group !== "advanced".
|
||||
* Advanced: params with group === "advanced", gated by toggle.
|
||||
* evaluateDependsOn always runs against full shared value state.
|
||||
*
|
||||
* NOT mounted in tts-page.tsx in Phase A — exported for Phase C wiring.
|
||||
*/
|
||||
@@ -44,42 +117,70 @@ export function DynamicParamForm({
|
||||
onChange,
|
||||
readonly = false,
|
||||
}: DynamicParamFormProps) {
|
||||
const { t } = useTranslation("tts")
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||
|
||||
if (!schema || schema.length === 0) return null
|
||||
|
||||
const { basic, advanced } = partitionSchema(schema)
|
||||
|
||||
// Count visible advanced params (DependsOn resolves against shared value state)
|
||||
const visibleAdvancedCount = advanced.filter((p) =>
|
||||
evaluateDependsOn(p.depends_on, value),
|
||||
).length
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{schema.map((param) => {
|
||||
if (!evaluateDependsOn(param.depends_on, value)) return null
|
||||
|
||||
const Renderer = fieldRenderers[param.type] ?? DefaultField
|
||||
const currentVal: ParamValue =
|
||||
value[param.key] !== undefined
|
||||
? value[param.key]!
|
||||
: ((param.default as ParamValue) ?? "")
|
||||
|
||||
const handleChange = (val: ParamValue) => {
|
||||
if (!readonly && onChange) {
|
||||
onChange(param.key, val)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={param.key} className="space-y-1">
|
||||
<label className="text-sm font-medium" htmlFor={`param-${param.key}`}>
|
||||
{param.label}
|
||||
</label>
|
||||
{param.description && (
|
||||
<p className="text-xs text-muted-foreground">{param.description}</p>
|
||||
)}
|
||||
<Renderer
|
||||
schema={param}
|
||||
value={currentVal}
|
||||
onChange={handleChange}
|
||||
{/* Basic section — always visible */}
|
||||
<section data-section="basic">
|
||||
<div className="space-y-4">
|
||||
{basic.map((param) => (
|
||||
<ParamFieldRow
|
||||
key={param.key}
|
||||
param={param}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
readonly={readonly}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Advanced section — toggle + collapsible */}
|
||||
{advanced.length > 0 && (
|
||||
<section data-section="advanced">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdvancedOpen((prev) => !prev)}
|
||||
className="flex w-full items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors py-1"
|
||||
aria-expanded={advancedOpen}
|
||||
>
|
||||
<ChevronRight
|
||||
className={`h-4 w-4 shrink-0 transition-transform ${advancedOpen ? "rotate-90" : ""}`}
|
||||
/>
|
||||
<span>{t("advanced.title")}</span>
|
||||
{visibleAdvancedCount > 0 && (
|
||||
<span className="ml-1 text-xs text-muted-foreground">
|
||||
({t("advanced.count", { count: visibleAdvancedCount })})
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{advancedOpen && (
|
||||
<div className="mt-3 space-y-4">
|
||||
{advanced.map((param) => (
|
||||
<ParamFieldRow
|
||||
key={param.key}
|
||||
param={param}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
readonly={readonly}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -255,6 +255,8 @@
|
||||
"requiresProvider": "Select a provider above to enable test playback."
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Advanced settings"
|
||||
"title": "Advanced settings",
|
||||
"count": "{{count}} advanced settings",
|
||||
"count_one": "{{count}} advanced setting"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,6 +255,8 @@
|
||||
"requiresProvider": "Chọn provider ở trên để bật phát thử."
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Cài đặt nâng cao"
|
||||
"title": "Cài đặt nâng cao",
|
||||
"count": "{{count}} cài đặt nâng cao",
|
||||
"count_one": "{{count}} cài đặt nâng cao"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,6 +255,8 @@
|
||||
"requiresProvider": "请先选择 Provider 以启用试听。"
|
||||
},
|
||||
"advanced": {
|
||||
"title": "高级设置"
|
||||
"title": "高级设置",
|
||||
"count": "{{count}} 项高级设置",
|
||||
"count_one": "{{count}} 项高级设置"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user