// 自由创作·输入条工具栏:模型/模式/比例/分辨率/时长/种子 下拉 + 预估消耗 + 清空 + 生成。 // 模型下拉列后端返回的全部视频模型(FC_MODELS 只提供好看的标签)。 // 约束联动(与后端校验一致):分辨率与时长档位取自所选模型的 metadata.capabilities, // 换档时把超出新模型能力的选择夹回去。 import { useEffect, useRef, useState } from "react"; import { ArrowRight, ChevronDown } from "lucide-react"; import type { ModelConfig } from "../../types"; import { DEFAULT_BILLING_RATES, FC_MODELS, FC_RATIOS, FC_RESOLUTIONS, MODE_LABELS, estimateCost, modelDurations, modelResolutions, type BillingRates, type FreeMode, type LocalRef } from "./constants"; type MenuItem = { value: string; label: string; desc?: string; disabled?: boolean; hint?: string }; function FcDropdown({ label, display, items, onSelect, disabled }: { label: string; display: string; items: MenuItem[]; onSelect: (value: string) => void; disabled?: boolean; }) { const [open, setOpen] = useState(false); const wrapRef = useRef(null); const menuRef = useRef(null); useEffect(() => { if (!open) return; const onDown = (event: MouseEvent) => { if (!wrapRef.current?.contains(event.target as Node)) setOpen(false); }; const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") setOpen(false); }; document.addEventListener("mousedown", onDown); document.addEventListener("keydown", onKey); return () => { document.removeEventListener("mousedown", onDown); document.removeEventListener("keydown", onKey); }; }, [open]); // 菜单封了高会滚动(时长有 27 档),打开时默认停在顶部会看不到当前选中项。 // 直接改菜单的 scrollTop,不用 scrollIntoView —— 后者会连带把整个页面滚一下。 useEffect(() => { if (!open) return; const menu = menuRef.current; const active = menu?.querySelector(".fc-dd-item.selected"); if (!menu || !active) return; menu.scrollTop = active.offsetTop - menu.clientHeight / 2 + active.offsetHeight / 2; }, [open]); return (
{open && (
{items.map((item) => ( ))}
)}
); } export function FreeToolbar({ mode, model, ratio, resolution, duration, seed, refs, videoConfigs, billingRates, hasPrompt, submitting, onModeChange, onModelChange, onRatioChange, onResolutionChange, onDurationChange, onSeedChange, onClear, onSend }: { mode: FreeMode; model: string; ratio: string; resolution: string; duration: number; seed: number; refs: LocalRef[]; videoConfigs: ModelConfig[]; billingRates?: BillingRates; hasPrompt: boolean; submitting: boolean; onModeChange: (mode: FreeMode) => void; onModelChange: (model: string) => void; onRatioChange: (ratio: string) => void; onResolutionChange: (resolution: string) => void; onDurationChange: (duration: number) => void; onSeedChange: (seed: number) => void; onClear: () => void; onSend: () => void; }) { const config = videoConfigs.find((c) => c.name === model); // 可选的分辨率/时长按所选模型的能力来,不再写死 —— Seedance 2.5 能出 30 秒,2.0 只有 15。 const allowedResolutions = modelResolutions(config); const allowedDurations = modelDurations(config); const { tokens, points } = estimateCost(config, { ratio, resolution, duration, refs }, billingRates || DEFAULT_BILLING_RATES); const [seedOpen, setSeedOpen] = useState(false); const seedRef = useRef(null); useEffect(() => { if (!seedOpen) return; const onDown = (event: MouseEvent) => { if (!seedRef.current?.contains(event.target as Node)) setSeedOpen(false); }; document.addEventListener("mousedown", onDown); return () => document.removeEventListener("mousedown", onDown); }, [seedOpen]); const uploading = refs.some((r) => r.uploading); const canSend = hasPrompt && !submitting && !uploading; return (
m.name === model)?.label || config?.display_name || model} items={videoConfigs.map((c) => { const known = FC_MODELS.find((m) => m.name === c.name); return { value: c.name, label: known?.label || c.display_name || c.name, desc: known?.desc }; })} onSelect={(value) => { onModelChange(value); // 换档后把分辨率/时长夹回新模型支持的范围,否则会带着上一档的选择提交然后被后端拒 const next = videoConfigs.find((c) => c.name === value); const resolutions = modelResolutions(next); if (!resolutions.includes(resolution)) { onResolutionChange(resolutions.includes("720p") ? "720p" : resolutions[0]); } const durations = modelDurations(next); if (!durations.includes(duration)) { onDurationChange(durations.includes(5) ? 5 : durations[0]); } }} /> onModeChange(value as FreeMode)} /> ({ value: r, label: r }))} onSelect={onRatioChange} /> ({ value: r, label: r.toUpperCase(), disabled: !allowedResolutions.includes(r), hint: "当前模型不支持这一档" }))} onSelect={onResolutionChange} /> ({ value: String(d), label: `${d}s` }))} onSelect={(value) => onDurationChange(Number(value))} />
{seedOpen && (
{ const v = parseInt(event.target.value, 10); onSeedChange(Number.isNaN(v) ? -1 : v); }} />
-1 = 随机;相同种子可复现相似结果
)}
≈ {tokens.toLocaleString()} tokens · {points.toLocaleString()} 积分
); }