feat(*): 生成工作台 - 新增项目画风配置与图片模型能力裁剪

This commit is contained in:
ddCat
2026-08-07 11:46:23 +08:00
parent 2b0d56f8e3
commit 1973afc356
52 changed files with 3309 additions and 361 deletions

View File

@@ -7,7 +7,7 @@ import { configuredModelMatchesCapability, defaultConfig, modelOptionName, resol
import { CreditSymbol, requestCreditCost } from "@/constant/credits";
import { canvasThemes } from "@/lib/canvas-theme";
import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options";
import { modelCapabilityConfigFor, normalizeVideoValue, videoDurationAllowed } from "@/lib/model-capabilities";
import { modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue, videoDurationAllowed, type ImageCapabilityConfig } from "@/lib/model-capabilities";
import { navigateToSettings } from "@/lib/settings-navigation";
import { useThemeStore } from "@/stores/use-theme-store";
import { useUserStore } from "@/stores/use-user-store";
@@ -46,6 +46,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
const mode = node.metadata?.generationMode || "image";
const simpleMode = workspaceMode === "simple";
const config = buildNodeConfig(globalConfig, node, mode);
const imageProfile = mode === "image" ? modelCapabilityConfigFor(config, config.model).image! : undefined;
const videoProfile = mode === "video" ? modelCapabilityConfigFor(config, config.model).video! : undefined;
const operationOptions = videoProfile ? videoOperationOptions.filter((item) => videoProfile.operations.includes(item.value) || item.value === "concat") : videoOperationOptions;
const count = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
@@ -55,7 +56,11 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
const chipStyle = { background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text };
const hasAnyInput = Boolean(inputSummary.textCount || inputSummary.imageCount || inputSummary.videoCount || inputSummary.audioCount);
const hasComposerContent = Boolean((node.metadata?.composerContent ?? node.metadata?.prompt ?? "").trim());
const capabilityError = videoProfile ? videoCapabilityError(videoProfile, config.videoSeconds, node.metadata?.composerContent ?? node.metadata?.prompt ?? "", inputSummary, node.metadata?.videoEditOperation) : "";
const capabilityError = imageProfile
? imageCapabilityError(imageProfile, node.metadata?.composerContent ?? node.metadata?.prompt ?? "", inputSummary)
: videoProfile
? videoCapabilityError(videoProfile, config.videoSeconds, node.metadata?.composerContent ?? node.metadata?.prompt ?? "", inputSummary, node.metadata?.videoEditOperation)
: "";
const canGenerate = (hasComposerContent || (mode === "audio" ? inputSummary.textCount > 0 : hasAnyInput)) && !capabilityError;
return (
@@ -218,14 +223,16 @@ function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: Can
const fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel;
const storedModel = node.metadata?.model;
const model = storedModel && configuredModelMatchesCapability(globalConfig, storedModel, mode) ? storedModel : defaultModel && configuredModelMatchesCapability(globalConfig, defaultModel, mode) ? defaultModel : fallbackModel;
const imageProfile = mode === "image" ? modelCapabilityConfigFor(globalConfig, model).image! : undefined;
const normalizedImage = imageProfile ? normalizeImageValue(imageProfile, { size: node.metadata?.size || globalConfig.size || defaultConfig.size, quality: node.metadata?.quality || globalConfig.quality || defaultConfig.quality, transparentBackground: node.metadata?.transparentBackground || globalConfig.transparentBackground, count: String(node.metadata?.count || globalConfig.canvasImageCount || globalConfig.count || defaultConfig.count) }) : undefined;
const videoProfile = mode === "video" ? modelCapabilityConfigFor(globalConfig, model).video! : undefined;
const normalizedVideo = videoProfile ? normalizeVideoValue(videoProfile, { seconds: node.metadata?.seconds || globalConfig.videoSeconds || defaultConfig.videoSeconds, ratio: node.metadata?.size || globalConfig.size || defaultConfig.size, resolution: node.metadata?.vquality || globalConfig.vquality || defaultConfig.vquality }) : undefined;
return {
...globalConfig,
model,
quality: node.metadata?.quality || globalConfig.quality || defaultConfig.quality,
size: normalizedVideo?.ratio || node.metadata?.size || globalConfig.size || defaultConfig.size,
transparentBackground: (node.metadata?.transparentBackground || globalConfig.transparentBackground) === "true" ? "true" : "false",
quality: normalizedImage?.quality || node.metadata?.quality || globalConfig.quality || defaultConfig.quality,
size: normalizedImage?.size || normalizedVideo?.ratio || node.metadata?.size || globalConfig.size || defaultConfig.size,
transparentBackground: normalizedImage?.transparentBackground || ((node.metadata?.transparentBackground || globalConfig.transparentBackground) === "true" ? "true" : "false"),
videoSeconds: normalizedVideo?.seconds || normalizeVideoDuration(node.metadata?.seconds || globalConfig.videoSeconds || defaultConfig.videoSeconds),
vquality: normalizedVideo?.resolution.replace(/p$/i, "") || normalizeVideoResolution(node.metadata?.vquality || globalConfig.vquality || defaultConfig.vquality),
videoGenerateAudio: videoProfile?.generateAudio.supported ? node.metadata?.generateAudio || globalConfig.videoGenerateAudio || String(videoProfile.generateAudio.default) : "false",
@@ -234,7 +241,7 @@ function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: Can
audioFormat: node.metadata?.audioFormat || globalConfig.audioFormat || defaultConfig.audioFormat,
audioSpeed: node.metadata?.audioSpeed || globalConfig.audioSpeed || defaultConfig.audioSpeed,
audioInstructions: node.metadata?.audioInstructions || globalConfig.audioInstructions || defaultConfig.audioInstructions,
count: String(node.metadata?.count || (mode === "image" ? globalConfig.canvasImageCount || globalConfig.count : globalConfig.count) || defaultConfig.count),
count: normalizedImage?.count || String(node.metadata?.count || (mode === "image" ? globalConfig.canvasImageCount || globalConfig.count : globalConfig.count) || defaultConfig.count),
};
}
@@ -254,6 +261,12 @@ function videoCapabilityError(profile: NonNullable<ReturnType<typeof modelCapabi
return "";
}
function imageCapabilityError(profile: ImageCapabilityConfig, prompt: string, input: CanvasConfigNodePanelProps["inputSummary"]) {
if (Array.from(prompt).length > profile.references.promptMaxChars) return `提示词超过模型限制(最多 ${profile.references.promptMaxChars} 字)`;
if (input.imageCount > profile.references.maxImages) return `当前图片模型最多支持 ${profile.references.maxImages} 张参考图`;
return "";
}
function audioConfigPatch(key: CanvasAudioSettingKey, value: string) {
if (key === "audioVoice") return { audioVoice: value };
if (key === "audioFormat") return { audioFormat: value };

View File

@@ -5,6 +5,7 @@ import { Button } from "antd";
import { ImageSettingsPanel, imageQualityLabel, imageSizeLabel } from "@/components/image-settings-panel";
import { canvasThemes } from "@/lib/canvas-theme";
import { modelCapabilityConfigFor, normalizeImageValue } from "@/lib/model-capabilities";
import { useThemeStore } from "@/stores/use-theme-store";
import type { AiConfig } from "@/stores/use-config-store";
@@ -26,11 +27,16 @@ export function CanvasImageSettingsPopover({ config, onConfigChange, onOpenChang
const panelRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [buttonRect, setButtonRect] = useState<DOMRect | null>(null);
const quality = config.quality || "auto";
const count = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
const activeSize = config.size || "auto";
const transparentLabel = config.transparentBackground === "true" ? " · 透明" : "";
const summary = showCount ? `${imageQualityLabel(quality)} · ${imageSizeLabel(activeSize)} · ${count} 张${transparentLabel}` : `${imageQualityLabel(quality)} · ${imageSizeLabel(activeSize)}${transparentLabel}`;
const profile = modelCapabilityConfigFor(config, config.model || config.imageModel).image!;
const normalized = normalizeImageValue(profile, config);
const summaryParts = [
...(profile.size.parameter !== "none" ? [imageSizeLabel(normalized.size)] : []),
...(profile.quality.supported ? [imageQualityLabel(normalized.quality)] : []),
...(showCount && profile.maxOutputs > 1 ? [`${normalized.count} 张`] : []),
...(profile.transparentBackground.supported && normalized.transparentBackground === "true" ? ["透明"] : []),
];
const summary = summaryParts.join(" · ");
const hasSettings = profile.size.parameter !== "none" || profile.quality.supported || profile.transparentBackground.supported || (showCount && profile.maxOutputs > 1);
const updateOpen = (nextOpen: boolean) => {
setOpen(nextOpen);
onOpenChange?.(nextOpen);
@@ -61,6 +67,8 @@ export function CanvasImageSettingsPopover({ config, onConfigChange, onOpenChang
const panel = open && buttonRect ? <ImageSettingsPortal buttonRect={buttonRect} panelRef={panelRef} placement={placement} theme={theme} config={config} showCount={showCount} onConfigChange={onConfigChange} /> : null;
if (!hasSettings) return null;
return (
<>
<span ref={buttonRef} className="inline-flex min-w-0">

View File

@@ -4,7 +4,7 @@ import { useVirtualizer } from "@tanstack/react-virtual";
import { ArrowUpRight, BookOpenText, ChevronLeft, ChevronRight, Crosshair, FolderKanban, GripVertical, Images, LocateFixed, LoaderCircle, Palette, Plus, Search, Settings2, X } from "lucide-react";
import { Link } from "react-router";
import { resolveCanvasStylePreset } from "@/components/canvas/canvas-style-picker-modal";
import { resolveProjectCanvasStyle } from "@/components/canvas/canvas-style-picker-modal";
import { getProject, getProjectUnit, type ProjectDetail, type ProjectUnit } from "@/services/api/projects";
export const CANVAS_PROJECT_CHAPTER_DND_TYPE = "application/x-infinite-canvas-project-chapter";
@@ -30,7 +30,7 @@ export function CanvasProjectSidebar({ projectId, detail, onAddChapter, onLocate
const deferredQuery = useDeferredValue(query.trim().toLocaleLowerCase("zh-CN"));
const projectQuery = useQuery({ queryKey: ["project", projectId], queryFn: () => getProject(projectId), enabled: Boolean(projectId) && !detail });
const projectDetail = detail || projectQuery.data;
const style = resolveCanvasStylePreset(projectDetail?.project.stylePresetId);
const style = resolveProjectCanvasStyle(projectDetail?.project.stylePresetId, projectDetail?.project.styleProfileJson);
const mediaAssetCount = projectDetail?.assets.filter((asset) => asset.mediaType === "image" || asset.mediaType === "video").length || 0;
const orderedUnits = useMemo(() => projectDetail?.units.slice().sort((left, right) => left.position - right.position) || [], [projectDetail?.units]);
const visibleUnits = useMemo(() => {

View File

@@ -1,22 +1,20 @@
import { useEffect, useMemo, useState, type CSSProperties } from "react";
import { Check, Eye, Palette } from "lucide-react";
import { Button, Modal, Segmented, Select } from "antd";
import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Clock3, Copy, Eye, Palette, Pencil, Plus, Search, SlidersHorizontal, Star, Trash2, UserRound } from "lucide-react";
import { App, Button, Input, Modal } from "antd";
import { nanoid } from "nanoid";
import { StyleProfileEditorModal } from "@/components/canvas/style-profile-editor-modal";
import { canvasThemes } from "@/lib/canvas-theme";
import { createStyleProfileSnapshot, parseStyleProfile, serializeStyleProfile, type StyleProfileSnapshot } from "@/lib/canvas/style-profile";
import {
compatibleProjectStyleCharacters,
compileCanvasStylePreset,
customCanvasStylePreset,
defaultProjectStyleSelection,
parseCanvasStyleSelection,
projectStyleMedia,
projectStyleTones,
projectStyleWorlds,
recommendedCanvasStylePresets,
type CanvasStylePreset,
type ProjectStyleMediumId,
type ProjectStyleSelection,
} from "@/lib/canvas/canvas-style-system";
import { createStyleProfile, deleteStyleProfile, listStyleProfiles, setStyleProfileFavorite, touchStyleProfile, updateStyleProfile, type UserStyleProfile } from "@/services/api/style-profiles";
import { useThemeStore } from "@/stores/use-theme-store";
export type { CanvasStylePreset } from "@/lib/canvas/canvas-style-system";
@@ -386,127 +384,288 @@ const legacyStyleUpgrades: Partial<Record<string, ProjectStyleSelection>> = {
const resolvedLegacyCanvasStylePresets = legacyCanvasStylePresets.map((preset) => {
const upgrade = legacyStyleUpgrades[preset.id];
return upgrade ? { ...compileCanvasStylePreset(upgrade), id: preset.id, imageUrl: preset.imageUrl } : preset;
if (!upgrade) return preset;
const compiled = compileCanvasStylePreset(upgrade);
const upgraded = { ...compiled, id: preset.id, imageUrl: preset.imageUrl };
return { ...upgraded, profile: createStyleProfileSnapshot({ ...compiled.profile!, presetId: preset.id }) };
});
export const canvasStylePresets: CanvasStylePreset[] = [...recommendedCanvasStylePresets, ...resolvedLegacyCanvasStylePresets];
const resolvableCanvasStylePresets: CanvasStylePreset[] = [...recommendedCanvasStylePresets, ...resolvedLegacyCanvasStylePresets];
// 风格中心不靠重复卡片虚增数量;旧 ID 仍留在解析集合中,保证历史项目可以恢复。
export const canvasStylePresets: CanvasStylePreset[] = resolvableCanvasStylePresets.filter((preset, index, presets) => presets.findIndex((candidate) => candidate.prompt === preset.prompt) === index);
export function resolveCanvasStylePreset(id?: string) {
return canvasStylePresets.find((preset) => preset.id === id) || customCanvasStylePreset(id);
return resolvableCanvasStylePresets.find((preset) => preset.id === id) || customCanvasStylePreset(id);
}
export function CanvasStylePickerModal({ open, value, onClose, onSelect }: { open: boolean; value?: string; onClose: () => void; onSelect: (preset: CanvasStylePreset) => void }) {
export function resolveProjectCanvasStyle(presetId?: string, profileJson?: string | null): CanvasStylePreset | undefined {
const preset = resolveCanvasStylePreset(presetId);
const profile = parseStyleProfile(profileJson);
if (!profile) return preset;
return {
id: profile.presetId,
title: profile.title,
category: preset?.category || "项目风格包",
description: profile.description,
tags: [...profile.tags],
prompt: profile.prompt,
imageUrl: profile.coverUrl || preset?.imageUrl || "/short-drama-styles/real-life.jpg",
selection: preset?.selection,
profile,
};
}
const STYLE_FAVORITES_KEY = "canvas:style-library:favorites";
const STYLE_RECENT_KEY = "canvas:style-library:recent";
type StyleCenterTab = "system" | "mine" | "favorites" | "recent";
type SystemStyleLibraryItem = { kind: "system"; preset: CanvasStylePreset };
type UserStyleLibraryItem = { kind: "user"; preset: CanvasStylePreset; entity: UserStyleProfile };
type StyleLibraryItem = SystemStyleLibraryItem | UserStyleLibraryItem;
type StyleEditorState = { profile: StyleProfileSnapshot; entityId?: string };
export function CanvasStylePickerModal({ open, value, currentProfile, startInEditor = false, applying = false, onClose, onSelect }: {
open: boolean;
value?: string;
currentProfile?: StyleProfileSnapshot | null;
startInEditor?: boolean;
applying?: boolean;
onClose: () => void;
onSelect: (preset: CanvasStylePreset) => void;
}) {
const { message, modal } = App.useApp();
const queryClient = useQueryClient();
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const [detailPreset, setDetailPreset] = useState<CanvasStylePreset | null>(null);
const [mode, setMode] = useState<"recommended" | "custom">("recommended");
const [selection, setSelection] = useState<ProjectStyleSelection>(defaultProjectStyleSelection);
const [tab, setTab] = useState<StyleCenterTab>("system");
const [query, setQuery] = useState("");
const [category, setCategory] = useState("全部");
const [systemFavoriteIds, setSystemFavoriteIds] = useState<string[]>([]);
const [recentIds, setRecentIds] = useState<string[]>([]);
const [editor, setEditor] = useState<StyleEditorState | null>(null);
const profilesQuery = useQuery({ queryKey: ["style-profiles"], queryFn: listStyleProfiles, enabled: open });
useEffect(() => {
if (!open) return;
const parsed = parseCanvasStyleSelection(value);
if (parsed) {
setSelection(parsed);
setMode(recommendedCanvasStylePresets.some((preset) => preset.id === value) ? "recommended" : "custom");
} else {
setSelection(defaultProjectStyleSelection);
setMode("recommended");
setTab("system");
setQuery("");
setCategory("全部");
setSystemFavoriteIds(readStyleIds(STYLE_FAVORITES_KEY));
setRecentIds(readStyleIds(STYLE_RECENT_KEY));
setEditor(startInEditor && currentProfile?.source !== "user" && currentProfile ? { profile: editableCopy(currentProfile) } : null);
}, [currentProfile, open, startInEditor]);
const userItems = useMemo<UserStyleLibraryItem[]>(() => (profilesQuery.data?.profiles || []).flatMap((entity) => {
const preset = userStylePreset(entity);
return preset ? [{ kind: "user" as const, preset, entity }] : [];
}), [profilesQuery.data?.profiles]);
const systemItems = useMemo<SystemStyleLibraryItem[]>(() => canvasStylePresets.map((preset) => ({ kind: "system", preset })), []);
const categories = useMemo(() => ["全部", ...Array.from(new Set(canvasStylePresets.map((preset) => preset.category)))], []);
useEffect(() => {
if (!open || !startInEditor || !currentProfile || currentProfile.source !== "user" || editor || profilesQuery.isFetching || profilesQuery.isError) return;
const sourceId = currentProfile.sourceProfileId || currentProfile.presetId;
const sourceEntity = (profilesQuery.data?.profiles || []).find((profile) => profile.id === sourceId);
const sourceProfile = sourceEntity ? parseStyleProfile(sourceEntity.profileJson) : null;
if (sourceEntity && sourceProfile) {
setEditor({ profile: editableCopy(sourceProfile, sourceEntity.id), entityId: sourceEntity.id });
return;
}
}, [open, value]);
const activePreset = resolveCanvasStylePreset(value);
const visiblePresets = activePreset && !recommendedCanvasStylePresets.some((preset) => preset.id === activePreset.id)
? [activePreset, ...recommendedCanvasStylePresets.filter((preset) => preset.prompt !== activePreset.prompt)]
: recommendedCanvasStylePresets;
if (profilesQuery.isFetched) setEditor({ profile: editableCopy(currentProfile) });
}, [currentProfile, editor, open, profilesQuery.data?.profiles, profilesQuery.isError, profilesQuery.isFetched, profilesQuery.isFetching, startInEditor]);
const visibleItems = useMemo(() => {
const keyword = query.trim().toLocaleLowerCase("zh-CN");
let source: StyleLibraryItem[] = tab === "system" ? systemItems : tab === "mine" ? userItems : tab === "favorites"
? [...systemItems.filter((item) => systemFavoriteIds.includes(item.preset.id)), ...userItems.filter((item) => item.kind === "user" && item.entity.favorite)]
: [...userItems.filter((item) => item.kind === "user" && item.entity.lastUsedAt).sort((a, b) => String(b.entity.lastUsedAt).localeCompare(String(a.entity.lastUsedAt))), ...recentIds.flatMap((id) => systemItems.filter((item) => item.preset.id === id))];
if (tab === "system" && category !== "全部") source = source.filter((item) => item.preset.category === category);
if (keyword) source = source.filter((item) => `${item.preset.title} ${item.preset.category} ${item.preset.description} ${item.preset.tags.join(" ")}`.toLocaleLowerCase("zh-CN").includes(keyword));
return source;
}, [category, query, recentIds, systemFavoriteIds, systemItems, tab, userItems]);
const saveMutation = useMutation({
mutationFn: ({ profile, apply }: { profile: StyleProfileSnapshot; apply: boolean }) => editor?.entityId
? updateStyleProfile(editor.entityId, serializeStyleProfile(profile)).then((result) => ({ ...result, apply }))
: createStyleProfile(serializeStyleProfile(profile)).then((result) => ({ ...result, apply })),
onSuccess: async ({ profile, apply }) => {
await queryClient.invalidateQueries({ queryKey: ["style-profiles"] });
const preset = userStylePreset(profile);
setEditor(null);
setTab("mine");
message.success("风格已保存到“我的风格”");
if (apply && preset) selectItem({ kind: "user", preset, entity: profile });
},
onError: (error) => message.error(error instanceof Error ? error.message : "风格保存失败"),
});
const favoriteMutation = useMutation({
mutationFn: ({ id, favorite }: { id: string; favorite: boolean }) => setStyleProfileFavorite(id, favorite),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["style-profiles"] }),
onError: (error) => message.error(error instanceof Error ? error.message : "收藏状态更新失败"),
});
const deleteMutation = useMutation({
mutationFn: deleteStyleProfile,
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["style-profiles"] }); message.success("风格已删除"); },
onError: (error) => message.error(error instanceof Error ? error.message : "风格删除失败"),
});
function selectItem(item: StyleLibraryItem) {
if (applying) return;
const { preset } = item;
const next = [preset.id, ...recentIds.filter((id) => id !== preset.id)].slice(0, 12);
setRecentIds(next);
localStorage.setItem(STYLE_RECENT_KEY, JSON.stringify(next));
if (item.kind === "user") void touchStyleProfile(item.entity.id).then(() => queryClient.invalidateQueries({ queryKey: ["style-profiles"] })).catch((error) => message.warning(error instanceof Error ? error.message : "最近使用记录更新失败"));
onSelect(preset);
}
function toggleSystemFavorite(presetId: string) {
const next = systemFavoriteIds.includes(presetId) ? systemFavoriteIds.filter((id) => id !== presetId) : [presetId, ...systemFavoriteIds];
setSystemFavoriteIds(next);
localStorage.setItem(STYLE_FAVORITES_KEY, JSON.stringify(next));
}
function createNewStyle() {
setEditor({ profile: blankUserStyle() });
}
function copyPreset(preset: CanvasStylePreset) {
setEditor({ profile: editableCopy(preset.profile || createStyleProfileSnapshot(preset), undefined, `${preset.title} 副本`, preset.imageUrl) });
}
function confirmDelete(entity: UserStyleProfile) {
modal.confirm({ title: `删除“${entity.name}”?`, content: "项目中已经保存的快照不会被删除,但该风格将从“我的风格”中移除。", okText: "删除", cancelText: "取消", okButtonProps: { danger: true }, onOk: () => deleteMutation.mutateAsync(entity.id) });
}
return (
<>
<Modal rootClassName="canvas-style-picker-modal" open={open} title={null} footer={null} centered width="min(920px, calc(100vw - 24px))" onCancel={onClose} styles={{ container: { padding: 0 }, body: { padding: 0 } }}>
<div className="overflow-hidden" style={{ color: theme.node.text, background: theme.node.panel }}>
<header className="flex flex-col gap-3 border-b px-4 py-3 pr-12 sm:flex-row sm:items-center sm:justify-between" style={{ borderColor: theme.node.stroke }}>
<h2 className="text-sm font-semibold">选择项目画风</h2>
<Segmented size="small" value={mode} options={[{ value: "recommended", label: "推荐画风" }, { value: "custom", label: "自定义组合" }]} onChange={(next) => setMode(next as typeof mode)} />
<Modal rootClassName="canvas-style-picker-modal" open={open} title={null} footer={null} centered width="min(1240px, calc(100vw - 24px))" onCancel={onClose} styles={{ container: { padding: 0 }, body: { padding: 0 } }}>
<div className="canvas-style-center-shell flex min-h-0 flex-col overflow-hidden" style={{ color: theme.node.text, background: theme.node.panel }}>
<header className="flex min-h-16 flex-col gap-3 border-b px-4 py-3 pr-12 sm:flex-row sm:items-center sm:justify-between sm:px-5 sm:pr-14" style={{ borderColor: theme.node.stroke }}>
<div className="min-w-0">
<h2 className="text-base font-semibold">风格中心</h2>
<p className="mt-0.5 text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>系统规范 · 个人风格 · 项目快照</p>
</div>
<div className="flex min-w-0 gap-2 sm:items-center">
<Input allowClear className="style-center-search min-w-0 flex-1" prefix={<Search className="size-3.5 text-foreground/35" />} value={query} placeholder="搜索风格、题材或媒介" onChange={(event) => setQuery(event.target.value)} />
<Button type="primary" className="shrink-0" icon={<Plus className="size-3.5" />} onClick={createNewStyle}>新建风格</Button>
</div>
</header>
{mode === "recommended" ? <div className="thin-scrollbar grid max-h-[76vh] grid-cols-1 gap-2 overflow-y-auto p-2 md:grid-cols-2 md:p-3">
{visiblePresets.map((preset) => {
const active = preset.id === value;
return (
<article key={preset.id} className="group relative">
<button type="button" className="flex min-h-24 w-full overflow-hidden rounded-[var(--r-md)] border text-left transition-colors focus-visible:outline-none focus-visible:ring-2" style={{ background: theme.canvas.background, borderColor: active ? theme.node.activeStroke : theme.node.stroke, boxShadow: active ? `inset 0 0 0 1px ${theme.node.activeStroke}` : undefined, "--tw-ring-color": theme.node.activeStroke } as CSSProperties} onClick={() => onSelect(preset)}>
<span className="relative w-28 shrink-0 overflow-hidden border-r" style={{ background: theme.canvas.background, borderColor: theme.node.stroke }}>
<img src={preset.imageUrl} width="960" height="540" alt={`${preset.title}画风示意`} className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.02]" style={preset.id === "black-white-noir" ? { filter: "grayscale(1) contrast(1.08)" } : undefined} loading="lazy" />
</span>
<span className="min-w-0 flex-1 px-3 py-2.5 pr-10">
<span className="flex items-center gap-1.5 text-xs font-semibold"><span className="truncate">{preset.title}</span>{active ? <span className="grid size-4 shrink-0 place-items-center rounded-[var(--r-full)]" style={{ background: theme.node.activeStroke, color: theme.canvas.background }}><Check className="size-3" /></span> : null}</span>
<span className="mt-1 block truncate text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>{preset.category}</span>
<span className="mt-1 line-clamp-2 block text-[var(--fs-tiny)] leading-4" style={{ color: theme.node.muted }}>{preset.description}</span>
</span>
</button>
<button type="button" className="absolute right-2 top-2 grid size-7 place-items-center rounded-[var(--r-sm)] transition-colors focus-visible:outline-none focus-visible:ring-2" style={{ background: theme.toolbar.itemHover, color: theme.node.muted, "--tw-ring-color": theme.node.activeStroke } as CSSProperties} onClick={() => setDetailPreset(preset)} aria-label={`查看${preset.title}规范`} title="查看画风规范"><Eye className="size-3.5" /></button>
</article>
);
})}
</div> : <CanvasStyleComposer selection={selection} value={value} theme={theme} onChange={setSelection} onSelect={onSelect} onDetail={setDetailPreset} />}
<div className="style-center-workspace grid min-h-0 flex-1">
<aside className="style-center-sidebar thin-scrollbar min-h-0 overflow-auto border-b p-2 sm:p-3 lg:border-b-0 lg:border-r" style={{ borderColor: theme.node.stroke }}>
<nav className="style-center-primary-nav flex gap-1 lg:flex-col" aria-label="风格中心视图">
<StyleCenterNavItem active={tab === "system"} icon={<Palette className="size-3.5" />} label="系统风格" count={systemItems.length} onClick={() => setTab("system")} />
<StyleCenterNavItem active={tab === "mine"} icon={<UserRound className="size-3.5" />} label="我的风格" count={userItems.length} onClick={() => setTab("mine")} />
<StyleCenterNavItem active={tab === "favorites"} icon={<Star className="size-3.5" />} label="收藏" onClick={() => setTab("favorites")} />
<StyleCenterNavItem active={tab === "recent"} icon={<Clock3 className="size-3.5" />} label="最近使用" onClick={() => setTab("recent")} />
</nav>
{tab === "system" ? (
<div className="style-center-category-panel mt-2 border-t pt-2 lg:mt-4 lg:pt-3" style={{ borderColor: theme.node.stroke }}>
<div className="hidden px-2 pb-1.5 text-[var(--fs-tiny)] font-medium lg:block" style={{ color: theme.node.muted }}>分类</div>
<nav className="style-center-category-nav flex gap-1 lg:flex-col" aria-label="系统风格分类">
{categories.map((item) => <button key={item} type="button" aria-current={category === item ? "page" : undefined} className={`style-center-category-item h-9 shrink-0 rounded-md px-2.5 text-left text-xs outline-none transition-colors focus-visible:ring-2 ${category === item ? "is-active font-medium" : ""}`} onClick={() => setCategory(item)}>{item}</button>)}
</nav>
</div>
) : null}
</aside>
<main className="flex min-h-0 min-w-0 flex-col">
<div className="flex h-11 shrink-0 items-center justify-between gap-3 border-b px-3 sm:px-4" style={{ borderColor: theme.node.stroke }}>
<div className="min-w-0 truncate text-xs font-medium">{styleCenterViewTitle(tab, category)}</div>
<div className="shrink-0 text-[var(--fs-tiny)] tabular-nums" style={{ color: theme.node.muted }}>{visibleItems.length} 个风格</div>
</div>
<div className="style-center-grid thin-scrollbar min-h-0 flex-1 content-start overflow-y-auto p-3 sm:p-4">
{visibleItems.map((item) => (
<StyleCenterCard
key={`${item.kind}-${item.preset.id}`}
item={item}
active={item.preset.id === value}
applying={applying}
favorite={item.kind === "user" ? item.entity.favorite : systemFavoriteIds.includes(item.preset.id)}
theme={theme}
onApply={() => selectItem(item)}
onDetail={() => setDetailPreset(item.preset)}
onFavorite={() => item.kind === "user" ? favoriteMutation.mutate({ id: item.entity.id, favorite: !item.entity.favorite }) : toggleSystemFavorite(item.preset.id)}
onCopy={() => copyPreset(item.preset)}
onEdit={item.kind === "user" ? () => setEditor({ profile: editableCopy(item.preset.profile!, item.entity.id), entityId: item.entity.id }) : undefined}
onDelete={item.kind === "user" ? () => confirmDelete(item.entity) : undefined}
/>
))}
{!visibleItems.length ? <EmptyStyleCenter tab={tab} loading={profilesQuery.isLoading} failed={profilesQuery.isError} color={theme.node.muted} onCreate={createNewStyle} onBrowse={() => { setTab("system"); setQuery(""); }} /> : null}
</div>
</main>
</div>
</div>
</Modal>
<CanvasStyleDetailModal open={Boolean(detailPreset)} preset={detailPreset} selected={detailPreset?.id === value} onClose={() => setDetailPreset(null)} onSelect={(preset) => { setDetailPreset(null); onSelect(preset); }} />
<CanvasStyleDetailModal open={Boolean(detailPreset)} preset={detailPreset} selected={detailPreset?.id === value} onClose={() => setDetailPreset(null)} onSelect={(preset) => { setDetailPreset(null); const item = [...systemItems, ...userItems].find((candidate) => candidate.preset.id === preset.id); if (item) selectItem(item); }} />
<StyleProfileEditorModal open={Boolean(editor)} initialProfile={editor?.profile || null} saving={saveMutation.isPending} onClose={() => setEditor(null)} onSave={(profile, apply) => saveMutation.mutate({ profile, apply })} />
</>
);
}
function CanvasStyleComposer({ selection, value, theme, onChange, onSelect, onDetail }: {
selection: ProjectStyleSelection;
value?: string;
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
onChange: (selection: ProjectStyleSelection) => void;
onSelect: (preset: CanvasStylePreset) => void;
onDetail: (preset: CanvasStylePreset) => void;
}) {
const preset = useMemo(() => compileCanvasStylePreset(selection), [selection]);
const characterOptions = compatibleProjectStyleCharacters(selection.medium);
const updateMedium = (medium: ProjectStyleMediumId) => {
const compatible = compatibleProjectStyleCharacters(medium);
const character = compatible.some((item) => item.id === selection.character) ? selection.character : compatible[0].id;
onChange({ ...selection, medium, character });
};
function readStyleIds(key: string) {
try {
const parsed = JSON.parse(localStorage.getItem(key) || "[]");
return Array.isArray(parsed) ? parsed.filter((value): value is string => typeof value === "string").slice(0, 100) : [];
} catch {
return [];
}
}
function StyleCenterCard({ item, active, applying, favorite, theme, onApply, onDetail, onFavorite, onCopy, onEdit, onDelete }: { item: StyleLibraryItem; active: boolean; applying: boolean; favorite: boolean; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onApply: () => void; onDetail: () => void; onFavorite: () => void; onCopy: () => void; onEdit?: () => void; onDelete?: () => void }) {
const { preset } = item;
return (
<div className="thin-scrollbar grid max-h-[76vh] overflow-y-auto md:grid-cols-5">
<section className="grid content-start gap-3 border-b p-4 md:col-span-2 md:border-b-0 md:border-r" style={{ borderColor: theme.node.stroke }}>
<StyleSelect label="题材世界" value={selection.world} options={projectStyleWorlds} onChange={(world) => onChange({ ...selection, world })} />
<StyleSelect label="叙事气质" value={selection.tone} options={projectStyleTones} onChange={(tone) => onChange({ ...selection, tone })} />
<StyleSelect label="视觉媒介" value={selection.medium} options={projectStyleMedia} onChange={updateMedium} />
<StyleSelect label="角色造型" value={selection.character} options={characterOptions} onChange={(character) => onChange({ ...selection, character })} />
</section>
<section className="flex min-h-0 flex-col md:col-span-3">
<div className="relative aspect-video overflow-hidden" style={{ background: theme.canvas.background }}>
<img src={preset.imageUrl} width="960" height="540" alt={`${preset.title}画风示意`} className="h-full w-full object-cover" />
</div>
<div className="flex min-h-0 flex-1 flex-col p-4">
<div className="text-sm font-semibold">{preset.title}</div>
<p className="mt-1.5 text-xs leading-5" style={{ color: theme.node.muted }}>{preset.description}</p>
<div className="mt-3 grid grid-cols-2 gap-x-4 gap-y-2 border-y py-3 text-[var(--fs-tiny)]" style={{ borderColor: theme.node.stroke }}>
<StyleSummary label="题材" value={projectStyleWorlds.find((item) => item.id === selection.world)?.label} muted={theme.node.muted} />
<StyleSummary label="气质" value={projectStyleTones.find((item) => item.id === selection.tone)?.label} muted={theme.node.muted} />
<StyleSummary label="媒介" value={projectStyleMedia.find((item) => item.id === selection.medium)?.label} muted={theme.node.muted} />
<StyleSummary label="角色" value={characterOptions.find((item) => item.id === selection.character)?.label} muted={theme.node.muted} />
</div>
<div className="mt-auto flex justify-end gap-2 pt-4">
<Button icon={<Eye className="size-3.5" />} onClick={() => onDetail(preset)}>查看规范</Button>
<Button type="primary" disabled={preset.id === value} icon={preset.id === value ? <Check className="size-3.5" /> : <Palette className="size-3.5" />} onClick={() => onSelect(preset)}>{preset.id === value ? "当前画风" : "应用组合"}</Button>
</div>
</div>
</section>
</div>
<article className={`style-center-card group flex min-w-0 flex-col overflow-hidden rounded-md border ${active ? "is-active" : ""}`} style={{ background: theme.canvas.background, borderColor: active ? theme.node.activeStroke : theme.node.stroke, boxShadow: active ? `inset 0 0 0 1px ${theme.node.activeStroke}` : undefined }}>
<div className="relative overflow-hidden border-b" style={{ borderColor: theme.node.stroke }}>
<button type="button" className="style-center-card-cover block w-full overflow-hidden outline-none focus-visible:ring-2 focus-visible:ring-inset" style={{ "--tw-ring-color": theme.node.activeStroke } as CSSProperties} onClick={onDetail} aria-label={`查看${preset.title}规范`}>
<img src={preset.imageUrl} width="640" height="360" alt={`${preset.title}画风示意`} className="h-full w-full object-cover transition-transform" />
</button>
<button type="button" className={`style-center-favorite-action absolute right-2 top-2 grid size-8 place-items-center rounded-md outline-none focus-visible:ring-2 ${favorite ? "is-active" : ""}`} onClick={onFavorite} aria-label={favorite ? "取消收藏" : "收藏"} title={favorite ? "取消收藏" : "收藏"}><Star className="size-3.5" fill={favorite ? "currentColor" : "none"} /></button>
</div>
<div className="flex min-h-0 flex-1 flex-col px-3 pt-3">
<div className="flex min-w-0 items-center gap-2 text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}><span className="truncate">{item.kind === "user" ? "我的风格" : preset.category}</span>{active ? <span className="ml-auto flex shrink-0 items-center gap-1 font-medium" style={{ color: theme.node.activeStroke }}><Check className="size-3" />当前</span> : null}</div>
<h3 className="mt-1.5 truncate text-sm font-semibold">{preset.title}</h3>
<p className="mt-1.5 line-clamp-2 text-xs leading-5" style={{ color: theme.node.muted }}>{preset.description}</p>
<div className="mt-2 flex min-h-6 flex-wrap gap-1">{preset.tags.slice(0, 3).map((tag) => <span key={tag} className="rounded bg-foreground/5 px-1.5 py-0.5 text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>{tag}</span>)}</div>
</div>
<footer className="mt-2 flex min-h-11 items-center gap-1 border-t px-2" style={{ borderColor: theme.node.stroke }}>
<IconAction label="查看规范" onClick={onDetail}><Eye className="size-3.5" /></IconAction>
<IconAction label="复制并编辑" onClick={onCopy}><Copy className="size-3.5" /></IconAction>
{onEdit ? <IconAction label="编辑风格" onClick={onEdit}><Pencil className="size-3.5" /></IconAction> : null}
{onDelete ? <IconAction label="删除风格" danger onClick={onDelete}><Trash2 className="size-3.5" /></IconAction> : null}
<Button type="default" size="small" className={`style-center-apply-button ml-auto ${active ? "is-current" : ""}`} disabled={active || applying} icon={active ? <Check className="size-3.5" /> : <Palette className="size-3.5" />} onClick={onApply}>{active ? "当前" : "应用"}</Button>
</footer>
</article>
);
}
function StyleSummary({ label, value, muted }: { label: string; value?: string; muted: string }) {
return <div className="flex min-w-0 items-center gap-2"><span className="shrink-0" style={{ color: muted }}>{label}</span><span className="truncate font-medium">{value}</span></div>;
function IconAction({ label, danger = false, onClick, children }: { label: string; danger?: boolean; onClick: () => void; children: ReactNode }) {
return <button type="button" className={`style-center-icon-action grid size-8 place-items-center rounded-md outline-none focus-visible:ring-2 ${danger ? "is-danger" : ""}`} onClick={onClick} aria-label={label} title={label}>{children}</button>;
}
function StyleSelect<T extends string>({ label, value, options, onChange }: { label: string; value: T; options: Array<{ id: T; label: string; description: string }>; onChange: (value: T) => void }) {
return (
<label className="grid gap-1.5 text-xs">
<span className="font-semibold">{label}</span>
<Select<T> value={value} optionLabelProp="label" onChange={onChange} options={options.map((option) => ({ value: option.id, label: option.label, title: option.description }))} optionRender={(option) => <div className="py-1"><div className="text-xs font-medium">{option.label}</div><div className="mt-0.5 whitespace-normal text-[var(--fs-tiny)] opacity-60">{option.data.title}</div></div>} />
</label>
);
function StyleCenterNavItem({ active, icon, label, count, onClick }: { active: boolean; icon: ReactNode; label: string; count?: number; onClick: () => void }) {
return <button type="button" aria-current={active ? "page" : undefined} className={`style-center-nav-item flex h-11 min-w-fit items-center gap-2 rounded-md px-2.5 text-xs outline-none transition-colors focus-visible:ring-2 lg:w-full ${active ? "is-active font-medium" : ""}`} onClick={onClick}>{icon}<span className="whitespace-nowrap">{label}</span>{count !== undefined ? <span className="ml-auto tabular-nums opacity-55">{count}</span> : null}</button>;
}
function styleCenterViewTitle(tab: StyleCenterTab, category: string) {
if (tab === "mine") return "我的风格";
if (tab === "favorites") return "收藏风格";
if (tab === "recent") return "最近使用";
return category === "全部" ? "全部系统风格" : category;
}
function EmptyStyleCenter({ tab, loading, failed, color, onCreate, onBrowse }: { tab: StyleCenterTab; loading: boolean; failed: boolean; color: string; onCreate: () => void; onBrowse: () => void }) {
return <div className="col-span-full grid min-h-64 place-items-center text-center"><div><Search className="mx-auto size-5" style={{ color }} /><p className="mt-2 text-xs font-medium">{loading ? "正在加载风格库" : failed && tab !== "system" ? "我的风格加载失败,请检查登录状态或后端服务" : tab === "mine" ? "还没有自己的风格" : tab === "favorites" ? "还没有收藏风格" : tab === "recent" ? "还没有使用记录" : "没有匹配的系统风格"}</p><div className="mt-3 flex justify-center gap-2">{tab === "mine" && !failed ? <Button size="small" icon={<Plus className="size-3.5" />} onClick={onCreate}>新建风格</Button> : null}<Button size="small" onClick={onBrowse}>浏览系统风格</Button></div></div></div>;
}
function userStylePreset(entity: UserStyleProfile): CanvasStylePreset | null {
const profile = parseStyleProfile(entity.profileJson);
if (!profile) return null;
return { id: profile.presetId, title: profile.title, category: "我的风格", description: profile.description, tags: [...profile.tags], prompt: profile.prompt, imageUrl: profile.coverUrl || entity.coverUrl || "/short-drama-styles/real-life.jpg", profile };
}
function blankUserStyle(): StyleProfileSnapshot {
return createStyleProfileSnapshot({ presetId: `draft-${nanoid()}`, title: "未命名风格", description: "", tags: [], prompt: "", assets: [], source: "user", revision: 1 });
}
function editableCopy(profile: StyleProfileSnapshot, entityId?: string, title?: string, coverUrl?: string): StyleProfileSnapshot {
return createStyleProfileSnapshot({ ...profile, presetId: entityId || `draft-${nanoid()}`, sourceProfileId: entityId, title: title || profile.title, coverUrl: profile.coverUrl || coverUrl, source: "user", revision: entityId ? profile.revision : 1 });
}
export function CanvasStyleDetailModal({ open, preset, selected = false, onClose, onSelect }: { open: boolean; preset: CanvasStylePreset | null; selected?: boolean; onClose: () => void; onSelect?: (preset: CanvasStylePreset) => void }) {
@@ -514,24 +673,39 @@ export function CanvasStyleDetailModal({ open, preset, selected = false, onClose
const sections = preset ? parseStyleSections(preset.prompt) : [];
return (
<Modal rootClassName="canvas-style-detail-modal" open={open} title={null} footer={null} centered destroyOnHidden width="min(820px, calc(100vw - 24px))" onCancel={onClose} styles={{ container: { padding: 0 }, body: { padding: 0 } }}>
{preset ? <div className="flex max-h-[84vh] flex-col overflow-hidden" style={{ color: theme.node.text, background: theme.node.panel }}>
{preset ? <div className="canvas-style-detail-shell flex flex-col overflow-hidden" style={{ color: theme.node.text, background: theme.node.panel }}>
<div className="flex h-44 shrink-0 items-center justify-center overflow-hidden border-b sm:h-52" style={{ borderColor: theme.node.stroke, background: theme.canvas.background }}>
<img src={preset.imageUrl} width="960" height="540" alt={`${preset.title}画风示意`} className="h-full w-full object-contain" style={preset.id === "black-white-noir" ? { filter: "grayscale(1) contrast(1.08)" } : undefined} />
</div>
<header className="border-b px-4 py-3 pr-12 sm:px-5 sm:pr-12" style={{ borderColor: theme.node.stroke }}>
<div className="text-[var(--fs-tiny)] font-medium" style={{ color: theme.node.activeStroke }}>{preset.category}</div>
<div className="flex items-center gap-2 text-[var(--fs-tiny)] font-medium" style={{ color: theme.node.activeStroke }}><span>{preset.category}</span><span className="flex items-center gap-1" style={{ color: theme.node.muted }}><SlidersHorizontal className="size-3" />{preset.profile?.assets.length ? `${preset.profile.assets.length} 个执行资产` : "Prompt 通用"}</span></div>
<h2 className="mt-1 text-base font-semibold">{preset.title}</h2>
<p className="mt-1.5 text-xs leading-5" style={{ color: theme.node.muted }}>{preset.description}</p>
</header>
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 sm:px-5">
{sections.map((section) => <section key={section.title} className="border-b py-3 last:border-b-0" style={{ borderColor: theme.node.stroke }}><h3 className="text-xs font-semibold">{section.title}</h3><p className="mt-1.5 text-xs leading-5" style={{ color: theme.node.muted }}>{section.content}</p></section>)}
<div className="grid shrink-0 grid-cols-3 divide-x border-b text-[var(--fs-tiny)]" style={{ borderColor: theme.node.stroke }}>
<StyleDetailMetric label="执行方式" value={preset.profile?.assets.length ? "组合资产" : "Prompt 通用"} muted={theme.node.muted} />
<StyleDetailMetric label="执行策略" value={preset.profile?.executionPolicy === "strict-assets" ? "严格校验" : "兼容降级"} muted={theme.node.muted} />
<StyleDetailMetric label="快照版本" value={`r${preset.profile?.revision || 1}`} muted={theme.node.muted} />
</div>
<footer className="flex shrink-0 justify-end gap-2 border-t px-4 py-3 sm:px-5" style={{ borderColor: theme.node.stroke }}><Button onClick={onClose}>关闭</Button>{onSelect ? <Button type="primary" disabled={selected} icon={selected ? <Check className="size-3.5" /> : <Palette className="size-3.5" />} onClick={() => onSelect(preset)}>{selected ? "当前画风" : "选择该画风"}</Button> : null}</footer>
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 sm:px-5">
{preset.profile?.assets.length ? <section className="border-b py-3" style={{ borderColor: theme.node.stroke }}><h3 className="text-xs font-semibold">执行资产</h3><div className="mt-2 divide-y" style={{ borderColor: theme.node.stroke }}>{preset.profile.assets.map((asset) => <div key={asset.id} className="flex items-center justify-between gap-3 py-2 text-xs"><span className="min-w-0"><span className="block truncate font-medium">{asset.title}</span><span className="mt-0.5 block text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>{kindOptionsLabel(asset.kind)} · {asset.provider}</span></span><span className="shrink-0 text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>{asset.status === "validated" ? "已验证" : asset.status === "unavailable" ? "不可用" : "待验证"}</span></div>)}</div></section> : null}
{sections.map((section) => <section key={section.title} className="border-b py-3 last:border-b-0" style={{ borderColor: theme.node.stroke }}><h3 className="text-xs font-semibold">{section.title}</h3><p className="mt-1.5 text-xs leading-5" style={{ color: theme.node.muted }}>{section.content}</p></section>)}
{preset.profile?.negativePrompt ? <section className="border-b py-3 last:border-b-0" style={{ borderColor: theme.node.stroke }}><h3 className="text-xs font-semibold">全局负面 Prompt</h3><p className="mt-1.5 whitespace-pre-wrap text-xs leading-5" style={{ color: theme.node.muted }}>{preset.profile.negativePrompt}</p></section> : null}
</div>
<footer className="flex shrink-0 flex-wrap justify-end gap-2 border-t px-4 py-3 sm:px-5" style={{ borderColor: theme.node.stroke }}><Button onClick={onClose}>关闭</Button>{onSelect ? <Button type="primary" disabled={selected} icon={selected ? <Check className="size-3.5" /> : <Palette className="size-3.5" />} onClick={() => onSelect(preset)}>{selected ? "当前画风" : "选择该画风"}</Button> : null}</footer>
</div> : null}
</Modal>
);
}
function StyleDetailMetric({ label, value, muted }: { label: string; value: string; muted: string }) {
return <div className="min-w-0 px-3 py-2.5 text-center"><span className="block" style={{ color: muted }}>{label}</span><span className="mt-0.5 block truncate font-medium">{value}</span></div>;
}
function kindOptionsLabel(kind: string) {
return kind === "lora" ? "LoRA" : kind === "template" ? "图片模板" : kind === "reference" ? "参考图组" : "提示词模块";
}
function parseStyleSections(prompt: string) {
return prompt.split("\n").map((line) => {
const match = line.match(/^【([^】]+)】(.*)$/);

View File

@@ -0,0 +1,349 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { Button, Input, InputNumber, Modal, Select, Switch } from "antd";
import { AlertTriangle, Box, Check, FileImage, Link2, Plus, Save, SlidersHorizontal, Trash2 } from "lucide-react";
import { nanoid } from "nanoid";
import {
createStyleProfileSnapshot,
MAX_STYLE_ASSETS,
styleAssetValidationMessage,
type StyleAssetBinding,
type StyleAssetKind,
type StyleAssetStatus,
type StyleProfileSnapshot,
} from "@/lib/canvas/style-profile";
const kindOptions: Array<{ value: StyleAssetKind; label: string }> = [
{ value: "lora", label: "LoRA" },
{ value: "template", label: "图片模板" },
{ value: "reference", label: "参考图组" },
{ value: "prompt", label: "提示词模块" },
];
const statusOptions: Array<{ value: StyleAssetStatus; label: string }> = [
{ value: "draft", label: "待验证" },
{ value: "validated", label: "已验证" },
{ value: "unavailable", label: "不可用" },
];
const policyOptions: Array<{ value: NonNullable<StyleProfileSnapshot["executionPolicy"]>; label: string }> = [
{ value: "compatible-fallback", label: "兼容降级" },
{ value: "strict-assets", label: "严格阻止" },
];
const emptyAsset = (): StyleAssetBinding => ({
id: nanoid(),
kind: "lora",
provider: "liblib",
title: "",
enabled: false,
weight: 0.8,
status: "draft",
});
type StyleAssetBindingModalProps = {
open: boolean;
profile: StyleProfileSnapshot | null;
onClose: () => void;
onApply: (profile: StyleProfileSnapshot) => void;
};
export function StyleAssetBindingModal({ open, profile, onClose, onApply }: StyleAssetBindingModalProps) {
const [draft, setDraft] = useState<StyleProfileSnapshot | null>(profile);
const [editingId, setEditingId] = useState("");
useEffect(() => {
if (!open) return;
setDraft(profile ? createStyleProfileSnapshot(profile) : null);
setEditingId(profile?.assets[0]?.id || "");
}, [open, profile]);
const editing = useMemo(() => draft?.assets.find((asset) => asset.id === editingId), [draft, editingId]);
const issues = useMemo(() => draft?.assets.map((asset) => ({ id: asset.id, message: styleAssetValidationMessage(asset) })).filter((issue) => issue.message) || [], [draft]);
if (!draft) return null;
const updateAsset = (patch: Partial<StyleAssetBinding>) => {
setDraft((current) => current ? { ...current, assets: current.assets.map((asset) => asset.id === editingId ? { ...asset, ...patch } : asset) } : current);
};
const addAsset = () => {
const asset = emptyAsset();
setDraft((current) => current ? { ...current, assets: [...current.assets, asset] } : current);
setEditingId(asset.id);
};
const deleteAsset = (id: string) => {
setDraft((current) => current ? { ...current, assets: current.assets.filter((asset) => asset.id !== id) } : current);
setEditingId((current) => current === id ? draft.assets.find((asset) => asset.id !== id)?.id || "" : current);
};
return (
<Modal
rootClassName="style-asset-binding-modal"
open={open}
title={null}
footer={null}
centered
width="min(980px, calc(100vw - 24px))"
onCancel={onClose}
styles={{ container: { padding: 0 }, body: { padding: 0 } }}
>
<div className="flex max-h-dvh min-h-0 flex-col overflow-hidden bg-background text-foreground">
<header className="flex items-start justify-between gap-3 border-b border-border px-4 py-3 pr-12 sm:items-center sm:px-5">
<div className="min-w-0">
<h2 className="text-sm font-semibold">风格执行资产</h2>
<p className="mt-0.5 text-[var(--fs-label)] text-foreground/45">绑定模型资产、兼容范围、执行参数和许可快照</p>
</div>
<Button className="shrink-0" icon={<Plus className="size-3.5" />} disabled={draft.assets.length >= MAX_STYLE_ASSETS} title={draft.assets.length >= MAX_STYLE_ASSETS ? `最多绑定 ${MAX_STYLE_ASSETS} 个资产` : undefined} onClick={addAsset}>添加资产</Button>
</header>
<div className="grid min-h-0 flex-1 md:grid-cols-3">
<aside className="thin-scrollbar max-h-52 min-h-0 overflow-y-auto border-b border-border md:col-span-1 md:max-h-none md:border-b-0 md:border-r">
<div className="sticky top-0 z-10 border-b border-border bg-background px-4 py-3">
<label className="flex items-center justify-between gap-3 text-xs">
<span className="min-w-0">
<span className="block font-medium">执行策略</span>
<span className="mt-0.5 block text-[var(--fs-tiny)] text-foreground/42">不兼容、未验证资产如何处理</span>
</span>
<Select<NonNullable<StyleProfileSnapshot["executionPolicy"]>>
className="shrink-0"
size="small"
value={draft.executionPolicy || "compatible-fallback"}
options={policyOptions}
onChange={(executionPolicy) => setDraft({ ...draft, executionPolicy })}
/>
</label>
</div>
{draft.assets.length ? (
<div className="divide-y divide-border">
{draft.assets.map((asset) => {
const issue = styleAssetValidationMessage(asset);
return (
<button
key={asset.id}
type="button"
className={`flex w-full items-start gap-3 px-4 py-3 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-inset ${editingId === asset.id ? "bg-foreground/10" : "hover:bg-foreground/5"}`}
onClick={() => setEditingId(asset.id)}
>
<AssetKindIcon kind={asset.kind} />
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-medium">{asset.title || "未命名资产"}</span>
<span className="mt-1 block truncate text-[var(--fs-tiny)] text-foreground/42">{assetKindLabel(asset.kind)} · {assetStatusLabel(asset.status)}</span>
</span>
<span className={`mt-1 size-1.5 shrink-0 rounded-full ${issue ? "bg-red-500" : asset.enabled ? asset.status === "validated" ? "bg-emerald-500" : "bg-amber-500" : "bg-foreground/20"}`} />
</button>
);
})}
</div>
) : (
<EmptyAssetList />
)}
</aside>
<main className="thin-scrollbar min-h-0 overflow-y-auto p-4 sm:p-5 md:col-span-2">
{editing ? (
<AssetEditor asset={editing} onChange={updateAsset} onDelete={() => deleteAsset(editing.id)} />
) : (
<div className="grid min-h-72 place-items-center text-center">
<div><Plus className="mx-auto size-5 text-foreground/25" /><p className="mt-2 text-xs text-foreground/50">添加或选择一个执行资产</p></div>
</div>
)}
</main>
</div>
<footer className="flex flex-col gap-3 border-t border-border px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-5">
<div className="flex min-w-0 items-start gap-2 text-[var(--fs-tiny)] text-foreground/45">
{issues.length ? <AlertTriangle className="mt-0.5 size-3.5 shrink-0 text-red-500" /> : <Check className="mt-0.5 size-3.5 shrink-0" />}
<span className="min-w-0 leading-5">{issues.length ? `${issues.length} 个资产需要处理:${issues[0].message}` : `${draft.assets.length} 个资产,${draft.assets.filter((asset) => asset.enabled).length} 个已启用`}</span>
</div>
<div className="flex shrink-0 justify-end gap-2">
<Button onClick={onClose}>取消</Button>
<Button
type="primary"
icon={<Save className="size-3.5" />}
disabled={issues.length > 0}
onClick={() => onApply(createStyleProfileSnapshot({ ...draft, source: "user", revision: draft.revision + 1 }))}
>
应用配置
</Button>
</div>
</footer>
</div>
</Modal>
);
}
function AssetEditor({ asset, onChange, onDelete }: { asset: StyleAssetBinding; onChange: (patch: Partial<StyleAssetBinding>) => void; onDelete: () => void }) {
const validationMessage = styleAssetValidationMessage(asset);
return (
<div className="space-y-5">
<div className="flex items-start justify-between gap-3 border-b border-border pb-4">
<div>
<h3 className="text-sm font-semibold">资产配置</h3>
<p className="mt-1 text-[var(--fs-label)] text-foreground/45">已验证且启用的资产才会进入执行计划</p>
</div>
<Button danger type="text" icon={<Trash2 className="size-3.5" />} onClick={onDelete}>删除</Button>
</div>
{validationMessage ? (
<div className="flex gap-2 border-l-2 border-red-500 bg-red-500/5 px-3 py-2 text-[var(--fs-label)] leading-5 text-foreground/65">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0 text-red-500" />
<span>{validationMessage}</span>
</div>
) : null}
<div className="grid gap-4 sm:grid-cols-2">
<Field label="资产类型">
<Select<StyleAssetKind> value={asset.kind} options={kindOptions} onChange={(kind) => onChange({ kind })} />
</Field>
<Field label="启用状态">
<div className="flex h-8 items-center justify-between border-b border-border">
<span className="text-xs text-foreground/55">参与生成执行计划</span>
<Switch size="small" checked={asset.enabled !== false} onChange={(enabled) => onChange({ enabled })} />
</div>
</Field>
<Field label="资产名称">
<Input value={asset.title} placeholder="例如:东方玄幻武侠修仙" onChange={(event) => onChange({ title: event.target.value })} />
</Field>
<Field label="验证状态">
<Select<StyleAssetStatus> value={asset.status} options={statusOptions} onChange={(status) => onChange({ status })} />
</Field>
<Field label="来源平台 / 适配器">
<Input value={asset.provider} placeholder="liblib、comfyui 或自定义适配器" onChange={(event) => onChange({ provider: event.target.value })} />
</Field>
<Field label="来源资产 ID">
<Input value={asset.sourceId} placeholder="模型 UUID 或模板 ID" onChange={(event) => onChange({ sourceId: event.target.value })} />
</Field>
<Field label="来源页面 URL" className="sm:col-span-2">
<Input value={asset.sourceUrl} placeholder="记录资产出处,不会自动向该地址发送生成请求" onChange={(event) => onChange({ sourceUrl: event.target.value })} />
</Field>
<Field label="资产模型标识">
<Input value={asset.model} placeholder="例如 Liblib 模型名称" onChange={(event) => onChange({ model: event.target.value })} />
</Field>
<Field label="模型版本">
<Input value={asset.version} placeholder="版本 UUID 或版本号" onChange={(event) => onChange({ version: event.target.value })} />
</Field>
<Field label="兼容基础模型" className="sm:col-span-2">
<Select<string[]> mode="tags" value={asset.baseModels || []} tokenSeparators={[","]} placeholder="填写实际生成模型名,输入后回车" onChange={(baseModels) => onChange({ baseModels })} />
</Field>
</div>
{asset.kind === "lora" ? <LoraFields asset={asset} onChange={onChange} /> : null}
{asset.kind === "reference" ? <ReferenceFields asset={asset} onChange={onChange} /> : null}
{asset.kind === "prompt" || asset.kind === "template" ? <PromptFields asset={asset} onChange={onChange} /> : null}
<GenerationParameterFields asset={asset} onChange={onChange} />
<LicenseFields asset={asset} onChange={onChange} />
{asset.enabled !== false && (asset.kind === "lora" || asset.kind === "reference") ? (
<div className="flex gap-2 border-l-2 border-amber-500 bg-amber-500/5 px-3 py-2 text-[var(--fs-label)] leading-5 text-foreground/60">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0 text-amber-500" />
<span>当前通用生成协议尚未启用{asset.kind === "lora" ? " LoRA" : "参考图组自动注入"}适配器。兼容降级会继续执行项目 Prompt;严格策略会在生成前阻止任务。</span>
</div>
) : null}
</div>
);
}
function LoraFields({ asset, onChange }: AssetFieldProps) {
return (
<section className="grid gap-4 border-t border-border pt-4 sm:grid-cols-2">
<Field label="LoRA 权重">
<InputNumber className="w-full" min={0} max={2} step={0.05} value={asset.weight} onChange={(weight) => onChange({ weight: weight ?? undefined })} />
</Field>
<Field label="触发词">
<Select<string[]> mode="tags" value={asset.triggerWords || []} tokenSeparators={[","]} placeholder="输入后回车" onChange={(triggerWords) => onChange({ triggerWords })} />
</Field>
</section>
);
}
function ReferenceFields({ asset, onChange }: AssetFieldProps) {
return (
<section className="grid gap-4 border-t border-border pt-4">
<Field label="参考图 URL">
<Select<string[]> mode="tags" value={asset.referenceUrls || []} tokenSeparators={[","]} placeholder="输入可访问的参考图地址后回车" onChange={(referenceUrls) => onChange({ referenceUrls })} />
</Field>
<Field label="项目资源 ID">
<Select<string[]> mode="tags" value={asset.referenceResourceIds || []} tokenSeparators={[","]} placeholder="输入已上传的 resource ID 后回车" onChange={(referenceResourceIds) => onChange({ referenceResourceIds })} />
</Field>
</section>
);
}
function PromptFields({ asset, onChange }: AssetFieldProps) {
return (
<section className="border-t border-border pt-4">
<Field label="提示词模块">
<Input.TextArea autoSize={{ minRows: 4, maxRows: 8 }} value={asset.promptFragment} placeholder="只填写该资产需要追加的稳定视觉要求" onChange={(event) => onChange({ promptFragment: event.target.value })} />
</Field>
</section>
);
}
function GenerationParameterFields({ asset, onChange }: AssetFieldProps) {
const parameters = asset.parameters || {};
const updateParameters = (patch: NonNullable<StyleAssetBinding["parameters"]>) => onChange({ parameters: { ...parameters, ...patch } });
return (
<section className="border-t border-border pt-4">
<h4 className="mb-3 text-xs font-semibold">建议生成参数</h4>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Field label="Sampler"><Input value={parameters.sampler} placeholder="可选" onChange={(event) => updateParameters({ sampler: event.target.value })} /></Field>
<Field label="Steps"><InputNumber className="w-full" min={1} max={200} value={parameters.steps} onChange={(steps) => updateParameters({ steps: steps ?? undefined })} /></Field>
<Field label="CFG"><InputNumber className="w-full" min={0} max={50} step={0.5} value={parameters.cfg} onChange={(cfg) => updateParameters({ cfg: cfg ?? undefined })} /></Field>
<Field label="推荐尺寸"><Input value={parameters.size} placeholder="例如 1024x1536" onChange={(event) => updateParameters({ size: event.target.value })} /></Field>
</div>
</section>
);
}
function LicenseFields({ asset, onChange }: AssetFieldProps) {
return (
<section className="border-t border-border pt-4">
<h4 className="mb-3 text-xs font-semibold">许可快照</h4>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="商业使用">
<Select
value={asset.license?.commercial === true ? "yes" : asset.license?.commercial === false ? "no" : "unknown"}
options={[{ value: "unknown", label: "尚未确认" }, { value: "yes", label: "允许商用" }, { value: "no", label: "不可商用" }]}
onChange={(value: "unknown" | "yes" | "no") => onChange({ license: { ...asset.license, commercial: value === "unknown" ? undefined : value === "yes" } })}
/>
</Field>
<Field label="许可来源"><Input value={asset.license?.source} placeholder="许可页面或协议版本" onChange={(event) => onChange({ license: { ...asset.license, source: event.target.value } })} /></Field>
<Field label="许可说明" className="sm:col-span-2"><Input value={asset.license?.note} placeholder="记录会员限制、署名或转售限制" onChange={(event) => onChange({ license: { ...asset.license, note: event.target.value } })} /></Field>
</div>
</section>
);
}
type AssetFieldProps = {
asset: StyleAssetBinding;
onChange: (patch: Partial<StyleAssetBinding>) => void;
};
function AssetKindIcon({ kind }: { kind: StyleAssetKind }) {
return (
<span className="mt-0.5 grid size-7 shrink-0 place-items-center rounded bg-foreground/5 text-foreground/55">
{kind === "lora" ? <SlidersHorizontal className="size-3.5" /> : kind === "reference" ? <FileImage className="size-3.5" /> : kind === "template" ? <Box className="size-3.5" /> : <Link2 className="size-3.5" />}
</span>
);
}
function EmptyAssetList() {
return (
<div className="grid min-h-52 place-items-center px-6 text-center">
<div><Box className="mx-auto size-5 text-foreground/25" /><p className="mt-2 text-xs font-medium">尚未绑定执行资产</p><p className="mt-1 text-[var(--fs-tiny)] leading-5 text-foreground/42">当前画风只通过项目 Prompt 执行</p></div>
</div>
);
}
function assetKindLabel(kind: StyleAssetKind) {
return kindOptions.find((item) => item.value === kind)?.label || kind;
}
function assetStatusLabel(status: StyleAssetStatus) {
return statusOptions.find((item) => item.value === status)?.label || status;
}
function Field({ label, className = "", children }: { label: string; className?: string; children: ReactNode }) {
return <label className={`grid gap-1.5 text-xs ${className}`}><span className="font-medium text-foreground/60">{label}</span>{children}</label>;
}

View File

@@ -0,0 +1,232 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { App, Button, Input, Modal, Segmented, Select } from "antd";
import { Braces, Image, Layers3, Save, Sparkles } from "lucide-react";
import { StyleAssetBindingModal } from "@/components/canvas/style-asset-binding-modal";
import { createStyleProfileSnapshot, styleProfileValidationMessage, type StyleProfileSnapshot } from "@/lib/canvas/style-profile";
type EditorSection = "identity" | "prompt" | "execution";
type StyleProfileEditorModalProps = {
open: boolean;
initialProfile: StyleProfileSnapshot | null;
saving?: boolean;
onClose: () => void;
onSave: (profile: StyleProfileSnapshot, applyToProject: boolean) => void;
};
export function StyleProfileEditorModal({ open, initialProfile, saving = false, onClose, onSave }: StyleProfileEditorModalProps) {
const { message, modal } = App.useApp();
const [section, setSection] = useState<EditorSection>("identity");
const [draft, setDraft] = useState<StyleProfileSnapshot | null>(initialProfile);
const [baseline, setBaseline] = useState("");
const [assetsOpen, setAssetsOpen] = useState(false);
useEffect(() => {
if (!open) return;
const next = initialProfile ? createStyleProfileSnapshot(initialProfile) : null;
setDraft(next);
setBaseline(next ? JSON.stringify(next) : "");
setSection("identity");
setAssetsOpen(false);
}, [initialProfile, open]);
const validationMessage = useMemo(() => draft ? styleProfileValidationMessage(draft) : "风格草稿不存在", [draft]);
if (!draft) return null;
const update = (patch: Partial<StyleProfileSnapshot>) => setDraft((current) => current ? { ...current, ...patch } : current);
const requestClose = () => {
if (JSON.stringify(draft) === baseline) {
onClose();
return;
}
modal.confirm({ title: "放弃未保存的风格修改?", content: "名称、Prompt、封面和执行配置中的改动都会丢失。", okText: "放弃修改", cancelText: "继续编辑", okButtonProps: { danger: true }, onOk: onClose });
};
const submit = (applyToProject: boolean) => {
if (validationMessage) {
message.error(validationMessage);
return;
}
onSave(createStyleProfileSnapshot({ ...draft, source: "user" }), applyToProject);
};
return (
<>
<Modal
rootClassName="style-profile-editor-modal"
open={open}
title={null}
footer={null}
centered
width="min(1120px, calc(100vw - 24px))"
onCancel={requestClose}
styles={{ container: { padding: 0 }, body: { padding: 0 } }}
>
<div className="flex max-h-dvh min-h-0 flex-col overflow-hidden bg-background text-foreground">
<header className="flex min-h-16 items-center border-b border-border px-4 pr-12 sm:px-5 sm:pr-14">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="grid size-8 shrink-0 place-items-center rounded-md border border-border bg-foreground/5"><Sparkles className="size-3.5" /></span>
<div>
<h2 className="text-sm font-semibold">风格编辑器</h2>
<p className="mt-0.5 truncate text-[var(--fs-tiny)] text-foreground/45">{draft.title || "未命名风格"} · 我的风格</p>
</div>
</div>
</div>
</header>
<nav className="style-profile-editor-tabs grid shrink-0 grid-cols-3 border-b border-border px-2 sm:flex sm:px-4" role="tablist" aria-label="风格编辑器分区">
<EditorTab id="style-profile-tab-identity" panelId="style-profile-panel-identity" active={section === "identity"} icon={<Image className="size-3.5" />} label="基本信息" onClick={() => setSection("identity")} />
<EditorTab id="style-profile-tab-prompt" panelId="style-profile-panel-prompt" active={section === "prompt"} icon={<Braces className="size-3.5" />} label="Prompt 规则" onClick={() => setSection("prompt")} />
<EditorTab id="style-profile-tab-execution" panelId="style-profile-panel-execution" active={section === "execution"} icon={<Layers3 className="size-3.5" />} label="执行配置" onClick={() => setSection("execution")} />
</nav>
<div className="style-profile-editor-workspace grid min-h-0 flex-1 lg:grid-cols-3">
<StylePreview profile={draft} />
<main id={`style-profile-panel-${section}`} className="thin-scrollbar min-h-0 overflow-y-auto p-4 sm:p-5 lg:col-span-2" role="tabpanel" aria-labelledby={`style-profile-tab-${section}`}>
{section === "identity" ? <IdentityFields profile={draft} onChange={update} /> : null}
{section === "prompt" ? <PromptFields profile={draft} onChange={update} /> : null}
{section === "execution" ? <ExecutionFields profile={draft} onChange={update} onEditAssets={() => setAssetsOpen(true)} /> : null}
</main>
</div>
<footer className="flex flex-col gap-3 border-t border-border px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-5">
<p className={`text-[var(--fs-tiny)] ${validationMessage ? "text-red-500" : "text-foreground/45"}`}>{validationMessage || "保存后进入“我的风格”;应用时会把当前版本复制为项目快照"}</p>
<div className="flex shrink-0 flex-wrap justify-end gap-2">
<Button onClick={requestClose}>取消</Button>
<Button icon={<Save className="size-3.5" />} disabled={Boolean(validationMessage)} loading={saving} onClick={() => submit(false)}>保存到我的风格</Button>
<Button type="primary" icon={<Sparkles className="size-3.5" />} disabled={Boolean(validationMessage)} loading={saving} onClick={() => submit(true)}>保存并应用</Button>
</div>
</footer>
</div>
</Modal>
<StyleAssetBindingModal
open={assetsOpen}
profile={draft}
onClose={() => setAssetsOpen(false)}
onApply={(profile) => {
setDraft(profile);
setAssetsOpen(false);
}}
/>
</>
);
}
function StylePreview({ profile }: { profile: StyleProfileSnapshot }) {
return (
<aside className="style-profile-editor-preview thin-scrollbar min-h-0 overflow-y-auto border-b border-border bg-foreground/5 lg:border-b-0 lg:border-r">
<div className="relative aspect-video overflow-hidden border-b border-border bg-foreground/5">
{profile.coverUrl ? <img src={profile.coverUrl} alt={`${profile.title || "未命名风格"}封面`} className="h-full w-full object-cover" /> : <div className="grid h-full place-items-center text-foreground/25"><Image className="size-7" /></div>}
<span className="absolute bottom-3 left-3 rounded bg-background/85 px-2 py-1 text-[var(--fs-tiny)] font-medium backdrop-blur">我的风格</span>
</div>
<div className="p-4 sm:p-5">
<h3 className="text-base font-semibold">{profile.title || "未命名风格"}</h3>
<p className="mt-1.5 text-xs leading-5 text-foreground/48">{profile.description || "填写简介,让风格库中更容易判断它适合什么项目。"}</p>
<div className="mt-3 flex flex-wrap gap-1.5">
{profile.tags.length ? profile.tags.map((tag) => <span key={tag} className="rounded bg-foreground/10 px-2 py-1 text-[var(--fs-tiny)] text-foreground/58">{tag}</span>) : <span className="text-[var(--fs-tiny)] text-foreground/35">暂无标签</span>}
</div>
<dl className="mt-5 grid grid-cols-2 border-y border-border py-3 text-[var(--fs-tiny)]">
<Metric label="Prompt" value={`${profile.prompt.length} 字`} />
<Metric label="负面约束" value={`${profile.negativePrompt?.length || 0} 字`} />
<Metric label="执行资产" value={`${profile.assets.length} 个`} />
<Metric label="策略" value={profile.executionPolicy === "strict-assets" ? "严格阻止" : "兼容降级"} />
</dl>
</div>
</aside>
);
}
function IdentityFields({ profile, onChange }: EditorFieldsProps) {
const selection = profile.selection || {};
const setSelection = (key: string, value: string) => onChange({ selection: { ...selection, [key]: value } });
return (
<div className="space-y-5">
<SectionHeading title="风格身份" description="名称、封面和标签会出现在“我的风格”中" />
<div className="grid gap-4 sm:grid-cols-2">
<Field label="风格名称" className="sm:col-span-2"><Input maxLength={80} showCount value={profile.title} placeholder="例如:东方志怪 · 工笔暗彩" onChange={(event) => onChange({ title: event.target.value })} /></Field>
<Field label="风格简介" className="sm:col-span-2"><Input.TextArea maxLength={500} showCount autoSize={{ minRows: 3, maxRows: 6 }} value={profile.description} placeholder="说明适用题材、画面气质和核心辨识度" onChange={(event) => onChange({ description: event.target.value })} /></Field>
<Field label="封面图 URL" className="sm:col-span-2"><Input value={profile.coverUrl} placeholder="可填写项目资源 URL 或可访问的图片地址" onChange={(event) => onChange({ coverUrl: event.target.value })} /></Field>
<Field label="标签" className="sm:col-span-2"><Select mode="tags" maxCount={20} tokenSeparators={[",", ","]} value={profile.tags} placeholder="输入题材、媒介、色彩或质感后回车" onChange={(tags) => onChange({ tags })} /></Field>
</div>
<SectionHeading title="辅助标注" description="这些字段用于检索和理解,不会限制你自由编写 Prompt" />
<div className="grid gap-4 sm:grid-cols-2">
<Field label="题材世界"><Input value={selection.world || ""} placeholder="仙侠、都市、悬疑……" onChange={(event) => setSelection("world", event.target.value)} /></Field>
<Field label="视觉媒介"><Input value={selection.medium || ""} placeholder="真人实拍、二维、水墨……" onChange={(event) => setSelection("medium", event.target.value)} /></Field>
<Field label="叙事气质"><Input value={selection.tone || ""} placeholder="史诗、克制、轻喜剧……" onChange={(event) => setSelection("tone", event.target.value)} /></Field>
<Field label="角色造型"><Input value={selection.character || ""} placeholder="写实、半写实、风格化……" onChange={(event) => setSelection("character", event.target.value)} /></Field>
</div>
</div>
);
}
function PromptFields({ profile, onChange }: EditorFieldsProps) {
return (
<div className="space-y-5">
<SectionHeading title="完整风格 Prompt" description="直接定义项目长期稳定的美术系统;这里没有固定组合限制" />
<Field label="正向 Prompt">
<Input.TextArea value={profile.prompt} autoSize={{ minRows: 16, maxRows: 28 }} placeholder="填写视觉媒介、角色设计、色彩、材质、建筑世界观、影像基线和一致性规则……" onChange={(event) => onChange({ prompt: event.target.value })} />
</Field>
<Field label="推荐负面 Prompt">
<Input.TextArea value={profile.negativePrompt} autoSize={{ minRows: 6, maxRows: 14 }} placeholder="填写需要全项目避免的媒介漂移、人物错误、材质错误、文字水印等" onChange={(event) => onChange({ negativePrompt: event.target.value })} />
</Field>
</div>
);
}
function ExecutionFields({ profile, onChange, onEditAssets }: EditorFieldsProps & { onEditAssets: () => void }) {
const enabled = profile.assets.filter((asset) => asset.enabled !== false);
return (
<div className="space-y-5">
<SectionHeading title="生成执行" description="控制模型资产不兼容时是继续使用 Prompt,还是阻止任务" />
<Field label="执行策略">
<Segmented
block
value={profile.executionPolicy || "compatible-fallback"}
options={[{ value: "compatible-fallback", label: "兼容降级" }, { value: "strict-assets", label: "严格阻止" }]}
onChange={(executionPolicy) => onChange({ executionPolicy: executionPolicy as StyleProfileSnapshot["executionPolicy"] })}
/>
</Field>
<div className="flex flex-col gap-3 border-y border-border py-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h4 className="text-sm font-medium">执行资产</h4>
<p className="mt-1 text-[var(--fs-label)] text-foreground/45">{profile.assets.length} 个已绑定,{enabled.length} 个已启用</p>
</div>
<Button icon={<Layers3 className="size-3.5" />} onClick={onEditAssets}>配置 LoRA、模板与参考图</Button>
</div>
{profile.assets.length ? <div className="divide-y divide-border border-b border-border">{profile.assets.map((asset) => <div key={asset.id} className="flex items-center gap-3 py-3 text-xs"><span className={`size-1.5 shrink-0 rounded-full ${asset.enabled !== false && asset.status === "validated" ? "bg-emerald-500" : asset.status === "unavailable" ? "bg-red-500" : "bg-amber-500"}`} /><span className="min-w-0 flex-1 truncate font-medium">{asset.title}</span><span className="shrink-0 text-foreground/40">{asset.kind.toUpperCase()} · {asset.status === "validated" ? "已验证" : asset.status === "unavailable" ? "不可用" : "待验证"}</span></div>)}</div> : <div className="grid min-h-36 place-items-center border-b border-border text-center text-xs text-foreground/38">尚未绑定执行资产,当前风格将只通过 Prompt 执行</div>}
<p className="text-[var(--fs-tiny)] leading-5 text-foreground/42">LoRA 与参考图已支持绑定、验证和兼容判断;具体生成渠道没有原生适配器时,兼容策略会降级到 Prompt,严格策略会阻止生成。</p>
</div>
);
}
type EditorFieldsProps = { profile: StyleProfileSnapshot; onChange: (patch: Partial<StyleProfileSnapshot>) => void };
function SectionHeading({ title, description }: { title: string; description: string }) {
return <div className="border-b border-border pb-3"><h3 className="text-sm font-semibold">{title}</h3><p className="mt-1 text-[var(--fs-label)] text-foreground/45">{description}</p></div>;
}
function Field({ label, className = "", children }: { label: string; className?: string; children: ReactNode }) {
return <label className={`grid gap-1.5 text-xs ${className}`}><span className="font-medium text-foreground/62">{label}</span>{children}</label>;
}
function Metric({ label, value }: { label: string; value: string }) {
return <div className="min-w-0 py-1"><dt className="text-foreground/38">{label}</dt><dd className="mt-0.5 truncate font-medium text-foreground/65">{value}</dd></div>;
}
function EditorTab({ id, panelId, active, icon, label, onClick }: { id: string; panelId: string; active: boolean; icon: ReactNode; label: string; onClick: () => void }) {
return (
<button
type="button"
id={id}
role="tab"
aria-selected={active}
aria-controls={panelId}
className={`style-profile-editor-tab relative flex h-11 min-w-0 items-center justify-center gap-2 px-3 text-xs font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-inset sm:justify-start ${active ? "is-active text-foreground" : "text-foreground/45 hover:text-foreground/75"}`}
onClick={onClick}
>
{icon}
<span className="truncate">{label}</span>
</button>
);
}

View File

@@ -2,6 +2,7 @@ import { type ReactNode, useState } from "react";
import { ConfigProvider, Switch } from "antd";
import { type CanvasTheme } from "@/lib/canvas-theme";
import { modelCapabilityConfigFor, normalizeImageValue, type ImageCapabilityConfig } from "@/lib/model-capabilities";
import { type AiConfig } from "@/stores/use-config-store";
const qualityOptions = [
@@ -42,15 +43,20 @@ type ImageSettingsPanelProps = {
export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle = true, showCount = true, className = "w-[304px] space-y-3 rounded-2xl px-1 py-0.5", maxCount = 15, quickCount = 3 }: ImageSettingsPanelProps) {
const [snapDimensionToStep, setSnapDimensionToStep] = useState(true);
const quality = config.quality || "auto";
const transparentBackground = config.transparentBackground === "true";
const count = Math.max(1, Math.min(maxCount, Math.floor(Math.abs(Number(config.count)) || 1)));
const activeSize = config.size || "auto";
const selectedAspect = aspectOptions.find((item) => (item.size || item.value) === activeSize || item.value === activeSize);
const profile = modelCapabilityConfigFor(config, config.model || config.imageModel).image!;
const normalized = normalizeImageValue(profile, config);
const quality = normalized.quality;
const transparentBackground = normalized.transparentBackground === "true";
const effectiveMaxCount = Math.min(maxCount, profile.maxOutputs);
const count = Math.max(1, Math.min(effectiveMaxCount, Number(normalized.count)));
const activeSize = normalized.size;
const availableAspects = aspectOptions.filter((item) => imageOptionAllowed(profile, item));
const selectedAspect = availableAspects.find((item) => imageOptionValue(profile, item) === activeSize || item.value === activeSize);
const dimensions = readSizeDimensions(activeSize, selectedAspect || aspectOptions[0]);
const activeQualityOptions = profile.quality.values.map((value) => qualityOptions.find((item) => item.value === value) || { value, label: value });
const selectAspect = (value: string) => {
const option = aspectOptions.find((item) => item.value === value);
onConfigChange("size", option?.size || option?.value || "auto");
onConfigChange("size", option ? imageOptionValue(profile, option) : "auto");
};
const updateDimension = (key: "width" | "height", value: number | null) => {
const next = Math.max(1, Math.floor(value || dimensions[key] || 1024));
@@ -71,17 +77,17 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle =
}}
>
{showTitle ? <div className="text-base font-semibold">图像设置</div> : null}
<div className="space-y-2">
{profile.quality.supported ? <div className="space-y-2">
<SettingTitle color={theme.node.muted}>质量</SettingTitle>
<div className="grid grid-cols-4 gap-1.5">
{qualityOptions.map((item) => (
{activeQualityOptions.map((item) => (
<OptionPill key={item.value} selected={quality === item.value} theme={theme} onClick={() => onConfigChange("quality", item.value)}>
{item.label}
</OptionPill>
))}
</div>
</div>
<div className="flex items-center justify-between gap-3">
</div> : null}
{profile.transparentBackground.supported ? <div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<SettingTitle color={theme.node.muted}>透明背景</SettingTitle>
<div className="mt-1 text-[var(--fs-label)]" style={{ color: theme.node.muted }}>
@@ -95,29 +101,29 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle =
onChange={(checked) => onConfigChange("transparentBackground", checked ? "true" : "false")}
/>
</span>
</div>
<div className="space-y-2">
</div> : null}
{profile.size.parameter !== "none" ? <div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<SettingTitle color={theme.node.muted}>尺寸</SettingTitle>
<div className="flex items-center gap-2">
{profile.size.allowCustom ? <div className="flex items-center gap-2">
<span className="text-xs font-medium" style={{ color: theme.node.muted }}>
16倍数对齐
</span>
<span title="输入完成后自动向上补成 16 的倍数" onMouseDown={(event) => event.stopPropagation()}>
<Switch size="small" checked={snapDimensionToStep} onChange={setSnapDimensionToStep} />
</span>
</div>
</div> : null}
</div>
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-1.5">
{profile.size.allowCustom ? <div className="grid grid-cols-[1fr_auto_1fr] items-center gap-1.5">
<DimensionInput prefix="W" value={dimensions.width} disabled={activeSize === "auto"} theme={theme} alignToStep={snapDimensionToStep} onChange={(value) => updateDimension("width", value)} />
<span className="text-sm opacity-45">↔</span>
<DimensionInput prefix="H" value={dimensions.height} disabled={activeSize === "auto"} theme={theme} alignToStep={snapDimensionToStep} onChange={(value) => updateDimension("height", value)} />
</div>
</div>
<div className="space-y-2">
</div> : null}
</div> : null}
{availableAspects.length ? <div className="space-y-2">
<SettingTitle color={theme.node.muted}>宽高比</SettingTitle>
<div className="grid grid-cols-4 gap-1.5 min-[380px]:grid-cols-5">
{aspectOptions.map((item) => (
{availableAspects.map((item) => (
<button
key={item.value}
type="button"
@@ -131,17 +137,17 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle =
</button>
))}
</div>
</div>
{showCount ? (
</div> : null}
{showCount && effectiveMaxCount > 1 ? (
<div className="space-y-2">
<SettingTitle color={theme.node.muted}>生成张数</SettingTitle>
<div className="grid grid-cols-4 gap-1.5">
{Array.from({ length: quickCount }, (_, index) => index + 1).map((value) => (
{Array.from({ length: Math.min(quickCount, effectiveMaxCount) }, (_, index) => index + 1).map((value) => (
<OptionPill key={value} selected={count === value} theme={theme} onClick={() => onConfigChange("count", String(value))}>
{value}
</OptionPill>
))}
<CountInput value={count} quickCount={quickCount} max={maxCount} theme={theme} onChange={(value) => onConfigChange("count", String(value || 1))} />
<CountInput value={count} quickCount={quickCount} max={effectiveMaxCount} theme={theme} onChange={(value) => onConfigChange("count", String(value || 1))} />
</div>
</div>
) : null}
@@ -150,6 +156,17 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle =
);
}
function imageOptionAllowed(profile: ImageCapabilityConfig, option: (typeof aspectOptions)[number]) {
if (profile.size.parameter === "none") return false;
if (profile.size.allowCustom && profile.size.values.length === 0) return true;
return [option.value, option.size, option.width && option.height ? `${option.width}x${option.height}` : ""].filter(Boolean).some((value) => profile.size.values.includes(String(value)));
}
function imageOptionValue(profile: ImageCapabilityConfig, option: (typeof aspectOptions)[number]) {
const candidates = [option.size, option.value, option.width && option.height ? `${option.width}x${option.height}` : ""].filter(Boolean).map(String);
return candidates.find((value) => profile.size.values.includes(value)) || option.size || option.value || "auto";
}
export function ImageSettingsTheme({ theme, children }: { theme: CanvasTheme; children: ReactNode }) {
return (
<ConfigProvider

View File

@@ -1,7 +1,7 @@
import { Input, InputNumber, Segmented, Select, Switch } from "antd";
import type { ReactNode } from "react";
import { defaultModelCapabilityConfig, type ModelCapabilityConfig, type VideoCapabilityConfig } from "@/lib/model-capabilities";
import { defaultImageCapabilityConfig, defaultModelCapabilityConfig, type ImageCapabilityConfig, type ModelCapabilityConfig, type VideoCapabilityConfig } from "@/lib/model-capabilities";
import type { ModelProtocol } from "@/lib/model-protocols";
const ratioOptions = ["16:9", "9:16", "1:1", "4:3", "3:4", "21:9"];
@@ -21,10 +21,15 @@ type Props = {
value?: ModelCapabilityConfig;
onChange?: (value: ModelCapabilityConfig) => void;
protocol?: ModelProtocol;
capability?: "image" | "video";
model?: string;
disabled?: boolean;
};
export function ModelCapabilityEditor({ value, onChange, protocol, disabled = false }: Props) {
export function ModelCapabilityEditor({ value, onChange, protocol, capability = "video", model = "", disabled = false }: Props) {
if (capability === "image") {
return <ImageCapabilityEditor value={value} onChange={onChange} protocol={protocol} model={model} disabled={disabled} />;
}
const profile = value?.video || defaultModelCapabilityConfig(protocol).video!;
const update = (patch: Partial<VideoCapabilityConfig>) => onChange?.({ version: 1, video: { ...profile, ...patch } });
const updateReferences = (patch: Partial<VideoCapabilityConfig["references"]>) => update({ references: { ...profile.references, ...patch } });
@@ -89,6 +94,66 @@ export function ModelCapabilityEditor({ value, onChange, protocol, disabled = fa
);
}
function ImageCapabilityEditor({ value, onChange, protocol, model, disabled }: Required<Pick<Props, "model" | "disabled">> & Pick<Props, "value" | "onChange" | "protocol">) {
const profile = value?.image || defaultImageCapabilityConfig(protocol, model);
const update = (patch: Partial<ImageCapabilityConfig>) => onChange?.({ version: 1, image: { ...profile, ...patch } });
const updateReferences = (patch: Partial<ImageCapabilityConfig["references"]>) => update({ references: { ...profile.references, ...patch } });
const updateSize = (patch: Partial<ImageCapabilityConfig["size"]>) => update({ size: { ...profile.size, ...patch } });
const updateQuality = (patch: Partial<ImageCapabilityConfig["quality"]>) => update({ quality: { ...profile.quality, ...patch } });
return (
<div className="space-y-3 rounded-md border border-border/70 bg-muted/10 p-3">
<div className="flex items-center justify-between gap-3">
<div><div className="text-sm font-medium">图片能力参数</div><div className="mt-0.5 text-[var(--fs-tiny)] text-foreground/48">生成界面和后端请求都会按此处裁剪参数</div></div>
<span className="text-[var(--fs-tiny)] text-foreground/40">当前模型独立生效</span>
</div>
<CapabilityGroup title="输入与输出限制">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<NumberField label="提示词字符数" value={profile.references.promptMaxChars} min={1} disabled={disabled} onChange={(promptMaxChars) => updateReferences({ promptMaxChars: promptMaxChars || 1 })} />
<NumberField label="最大参考图" value={profile.references.maxImages} min={0} disabled={disabled} onChange={(maxImages) => updateReferences({ maxImages: maxImages || 0 })} />
<NumberField label="单图上限 MB" value={bytesToMB(profile.references.maxImageBytes)} min={0} disabled={disabled} onChange={(maxImageBytes) => updateReferences({ maxImageBytes: mbToBytes(maxImageBytes) })} />
<NumberField label="单次生成张数" value={profile.maxOutputs} min={1} disabled={disabled} onChange={(maxOutputs) => update({ maxOutputs: maxOutputs || 1 })} />
</div>
<ParameterField label="蒙版编辑" description="允许调用图片编辑接口并提交 mask" supported={profile.references.maskSupported} disabled={disabled} onChange={(maskSupported) => updateReferences({ maskSupported })} />
</CapabilityGroup>
<CapabilityGroup title="尺寸参数">
<Segmented
block
disabled={disabled}
value={profile.size.parameter}
options={[{ label: "不发送", value: "none" }, { label: "size", value: "size" }, { label: "aspect_ratio", value: "aspect_ratio" }]}
onChange={(value) => {
const parameter = value as ImageCapabilityConfig["size"]["parameter"];
updateSize(parameter === "none" ? { parameter, values: [], default: "auto", allowCustom: false } : { parameter, values: profile.size.values.length ? profile.size.values : ["1:1"], default: profile.size.default === "auto" ? "1:1" : profile.size.default });
}}
/>
{profile.size.parameter !== "none" ? <>
<Field label="支持值"><Select mode="tags" className="w-full" disabled={disabled} value={profile.size.values} tokenSeparators={[","]} placeholder="例如 1:1、1024x1024" onChange={(values) => updateSize({ values, default: values.includes(profile.size.default) || profile.size.allowCustom ? profile.size.default : values[0] || "auto" })} /></Field>
<div className="grid gap-2 sm:grid-cols-2">
<Field label="默认值"><Select className="w-full" disabled={disabled} value={profile.size.default} options={profile.size.values.map((item) => ({ label: item, value: item }))} onChange={(defaultValue) => updateSize({ default: defaultValue })} /></Field>
<ParameterField label="允许自定义" description="允许用户输入支持值之外的尺寸" supported={profile.size.allowCustom} disabled={disabled} onChange={(allowCustom) => updateSize({ allowCustom })} />
</div>
</> : null}
</CapabilityGroup>
<CapabilityGroup title="可选生成参数">
<ParameterField label="图片质量" description="发送 quality 参数" supported={profile.quality.supported} disabled={disabled} onChange={(supported) => updateQuality({ supported })} />
{profile.quality.supported ? <div className="grid gap-2 sm:grid-cols-2">
<Field label="质量支持值"><Select mode="tags" className="w-full" disabled={disabled} value={profile.quality.values} tokenSeparators={[","]} onChange={(values) => updateQuality({ values, default: values.includes(profile.quality.default) ? profile.quality.default : values[0] || "auto" })} /></Field>
<Field label="默认质量"><Select className="w-full" disabled={disabled} value={profile.quality.default} options={profile.quality.values.map((item) => ({ label: item, value: item }))} onChange={(defaultValue) => updateQuality({ default: defaultValue })} /></Field>
</div> : null}
<BooleanField label="透明背景" value={profile.transparentBackground} disabled={disabled} onChange={(transparentBackground) => update({ transparentBackground })} />
<div className="grid gap-2 sm:grid-cols-2">
<ParameterField label="response_format" description="发送 b64_json 响应格式" supported={profile.responseFormat.supported} disabled={disabled} onChange={(supported) => update({ responseFormat: { supported } })} />
<ParameterField label="output_format" description="发送 PNG 输出格式" supported={profile.outputFormat.supported} disabled={disabled} onChange={(supported) => update({ outputFormat: { supported } })} />
</div>
</CapabilityGroup>
</div>
);
}
function CapabilityGroup({ title, children }: { title: string; children: ReactNode }) {
return <section className="space-y-2"><div className="text-xs font-semibold text-foreground/65">{title}</div>{children}</section>;
}
@@ -105,6 +170,10 @@ function BooleanField({ label, value, disabled, onChange }: { label: string; val
return <div className="flex items-center justify-between rounded-md border border-border/60 px-2.5 py-2"><div><div className="text-xs font-medium">{label}</div><div className="text-[var(--fs-tiny)] text-foreground/45">支持该参数</div></div><div className="flex items-center gap-2"><Switch size="small" disabled={disabled} checked={value.supported} onChange={(supported) => onChange({ ...value, supported })} /><Switch size="small" disabled={disabled || !value.supported} checked={value.default} onChange={(defaultValue) => onChange({ ...value, default: defaultValue })} /></div></div>;
}
function ParameterField({ label, description, supported, disabled, onChange }: { label: string; description: string; supported: boolean; disabled: boolean; onChange: (supported: boolean) => void }) {
return <div className="flex min-h-11 items-center justify-between gap-3 rounded-md border border-border/60 px-2.5 py-2"><div className="min-w-0"><div className="truncate text-xs font-medium">{label}</div><div className="mt-0.5 text-[var(--fs-tiny)] text-foreground/45">{description}</div></div><Switch size="small" disabled={disabled} checked={supported} onChange={onChange} /></div>;
}
function bytesToMB(value: number) {
return value ? Math.round((value / (1024 * 1024)) * 10) / 10 : 0;
}

View File

@@ -35,8 +35,6 @@ export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle =
}
const seconds = normalizeVideoDuration(config.videoSeconds);
const secondOptions = videoDurationOptions(profile);
const durationColumns = secondOptions.length + (profile.duration.selection === "range" ? 1 : 0);
const size = normalizeVideoSizeValue(config.size);
const dimensions = readSizeDimensions(size);
const resolution = normalizeVideoResolutionValue(config.vquality);
@@ -84,14 +82,7 @@ export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle =
</div>
</SettingGroup>
<SettingGroup title="秒数" color={theme.node.muted}>
<div className="grid gap-1.5" style={{ gridTemplateColumns: `repeat(${durationColumns}, minmax(0, 1fr))` }}>
{secondOptions.map((value) => (
<OptionPill key={value} selected={seconds === String(value)} theme={theme} onClick={() => onConfigChange("videoSeconds", String(value))}>
{value}s
</OptionPill>
))}
{profile.duration.selection === "range" ? <DurationInput value={Number(seconds)} min={profile.duration.min || VIDEO_DURATION_MIN} max={profile.duration.max} theme={theme} onChange={(value) => onConfigChange("videoSeconds", String(value))} /> : null}
</div>
<VideoDurationControl profile={profile} value={Number(seconds)} theme={theme} onChange={(value) => onConfigChange("videoSeconds", String(value))} />
</SettingGroup>
{profile.generateAudio.supported || profile.watermark.supported ? <SettingGroup title="输出" color={theme.node.muted}><div className="grid grid-cols-2 gap-3 rounded-md px-2" style={{ background: theme.toolbar.itemHover }}>{profile.generateAudio.supported ? <SwitchRow label="生成声音" checked={generateAudio} theme={theme} onChange={(checked) => onConfigChange("videoGenerateAudio", String(checked))} /> : null}{profile.watermark.supported ? <SwitchRow label="添加水印" checked={watermark} theme={theme} onChange={(checked) => onConfigChange("videoWatermark", String(checked))} /> : null}</div></SettingGroup> : null}
</div>
@@ -111,9 +102,7 @@ function JiMengVideoSettingsPanel({ config, profile, onConfigChange, theme, show
</div>
</SettingGroup>
<SettingGroup title="秒数" color={theme.node.muted}>
<div className="grid grid-cols-2 gap-1.5">
{videoDurationOptions(profile).map((value) => <OptionPill key={value} selected={seconds === String(value)} theme={theme} onClick={() => onConfigChange("videoSeconds", String(value))}>{value}s</OptionPill>)}
</div>
<VideoDurationControl profile={profile} value={Number(seconds)} theme={theme} onChange={(value) => onConfigChange("videoSeconds", String(value))} />
</SettingGroup>
</div>
</ImageSettingsTheme>
@@ -127,8 +116,6 @@ function SeedanceVideoSettingsPanel({ config, profile, onConfigChange, theme, sh
const duration = normalizeSeedanceDuration(config.videoSeconds);
const generateAudio = boolConfig(config.videoGenerateAudio, profile.generateAudio.default);
const watermark = boolConfig(config.videoWatermark, profile.watermark.default);
const durationOptions = videoDurationOptions(profile);
const durationColumns = durationOptions.length + (profile.duration.selection === "range" ? 1 : 0);
return (
<ImageSettingsTheme theme={theme}>
@@ -171,14 +158,7 @@ function SeedanceVideoSettingsPanel({ config, profile, onConfigChange, theme, sh
</div>
</SettingGroup>
<SettingGroup title="时长" color={theme.node.muted}>
<div className="grid gap-1.5" style={{ gridTemplateColumns: `repeat(${durationColumns}, minmax(0, 1fr))` }}>
{durationOptions.map((value) => (
<OptionPill key={value} selected={duration === value} theme={theme} onClick={() => onConfigChange("videoSeconds", String(value))}>
{value}s
</OptionPill>
))}
{profile.duration.selection === "range" ? <DurationInput value={duration} min={profile.duration.min || VIDEO_DURATION_MIN} max={profile.duration.max} theme={theme} onChange={(value) => onConfigChange("videoSeconds", String(value))} /> : null}
</div>
<VideoDurationControl profile={profile} value={duration} theme={theme} onChange={(value) => onConfigChange("videoSeconds", String(value))} />
</SettingGroup>
<SettingGroup title="输出" color={theme.node.muted}>
<div className="grid grid-cols-2 gap-3 rounded-md px-2" style={{ background: theme.toolbar.itemHover }}>
@@ -255,7 +235,7 @@ function DurationInput({ value, min, max, theme, onChange }: { value: number; mi
};
return (
<label className="flex h-8 min-w-0 items-center overflow-hidden rounded-md border text-[var(--fs-label)]" style={{ background: theme.toolbar.itemHover, borderColor: theme.toolbar.border, color: theme.node.text }}>
<label className="flex h-8 w-20 shrink-0 items-center overflow-hidden rounded-md border text-[var(--fs-label)]" style={{ background: theme.toolbar.itemHover, borderColor: theme.toolbar.border, color: theme.node.text }}>
<input
key={`${min}-${value}`}
type="number"
@@ -276,6 +256,56 @@ function DurationInput({ value, min, max, theme, onChange }: { value: number; mi
);
}
function VideoDurationControl({ profile, value, theme, onChange }: { profile: VideoCapabilityConfig; value: number; theme: CanvasTheme; onChange: (value: number) => void }) {
if (profile.duration.selection === "range") {
const min = profile.duration.min || VIDEO_DURATION_MIN;
const max = Math.max(min, profile.duration.max || min);
const step = Math.max(1, profile.duration.step || 1);
const normalized = normalizeDurationValue(value, profile.duration.default, min, max, step);
return <DurationRangeControl value={normalized} min={min} max={max} step={step} theme={theme} onChange={onChange} />;
}
const options = videoDurationOptions(profile);
return <div className="grid gap-1.5" style={{ gridTemplateColumns: `repeat(${Math.min(options.length, 4)}, minmax(0, 1fr))` }}>
{options.map((option) => <OptionPill key={option} selected={normalizedNumber(value) === option} theme={theme} onClick={() => onChange(option)}>{option}s</OptionPill>)}
</div>;
}
function DurationRangeControl({ value, min, max, step, theme, onChange }: { value: number; min: number; max: number; step: number; theme: CanvasTheme; onChange: (value: number) => void }) {
return <div className="space-y-1.5">
<div className="flex min-w-0 items-center gap-2">
<input
type="range"
min={min}
max={max}
step={step}
value={value}
aria-label="视频时长(秒)"
className="video-duration-range h-8 min-w-0 flex-1"
style={{ accentColor: theme.accent.primary }}
onChange={(event) => onChange(Number(event.target.value))}
onMouseDown={(event) => event.stopPropagation()}
/>
<DurationInput value={value} min={min} max={max} theme={theme} onChange={onChange} />
</div>
<div className="flex justify-between px-0.5 text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>
<span>{min}s</span>
<span>{max}s</span>
</div>
</div>;
}
function normalizeDurationValue(value: number, fallback: number, min: number, max: number, step: number) {
const candidate = Number.isFinite(value) ? value : fallback;
const clamped = Math.min(max, Math.max(min, Math.floor(candidate)));
const maxStep = Math.max(0, Math.floor((max - min) / step));
return min + Math.min(maxStep, Math.max(0, Math.round((clamped - min) / step))) * step;
}
function normalizedNumber(value: number) {
return Number.isFinite(value) ? Math.floor(value) : 0;
}
function SizePreview({ width, height, color }: { width: number; height: number; color: string }) {
if (!width || !height) return null;
const longSide = Math.max(width, height);