feat: add per-model 1K/2K/4K resolution toggles & unify toolbar UI style
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

- Add supports1K/supports2K/supports4K fields to imageGenConfig for
  per-model resolution control in admin settings
- Unify video-params-bar inline mode to match image-params-bar glass
  panel popover style (rounded corners, consistent colors, grid layout)
- Unify image-params-bar count selector and advanced settings to same
  glass panel style
- Update imageGenConfig types across all consuming components

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-02-26 11:59:25 +08:00
parent 77c9ac244b
commit afaab632ed
9 changed files with 421 additions and 245 deletions

View File

@ -134,6 +134,9 @@ type AiProvider = {
type ImageGenConfig = {
maxImages?: number; // 最大输出图片数,如阿里 1-6,Gemini 1-2
supportsSize?: boolean; // 是否支持自定义分辨率
supports1K?: boolean; // 是否支持 1K 分辨率
supports2K?: boolean; // 是否支持 2K 分辨率
supports4K?: boolean; // 是否支持 4K 分辨率
supportsPromptExtend?: boolean; // 是否支持提示词智能优化
supportsNegativePrompt?: boolean; // 是否支持反向提示词
supportsWatermark?: boolean; // 是否支持水印开关
@ -1117,6 +1120,9 @@ export function AdminSettingsClient() {
imageGenConfig: {
maxImages: 2,
supportsSize: false,
supports1K: true,
supports2K: true,
supports4K: true,
supportsPromptExtend: false,
supportsNegativePrompt: false,
supportsWatermark: false,
@ -6466,7 +6472,7 @@ export function AdminSettingsClient() {
/>
<Label className="text-sm">支持自定义分辨率</Label>
</div>
<div className="flex items-center gap-2">
<Switch
checked={modelForm.imageGenConfig?.enableLoadingPlaceholder !== false}
@ -6481,6 +6487,53 @@ export function AdminSettingsClient() {
<Label className="text-sm">启用加载占位图</Label>
</div>
</div>
{/* 分辨率档位开关 */}
<div className="border rounded-md p-3 space-y-2 bg-muted/20">
<div className="text-sm font-medium text-muted-foreground">分辨率档位控制</div>
<p className="text-xs text-muted-foreground">控制前台用户可选的分辨率档位,关闭后用户将无法选择对应分辨率</p>
<div className="grid grid-cols-3 gap-4 mt-2">
<div className="flex items-center gap-2">
<Switch
checked={modelForm.imageGenConfig?.supports1K !== false}
onCheckedChange={(checked) => setModelForm({
...modelForm,
imageGenConfig: {
...modelForm.imageGenConfig,
supports1K: checked,
},
})}
/>
<Label className="text-sm">1K</Label>
</div>
<div className="flex items-center gap-2">
<Switch
checked={modelForm.imageGenConfig?.supports2K !== false}
onCheckedChange={(checked) => setModelForm({
...modelForm,
imageGenConfig: {
...modelForm.imageGenConfig,
supports2K: checked,
},
})}
/>
<Label className="text-sm">2K</Label>
</div>
<div className="flex items-center gap-2">
<Switch
checked={modelForm.imageGenConfig?.supports4K !== false}
onCheckedChange={(checked) => setModelForm({
...modelForm,
imageGenConfig: {
...modelForm.imageGenConfig,
supports4K: checked,
},
})}
/>
<Label className="text-sm">4K</Label>
</div>
</div>
</div>
</div>
)}
</>
@ -6545,6 +6598,9 @@ export function AdminSettingsClient() {
imageGenConfig: (model as any).imageGenConfig || {
maxImages: 2,
supportsSize: false,
supports1K: true,
supports2K: true,
supports4K: true,
supportsPromptExtend: false,
supportsNegativePrompt: false,
supportsWatermark: false,

View File

@ -20,6 +20,9 @@ import {
const imageGenConfigSchema = z.object({
maxImages: z.number().min(1).max(10).optional(),
supportsSize: z.boolean().optional(),
supports1K: z.boolean().optional(),
supports2K: z.boolean().optional(),
supports4K: z.boolean().optional(),
supportsPromptExtend: z.boolean().optional(),
supportsNegativePrompt: z.boolean().optional(),
supportsWatermark: z.boolean().optional(),

View File

@ -2005,6 +2005,9 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
imageGenConfig?: {
maxImages?: number;
supportsSize?: boolean;
supports1K?: boolean;
supports2K?: boolean;
supports4K?: boolean;
supportsPromptExtend?: boolean;
supportsNegativePrompt?: boolean;
supportsWatermark?: boolean;
@ -2020,6 +2023,9 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
imageGenConfig?: {
maxImages?: number;
supportsSize?: boolean;
supports1K?: boolean;
supports2K?: boolean;
supports4K?: boolean;
supportsPromptExtend?: boolean;
supportsNegativePrompt?: boolean;
supportsWatermark?: boolean;

View File

@ -378,6 +378,9 @@ export function Chat({
imageGenConfig?: {
maxImages?: number;
supportsSize?: boolean;
supports1K?: boolean;
supports2K?: boolean;
supports4K?: boolean;
supportsPromptExtend?: boolean;
supportsNegativePrompt?: boolean;
supportsWatermark?: boolean;

View File

@ -99,6 +99,9 @@ type ModelOption = {
imageGenConfig?: {
maxImages?: number;
supportsSize?: boolean;
supports1K?: boolean;
supports2K?: boolean;
supports4K?: boolean;
supportsPromptExtend?: boolean;
supportsNegativePrompt?: boolean;
supportsWatermark?: boolean;
@ -408,6 +411,9 @@ export function LovartHome({
imageGenConfig?: {
maxImages?: number;
supportsSize?: boolean;
supports1K?: boolean;
supports2K?: boolean;
supports4K?: boolean;
supportsPromptExtend?: boolean;
supportsNegativePrompt?: boolean;
supportsWatermark?: boolean;

View File

@ -2,13 +2,7 @@
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Check, Link2, Scan, SlidersHorizontal, Sparkles } from "lucide-react";
import { Link2, Scan, SlidersHorizontal, Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
@ -17,7 +11,6 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Label } from "@/components/ui/label";
// 图片生成参数接口
export interface ImageGenerationParams {
@ -44,6 +37,9 @@ interface ImageParamsBarProps {
imageGenConfig?: {
maxImages?: number;
supportsSize?: boolean;
supports1K?: boolean;
supports2K?: boolean;
supports4K?: boolean;
supportsPromptExtend?: boolean;
supportsNegativePrompt?: boolean;
supportsWatermark?: boolean;
@ -114,9 +110,22 @@ export function ImageParamsBar({
const options = getImageCountOptions();
const fallbackCount = options[0] ?? 1;
const imageCount = options.includes(raw.imageCount || fallbackCount) ? (raw.imageCount || fallbackCount) : fallbackCount;
// 根据模型配置校正分辨率:如果当前选择的档位被禁用,则降级到可用的档位
let imageSize = raw.imageSize || "2K";
if (imageGenConfig) {
const sizeAllowed = (s: string) => {
if (s === "1K") return imageGenConfig.supports1K !== false;
if (s === "2K") return imageGenConfig.supports2K !== false;
if (s === "4K") return imageGenConfig.supports4K !== false;
return true;
};
if (!sizeAllowed(imageSize)) {
imageSize = ["2K", "1K", "4K"].find(sizeAllowed) || "2K";
}
}
return {
...raw,
imageSize: raw.imageSize || "2K",
imageSize,
imageCount,
};
};
@ -153,6 +162,16 @@ export function ImageParamsBar({
const showPromptExtend = isAlibabaImage || imageGenConfig?.supportsPromptExtend;
const showNegativePrompt = isAlibabaImage || imageGenConfig?.supportsNegativePrompt;
// 根据模型配置过滤可用的分辨率档位
const availableSizeOptions = IMAGE_SIZE_OPTIONS.filter((option) => {
if (option.disabled) return false;
if (!imageGenConfig) return true; // 无配置时全部显示
if (option.value === "1K" && imageGenConfig.supports1K === false) return false;
if (option.value === "2K" && imageGenConfig.supports2K === false) return false;
if (option.value === "4K" && imageGenConfig.supports4K === false) return false;
return true;
});
const getComputedOutputSize = (aspectRatio: string, imageSize: string) => {
const size = IMAGE_SIZE_PIXEL_MAP[imageSize] || IMAGE_SIZE_PIXEL_MAP["2K"];
if (aspectRatio === "auto" || aspectRatio === "1:1") {
@ -221,7 +240,7 @@ export function ImageParamsBar({
if (initialParams !== undefined && onParamsChange && !isSameParams(rawParams, nextParams)) {
onParamsChange(nextParams);
}
}, [initialParams, providerType, imageGenConfig?.maxImages]);
}, [initialParams, providerType, imageGenConfig?.maxImages, imageGenConfig?.supports1K, imageGenConfig?.supports2K, imageGenConfig?.supports4K]);
const updateParam = <K extends keyof ImageGenerationParams>(
key: K,
@ -295,7 +314,7 @@ export function ImageParamsBar({
<div>
<p className="text-[11px] font-medium text-[#8a94a3]">选择分辨率</p>
<div className="mt-1.5 grid grid-cols-2 gap-1.5 rounded-[12px] bg-[#eceff2]/90 p-1">
{IMAGE_SIZE_OPTIONS.filter((option) => !option.disabled).map((option) => (
{availableSizeOptions.map((option) => (
<button
key={option.value}
type="button"
@ -339,41 +358,58 @@ export function ImageParamsBar({
</Popover>
{showImageCountSelector && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
className="h-9 min-w-[56px] rounded-[14px] border border-[#d6dbe2] bg-[#f3f4f6]/90 px-2.5 text-[12px] font-semibold text-[#2f3b48] hover:bg-[#edeff2]/90 focus-visible:ring-0 focus-visible:ring-offset-0"
className="h-8 min-w-[56px] rounded-[12px] border border-[#d6dbe2] bg-[#f3f4f6]/90 px-2.5 text-[12px] font-semibold text-[#2f3b48] hover:bg-[#edeff2]/90 focus-visible:ring-0 focus-visible:ring-offset-0"
title={`数量:${params.imageCount || 1}张`}
>
<Sparkles className="mr-1 h-3.5 w-3.5 text-[#6b7280]" />
{params.imageCount || 1}张
<span className="sr-only">{`数量:${params.imageCount || 1}张`}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="min-w-[80px]">
{imageCountOptions.map((count) => (
<DropdownMenuItem
key={count}
onClick={() => updateParam("imageCount", count)}
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none focus:ring-0"
>
<Check className={cn("mr-2 h-4 w-4", (params.imageCount || 1) === count ? "opacity-100" : "opacity-0")} />
{count}张
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</PopoverTrigger>
<PopoverContent
align="center"
side="top"
sideOffset={10}
className="w-auto min-w-[120px] rounded-[14px] border border-[#d8dde4] bg-[#f3f4f6]/95 p-2 shadow-[0_10px_24px_rgba(15,23,42,0.14)]"
>
<div>
<p className="text-[11px] font-medium text-[#8a94a3]">生成数量</p>
<div className={cn(
"mt-1.5 grid gap-1.5 rounded-[12px] bg-[#eceff2]/90 p-1.5",
imageCountOptions.length <= 3 ? "grid-cols-3" : "grid-cols-3"
)}>
{imageCountOptions.map((count) => (
<button
key={count}
type="button"
className={cn(
"flex h-8 min-w-[36px] items-center justify-center rounded-[9px] border text-[12px] font-semibold transition",
(params.imageCount || 1) === count
? "border-white bg-white text-[#1f2937] shadow-[0_1px_5px_rgba(15,23,42,0.08)]"
: "border-transparent text-[#5f6b7a] hover:bg-white/70"
)}
onClick={() => updateParam("imageCount", count)}
>
{count}张
</button>
))}
</div>
</div>
</PopoverContent>
</Popover>
)}
{/* 🆕 阿里生图专用:提示词优化 & 反向提示词 */}
{!hideAdvancedOptions && (showPromptExtend || showNegativePrompt) && (
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
<Button
variant="ghost"
className={cn(
"h-9 w-9 rounded-[14px] border border-[#d6dbe2] bg-[#f3f4f6]/90 p-0 text-[#5f6b7a] hover:bg-[#edeff2]/90 focus-visible:ring-0 focus-visible:ring-offset-0",
"h-8 w-8 shrink-0 rounded-[12px] border border-[#d6dbe2] bg-[#f3f4f6]/90 p-0 text-[#5f6b7a] hover:bg-[#edeff2]/90 focus-visible:ring-0 focus-visible:ring-offset-0",
(params.promptExtend || params.negativePrompt) && "text-primary"
)}
title="高级设置"
@ -382,16 +418,19 @@ export function ImageParamsBar({
<span className="sr-only">高级设置</span>
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-72">
<div className="space-y-4">
<div className="font-medium text-sm">高级选项</div>
<PopoverContent
align="end"
side="top"
sideOffset={10}
className="w-[280px] max-w-[calc(100vw-24px)] rounded-[14px] border border-[#d8dde4] bg-[#f3f4f6]/95 p-2 shadow-[0_10px_24px_rgba(15,23,42,0.14)]"
>
<div className="space-y-2.5">
{/* 提示词智能优化 */}
{showPromptExtend && (
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="text-sm">提示词优化</Label>
<p className="text-xs text-muted-foreground">AI 自动优化您的描述</p>
<span className="text-[12px] font-semibold text-[#2f3b48]">提示词优化</span>
<p className="text-[11px] text-[#8a94a3]">AI 自动优化您的描述</p>
</div>
<Switch
checked={params.promptExtend ?? true}
@ -399,16 +438,16 @@ export function ImageParamsBar({
/>
</div>
)}
{/* 反向提示词 */}
{showNegativePrompt && (
<div className="space-y-2">
<Label className="text-sm">反向提示词</Label>
<div className="space-y-1">
<span className="text-[12px] font-semibold text-[#2f3b48]">反向提示词</span>
<Input
placeholder="不希望出现的内容,如:低质量、模糊..."
value={params.negativePrompt || ""}
onChange={(e) => updateParam("negativePrompt", e.target.value)}
className="h-8 text-xs"
className="h-7 rounded-[8px] border-[#dce1e8] bg-[#eef1f4]/90 text-[12px] text-[#2f3b48] placeholder:text-[#b0b8c4]"
/>
</div>
)}

View File

@ -21,6 +21,9 @@ type DynamicModel = {
imageGenConfig?: {
maxImages?: number;
supportsSize?: boolean;
supports1K?: boolean;
supports2K?: boolean;
supports4K?: boolean;
supportsPromptExtend?: boolean;
supportsNegativePrompt?: boolean;
supportsWatermark?: boolean;
@ -153,6 +156,9 @@ function PureMultimodalInput({
imageGenConfig?: {
maxImages?: number;
supportsSize?: boolean;
supports1K?: boolean;
supports2K?: boolean;
supports4K?: boolean;
supportsPromptExtend?: boolean;
supportsNegativePrompt?: boolean;
supportsWatermark?: boolean;

View File

@ -8,6 +8,11 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Dialog,
DialogContent,
@ -18,7 +23,6 @@ import {
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import {
Video,
@ -32,6 +36,9 @@ import {
Clapperboard,
Monitor,
Ratio,
SlidersHorizontal,
Volume2,
VolumeX,
} from "lucide-react";
import { cn } from "@/lib/utils";
@ -265,219 +272,266 @@ const getImageUploadConfig = () => {
onParamsChange?.(newParams);
};
// 🆕 内联模式:只返回参数按钮,嵌入工具栏
// 🆕 内联模式:只返回参数按钮,嵌入工具栏(统一玻璃面板风格)
if (inline) {
const modeLabel = modeOptions.find((o) => o.value === params.mode)?.label || MODE_LABELS[params.mode];
const durationLabel = `${params.duration}s`;
return (
<div className="flex items-center ml-1 border-l border-border/50 pl-1">
{/* 模式选择 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="ml-1 flex items-center gap-1 border-l border-border/50 pl-1">
{/* 主参数面板:模式 | 比例 | 时长 */}
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
className="h-8 px-2 focus-visible:ring-0 focus-visible:ring-offset-0"
title={`模式:${modeOptions.find((option) => option.value === params.mode)?.label || MODE_LABELS[params.mode]}`}
className="h-8 max-w-[200px] shrink rounded-[12px] border border-[#d6dbe2] bg-[#f3f4f6]/90 px-2 text-[11px] font-semibold text-[#2f3b48] hover:bg-[#edeff2]/90"
title={`${modeLabel},${params.ratio},${durationLabel}`}
>
<Clapperboard size={14} />
<span className="sr-only">
{`模式:${modeOptions.find((option) => option.value === params.mode)?.label || MODE_LABELS[params.mode]}`}
</span>
<Clapperboard className="mr-1 h-3.5 w-3.5 text-[#20262f]" />
<span className="truncate">{modeLabel}</span>
<span className="mx-1.5 h-3 w-px bg-[#cfd5de]" />
<span className="truncate">{params.ratio}</span>
<span className="mx-1.5 h-3 w-px bg-[#cfd5de]" />
<span className="truncate">{durationLabel}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-36">
{modeOptions.map((option) => (
<DropdownMenuItem
key={option.value}
onClick={() => updateParam("mode", option.value)}
>
<Check
className={cn(
"mr-2 h-4 w-4",
params.mode === option.value ? "opacity-100" : "opacity-0"
)}
/>
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* 宽高比 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 px-2 focus-visible:ring-0 focus-visible:ring-offset-0"
title={`比例:${params.ratio}`}
>
<Ratio size={14} />
<span className="sr-only">{`比例:${params.ratio}`}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="w-48">
<div className="p-1.5 grid grid-cols-3 gap-1">
{ratioOptions.map((option) => (
<Button
key={option.value}
variant={params.ratio === option.value ? "secondary" : "ghost"}
size="sm"
className="h-7 text-xs"
onClick={() => updateParam("ratio", option.value)}
>
{option.label}
</Button>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
{!isFalVideo && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 px-2 focus-visible:ring-0 focus-visible:ring-offset-0"
title={`分辨率:${params.resolution}`}
>
<Monitor size={14} />
<span className="sr-only">{`分辨率:${params.resolution}`}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="min-w-[100px]">
{RESOLUTION_OPTIONS.map((option) => (
<DropdownMenuItem key={option.value} onClick={() => updateParam("resolution", option.value)}>
<Check className={cn("mr-2 h-4 w-4", params.resolution === option.value ? "opacity-100" : "opacity-0")} />
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* 时长 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 px-2 focus-visible:ring-0 focus-visible:ring-offset-0"
title={`时长:${params.duration}s`}
>
<Clock size={14} />
<span className="sr-only">{`时长:${params.duration}s`}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="min-w-[80px]">
{durationOptions.map((duration) => (
<DropdownMenuItem key={duration} onClick={() => updateParam("duration", duration)}>
<Check className={cn("mr-2 h-4 w-4", params.duration === duration ? "opacity-100" : "opacity-0")} />
{duration}s
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{isFalVideo ? (
<Button
variant="ghost"
className="h-8 px-2 focus-visible:ring-0 focus-visible:ring-offset-0"
onClick={() => updateParam("generateAudio", !(params.generateAudio ?? true))}
title={`音频:${params.generateAudio === false ? "静音" : "带音频"}`}
</PopoverTrigger>
<PopoverContent
align="center"
side="top"
sideOffset={10}
className="w-[312px] max-w-[calc(100vw-24px)] rounded-[14px] border border-[#d8dde4] bg-[#f3f4f6]/95 p-2 shadow-[0_10px_24px_rgba(15,23,42,0.14)]"
>
<Sparkles size={14} />
<span className="sr-only">{`音频:${params.generateAudio === false ? "静音" : "带音频"}`}</span>
</Button>
) : (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 px-2 focus-visible:ring-0 focus-visible:ring-offset-0"
title={`帧率:${params.fps || 24}fps`}
>
<Video size={14} />
<span className="sr-only">{`帧率:${params.fps || 24}fps`}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="min-w-[90px]">
{FPS_OPTIONS.map((option) => (
<DropdownMenuItem key={option.value} onClick={() => updateParam("fps", option.value as VideoGenerationParams["fps"])}>
<Check className={cn("mr-2 h-4 w-4", (params.fps || 24) === option.value ? "opacity-100" : "opacity-0")} />
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 px-2 focus-visible:ring-0 focus-visible:ring-offset-0"
title={`运镜:${getCurrentCameraLabel()}`}
>
<Camera size={14} />
<span className="sr-only">{`运镜:${getCurrentCameraLabel()}`}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="min-w-[100px]">
{CAMERA_MOVE_OPTIONS.map((option) => (
<DropdownMenuItem
key={option.value}
onClick={() => {
updateParam("cameraMove", option.value);
updateParam("cameraFixed", option.value === "static");
}}
>
<Check className={cn("mr-2 h-4 w-4", params.cameraMove === option.value ? "opacity-100" : "opacity-0")} />
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</>
)}
{/* 高级设置 */}
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost" className="h-8 w-8 px-0 focus-visible:ring-0 focus-visible:ring-offset-0">
<Settings2 size={14} />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[280px]">
<DialogHeader>
<DialogTitle className="text-sm">高级设置</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="flex items-center justify-between">
<Label className="text-xs">返回尾帧</Label>
<Switch
checked={params.returnLastFrame || false}
onCheckedChange={(checked) => updateParam("returnLastFrame", checked)}
/>
<div className="space-y-2.5">
{/* 模式 */}
<div>
<p className="text-[11px] font-medium text-[#8a94a3]">选择模式</p>
<div className="mt-1.5 grid grid-cols-2 gap-1.5 rounded-[12px] bg-[#eceff2]/90 p-1.5">
{modeOptions.map((option) => (
<button
key={option.value}
type="button"
className={cn(
"flex h-8 items-center justify-center rounded-[9px] border text-[12px] font-semibold transition",
params.mode === option.value
? "border-white bg-white text-[#1f2937] shadow-[0_1px_5px_rgba(15,23,42,0.08)]"
: "border-transparent text-[#5f6b7a] hover:bg-white/70"
)}
onClick={() => updateParam("mode", option.value)}
>
{option.label}
</button>
))}
</div>
</div>
{isFalVideo && (
<div className="flex items-center justify-between">
<Label className="text-xs">生成音频</Label>
<Switch
checked={params.generateAudio ?? true}
onCheckedChange={(checked) => updateParam("generateAudio", checked)}
/>
{/* 比例 */}
<div>
<p className="text-[11px] font-medium text-[#8a94a3]">选择比例</p>
<div className="mt-1.5 grid grid-cols-3 gap-1.5 rounded-[12px] bg-[#eceff2]/90 p-1.5">
{ratioOptions.map((option) => (
<button
key={option.value}
type="button"
className={cn(
"flex h-8 items-center justify-center rounded-[9px] border text-[12px] font-semibold transition",
params.ratio === option.value
? "border-white bg-white text-[#1f2937] shadow-[0_1px_5px_rgba(15,23,42,0.08)]"
: "border-transparent text-[#5f6b7a] hover:bg-white/70"
)}
onClick={() => updateParam("ratio", option.value)}
>
{option.label}
</button>
))}
</div>
</div>
{/* 分辨率(非 fal) */}
{!isFalVideo && (
<div>
<p className="text-[11px] font-medium text-[#8a94a3]">选择分辨率</p>
<div className="mt-1.5 grid grid-cols-3 gap-1.5 rounded-[12px] bg-[#eceff2]/90 p-1">
{RESOLUTION_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
className={cn(
"h-8 rounded-[9px] text-[12px] font-semibold transition",
params.resolution === option.value
? "bg-white text-[#111827] shadow-[0_1px_5px_rgba(15,23,42,0.08)]"
: "bg-transparent text-[#5f6b7a] hover:bg-white/70"
)}
onClick={() => updateParam("resolution", option.value)}
>
{option.label}
</button>
))}
</div>
</div>
)}
<div className="space-y-1.5">
<Label className="text-xs">随机种子</Label>
<Input
type="number"
placeholder="留空则随机"
value={params.seed || ""}
onChange={(e) => updateParam("seed", Number(e.target.value) || undefined)}
className="h-8 text-xs"
/>
{/* 时长 */}
<div>
<p className="text-[11px] font-medium text-[#8a94a3]">选择时长</p>
<div className={cn(
"mt-1.5 grid gap-1.5 rounded-[12px] bg-[#eceff2]/90 p-1.5",
durationOptions.length > 6 ? "grid-cols-5" : "grid-cols-2"
)}>
{durationOptions.map((duration) => (
<button
key={duration}
type="button"
className={cn(
"flex h-8 items-center justify-center rounded-[9px] border text-[12px] font-semibold transition",
params.duration === duration
? "border-white bg-white text-[#1f2937] shadow-[0_1px_5px_rgba(15,23,42,0.08)]"
: "border-transparent text-[#5f6b7a] hover:bg-white/70"
)}
onClick={() => updateParam("duration", duration)}
>
{duration}s
</button>
))}
</div>
</div>
</div>
</DialogContent>
</Dialog>
</PopoverContent>
</Popover>
{/* 副参数:帧率 / 运镜 / 音频(合并为一个 Popover) */}
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 shrink-0 rounded-[12px] border border-[#d6dbe2] bg-[#f3f4f6]/90 p-0 text-[#5f6b7a] hover:bg-[#edeff2]/90"
title="更多参数"
>
<SlidersHorizontal size={14} />
</Button>
</PopoverTrigger>
<PopoverContent
align="end"
side="top"
sideOffset={10}
className="w-[280px] max-w-[calc(100vw-24px)] rounded-[14px] border border-[#d8dde4] bg-[#f3f4f6]/95 p-2 shadow-[0_10px_24px_rgba(15,23,42,0.14)]"
>
<div className="space-y-2.5">
{isFalVideo ? (
/* fal-video:音频开关 */
<div>
<p className="text-[11px] font-medium text-[#8a94a3]">音频</p>
<div className="mt-1.5 grid grid-cols-2 gap-1.5 rounded-[12px] bg-[#eceff2]/90 p-1.5">
<button
type="button"
className={cn(
"flex h-8 items-center justify-center gap-1.5 rounded-[9px] border text-[12px] font-semibold transition",
(params.generateAudio ?? true)
? "border-white bg-white text-[#1f2937] shadow-[0_1px_5px_rgba(15,23,42,0.08)]"
: "border-transparent text-[#5f6b7a] hover:bg-white/70"
)}
onClick={() => updateParam("generateAudio", true)}
>
<Volume2 size={13} />
带音频
</button>
<button
type="button"
className={cn(
"flex h-8 items-center justify-center gap-1.5 rounded-[9px] border text-[12px] font-semibold transition",
params.generateAudio === false
? "border-white bg-white text-[#1f2937] shadow-[0_1px_5px_rgba(15,23,42,0.08)]"
: "border-transparent text-[#5f6b7a] hover:bg-white/70"
)}
onClick={() => updateParam("generateAudio", false)}
>
<VolumeX size={13} />
静音
</button>
</div>
</div>
) : (
<>
{/* 帧率 */}
<div>
<p className="text-[11px] font-medium text-[#8a94a3]">帧率</p>
<div className="mt-1.5 grid grid-cols-3 gap-1.5 rounded-[12px] bg-[#eceff2]/90 p-1.5">
{FPS_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
className={cn(
"flex h-8 items-center justify-center rounded-[9px] border text-[12px] font-semibold transition",
(params.fps || 24) === option.value
? "border-white bg-white text-[#1f2937] shadow-[0_1px_5px_rgba(15,23,42,0.08)]"
: "border-transparent text-[#5f6b7a] hover:bg-white/70"
)}
onClick={() => updateParam("fps", option.value as VideoGenerationParams["fps"])}
>
{option.label}
</button>
))}
</div>
</div>
{/* 运镜 */}
<div>
<p className="text-[11px] font-medium text-[#8a94a3]">运镜模式</p>
<div className="mt-1.5 grid grid-cols-3 gap-1.5 rounded-[12px] bg-[#eceff2]/90 p-1.5">
{CAMERA_MOVE_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
className={cn(
"flex h-8 items-center justify-center rounded-[9px] border text-[11px] font-semibold transition",
params.cameraMove === option.value
? "border-white bg-white text-[#1f2937] shadow-[0_1px_5px_rgba(15,23,42,0.08)]"
: "border-transparent text-[#5f6b7a] hover:bg-white/70"
)}
onClick={() => {
updateParam("cameraMove", option.value);
updateParam("cameraFixed", option.value === "static");
}}
>
{option.label}
</button>
))}
</div>
</div>
</>
)}
{/* 高级选项 */}
<div className="space-y-2.5 border-t border-[#dce1e8] pt-2.5">
<div className="flex items-center justify-between">
<span className="text-[11px] font-medium text-[#8a94a3]">返回尾帧</span>
<Switch
checked={params.returnLastFrame || false}
onCheckedChange={(checked) => updateParam("returnLastFrame", checked)}
/>
</div>
{isFalVideo && (
<div className="flex items-center justify-between">
<span className="text-[11px] font-medium text-[#8a94a3]">生成音频</span>
<Switch
checked={params.generateAudio ?? true}
onCheckedChange={(checked) => updateParam("generateAudio", checked)}
/>
</div>
)}
<div className="space-y-1">
<span className="text-[11px] font-medium text-[#8a94a3]">随机种子</span>
<Input
type="number"
placeholder="留空则随机"
value={params.seed || ""}
onChange={(e) => updateParam("seed", Number(e.target.value) || undefined)}
className="h-7 rounded-[8px] border-[#dce1e8] bg-[#eef1f4]/90 text-[12px] text-[#2f3b48] placeholder:text-[#b0b8c4]"
/>
</div>
</div>
</div>
</PopoverContent>
</Popover>
</div>
);
}

View File

@ -1414,6 +1414,9 @@ export async function getDefaultAiModel(): Promise<AiModel | undefined> {
interface ImageGenConfig {
maxImages?: number;
supportsSize?: boolean;
supports1K?: boolean;
supports2K?: boolean;
supports4K?: boolean;
supportsPromptExtend?: boolean;
supportsNegativePrompt?: boolean;
supportsWatermark?: boolean;