853 lines
32 KiB
TypeScript
853 lines
32 KiB
TypeScript
"use client";
|
||
import { filterCanvasModels } from "@/lib/video-canvas/model-matching";
|
||
|
||
import Link from "next/link";
|
||
import { ArrowLeft, Plus, Trash2 } from "lucide-react";
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import useSWR from "swr";
|
||
import { toast } from "sonner";
|
||
import { Button } from "@/components/ui/button";
|
||
import {
|
||
Card,
|
||
CardContent,
|
||
CardDescription,
|
||
CardHeader,
|
||
CardTitle,
|
||
} from "@/components/ui/card";
|
||
import { Label } from "@/components/ui/label";
|
||
import { Input } from "@/components/ui/input";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import { Switch } from "@/components/ui/switch";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import { fetcher } from "@/lib/utils";
|
||
import {
|
||
getVideoCanvasFunctionBillingCopy,
|
||
type VideoCanvasFunctionConfig,
|
||
type VideoCanvasFunctionDefinition,
|
||
type VideoCanvasEffectPreset,
|
||
type VideoCanvasMotionPreset,
|
||
type VideoCanvasRolePreset,
|
||
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
|
||
defaultVideoCanvasFunctionConfig,
|
||
normalizeVideoCanvasFunctionConfig,
|
||
} from "@/lib/video-canvas/config";
|
||
|
||
type SettingsResponse = {
|
||
setting?: {
|
||
value?: unknown;
|
||
};
|
||
};
|
||
|
||
type AiModelOption = {
|
||
id: string;
|
||
modelId: string;
|
||
displayName: string;
|
||
providerName?: string;
|
||
providerType?: string;
|
||
supportsVision?: boolean;
|
||
supportsImageGen?: boolean;
|
||
isEnabled?: boolean;
|
||
isHidden?: boolean;
|
||
isMultimodal?: boolean;
|
||
capabilities?: unknown;
|
||
};
|
||
|
||
type AiModelsResponse = {
|
||
models?: AiModelOption[];
|
||
};
|
||
|
||
const INHERIT_MODEL_VALUE = "__inherit__";
|
||
|
||
const functionKindLabels: Record<VideoCanvasFunctionDefinition["kind"], string> = {
|
||
audio: "音频",
|
||
image: "图片生成",
|
||
"image-edit": "图片编辑",
|
||
multimodal: "多模态",
|
||
utility: "工具",
|
||
video: "视频生成",
|
||
workflow: "工作流",
|
||
};
|
||
|
||
function getModelOptionValue(model: AiModelOption) {
|
||
return String(model.modelId || model.id || "").trim();
|
||
}
|
||
|
||
function getModelOptionLabel(model: AiModelOption) {
|
||
const name = model.displayName || model.modelId || model.id;
|
||
const id = model.modelId && model.modelId !== name ? ` · ${model.modelId}` : "";
|
||
return `${name}${id}`;
|
||
}
|
||
|
||
function ModelSelect({
|
||
allowInherit = false,
|
||
kind,
|
||
functionKey,
|
||
models,
|
||
onChange,
|
||
value,
|
||
}: {
|
||
allowInherit?: boolean;
|
||
kind: VideoCanvasFunctionDefinition["kind"];
|
||
functionKey?: string;
|
||
models: AiModelOption[];
|
||
onChange: (value: string) => void;
|
||
value: string;
|
||
}) {
|
||
const modelKind = functionKey === "audio-to-video" ? "audio-video" : kind === "image" || kind === "image-edit" || kind === "video" || kind === "multimodal" ? kind : "text";
|
||
const filteredModels = filterCanvasModels(models, modelKind);
|
||
const normalizedValue = value?.trim() || (allowInherit ? INHERIT_MODEL_VALUE : "auto");
|
||
const hasCurrent =
|
||
normalizedValue === "auto" ||
|
||
normalizedValue === INHERIT_MODEL_VALUE ||
|
||
filteredModels.some((model) => getModelOptionValue(model) === normalizedValue);
|
||
|
||
return (
|
||
<Select
|
||
onValueChange={(nextValue) => {
|
||
if (nextValue === INHERIT_MODEL_VALUE) {
|
||
onChange("");
|
||
return;
|
||
}
|
||
onChange(nextValue);
|
||
}}
|
||
value={normalizedValue}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="选择模型" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{allowInherit ? (
|
||
<SelectItem value={INHERIT_MODEL_VALUE}>继承全局默认模型</SelectItem>
|
||
) : (
|
||
<SelectItem value="auto">自动选择可用模型</SelectItem>
|
||
)}
|
||
{!hasCurrent && normalizedValue ? (
|
||
<SelectItem value={normalizedValue}>当前保存值:{normalizedValue}</SelectItem>
|
||
) : null}
|
||
{filteredModels.map((model) => {
|
||
const optionValue = getModelOptionValue(model);
|
||
return (
|
||
<SelectItem key={`${model.id}-${optionValue}`} value={optionValue}>
|
||
{getModelOptionLabel(model)}
|
||
</SelectItem>
|
||
);
|
||
})}
|
||
{filteredModels.length === 0 ? (
|
||
<SelectItem disabled value="__no_models__">
|
||
暂无已启用模型
|
||
</SelectItem>
|
||
) : null}
|
||
</SelectContent>
|
||
</Select>
|
||
);
|
||
}
|
||
|
||
export function VideoCanvasSettingsClient() {
|
||
const { data, isLoading, mutate } = useSWR<SettingsResponse>(
|
||
`/api/admin/settings?key=${VIDEO_CANVAS_FUNCTION_CONFIG_KEY}`,
|
||
fetcher,
|
||
);
|
||
const { data: aiModelsData } = useSWR<AiModelsResponse>("/api/admin/ai-models", fetcher);
|
||
const [config, setConfig] = useState<VideoCanvasFunctionConfig>(defaultVideoCanvasFunctionConfig);
|
||
const [saving, setSaving] = useState(false);
|
||
const modelOptions = useMemo(
|
||
() => (aiModelsData?.models || []).filter((model) => model.isEnabled !== false),
|
||
[aiModelsData?.models],
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (!data) return;
|
||
setConfig(normalizeVideoCanvasFunctionConfig(data.setting?.value));
|
||
}, [data]);
|
||
|
||
const functions = useMemo(
|
||
() =>
|
||
Object.values(config.functions).sort((a, b) => {
|
||
const compare = (left: string, right: string) =>
|
||
left === right ? 0 : left < right ? -1 : 1;
|
||
return compare(a.kind, b.kind) || compare(a.label, b.label);
|
||
}),
|
||
[config.functions],
|
||
);
|
||
|
||
const updateRoot = (updates: Partial<VideoCanvasFunctionConfig>) => {
|
||
setConfig((current) => ({ ...current, ...updates }));
|
||
};
|
||
|
||
const updateFunction = (
|
||
key: string,
|
||
updates: Partial<VideoCanvasFunctionDefinition>,
|
||
) => {
|
||
setConfig((current) => ({
|
||
...current,
|
||
functions: {
|
||
...current.functions,
|
||
[key]: {
|
||
...current.functions[key],
|
||
...updates,
|
||
},
|
||
},
|
||
}));
|
||
};
|
||
|
||
const updateEffectPreset = (
|
||
index: number,
|
||
updates: Partial<VideoCanvasEffectPreset>,
|
||
) => {
|
||
setConfig((current) => ({
|
||
...current,
|
||
effectPresets: current.effectPresets.map((preset, presetIndex) =>
|
||
presetIndex === index ? { ...preset, ...updates } : preset,
|
||
),
|
||
}));
|
||
};
|
||
|
||
const updateMotionPreset = (
|
||
index: number,
|
||
updates: Partial<VideoCanvasMotionPreset>,
|
||
) => {
|
||
setConfig((current) => ({
|
||
...current,
|
||
motionPresets: current.motionPresets.map((preset, presetIndex) =>
|
||
presetIndex === index ? { ...preset, ...updates } : preset,
|
||
),
|
||
}));
|
||
};
|
||
|
||
const addMotionPreset = () => {
|
||
const createdAt = Date.now();
|
||
setConfig((current) => ({
|
||
...current,
|
||
motionPresets: [
|
||
...current.motionPresets,
|
||
{
|
||
id: `motion-${createdAt}`,
|
||
name: "新运镜预设",
|
||
previewUrl: "/video-canvas/effects/multi-angle.webp",
|
||
prompt: "镜头平稳移动,保持主体和空间关系清晰。",
|
||
},
|
||
],
|
||
}));
|
||
};
|
||
|
||
const removeMotionPreset = (index: number) => {
|
||
setConfig((current) => ({
|
||
...current,
|
||
motionPresets: current.motionPresets.filter(
|
||
(_, presetIndex) => presetIndex !== index,
|
||
),
|
||
}));
|
||
};
|
||
|
||
const addEffectPreset = () => {
|
||
setConfig((current) => ({
|
||
...current,
|
||
effectPresets: [
|
||
...current.effectPresets,
|
||
{
|
||
id: `effect-${Date.now()}`,
|
||
title: "新视频特效",
|
||
author: "官方预设",
|
||
usageCount: 0,
|
||
model: "Seedance 2.0",
|
||
thumbnailUrl: "/video-canvas/effects/product-light-sweep.webp",
|
||
prompt: "保持参考主体稳定,应用自然、连续的视频特效和镜头运动。",
|
||
commercial: true,
|
||
},
|
||
],
|
||
}));
|
||
};
|
||
|
||
const removeEffectPreset = (index: number) => {
|
||
setConfig((current) => ({
|
||
...current,
|
||
effectPresets: current.effectPresets.filter((_, presetIndex) => presetIndex !== index),
|
||
}));
|
||
};
|
||
|
||
const updateRolePreset = (
|
||
index: number,
|
||
updates: Partial<VideoCanvasRolePreset>,
|
||
) => {
|
||
setConfig((current) => ({
|
||
...current,
|
||
rolePresets: current.rolePresets.map((preset, presetIndex) =>
|
||
presetIndex === index ? { ...preset, ...updates } : preset,
|
||
),
|
||
}));
|
||
};
|
||
|
||
const addRolePreset = () => {
|
||
const createdAt = Date.now();
|
||
setConfig((current) => ({
|
||
...current,
|
||
rolePresets: [
|
||
...current.rolePresets,
|
||
{
|
||
id: `role-${createdAt}`,
|
||
assetId: `public-role:role-${createdAt}`,
|
||
name: "新角色预设",
|
||
accent: "#525252",
|
||
description: "管理员配置的公共角色素材。",
|
||
prompt: "保持角色身份、五官、发型、服装和体型在所有视频镜头中一致。",
|
||
tags: "角色",
|
||
compliant: true,
|
||
images: {
|
||
expression: "",
|
||
face: "",
|
||
full: "/video-canvas/effects/product-light-sweep.webp",
|
||
threeView: "",
|
||
},
|
||
filters: {
|
||
age: "青年",
|
||
build: "匀称",
|
||
culture: "东亚",
|
||
era: "现代",
|
||
gender: "中性",
|
||
hairColor: "黑发",
|
||
species: "人类",
|
||
},
|
||
},
|
||
],
|
||
}));
|
||
};
|
||
|
||
const removeRolePreset = (index: number) => {
|
||
setConfig((current) => ({
|
||
...current,
|
||
rolePresets: current.rolePresets.filter((_, presetIndex) => presetIndex !== index),
|
||
}));
|
||
};
|
||
|
||
const save = async () => {
|
||
setSaving(true);
|
||
try {
|
||
const response = await fetch("/api/admin/settings", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
|
||
value: config,
|
||
}),
|
||
});
|
||
const payload = await response.json().catch(() => ({}));
|
||
if (!response.ok) {
|
||
throw new Error(payload?.message || payload?.error || "保存失败");
|
||
}
|
||
toast.success("视频画布功能配置已保存");
|
||
await mutate();
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "保存失败");
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const resetToDefault = () => {
|
||
setConfig(defaultVideoCanvasFunctionConfig);
|
||
toast.info("已恢复默认配置,保存后生效");
|
||
};
|
||
|
||
return (
|
||
<main className="settings-workspace mx-auto flex w-full max-w-7xl flex-col gap-4 px-4 py-5 sm:px-5">
|
||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||
<div>
|
||
<Button asChild className="mb-2" size="sm" variant="outline">
|
||
<Link href="/settings">
|
||
<ArrowLeft className="mr-1.5 h-3.5 w-3.5" />
|
||
返回设置
|
||
</Link>
|
||
</Button>
|
||
<h1 className="text-xl font-semibold">视频画布功能设置</h1>
|
||
<p className="mt-1 max-w-3xl text-xs leading-5 text-muted-foreground">
|
||
这里集中配置复刻画布里的工具能力、预设提示词和默认模型。积分与扣费统一跟随全站模型/功能配置,不再在视频画布里单独维护一套价格。
|
||
</p>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<Button disabled={saving || isLoading} onClick={resetToDefault} type="button" variant="outline">
|
||
恢复默认
|
||
</Button>
|
||
<Button disabled={saving || isLoading} onClick={save} type="button">
|
||
{saving ? "保存中..." : "保存配置"}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<Card>
|
||
<CardHeader className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||
<div>
|
||
<CardTitle className="text-xl">全局模型与开关</CardTitle>
|
||
<CardDescription>
|
||
默认模型从后台已启用模型中选择;保持自动时由各接口按当前可用模型选择。
|
||
</CardDescription>
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<Label htmlFor="video-canvas-enabled">启用视频画布功能串联</Label>
|
||
<Switch
|
||
checked={config.enabled}
|
||
id="video-canvas-enabled"
|
||
onCheckedChange={(checked) => updateRoot({ enabled: checked })}
|
||
/>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="grid gap-4 md:grid-cols-3">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="defaultImageModel">默认图片/编辑模型</Label>
|
||
<ModelSelect
|
||
kind="image-edit"
|
||
models={modelOptions}
|
||
onChange={(value) => updateRoot({ defaultImageModel: value || "auto" })}
|
||
value={config.defaultImageModel}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="defaultVideoModel">默认视频模型</Label>
|
||
<ModelSelect
|
||
kind="video"
|
||
models={modelOptions}
|
||
onChange={(value) => updateRoot({ defaultVideoModel: value || "auto" })}
|
||
value={config.defaultVideoModel}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="defaultMultimodalModel">默认多模态模型</Label>
|
||
<ModelSelect
|
||
kind="multimodal"
|
||
models={modelOptions}
|
||
onChange={(value) => updateRoot({ defaultMultimodalModel: value || "auto" })}
|
||
value={config.defaultMultimodalModel}
|
||
/>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<section className="space-y-4">
|
||
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||
<div>
|
||
<h2 className="text-lg font-semibold tracking-tight">视频运镜素材预设</h2>
|
||
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">
|
||
视频节点“运镜广场”的名称、预览图和插入提示词都从这里读取。点击运镜卡会按竞品规则把提示词追加到用户输入框,允许组合和重复使用。
|
||
</p>
|
||
</div>
|
||
<Button onClick={addMotionPreset} size="sm" type="button" variant="outline">
|
||
<Plus className="mr-1.5 h-4 w-4" />
|
||
添加运镜
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="grid gap-3">
|
||
{config.motionPresets.map((preset, index) => (
|
||
<details
|
||
className="group rounded-lg border border-border bg-card px-4 py-3"
|
||
key={`${preset.id}-${index}`}
|
||
>
|
||
<summary className="flex cursor-pointer list-none items-center gap-3">
|
||
<img
|
||
alt=""
|
||
className="h-14 w-14 shrink-0 rounded-md object-cover"
|
||
src={preset.previewUrl}
|
||
/>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="truncate text-sm font-medium">{preset.name}</div>
|
||
<div className="mt-1 truncate text-xs text-muted-foreground">
|
||
{preset.prompt || "未设置插入提示词"}
|
||
</div>
|
||
</div>
|
||
<Button
|
||
aria-label={`删除运镜 ${preset.name}`}
|
||
className="shrink-0"
|
||
onClick={(event) => {
|
||
event.preventDefault();
|
||
removeMotionPreset(index);
|
||
}}
|
||
size="icon"
|
||
title={`删除运镜 ${preset.name}`}
|
||
type="button"
|
||
variant="ghost"
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</Button>
|
||
</summary>
|
||
|
||
<div className="mt-4 grid gap-4 border-t border-border pt-4 md:grid-cols-2">
|
||
<div className="space-y-2">
|
||
<Label htmlFor={`motion-name-${index}`}>运镜名称</Label>
|
||
<Input
|
||
id={`motion-name-${index}`}
|
||
onChange={(event) =>
|
||
updateMotionPreset(index, { name: event.target.value })
|
||
}
|
||
value={preset.name}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor={`motion-preview-${index}`}>预览图地址</Label>
|
||
<Input
|
||
id={`motion-preview-${index}`}
|
||
onChange={(event) =>
|
||
updateMotionPreset(index, { previewUrl: event.target.value })
|
||
}
|
||
value={preset.previewUrl}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2 md:col-span-2">
|
||
<Label htmlFor={`motion-prompt-${index}`}>插入提示词</Label>
|
||
<Textarea
|
||
id={`motion-prompt-${index}`}
|
||
onChange={(event) =>
|
||
updateMotionPreset(index, { prompt: event.target.value })
|
||
}
|
||
value={preset.prompt}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
<section className="space-y-4">
|
||
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||
<div>
|
||
<h2 className="text-lg font-semibold tracking-tight">视频特效素材预设</h2>
|
||
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">
|
||
视频节点“特效”面板、素材节点封面、模型约束和生成提示词都从这里读取。选择特效时会创建结构化素材节点,不会把描述硬写进用户提示词。
|
||
</p>
|
||
</div>
|
||
<Button onClick={addEffectPreset} size="sm" type="button" variant="outline">
|
||
<Plus className="mr-1.5 h-4 w-4" />
|
||
添加特效
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="grid gap-3">
|
||
{config.effectPresets.map((preset, index) => (
|
||
<details
|
||
className="group rounded-lg border border-border bg-card px-4 py-3"
|
||
key={`${preset.id}-${index}`}
|
||
>
|
||
<summary className="flex cursor-pointer list-none items-center gap-3">
|
||
<img
|
||
alt=""
|
||
className="h-12 w-12 shrink-0 rounded-md object-cover"
|
||
src={preset.thumbnailUrl}
|
||
/>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="truncate text-sm font-medium">{preset.title}</div>
|
||
<div className="mt-1 truncate text-xs text-muted-foreground">
|
||
{preset.author} · {preset.model} · {preset.usageCount.toLocaleString()} 次使用
|
||
</div>
|
||
</div>
|
||
<Button
|
||
aria-label={`删除特效 ${preset.title}`}
|
||
className="shrink-0"
|
||
onClick={(event) => {
|
||
event.preventDefault();
|
||
removeEffectPreset(index);
|
||
}}
|
||
size="icon"
|
||
title={`删除特效 ${preset.title}`}
|
||
type="button"
|
||
variant="ghost"
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</Button>
|
||
</summary>
|
||
|
||
<div className="mt-4 grid gap-4 border-t border-border pt-4 md:grid-cols-2">
|
||
<div className="space-y-2">
|
||
<Label htmlFor={`effect-title-${index}`}>特效名称</Label>
|
||
<Input
|
||
id={`effect-title-${index}`}
|
||
onChange={(event) => updateEffectPreset(index, { title: event.target.value })}
|
||
value={preset.title}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor={`effect-author-${index}`}>作者</Label>
|
||
<Input
|
||
id={`effect-author-${index}`}
|
||
onChange={(event) => updateEffectPreset(index, { author: event.target.value })}
|
||
value={preset.author}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor={`effect-model-${index}`}>适用模型</Label>
|
||
<Input
|
||
id={`effect-model-${index}`}
|
||
onChange={(event) => updateEffectPreset(index, { model: event.target.value })}
|
||
value={preset.model}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor={`effect-count-${index}`}>展示使用次数</Label>
|
||
<Input
|
||
id={`effect-count-${index}`}
|
||
min={0}
|
||
onChange={(event) =>
|
||
updateEffectPreset(index, {
|
||
usageCount: Math.max(0, Number(event.target.value) || 0),
|
||
})
|
||
}
|
||
type="number"
|
||
value={preset.usageCount}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2 md:col-span-2">
|
||
<Label htmlFor={`effect-thumbnail-${index}`}>封面地址</Label>
|
||
<Input
|
||
id={`effect-thumbnail-${index}`}
|
||
onChange={(event) =>
|
||
updateEffectPreset(index, { thumbnailUrl: event.target.value })
|
||
}
|
||
value={preset.thumbnailUrl}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2 md:col-span-2">
|
||
<Label htmlFor={`effect-prompt-${index}`}>生成提示词 / 后端规则</Label>
|
||
<Textarea
|
||
id={`effect-prompt-${index}`}
|
||
onChange={(event) => updateEffectPreset(index, { prompt: event.target.value })}
|
||
value={preset.prompt}
|
||
/>
|
||
</div>
|
||
<div className="flex items-center gap-3 md:col-span-2">
|
||
<Switch
|
||
checked={preset.commercial}
|
||
id={`effect-commercial-${index}`}
|
||
onCheckedChange={(checked) => updateEffectPreset(index, { commercial: checked })}
|
||
/>
|
||
<Label htmlFor={`effect-commercial-${index}`}>标记为可商用</Label>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
<section className="space-y-4">
|
||
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||
<div>
|
||
<h2 className="text-lg font-semibold tracking-tight">视频角色素材预设</h2>
|
||
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">
|
||
视频节点“角色库”的公共角色、Seedance 合规资产 ID、四类角色图和隐藏生成提示词都从这里读取。角色引用通过画布连线保存,不会改写用户输入框。
|
||
</p>
|
||
</div>
|
||
<Button onClick={addRolePreset} size="sm" type="button" variant="outline">
|
||
<Plus className="mr-1.5 h-4 w-4" />
|
||
添加角色
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="grid gap-3">
|
||
{config.rolePresets.map((preset, index) => (
|
||
<details
|
||
className="group rounded-lg border border-border bg-card px-4 py-3"
|
||
key={`${preset.id}-${index}`}
|
||
>
|
||
<summary className="flex cursor-pointer list-none items-center gap-3">
|
||
<img
|
||
alt=""
|
||
className="h-14 w-11 shrink-0 rounded-md object-cover object-top"
|
||
src={preset.images.full}
|
||
/>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="truncate text-sm font-medium">{preset.name}</div>
|
||
<div className="mt-1 truncate text-xs text-muted-foreground">
|
||
{preset.assetId} · {preset.tags || "未设置标签"}
|
||
</div>
|
||
</div>
|
||
<Button
|
||
aria-label={`删除角色 ${preset.name}`}
|
||
className="shrink-0"
|
||
onClick={(event) => {
|
||
event.preventDefault();
|
||
removeRolePreset(index);
|
||
}}
|
||
size="icon"
|
||
title={`删除角色 ${preset.name}`}
|
||
type="button"
|
||
variant="ghost"
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</Button>
|
||
</summary>
|
||
|
||
<div className="mt-4 grid gap-4 border-t border-border pt-4 md:grid-cols-2">
|
||
<div className="space-y-2">
|
||
<Label htmlFor={`role-name-${index}`}>角色名称</Label>
|
||
<Input
|
||
id={`role-name-${index}`}
|
||
onChange={(event) => updateRolePreset(index, { name: event.target.value })}
|
||
value={preset.name}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor={`role-asset-${index}`}>合规资产 ID</Label>
|
||
<Input
|
||
id={`role-asset-${index}`}
|
||
onChange={(event) => updateRolePreset(index, { assetId: event.target.value })}
|
||
value={preset.assetId}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2 md:col-span-2">
|
||
<Label htmlFor={`role-tags-${index}`}>展示标签</Label>
|
||
<Input
|
||
id={`role-tags-${index}`}
|
||
onChange={(event) => updateRolePreset(index, { tags: event.target.value })}
|
||
value={preset.tags}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2 md:col-span-2">
|
||
<Label htmlFor={`role-description-${index}`}>角色说明</Label>
|
||
<Textarea
|
||
id={`role-description-${index}`}
|
||
onChange={(event) => updateRolePreset(index, { description: event.target.value })}
|
||
value={preset.description}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2 md:col-span-2">
|
||
<Label htmlFor={`role-prompt-${index}`}>生成提示词 / 身份一致性规则</Label>
|
||
<Textarea
|
||
id={`role-prompt-${index}`}
|
||
onChange={(event) => updateRolePreset(index, { prompt: event.target.value })}
|
||
value={preset.prompt}
|
||
/>
|
||
</div>
|
||
{([
|
||
["full", "角色立绘"],
|
||
["face", "脸部近景"],
|
||
["expression", "表情参考"],
|
||
["threeView", "三视图"],
|
||
] as const).map(([key, label]) => (
|
||
<div className="space-y-2" key={key}>
|
||
<Label htmlFor={`role-${key}-${index}`}>{label}地址</Label>
|
||
<Input
|
||
id={`role-${key}-${index}`}
|
||
onChange={(event) =>
|
||
updateRolePreset(index, {
|
||
images: { ...preset.images, [key]: event.target.value },
|
||
})
|
||
}
|
||
value={preset.images[key]}
|
||
/>
|
||
</div>
|
||
))}
|
||
{([
|
||
["gender", "性别"],
|
||
["age", "年龄段"],
|
||
["species", "种族"],
|
||
["era", "时代"],
|
||
["culture", "文化区域"],
|
||
["build", "体型"],
|
||
["hairColor", "发色"],
|
||
] as const).map(([key, label]) => (
|
||
<div className="space-y-2" key={key}>
|
||
<Label htmlFor={`role-filter-${key}-${index}`}>{label}</Label>
|
||
<Input
|
||
id={`role-filter-${key}-${index}`}
|
||
onChange={(event) =>
|
||
updateRolePreset(index, {
|
||
filters: { ...preset.filters, [key]: event.target.value },
|
||
})
|
||
}
|
||
value={preset.filters[key]}
|
||
/>
|
||
</div>
|
||
))}
|
||
<div className="flex items-center gap-3 md:col-span-2">
|
||
<Switch
|
||
checked={preset.compliant}
|
||
id={`role-compliant-${index}`}
|
||
onCheckedChange={(checked) => updateRolePreset(index, { compliant: checked })}
|
||
/>
|
||
<Label htmlFor={`role-compliant-${index}`}>可在 Seedance2.0 合规素材库中使用</Label>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
<section className="space-y-4">
|
||
<div>
|
||
<h2 className="text-lg font-semibold tracking-tight">功能预设提示词</h2>
|
||
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">
|
||
视频画布运行时会通过 /api/video-canvas/config 读取这里保存的提示词。比如“多机位九宫格”的分镜规则、黑色分割线要求和镜头说明,都由管理员在这里维护,前端不再写死覆盖。
|
||
</p>
|
||
<p className="mt-2 max-w-3xl text-sm text-muted-foreground">
|
||
计费说明只展示来源,不在这里改价;需要调整积分请回到全站积分设置、模型设置或对应功能设置统一修改。
|
||
</p>
|
||
</div>
|
||
|
||
<div className="grid gap-4">
|
||
{functions.map((item) => (
|
||
<Card className={!item.enabled ? "opacity-70" : undefined} key={item.key}>
|
||
<CardHeader className="space-y-4">
|
||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||
<div>
|
||
<CardTitle className="flex flex-wrap items-center gap-2 text-base">
|
||
<span>{item.label}</span>
|
||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-normal text-muted-foreground">
|
||
{functionKindLabels[item.kind]}
|
||
</span>
|
||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-normal text-muted-foreground">
|
||
{item.route}
|
||
</span>
|
||
</CardTitle>
|
||
<CardDescription className="mt-2">{item.description}</CardDescription>
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<Label htmlFor={`enabled-${item.key}`}>启用</Label>
|
||
<Switch
|
||
checked={item.enabled}
|
||
id={`enabled-${item.key}`}
|
||
onCheckedChange={(checked) => updateFunction(item.key, { enabled: checked })}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="grid gap-4 md:grid-cols-1">
|
||
<div className="space-y-2">
|
||
<Label htmlFor={`model-${item.key}`}>模型覆盖</Label>
|
||
{["canvas-generate-image", "canvas-generate-video", "canvas-workflow-analyze", "pick-object"].includes(item.route) ? <ModelSelect
|
||
allowInherit
|
||
functionKey={item.key}
|
||
kind={item.kind}
|
||
models={modelOptions}
|
||
onChange={(value) => updateFunction(item.key, { model: value })}
|
||
value={item.model || ""}
|
||
/> : <p className="text-sm text-muted-foreground">{item.route === "local" ? "本地处理,无需生成模型" : "使用此功能的专用服务接口"}</p>}
|
||
</div>
|
||
<div className="rounded-lg border border-dashed border-border/70 bg-muted/20 px-3 py-2">
|
||
<div className="text-xs font-medium text-foreground">
|
||
积分来源:{getVideoCanvasFunctionBillingCopy(item).shortLabel}
|
||
</div>
|
||
<div className="mt-1 text-xs leading-5 text-muted-foreground">
|
||
{getVideoCanvasFunctionBillingCopy(item).description}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<Label htmlFor={`prompt-${item.key}`}>管理员可编辑的预设提示词 / 后端规则</Label>
|
||
<Textarea
|
||
className="mt-2 min-h-28"
|
||
id={`prompt-${item.key}`}
|
||
onChange={(event) => updateFunction(item.key, { prompt: event.target.value })}
|
||
value={item.prompt}
|
||
/>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
</section>
|
||
</main>
|
||
);
|
||
}
|