feat: clarify editable PSD slots in agent dialog
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-03-27 21:36:50 +08:00
parent e562e48c13
commit 510b0c13a6

View File

@ -88,6 +88,25 @@ export function AgentPsdTemplateEditorDialog({
[selectedTemplateId, templates]
);
const editableImageLayers = useMemo(
() =>
(selectedTemplate?.layers || []).filter((layer) =>
isAgentPsdImageLayerSlot(layer)
),
[selectedTemplate]
);
const editableTextLayers = useMemo(
() =>
(selectedTemplate?.layers || []).filter((layer) =>
isAgentPsdTextLayerSlot(layer)
),
[selectedTemplate]
);
const fixedLayerCount = useMemo(() => {
if (!selectedTemplate) return 0;
return Math.max(0, selectedTemplate.layerCount - selectedTemplate.layers.length);
}, [selectedTemplate]);
useEffect(() => {
if (!open) return;
@ -123,7 +142,7 @@ export function AgentPsdTemplateEditorDialog({
...current,
[slotId]: payload.url,
}));
toast.success("素材已上传");
toast.success("替换图片已上传");
} catch (error) {
toast.error(error instanceof Error ? error.message : "素材上传失败");
} finally {
@ -159,7 +178,7 @@ export function AgentPsdTemplateEditorDialog({
}
setRenderedResultUrl(payload.result.url);
toast.success("套版结果已生成");
toast.success("模板替换结果已生成");
} catch (error) {
toast.error(error instanceof Error ? error.message : "PSD 模板渲染失败");
} finally {
@ -176,7 +195,7 @@ export function AgentPsdTemplateEditorDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
overlayClassName="bg-black/85 backdrop-blur-sm"
className="max-h-[90vh] max-w-6xl overflow-hidden border border-white/10 bg-[#09090b] p-0 text-white shadow-[0_30px_120px_rgba(0,0,0,0.65)]"
className="flex h-[92vh] w-[96vw] max-w-[1600px] flex-col overflow-hidden border border-white/10 bg-[#09090b] p-0 text-white shadow-[0_30px_120px_rgba(0,0,0,0.65)]"
>
<DialogHeader className="border-b border-white/10 bg-[#09090b] px-6 py-5">
<DialogTitle className="flex items-center gap-2">
@ -184,266 +203,300 @@ export function AgentPsdTemplateEditorDialog({
套用 PSD 模板
</DialogTitle>
<DialogDescription>
当前结果图会自动填入默认图片槽位,文字槽位可直接输入,其他图片槽位可继续上传素材替换。
这里是直接把当前图片和输入文字替换进 PSD 模板,不会再次调用 AI 生图模型。
</DialogDescription>
</DialogHeader>
<div className="grid min-h-0 gap-0 lg:grid-cols-[360px,minmax(0,1fr),minmax(0,1fr)]">
<div className="border-r border-white/10 bg-[#09090b] p-5">
<div className="space-y-4">
<div className="space-y-2">
<Label>选择模板</Label>
<Select value={selectedTemplateId} onValueChange={setSelectedTemplateId}>
<SelectTrigger>
<SelectValue placeholder={isLoading ? "加载模板中..." : "请选择模板"} />
</SelectTrigger>
<SelectContent>
{templates.map((template) => (
<SelectItem key={template.id} value={template.id}>
{template.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedTemplate ? (
<>
<div className="mx-auto w-full max-w-[240px] overflow-hidden rounded-2xl border bg-muted/20">
<div
style={{
aspectRatio: `${Math.max(1, selectedTemplate.width)} / ${Math.max(1, selectedTemplate.height)}`,
}}
>
<img
src={selectedTemplate.previewUrl}
alt={selectedTemplate.name}
className="h-full w-full object-contain"
/>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary">{selectedTemplate.brandName}</Badge>
<Badge variant="secondary">
{selectedTemplate.width} x {selectedTemplate.height}
</Badge>
<Badge variant="outline">
{
selectedTemplate.layers.filter((layer) => layer.slotType === "image").length
}{" "}
个图片槽位
</Badge>
<Badge variant="outline">
{
selectedTemplate.layers.filter((layer) => layer.slotType === "text").length
}{" "}
个文字槽位
</Badge>
</div>
{selectedTemplate.description ? (
<div className="rounded-xl border p-3 text-sm text-muted-foreground">
{selectedTemplate.description}
</div>
) : null}
<Button onClick={handleRender} disabled={isRendering} className="w-full">
{isRendering ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
生成套版结果
</Button>
</>
) : (
<div className="rounded-2xl border border-dashed p-5 text-sm text-muted-foreground">
{isLoading
? "正在加载可用模板..."
: normalizedBrandName
? `当前品牌“${normalizedBrandName}”下还没有可用 PSD 模板,请先去后台配置。`
: "当前还没有启用的 PSD 模板,请先去后台配置。"}
</div>
)}
</div>
</div>
<div className="border-r border-white/10 bg-[#09090b] p-5">
<div className="mb-4">
<div className="text-sm font-medium">槽位内容</div>
<div className="mt-1 text-xs text-muted-foreground">
图片槽位支持上传和套用当前结果图,文字槽位直接输入即可。
</div>
</div>
<ScrollArea className="h-[65vh] pr-4">
<div className="grid min-h-0 flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[340px_minmax(0,1fr)] xl:grid-cols-[340px_minmax(0,1fr)_360px]">
<div className="min-h-0 border-b border-white/10 bg-[#09090b] p-5 lg:border-b-0 lg:border-r">
<ScrollArea className="h-full pr-4">
<div className="space-y-4">
{selectedTemplate?.layers.length ? (
selectedTemplate.layers.map((layer) => {
if (isAgentPsdImageLayerSlot(layer)) {
const assignedUrl = imageAssignments[layer.id] || "";
const slotUploadInputId = `psd-slot-upload-${layer.id}`;
<div className="space-y-2">
<Label>选择模板</Label>
<Select value={selectedTemplateId} onValueChange={setSelectedTemplateId}>
<SelectTrigger>
<SelectValue placeholder={isLoading ? "加载模板中..." : "请选择模板"} />
</SelectTrigger>
<SelectContent>
{templates.map((template) => (
<SelectItem key={template.id} value={template.id}>
{template.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
return (
<div key={layer.id} className="rounded-2xl border p-4">
<div className="flex flex-wrap items-center gap-2">
<div className="text-sm font-medium">{layer.layerName}</div>
<Badge variant="outline">图片槽位</Badge>
{layer.isRequired ? <Badge variant="destructive">必传</Badge> : null}
{layer.isDefaultResultSource ? (
<Badge variant="secondary">默认结果图</Badge>
) : null}
<Badge variant="outline">
{layer.width} x {layer.height}
</Badge>
</div>
<div className="mt-1 text-xs text-muted-foreground">{layer.layerPath}</div>
{assignedUrl ? (
<div className="mt-3 overflow-hidden rounded-xl border bg-muted/20">
<div className="aspect-[4/3]">
<img
src={assignedUrl}
alt={layer.layerName}
className="h-full w-full object-contain"
/>
</div>
</div>
) : (
<div className="mt-3 flex aspect-[4/3] items-center justify-center rounded-xl border border-dashed bg-muted/10 text-xs text-muted-foreground">
暂未分配素材
</div>
)}
<div className="mt-3 flex flex-wrap gap-2">
{sourceImageUrl ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
setImageAssignments((current) => ({
...current,
[layer.id]: sourceImageUrl,
}))
}
>
使用当前结果图
</Button>
) : null}
{layer.allowUserUploadOverride ? (
<>
<Input
id={slotUploadInputId}
className="hidden"
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={(event) => handleSlotUpload(layer.id, event)}
/>
<Label htmlFor={slotUploadInputId} className="inline-flex">
<Button
type="button"
variant="outline"
size="sm"
disabled={uploadingSlotId === layer.id}
onClick={(event) => {
if (uploadingSlotId === layer.id) {
event.preventDefault();
}
}}
>
{uploadingSlotId === layer.id ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
上传替换图
</Button>
</Label>
</>
) : null}
{assignedUrl ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() =>
setImageAssignments((current) => {
const nextAssignments = { ...current };
delete nextAssignments[layer.id];
return nextAssignments;
})
}
>
清空
</Button>
) : null}
</div>
</div>
);
}
const textValue = textAssignments[layer.id] || "";
return (
<div key={layer.id} className="rounded-2xl border p-4">
<div className="flex flex-wrap items-center gap-2">
<div className="text-sm font-medium">{layer.layerName}</div>
<Badge variant="default">文字槽位</Badge>
{layer.isRequired ? <Badge variant="destructive">必填</Badge> : null}
<Badge variant="outline">
{layer.width} x {layer.height}
</Badge>
{layer.fontLabel || layer.fontFamily ? (
<Badge variant="secondary">{layer.fontLabel || layer.fontFamily}</Badge>
) : null}
</div>
<div className="mt-1 text-xs text-muted-foreground">{layer.layerPath}</div>
<div className="mt-3 grid gap-3">
<Textarea
rows={4}
value={textValue}
placeholder={layer.placeholder || "请输入文字"}
onChange={(event) =>
setTextAssignments((current) => ({
...current,
[layer.id]: event.target.value,
}))
}
/>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span>字体:{layer.fontLabel || layer.fontFamily || "系统字体"}</span>
<span>字号:{layer.fontSize}px</span>
<span>行高:{layer.lineHeight}</span>
<span>对齐:{layer.textAlign}</span>
</div>
</div>
{selectedTemplate ? (
<>
<div className="mx-auto w-full max-w-[240px] overflow-hidden rounded-2xl border border-white/10 bg-white/5">
<div
style={{
aspectRatio: `${Math.max(1, selectedTemplate.width)} / ${Math.max(1, selectedTemplate.height)}`,
}}
>
<img
src={selectedTemplate.previewUrl}
alt={selectedTemplate.name}
className="h-full w-full object-contain"
/>
</div>
);
})
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary">{selectedTemplate.brandName}</Badge>
<Badge variant="secondary">
{selectedTemplate.width} x {selectedTemplate.height}
</Badge>
<Badge variant="outline">{editableImageLayers.length} 个可替换图片</Badge>
<Badge variant="outline">{editableTextLayers.length} 个可编辑文字</Badge>
{fixedLayerCount > 0 ? (
<Badge variant="outline">{fixedLayerCount} 个固定图层</Badge>
) : null}
</div>
{selectedTemplate.description ? (
<div className="rounded-xl border border-white/10 bg-white/[0.03] p-3 text-sm text-muted-foreground">
{selectedTemplate.description}
</div>
) : null}
<div className="rounded-xl border border-dashed border-white/15 bg-white/[0.02] p-3 text-xs leading-6 text-muted-foreground">
用户侧这里只会显示后台已经开放的槽位:
图片图层显示为“可替换图片”,文字图层显示为“可编辑文字”,其余固定图层不会出现在这里。
</div>
<Button
onClick={handleRender}
disabled={isRendering}
className="w-full bg-gradient-to-r from-orange-600 to-rose-600 hover:from-orange-500 hover:to-rose-500"
>
{isRendering ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
应用模板并导出
</Button>
</>
) : (
<div className="rounded-2xl border border-dashed p-5 text-sm text-muted-foreground">
{normalizedBrandName ? "暂无可编辑槽位。" : "请先在工作流中选择品牌后再使用 PSD 模板。"}
<div className="rounded-2xl border border-dashed border-white/15 p-5 text-sm text-muted-foreground">
{isLoading
? "正在加载可用模板..."
: normalizedBrandName
? `当前品牌“${normalizedBrandName}”下还没有可用 PSD 模板,请先去后台配置。`
: "当前还没有启用的 PSD 模板,请先去后台配置。"}
</div>
)}
</div>
</ScrollArea>
</div>
<div className="bg-[#09090b] p-5">
<div className="min-h-0 border-b border-white/10 bg-[#09090b] p-5 xl:border-b-0 xl:border-r">
<div className="mb-4 flex flex-wrap items-center gap-2">
<div className="text-sm font-medium">槽位内容</div>
{selectedTemplate ? (
<>
<Badge variant="outline">{editableImageLayers.length} 个可替换图片</Badge>
<Badge variant="outline">{editableTextLayers.length} 个可编辑文字</Badge>
</>
) : null}
</div>
<div className="mb-4 text-xs text-muted-foreground">
图片槽位直接替换图片,文字槽位直接修改文案,不会再次调用 AI 生图。
</div>
<ScrollArea className="h-full min-h-0 pr-4">
{selectedTemplate ? (
<div className="space-y-6">
<section className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-medium">可替换图片</div>
<Badge variant="secondary">{editableImageLayers.length}</Badge>
</div>
{editableImageLayers.length > 0 ? (
editableImageLayers.map((layer) => {
const assignedUrl = imageAssignments[layer.id] || "";
const slotUploadInputId = `psd-slot-upload-${layer.id}`;
return (
<div key={layer.id} className="rounded-2xl border border-white/10 bg-white/[0.02] p-4">
<div className="flex flex-wrap items-center gap-2">
<div className="text-sm font-medium">{layer.layerName}</div>
<Badge variant="default">可替换图片</Badge>
{layer.isRequired ? <Badge variant="destructive">必传</Badge> : null}
{layer.isDefaultResultSource ? (
<Badge variant="secondary">默认使用当前图片</Badge>
) : null}
<Badge variant="outline">
{layer.width} x {layer.height}
</Badge>
</div>
<div className="mt-1 text-xs text-muted-foreground">{layer.layerPath}</div>
{assignedUrl ? (
<div className="mt-3 overflow-hidden rounded-xl border border-white/10 bg-white/5">
<div className="aspect-[4/3]">
<img
src={assignedUrl}
alt={layer.layerName}
className="h-full w-full object-contain"
/>
</div>
</div>
) : (
<div className="mt-3 flex aspect-[4/3] items-center justify-center rounded-xl border border-dashed border-white/15 bg-white/[0.02] text-xs text-muted-foreground">
暂未替换图片
</div>
)}
<div className="mt-3 flex flex-wrap gap-2">
{sourceImageUrl ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
setImageAssignments((current) => ({
...current,
[layer.id]: sourceImageUrl,
}))
}
>
使用当前图片
</Button>
) : null}
{layer.allowUserUploadOverride ? (
<>
<Input
id={slotUploadInputId}
className="hidden"
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={(event) => handleSlotUpload(layer.id, event)}
/>
<Label htmlFor={slotUploadInputId} className="inline-flex">
<Button
type="button"
variant="outline"
size="sm"
disabled={uploadingSlotId === layer.id}
onClick={(event) => {
if (uploadingSlotId === layer.id) {
event.preventDefault();
}
}}
>
{uploadingSlotId === layer.id ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
上传替换图片
</Button>
</Label>
</>
) : null}
{assignedUrl ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() =>
setImageAssignments((current) => {
const nextAssignments = { ...current };
delete nextAssignments[layer.id];
return nextAssignments;
})
}
>
清空
</Button>
) : null}
</div>
</div>
);
})
) : (
<div className="rounded-2xl border border-dashed border-white/15 p-5 text-sm text-muted-foreground">
这个模板当前没有开放可替换图片槽位。
</div>
)}
</section>
<section className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-medium">可编辑文字</div>
<Badge variant="secondary">{editableTextLayers.length}</Badge>
</div>
{editableTextLayers.length > 0 ? (
editableTextLayers.map((layer) => {
const textValue = textAssignments[layer.id] || "";
return (
<div key={layer.id} className="rounded-2xl border border-white/10 bg-white/[0.02] p-4">
<div className="flex flex-wrap items-center gap-2">
<div className="text-sm font-medium">{layer.layerName}</div>
<Badge variant="default">可编辑文字</Badge>
{layer.isRequired ? <Badge variant="destructive">必填</Badge> : null}
<Badge variant="outline">
{layer.width} x {layer.height}
</Badge>
{layer.fontLabel || layer.fontFamily ? (
<Badge variant="secondary">{layer.fontLabel || layer.fontFamily}</Badge>
) : null}
</div>
<div className="mt-1 text-xs text-muted-foreground">{layer.layerPath}</div>
<div className="mt-3 grid gap-3">
<Textarea
rows={4}
value={textValue}
placeholder={layer.placeholder || "请输入文字"}
onChange={(event) =>
setTextAssignments((current) => ({
...current,
[layer.id]: event.target.value,
}))
}
/>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span>字体:{layer.fontLabel || layer.fontFamily || "系统字体"}</span>
<span>字号:{layer.fontSize}px</span>
<span>行高:{layer.lineHeight}</span>
<span>对齐:{layer.textAlign}</span>
</div>
</div>
</div>
);
})
) : (
<div className="rounded-2xl border border-dashed border-white/15 p-5 text-sm text-muted-foreground">
这个模板当前没有开放可编辑文字槽位。
</div>
)}
</section>
</div>
) : (
<div className="rounded-2xl border border-dashed border-white/15 p-5 text-sm text-muted-foreground">
{normalizedBrandName ? "请先选择一个模板。" : "请先在工作流中选择品牌后再使用 PSD 模板。"}
</div>
)}
</ScrollArea>
</div>
<div className="min-h-0 bg-[#09090b] p-5">
<div className="mb-4">
<div className="text-sm font-medium">套版结果</div>
<div className="text-sm font-medium">导出结果</div>
<div className="mt-1 text-xs text-muted-foreground">
生成后可直接预览并下载结果图。
直接完成模板替换后,这里会显示最终导出的成品图。
</div>
</div>
{renderedResultUrl ? (
<div className="space-y-4">
<div className="mx-auto w-full max-w-[280px] overflow-hidden rounded-2xl border bg-muted/20">
<div className="flex h-full min-h-0 flex-col justify-between gap-4">
<div className="mx-auto w-full max-w-[280px] overflow-hidden rounded-2xl border border-white/10 bg-white/5">
<div
style={{
aspectRatio: selectedTemplate
@ -453,7 +506,7 @@ export function AgentPsdTemplateEditorDialog({
>
<img
src={renderedResultUrl}
alt="PSD 模板渲染结果"
alt="PSD 模板导出结果"
className="h-full w-full object-contain"
/>
</div>
@ -465,18 +518,18 @@ export function AgentPsdTemplateEditorDialog({
</Button>
</div>
) : (
<div className="flex h-[65vh] flex-col items-center justify-center rounded-2xl border border-dashed bg-muted/10 px-6 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-muted">
{selectedTemplate?.layers.some((layer) => layer.slotType === "text") ? (
<div className="flex h-full min-h-0 flex-col items-center justify-center rounded-2xl border border-dashed border-white/15 bg-white/[0.02] px-6 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-white/5">
{editableTextLayers.length > 0 ? (
<Type className="h-8 w-8 text-muted-foreground" />
) : (
<ImageIcon className="h-8 w-8 text-muted-foreground" />
)}
</div>
<div className="mt-4 text-sm font-medium">结果还没生成</div>
<div className="mt-4 text-sm font-medium">还未导出成品图</div>
<div className="mt-2 text-xs leading-6 text-muted-foreground">
{normalizedBrandName
? "选好模板和槽位内容后点击“生成套版结果”,这里会显示导出的图片。"
? "确认替换图片和文字后,点击“应用模板并导出”,这里会显示最终图片。"
: "请先返回工作流区域选择品牌,再打开 PSD 模板。"}
</div>
</div>