Files
ttstd-infinite-canvas/web/src/components/canvas/style-profile-editor-modal.tsx

234 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { App, Button, Input, Segmented, Select } from "antd";
import { AppModal } from "@/components/ui/product/app-modal";
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 (
<>
<AppModal
rootClassName="style-profile-editor-modal"
open={open}
title={null}
footer={null}
centered
width="min(1120px, calc(100vw - 24px))"
onCancel={requestClose}
flush
>
<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>
</AppModal>
<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>
);
}