fix: polish psd editor and settings layout
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-04-02 15:09:20 +08:00
parent ab0dc8bce3
commit a90e28d115
3 changed files with 212 additions and 190 deletions

View File

@ -3139,7 +3139,7 @@ export function AgentWorkflowSettingsClient({
variants={containerVariants}
className="grid gap-6 lg:grid-cols-2"
>
<motion.div variants={itemVariants}>
<motion.div variants={itemVariants} className="lg:col-span-2">
<Card>
<CardHeader className="pb-4">
<div className="flex items-center gap-3">

View File

@ -21,7 +21,6 @@ import {
Dialog,
DialogContent,
} from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@ -107,6 +106,63 @@ const PREVIEW_STAGE_TARGET_HEIGHT = 760;
const MIN_IMAGE_SCALE = 0.4;
const MAX_IMAGE_SCALE = 3;
function getTemplateMetaLabel(
template: AgentPsdTemplate,
showSecondaryCategory: boolean
) {
const metaParts = [
`${template.width} x ${template.height}`,
showSecondaryCategory ? String(template.secondaryCategory || "").trim() : "",
].filter(Boolean);
return metaParts.join(" · ");
}
function renderTemplateSelectContent(
template: AgentPsdTemplate,
params?: {
showSecondaryCategory?: boolean;
compact?: boolean;
}
) {
const showSecondaryCategory = params?.showSecondaryCategory === true;
const compact = params?.compact === true;
const metaLabel = getTemplateMetaLabel(template, showSecondaryCategory);
return (
<div
className={
compact
? "grid min-w-0 flex-1 grid-cols-[48px_minmax(0,1fr)] items-center gap-3"
: "grid min-w-0 flex-1 grid-cols-[56px_minmax(0,1fr)] items-center gap-3"
}
>
<div
className={
compact
? "flex h-12 w-12 shrink-0 overflow-hidden rounded-xl border border-white/10 bg-white/5"
: "flex h-14 w-14 shrink-0 overflow-hidden rounded-xl border border-white/10 bg-white/5"
}
>
<img
src={template.previewUrl}
alt={template.name}
className="h-full w-full object-cover"
/>
</div>
<div className="min-w-0 text-left">
<div className={compact ? "truncate text-sm font-medium leading-tight" : "truncate text-base font-semibold leading-tight"}>
{template.name}
</div>
{metaLabel ? (
<div className={compact ? "mt-1 truncate text-[11px] text-white/45" : "mt-1 truncate text-xs text-white/45"}>
{metaLabel}
</div>
) : null}
</div>
</div>
);
}
function clampImageScale(value: number) {
if (!Number.isFinite(value)) {
return 1;
@ -507,10 +563,6 @@ export function AgentPsdTemplateEditorDialog({
),
[selectedTemplateLayers]
);
const fixedLayerCount = useMemo(() => {
if (!selectedTemplate) return 0;
return selectedTemplateLayers.filter((layer) => !layer.isEditable).length;
}, [selectedTemplate, selectedTemplateLayers]);
const orderedPreviewLayers = useMemo(
() => [...selectedTemplateLayers].reverse(),
[selectedTemplateLayers]
@ -617,6 +669,81 @@ export function AgentPsdTemplateEditorDialog({
const selectedTextColor = selectedTextLayer
? effectiveTextColorAssignments[selectedTextLayer.id] || selectedTextLayer.textColor
: "#111111";
const selectedImageAdjustmentPanel =
selectedImageLayer && effectiveImageAssignments[selectedImageLayer.id] ? (
<div className="space-y-3 rounded-2xl border border-white/10 bg-white/[0.03] p-3.5">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Move className="h-3.5 w-3.5" />
选中画布中的图片后可直接拖动位置
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<ZoomIn className="h-3.5 w-3.5" />
缩放
</div>
<span className="rounded-full border border-emerald-300/35 bg-emerald-500/15 px-2 py-0.5 font-medium text-emerald-100">
{Math.round(selectedImagePlacement.scale * 100)}%
</span>
</div>
<div className="rounded-xl border border-white/12 bg-black/45 p-2.5">
<div className="grid grid-cols-[34px_minmax(0,1fr)_34px] items-center gap-2">
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-full border border-white/18 bg-white/[0.04] text-white transition-colors hover:bg-white/[0.12]"
onClick={() => nudgeImageScale(selectedImageLayer.id, -0.05)}
aria-label="缩小图片"
title="缩小"
>
<ZoomOut className="h-4 w-4" />
</button>
<Slider
min={MIN_IMAGE_SCALE}
max={MAX_IMAGE_SCALE}
step={0.01}
value={[selectedImagePlacement.scale]}
className="py-1"
trackClassName="h-2.5 rounded-full border border-white/20 bg-[#0f1014]"
rangeClassName="bg-gradient-to-r from-emerald-300 via-green-400 to-teal-400 shadow-[0_0_12px_rgba(16,185,129,0.38)]"
thumbClassName="h-5 w-5 border-2 border-emerald-100 bg-[#0a0a0d] shadow-[0_0_0_5px_rgba(16,185,129,0.2)]"
onValueChange={(value) => {
const newScale = clampImageScale(value[0] || 1);
updateImagePlacement(selectedImageLayer.id, (current) => ({
...current,
scale: newScale,
}));
}}
/>
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-full border border-white/18 bg-white/[0.04] text-white transition-colors hover:bg-white/[0.12]"
onClick={() => nudgeImageScale(selectedImageLayer.id, 0.05)}
aria-label="放大图片"
title="放大"
>
<ZoomIn className="h-4 w-4" />
</button>
</div>
<div className="mt-2 flex items-center justify-between px-0.5 text-[10px] text-white/45">
<span>40%</span>
<span className="text-emerald-200">100%</span>
<span>300%</span>
</div>
</div>
</div>
<div className="flex justify-end">
<Button
type="button"
variant="ghost"
size="sm"
className="px-2 text-white/70 hover:text-white"
onClick={() => resetImagePlacement(selectedImageLayer.id)}
>
重置位置与缩放
</Button>
</div>
</div>
) : null;
const fontFaceCss = useMemo(() => buildPreviewFontFaces(selectedTemplate), [selectedTemplate]);
const selectedTemplateSelectValue = selectedTemplate?.id;
const hasTemplateOptions = templates.length > 0;
@ -1027,28 +1154,13 @@ export function AgentPsdTemplateEditorDialog({
<Label>选择模板</Label>
{hasTemplateOptions ? (
<Select value={selectedTemplateSelectValue} onValueChange={setSelectedTemplateId}>
<SelectTrigger className="h-[68px] items-center">
<SelectTrigger className="h-auto min-h-[84px] items-center px-3 py-3 [&>span]:contents">
{selectedTemplate ? (
<span className="flex min-w-0 items-center gap-3">
<span className="flex h-12 w-12 shrink-0 overflow-hidden rounded-xl border border-white/10 bg-white/5">
<img
src={selectedTemplate.previewUrl}
alt={selectedTemplate.name}
className="h-full w-full object-cover"
/>
</span>
<span className="flex min-w-0 flex-col justify-center text-left leading-tight">
{shouldGroupTemplatesBySecondaryCategory &&
selectedTemplate.secondaryCategory ? (
<span className="block truncate text-[11px] text-white/50">
{selectedTemplate.secondaryCategory}
</span>
) : null}
<span className="block truncate text-base font-medium">
{selectedTemplate.name}
</span>
</span>
</span>
<div className="min-w-0 flex-1 pr-3">
{renderTemplateSelectContent(selectedTemplate, {
showSecondaryCategory: shouldGroupTemplatesBySecondaryCategory,
})}
</div>
) : (
<SelectValue placeholder={isLoading ? "加载模板中..." : "请选择模板"} />
)}
@ -1064,18 +1176,10 @@ export function AgentPsdTemplateEditorDialog({
value={template.id}
className="py-2.5 pr-3"
>
<span className="flex items-center gap-3">
<span className="flex h-12 w-12 shrink-0 overflow-hidden rounded-lg border border-black/10 bg-black/5">
<img
src={template.previewUrl}
alt={template.name}
className="h-full w-full object-cover"
/>
</span>
<span className="min-w-0 truncate text-sm font-medium">
{template.name}
</span>
</span>
{renderTemplateSelectContent(template, {
showSecondaryCategory: shouldGroupTemplatesBySecondaryCategory,
compact: true,
})}
</SelectItem>
))}
</SelectGroup>
@ -1086,18 +1190,7 @@ export function AgentPsdTemplateEditorDialog({
value={template.id}
className="py-2.5 pr-3"
>
<span className="flex items-center gap-3">
<span className="flex h-12 w-12 shrink-0 overflow-hidden rounded-lg border border-black/10 bg-black/5">
<img
src={template.previewUrl}
alt={template.name}
className="h-full w-full object-cover"
/>
</span>
<span className="min-w-0 truncate text-sm font-medium">
{template.name}
</span>
</span>
{renderTemplateSelectContent(template, { compact: true })}
</SelectItem>
))}
</SelectContent>
@ -1129,18 +1222,6 @@ export function AgentPsdTemplateEditorDialog({
</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>
<Button
onClick={handleRender}
disabled={isRendering}
@ -1153,6 +1234,8 @@ export function AgentPsdTemplateEditorDialog({
)}
应用模板并导出
</Button>
{selectedImageAdjustmentPanel}
</>
) : (
<div className="rounded-2xl border border-dashed border-white/15 p-5 text-sm text-muted-foreground">
@ -1606,144 +1689,78 @@ export function AgentPsdTemplateEditorDialog({
<div className="flex min-h-0 flex-1 flex-col rounded-2xl border border-white/10 bg-white/[0.02] p-3.5 xl:p-4">
{selectedImageLayer ? (
<div className="flex h-full flex-col gap-3">
{effectiveImageAssignments[selectedImageLayer.id] ? (
<div className="space-y-3 rounded-xl border border-white/10 bg-white/[0.03] p-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Move className="h-3.5 w-3.5" />
选中画布中的图片后可直接拖动位置
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<ZoomIn className="h-3.5 w-3.5" />
缩放
</div>
<span className="rounded-full border border-emerald-300/35 bg-emerald-500/15 px-2 py-0.5 font-medium text-emerald-100">
{Math.round(selectedImagePlacement.scale * 100)}%
</span>
</div>
<div className="rounded-xl border border-white/12 bg-black/45 p-2.5">
<div className="grid grid-cols-[34px_minmax(0,1fr)_34px] items-center gap-2">
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-full border border-white/18 bg-white/[0.04] text-white transition-colors hover:bg-white/[0.12]"
onClick={() => nudgeImageScale(selectedImageLayer.id, -0.05)}
aria-label="缩小图片"
title="缩小"
>
<ZoomOut className="h-4 w-4" />
</button>
<Slider
min={MIN_IMAGE_SCALE}
max={MAX_IMAGE_SCALE}
step={0.01}
value={[selectedImagePlacement.scale]}
className="py-1"
trackClassName="h-2.5 rounded-full border border-white/20 bg-[#0f1014]"
rangeClassName="bg-gradient-to-r from-emerald-300 via-green-400 to-teal-400 shadow-[0_0_12px_rgba(16,185,129,0.38)]"
thumbClassName="h-5 w-5 border-2 border-emerald-100 bg-[#0a0a0d] shadow-[0_0_0_5px_rgba(16,185,129,0.2)]"
onValueChange={(value) => {
const newScale = clampImageScale(value[0] || 1);
updateImagePlacement(selectedImageLayer.id, (current) => ({
...current,
scale: newScale,
}));
}}
/>
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-full border border-white/18 bg-white/[0.04] text-white transition-colors hover:bg-white/[0.12]"
onClick={() => nudgeImageScale(selectedImageLayer.id, 0.05)}
aria-label="放大图片"
title="放大"
>
<ZoomIn className="h-4 w-4" />
</button>
</div>
<div className="mt-2 flex items-center justify-between px-0.5 text-[10px] text-white/45">
<span>40%</span>
<span className="text-emerald-200">100%</span>
<span>300%</span>
</div>
</div>
</div>
<div className="flex flex-wrap gap-2">
<div className="rounded-xl border border-white/10 bg-white/[0.02] p-3">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{sourceImageUrl ? (
<Button
type="button"
variant="ghost"
variant="outline"
size="sm"
onClick={() => resetImagePlacement(selectedImageLayer.id)}
className="h-10 justify-center"
onClick={() => {
setImageAssignments((current) => ({
...current,
[selectedImageLayer.id]: sourceImageUrl,
}));
setImagePlacements((current) => ({
...current,
[selectedImageLayer.id]: getDefaultAgentPsdImagePlacement(),
}));
setImageSizes((current) => {
const next = { ...current };
delete next[selectedImageLayer.id];
return next;
});
}}
>
重置位置与缩放
使用当前图片
</Button>
</div>
</div>
) : null}
<div className="mt-auto flex flex-wrap gap-2">
{sourceImageUrl ? (
) : null}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setImageAssignments((current) => ({
...current,
[selectedImageLayer.id]: sourceImageUrl,
}));
setImagePlacements((current) => ({
...current,
[selectedImageLayer.id]: getDefaultAgentPsdImagePlacement(),
}));
setImageSizes((current) => {
const next = { ...current };
delete next[selectedImageLayer.id];
return next;
});
}}
className="h-10 justify-center"
disabled={uploadingSlotId === selectedImageLayer.id}
onClick={() => openImageUpload(selectedImageLayer.id)}
>
使用当前图片
{uploadingSlotId === selectedImageLayer.id ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
上传替换图片
</Button>
) : null}
<Button
type="button"
variant="outline"
size="sm"
disabled={uploadingSlotId === selectedImageLayer.id}
onClick={() => openImageUpload(selectedImageLayer.id)}
>
{uploadingSlotId === selectedImageLayer.id ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
上传替换图片
</Button>
{effectiveImageAssignments[selectedImageLayer.id] ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setImageAssignments((current) => {
const nextAssignments = { ...current };
delete nextAssignments[selectedImageLayer.id];
return nextAssignments;
});
setImagePlacements((current) => {
const next = { ...current };
delete next[selectedImageLayer.id];
return next;
});
setImageSizes((current) => {
const next = { ...current };
delete next[selectedImageLayer.id];
return next;
});
}}
>
清空
</Button>
) : null}
</div>
<div className="mt-2 flex items-center justify-end gap-2">
{effectiveImageAssignments[selectedImageLayer.id] ? (
<Button
type="button"
variant="ghost"
size="sm"
className="px-2 text-white/70 hover:text-white"
onClick={() => {
setImageAssignments((current) => {
const nextAssignments = { ...current };
delete nextAssignments[selectedImageLayer.id];
return nextAssignments;
});
setImagePlacements((current) => {
const next = { ...current };
delete next[selectedImageLayer.id];
return next;
});
setImageSizes((current) => {
const next = { ...current };
delete next[selectedImageLayer.id];
return next;
});
}}
>
清空当前替换图
</Button>
) : null}
</div>
</div>
</div>
) : selectedTextLayer ? (

View File

@ -54,6 +54,8 @@ export function SearchableSelect({
className={cn(
"h-10 w-full justify-between px-3 text-left font-normal",
disabled && "cursor-not-allowed opacity-60",
!isDark &&
"border-slate-200 bg-white text-slate-900 shadow-sm hover:bg-slate-50 hover:text-slate-900",
isDark &&
"h-12 rounded-2xl border-white/14 bg-white/[0.05] text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.04)] hover:bg-white/[0.08] hover:text-white"
)}
@ -82,6 +84,7 @@ export function SearchableSelect({
align="start"
className={cn(
"w-[var(--radix-popover-trigger-width)] p-3",
!isDark && "border-slate-200 bg-white text-slate-900 shadow-xl",
isDark && "border-white/12 bg-[#0c0f11]/98 text-white backdrop-blur-xl"
)}
>
@ -93,6 +96,8 @@ export function SearchableSelect({
placeholder={searchPlaceholder}
disabled={disabled}
className={cn(
!isDark &&
"border-slate-200 bg-white text-slate-900 placeholder:text-slate-400 focus-visible:ring-slate-300/80 focus-visible:ring-offset-0",
isDark &&
"border-white/12 bg-white/[0.04] text-white placeholder:text-white/28 focus-visible:ring-emerald-400/30 focus-visible:ring-offset-0"
)}