feat: enhance psd template editor workflow
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 09:35:34 +08:00
parent e7cc0c9555
commit 305dd21dd5
10 changed files with 388 additions and 57 deletions

View File

@ -67,6 +67,11 @@ import {
defaultAgentPsdTextColorConfig,
normalizeAgentPsdTextColorConfig,
} from "@/lib/agents/psd-text-color-config";
import {
defaultAgentPsdTemplateSecondaryCategoryConfig,
type AgentPsdTemplateSecondaryCategoryConfig,
normalizeAgentPsdTemplateSecondaryCategoryConfig,
} from "@/lib/agents/psd-template-secondary-category-config";
import { normalizeHexColor } from "@/lib/agents/psd-text-layout";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import { Button } from "@/components/ui/button";
@ -474,6 +479,7 @@ export function AgentWorkflowSettingsClient({
agentWorkflowBrandModelConfig?: AgentWorkflowBrandModelConfig;
agentWorkflowShowcaseConfig?: AgentWorkflowShowcaseConfig;
agentPsdTextColorConfig?: AgentPsdTextColorConfig;
agentPsdTemplateSecondaryCategoryConfig?: AgentPsdTemplateSecondaryCategoryConfig;
}>("/api/site-settings", fetcher);
const [presetLibraryConfig, setPresetLibraryConfig] = useState<AgentPresetLibraryConfig>(
defaultAgentPresetLibraryConfig
@ -491,6 +497,10 @@ export function AgentWorkflowSettingsClient({
const [psdTextColorConfig, setPsdTextColorConfig] = useState<AgentPsdTextColorConfig>(
defaultAgentPsdTextColorConfig
);
const [psdTemplateSecondaryCategoryConfig, setPsdTemplateSecondaryCategoryConfig] =
useState<AgentPsdTemplateSecondaryCategoryConfig>(
defaultAgentPsdTemplateSecondaryCategoryConfig
);
const [brandModelConfig, setBrandModelConfig] = useState<AgentWorkflowBrandModelConfig>(
defaultAgentWorkflowBrandModelConfig
);
@ -536,6 +546,11 @@ export function AgentWorkflowSettingsClient({
setPsdTextColorConfig(
normalizeAgentPsdTextColorConfig(data.agentPsdTextColorConfig)
);
setPsdTemplateSecondaryCategoryConfig(
normalizeAgentPsdTemplateSecondaryCategoryConfig(
data.agentPsdTemplateSecondaryCategoryConfig
)
);
setBrandModelConfig(
normalizeAgentWorkflowBrandModelConfig(data.agentWorkflowBrandModelConfig)
);
@ -1062,6 +1077,7 @@ export function AgentWorkflowSettingsClient({
agentWorkflowBrandModelConfig: brandModelConfig,
agentWorkflowShowcaseConfig: showcaseConfig,
agentPsdTextColorConfig: psdTextColorConfig,
agentPsdTemplateSecondaryCategoryConfig: psdTemplateSecondaryCategoryConfig,
}),
});
@ -2646,6 +2662,38 @@ export function AgentWorkflowSettingsClient({
</motion.div>
<motion.div variants={itemVariants} className="lg:col-span-2">
<Card className="mb-6">
<CardHeader className="pb-4">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-500/10">
<Layers className="h-4 w-4 text-emerald-600" />
</div>
<div>
<CardTitle className="text-base">PSD 模板二级分类</CardTitle>
<CardDescription>
控制前台“选择模板”下拉是否按二级分类分组展示。
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-3">
<div>
<div className="text-sm font-medium">启用 PSD 模板二级分类</div>
<div className="text-xs text-muted-foreground">
开启后,用户前台会按模板配置里的二级分类分组显示;未填写分类的模板会归入“未分组”。
</div>
</div>
<Switch
checked={psdTemplateSecondaryCategoryConfig.enabled}
onCheckedChange={(checked) =>
setPsdTemplateSecondaryCategoryConfig({ enabled: checked })
}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-4">
<div className="flex items-center gap-3">

View File

@ -163,6 +163,7 @@ export function PsdTemplatesSettingsClient({
const [layerKeyword, setLayerKeyword] = useState("");
const [uploadName, setUploadName] = useState("");
const [uploadBrandName, setUploadBrandName] = useState("");
const [uploadSecondaryCategory, setUploadSecondaryCategory] = useState("");
const [uploadDescription, setUploadDescription] = useState("");
const [uploadFile, setUploadFile] = useState<File | null>(null);
const [fontUploadName, setFontUploadName] = useState("");
@ -305,6 +306,7 @@ export function PsdTemplatesSettingsClient({
const formData = new FormData();
formData.set("name", uploadName.trim() || uploadFile.name.replace(/\.[^.]+$/, ""));
formData.set("brandName", uploadBrandName.trim());
formData.set("secondaryCategory", uploadSecondaryCategory.trim());
formData.set("description", uploadDescription.trim());
formData.set("status", "disabled");
formData.set("file", uploadFile);
@ -370,6 +372,7 @@ export function PsdTemplatesSettingsClient({
);
setUploadName("");
setUploadBrandName("");
setUploadSecondaryCategory("");
setUploadDescription("");
setUploadFile(null);
await mutate();
@ -472,6 +475,7 @@ export function PsdTemplatesSettingsClient({
body: JSON.stringify({
name: draftTemplate.name,
brandName: draftTemplate.brandName,
secondaryCategory: draftTemplate.secondaryCategory || "",
description: draftTemplate.description || "",
status: draftTemplate.status,
layers: draftTemplate.layers,
@ -731,6 +735,18 @@ export function PsdTemplatesSettingsClient({
/>
)}
</div>
<div className="space-y-2">
<Label htmlFor="psdTemplateSecondaryCategory">二级分类</Label>
<Input
id="psdTemplateSecondaryCategory"
value={uploadSecondaryCategory}
onChange={(event) => setUploadSecondaryCategory(event.target.value)}
placeholder="例如:凳子类 / 升降桌类"
/>
<p className="text-xs text-muted-foreground">
开启前台模板二级分类后,会按这里的分类分组展示模板。
</p>
</div>
<div className="space-y-2">
<Label htmlFor="psdTemplateDescription">模板说明</Label>
<Textarea
@ -924,6 +940,11 @@ export function PsdTemplatesSettingsClient({
<div className="mt-1 text-xs text-muted-foreground">
品牌:{template.brandName}
</div>
{template.secondaryCategory ? (
<div className="mt-1 text-xs text-muted-foreground">
二级分类:{template.secondaryCategory}
</div>
) : null}
</div>
<div className="flex items-center gap-2">
<Badge variant={template.status === "active" ? "default" : "outline"}>
@ -1153,6 +1174,21 @@ export function PsdTemplatesSettingsClient({
/>
)}
</div>
<div className="space-y-2">
<Label htmlFor="draftTemplateSecondaryCategory">二级分类</Label>
<Input
id="draftTemplateSecondaryCategory"
value={draftTemplate.secondaryCategory || ""}
onChange={(event) =>
setDraftTemplate((current) =>
current
? { ...current, secondaryCategory: event.target.value }
: current
)
}
placeholder="例如:凳子类 / 升降桌类"
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
@ -1204,6 +1240,9 @@ export function PsdTemplatesSettingsClient({
<div className="flex flex-wrap items-center gap-3">
<Badge variant="secondary">品牌:{draftTemplate.brandName}</Badge>
{draftTemplate.secondaryCategory ? (
<Badge variant="secondary">分类:{draftTemplate.secondaryCategory}</Badge>
) : null}
<Badge variant="outline">文件:{draftTemplate.fileName}</Badge>
<Badge variant={getTemplateParseBadgeVariant(draftTemplate)}>
{getTemplateParseLabel(draftTemplate)}

View File

@ -28,6 +28,7 @@ export const runtime = "nodejs";
const updateTemplateSchema = z.object({
name: z.string().trim().min(1).max(80),
brandName: z.string().trim().min(1).max(64),
secondaryCategory: z.string().trim().max(64).optional().nullable(),
description: z.string().optional().nullable(),
status: z.string().optional(),
layers: z.array(z.any()).default([]),
@ -118,6 +119,7 @@ export async function PUT(
...currentTemplate,
name: body.name.trim(),
brandName: body.brandName.trim(),
secondaryCategory: String(body.secondaryCategory || "").trim() || undefined,
description: String(body.description || "").trim() || undefined,
status: nextStatus,
updatedAt: new Date().toISOString(),
@ -206,6 +208,7 @@ export async function PUT(
...currentTemplate,
name: body.name.trim(),
brandName: body.brandName.trim(),
secondaryCategory: String(body.secondaryCategory || "").trim() || undefined,
description: String(body.description || "").trim() || undefined,
status: nextStatus,
renderBaseUrl: uploadedRenderBase.url,

View File

@ -112,6 +112,7 @@ export async function POST(request: NextRequest) {
String(formData.get("name") || "").trim() ||
(file instanceof File ? getDefaultTemplateName(file.name) : "");
const brandName = String(formData.get("brandName") || "").trim();
const secondaryCategory = String(formData.get("secondaryCategory") || "").trim();
const description = String(formData.get("description") || "").trim();
const status = normalizeAgentPsdTemplateStatus(formData.get("status"));
@ -131,6 +132,10 @@ export async function POST(request: NextRequest) {
return jsonMutationError(400, "模板品牌不能超过 64 个字符");
}
if (secondaryCategory.length > 64) {
return jsonMutationError(400, "模板二级分类不能超过 64 个字符");
}
if (!(file instanceof File)) {
return jsonMutationError(400, "缺少 PSD 模板文件");
}
@ -164,6 +169,7 @@ export async function POST(request: NextRequest) {
id: templateId,
name,
brandName,
secondaryCategory: secondaryCategory || undefined,
description: description || undefined,
status,
parseStatus: "pending",

View File

@ -16,6 +16,11 @@ import {
defaultAgentPsdTextColorConfig,
normalizeAgentPsdTextColorConfig,
} from "@/lib/agents/psd-text-color-config";
import {
AGENT_PSD_TEMPLATE_SECONDARY_CATEGORY_CONFIG_KEY,
defaultAgentPsdTemplateSecondaryCategoryConfig,
normalizeAgentPsdTemplateSecondaryCategoryConfig,
} from "@/lib/agents/psd-template-secondary-category-config";
import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings";
import { repairAgentPsdTemplateAssetsIfNeeded } from "@/lib/agents/psd-template-repair";
import { resolveAgentPsdTemplateTextStyles } from "@/lib/agents/psd-template-style-resolver";
@ -41,7 +46,7 @@ export async function GET(request: NextRequest) {
try {
const requestedBrand = String(request.nextUrl.searchParams.get("brand") || "").trim();
const [allTemplates, textColorSetting] = await Promise.all([
const [allTemplates, textColorSetting, secondaryCategorySetting] = await Promise.all([
readAgentPsdTemplates(),
db
.select()
@ -50,6 +55,13 @@ export async function GET(request: NextRequest) {
.limit(1)
.then((rows) => rows[0])
.catch(() => null),
db
.select()
.from(systemSettings)
.where(eq(systemSettings.key, AGENT_PSD_TEMPLATE_SECONDARY_CATEGORY_CONFIG_KEY))
.limit(1)
.then((rows) => rows[0])
.catch(() => null),
]);
const repairedTemplateMap = new Map<
string,
@ -132,6 +144,9 @@ export async function GET(request: NextRequest) {
textColorConfig: textColorSetting
? normalizeAgentPsdTextColorConfig(textColorSetting.value)
: defaultAgentPsdTextColorConfig,
secondaryCategoryConfig: secondaryCategorySetting
? normalizeAgentPsdTemplateSecondaryCategoryConfig(secondaryCategorySetting.value)
: defaultAgentPsdTemplateSecondaryCategoryConfig,
} satisfies AgentPsdTemplateListResponse);
} catch (error) {
console.error("[PSD Templates] list failed:", error);

View File

@ -62,6 +62,11 @@ import {
defaultAgentPsdTextColorConfig,
normalizeAgentPsdTextColorConfig,
} from "@/lib/agents/psd-text-color-config";
import {
AGENT_PSD_TEMPLATE_SECONDARY_CATEGORY_CONFIG_KEY,
defaultAgentPsdTemplateSecondaryCategoryConfig,
normalizeAgentPsdTemplateSecondaryCategoryConfig,
} from "@/lib/agents/psd-template-secondary-category-config";
import {
AGENT_PRESET_LIBRARY_CONFIG_KEY,
defaultAgentPresetLibraryConfig,
@ -272,6 +277,7 @@ export async function GET() {
agentWorkflowBrandModelConfigSetting,
agentWorkflowShowcaseConfigSetting,
agentPsdTextColorConfigSetting,
agentPsdTemplateSecondaryCategoryConfigSetting,
] = await Promise.all([
db.select().from(siteSettings).limit(1).then(rows => rows[0]),
db
@ -430,6 +436,12 @@ export async function GET() {
.where(eq(systemSettings.key, AGENT_PSD_TEXT_COLOR_CONFIG_KEY))
.limit(1)
.then(rows => rows[0]),
db
.select()
.from(systemSettings)
.where(eq(systemSettings.key, AGENT_PSD_TEMPLATE_SECONDARY_CATEGORY_CONFIG_KEY))
.limit(1)
.then(rows => rows[0]),
]);
const notification = notificationSetting?.value || { enabled: false, title: "", content: "" };
@ -499,6 +511,10 @@ export async function GET() {
const agentPsdTextColorConfig = normalizeAgentPsdTextColorConfig(
agentPsdTextColorConfigSetting?.value
);
const agentPsdTemplateSecondaryCategoryConfig =
normalizeAgentPsdTemplateSecondaryCategoryConfig(
agentPsdTemplateSecondaryCategoryConfigSetting?.value
);
// 如果没有设置记录,返回默认值
const baseSettings = settings || {
@ -535,6 +551,8 @@ export async function GET() {
agentWorkflowBrandModelConfig: defaultAgentWorkflowBrandModelConfig,
agentWorkflowShowcaseConfig: defaultAgentWorkflowShowcaseConfig,
agentPsdTextColorConfig: defaultAgentPsdTextColorConfig,
agentPsdTemplateSecondaryCategoryConfig:
defaultAgentPsdTemplateSecondaryCategoryConfig,
realtimeCanvasEnabled: true,
realtimeCanvasServiceEnabled: true,
storageFailoverConfig,
@ -586,6 +604,7 @@ export async function GET() {
agentWorkflowBrandModelConfig,
agentWorkflowShowcaseConfig,
agentPsdTextColorConfig,
agentPsdTemplateSecondaryCategoryConfig,
membershipConfig,
membershipUserOverrides,
pointsSystemConfig,
@ -813,6 +832,11 @@ const updateSchema = z.object({
colors: z.array(z.string().trim().max(32)).length(2).optional(),
})
.optional(),
agentPsdTemplateSecondaryCategoryConfig: z
.object({
enabled: z.boolean().optional(),
})
.optional(),
realtimeCanvasEnabled: z.boolean().optional(),
aiDetailGenerationEnabled: z.boolean().optional(),
canvasDefaultModelId: z.string().nullable().optional(),
@ -901,6 +925,7 @@ export async function PUT(request: NextRequest) {
"agentWorkflowBrandModelConfig",
"agentWorkflowShowcaseConfig",
"agentPsdTextColorConfig",
"agentPsdTemplateSecondaryCategoryConfig",
]);
const hasRestrictedKeys = Object.keys(body || {}).some((key) => !allowedKeys.has(key));
if (hasRestrictedKeys) {
@ -925,6 +950,7 @@ export async function PUT(request: NextRequest) {
agentWorkflowBrandModelConfig,
agentWorkflowShowcaseConfig,
agentPsdTextColorConfig,
agentPsdTemplateSecondaryCategoryConfig,
membershipConfig,
membershipUserOverrides,
pointsSystemConfig,
@ -1337,6 +1363,28 @@ export async function PUT(request: NextRequest) {
});
}
if (agentPsdTemplateSecondaryCategoryConfig !== undefined) {
const normalized = normalizeAgentPsdTemplateSecondaryCategoryConfig(
agentPsdTemplateSecondaryCategoryConfig
);
await db
.insert(systemSettings)
.values({
key: AGENT_PSD_TEMPLATE_SECONDARY_CATEGORY_CONFIG_KEY,
value: normalized,
updatedAt: new Date(),
updatedBy: session.user.id,
})
.onConflictDoUpdate({
target: systemSettings.key,
set: {
value: normalized,
updatedAt: new Date(),
updatedBy: session.user.id,
},
});
}
if (membershipConfig !== undefined) {
const normalized = normalizeMembershipConfig(membershipConfig);
await db
@ -1539,6 +1587,12 @@ export async function PUT(request: NextRequest) {
agentPsdTextColorConfig !== undefined
? normalizeAgentPsdTextColorConfig(agentPsdTextColorConfig)
: defaultAgentPsdTextColorConfig,
agentPsdTemplateSecondaryCategoryConfig:
agentPsdTemplateSecondaryCategoryConfig !== undefined
? normalizeAgentPsdTemplateSecondaryCategoryConfig(
agentPsdTemplateSecondaryCategoryConfig
)
: defaultAgentPsdTemplateSecondaryCategoryConfig,
membershipConfig:
membershipConfig !== undefined
? normalizeMembershipConfig(membershipConfig)

View File

@ -29,7 +29,15 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Slider } from "@/components/ui/slider";
import { Textarea } from "@/components/ui/textarea";
import {
@ -54,6 +62,10 @@ import {
type AgentPsdTemplateRenderResponse,
type AgentPsdTemplateTextLayerSlot,
} from "@/lib/agents/psd-template-config";
import {
defaultAgentPsdTemplateSecondaryCategoryConfig,
normalizeAgentPsdTemplateSecondaryCategoryConfig,
} from "@/lib/agents/psd-template-secondary-category-config";
import { normalizeHexColor, resolveTextLayout } from "@/lib/agents/psd-text-layout";
const fetcher = async (url: string): Promise<AgentPsdTemplateListResponse> => {
@ -68,6 +80,7 @@ const fetcher = async (url: string): Promise<AgentPsdTemplateListResponse> => {
success: false,
templates: [],
textColorConfig: defaultAgentPsdTextColorConfig,
secondaryCategoryConfig: defaultAgentPsdTemplateSecondaryCategoryConfig,
error: typeof payload?.error === "string" ? payload.error : "PSD 模板加载失败",
};
}
@ -76,12 +89,16 @@ const fetcher = async (url: string): Promise<AgentPsdTemplateListResponse> => {
success: true,
templates: parseAgentPsdTemplates(payload.templates),
textColorConfig: normalizeAgentPsdTextColorConfig(payload.textColorConfig),
secondaryCategoryConfig: normalizeAgentPsdTemplateSecondaryCategoryConfig(
payload.secondaryCategoryConfig
),
};
} catch {
return {
success: false,
templates: [],
textColorConfig: defaultAgentPsdTextColorConfig,
secondaryCategoryConfig: defaultAgentPsdTemplateSecondaryCategoryConfig,
error: "PSD 模板加载失败",
};
}
@ -342,7 +359,6 @@ function getPlacedAssignedImageStyle(params: {
top: frame.top,
width: frame.width,
height: frame.height,
...getAssignedImageMaskStyle(layer),
};
}
@ -410,6 +426,10 @@ export function AgentPsdTemplateEditorDialog({
() => normalizeAgentPsdTextColorConfig(data?.textColorConfig),
[data?.textColorConfig]
);
const secondaryCategoryConfig = useMemo(
() => normalizeAgentPsdTemplateSecondaryCategoryConfig(data?.secondaryCategoryConfig),
[data?.secondaryCategoryConfig]
);
const [selectedTemplateId, setSelectedTemplateId] = useState<string>("");
const [selectedLayerId, setSelectedLayerId] = useState<string>("");
@ -438,6 +458,30 @@ export function AgentPsdTemplateEditorDialog({
() => templates.find((template) => template.id === selectedTemplateId) || null,
[selectedTemplateId, templates]
);
const shouldGroupTemplatesBySecondaryCategory = useMemo(
() =>
secondaryCategoryConfig.enabled &&
templates.some((template) => Boolean(String(template.secondaryCategory || "").trim())),
[secondaryCategoryConfig.enabled, templates]
);
const groupedTemplates = useMemo(() => {
if (!shouldGroupTemplatesBySecondaryCategory) {
return [] as Array<{ label: string; templates: AgentPsdTemplate[] }>;
}
const groups = new Map<string, AgentPsdTemplate[]>();
for (const template of templates) {
const groupLabel = String(template.secondaryCategory || "").trim() || "未分组";
const currentGroup = groups.get(groupLabel) || [];
currentGroup.push(template);
groups.set(groupLabel, currentGroup);
}
return Array.from(groups.entries()).map(([label, grouped]) => ({
label,
templates: grouped,
}));
}, [shouldGroupTemplatesBySecondaryCategory, templates]);
const selectedTemplateLayers = useMemo(() => {
if (!selectedTemplate || !Array.isArray(selectedTemplate.layers)) {
return [] as AgentPsdTemplateLayerSlot[];
@ -967,9 +1011,9 @@ export function AgentPsdTemplateEditorDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
overlayClassName="bg-black/85 backdrop-blur-sm"
className="flex h-[92vh] w-[96vw] max-w-[1700px] flex-col 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-[96vh] w-[96vw] max-w-[1700px] 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">
<DialogHeader className="border-b border-white/10 bg-[#09090b] px-5 py-4">
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-orange-500" />
套用 PSD 模板
@ -989,15 +1033,79 @@ export function AgentPsdTemplateEditorDialog({
<Label>选择模板</Label>
{hasTemplateOptions ? (
<Select value={selectedTemplateSelectValue} onValueChange={setSelectedTemplateId}>
<SelectTrigger>
<SelectValue placeholder={isLoading ? "加载模板中..." : "请选择模板"} />
<SelectTrigger className="h-[74px] items-center">
{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="min-w-0 text-left">
{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>
) : (
<SelectValue placeholder={isLoading ? "加载模板中..." : "请选择模板"} />
)}
</SelectTrigger>
<SelectContent>
{templates.map((template) => (
<SelectItem key={template.id} value={template.id}>
{template.name}
</SelectItem>
))}
{shouldGroupTemplatesBySecondaryCategory
? groupedTemplates.map((group) => (
<SelectGroup key={`psd-template-group-${group.label}`}>
<SelectLabel>{group.label}</SelectLabel>
{group.templates.map((template) => (
<SelectItem
key={template.id}
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>
</SelectItem>
))}
</SelectGroup>
))
: templates.map((template) => (
<SelectItem
key={template.id}
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>
</SelectItem>
))}
</SelectContent>
</Select>
) : (
@ -1066,19 +1174,6 @@ export function AgentPsdTemplateEditorDialog({
</div>
<div className="flex min-h-0 flex-col 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">
点模板上的橙色框替换图片,点蓝色框直接修改文字。
</div>
<div
ref={previewViewportRef}
className="psd-preview-viewport relative flex min-h-0 flex-1 items-center justify-center overflow-auto rounded-2xl border border-white/10 bg-[#111114] p-4"
@ -1123,12 +1218,18 @@ export function AgentPsdTemplateEditorDialog({
if (!layerImageUrl) {
return null;
}
const assignedImageContainerStyle = assignedUrl
? {
...layerBoxStyle,
...getAssignedImageMaskStyle(layer),
}
: layerBoxStyle;
return (
<div
key={layer.id}
className="absolute overflow-hidden"
style={layerBoxStyle}
style={assignedImageContainerStyle}
>
<img
src={layerImageUrl}
@ -1146,7 +1247,6 @@ export function AgentPsdTemplateEditorDialog({
width: "100%",
height: "100%",
objectFit: getImageFit(layer.replaceMode),
...getAssignedImageMaskStyle(layer),
}
: {
width: "100%",
@ -1442,8 +1542,8 @@ export function AgentPsdTemplateEditorDialog({
</div>
</div>
<div className="min-h-0 bg-[#09090b] p-5">
<div className="mb-4 text-sm font-medium">当前选中图层</div>
<div className="flex min-h-0 flex-col bg-[#09090b] p-4 xl:p-5">
<div className="mb-3 text-sm font-medium">当前选中图层</div>
<Input
ref={uploadInputRef}
@ -1456,11 +1556,10 @@ export function AgentPsdTemplateEditorDialog({
}}
/>
<ScrollArea className="h-full pr-3">
<div className="flex min-h-full flex-col gap-5">
<div className="rounded-2xl border border-white/10 bg-white/[0.02] p-4">
<div className="grid min-h-0 flex-1 gap-4 xl:grid-rows-[minmax(0,1fr)_minmax(0,0.78fr)]">
<div className="flex min-h-0 flex-col rounded-2xl border border-white/10 bg-white/[0.02] p-3.5 xl:p-4">
{selectedImageLayer ? (
<div className="space-y-4">
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="default">可替换图片</Badge>
<div className="text-sm font-medium">{selectedImageLayer.layerName}</div>
@ -1470,7 +1569,7 @@ export function AgentPsdTemplateEditorDialog({
</div>
<div className="text-xs text-muted-foreground">{selectedImageLayer.layerPath}</div>
<div className="overflow-hidden rounded-xl border border-white/10 bg-white/5">
<div className="h-[220px]">
<div className="h-[150px] xl:h-[160px]">
{effectiveImageAssignments[selectedImageLayer.id] ? (
<img
src={effectiveImageAssignments[selectedImageLayer.id]}
@ -1496,7 +1595,7 @@ export function AgentPsdTemplateEditorDialog({
<ZoomIn className="h-3.5 w-3.5" />
缩放
</div>
<span className="rounded-full border border-orange-300/35 bg-orange-500/15 px-2 py-0.5 font-medium text-orange-100">
<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>
@ -1518,8 +1617,8 @@ export function AgentPsdTemplateEditorDialog({
value={[selectedImagePlacement.scale]}
className="py-1"
trackClassName="h-2.5 rounded-full border border-white/20 bg-[#0f1014]"
rangeClassName="bg-gradient-to-r from-amber-300 via-orange-400 to-rose-400 shadow-[0_0_12px_rgba(251,146,60,0.45)]"
thumbClassName="h-5 w-5 border-2 border-orange-100 bg-[#0a0a0d] shadow-[0_0_0_5px_rgba(251,146,60,0.22)]"
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);
// offset 本身就是相对中心位移,缩放时不应按比例放大,否则会导致画面偏移看起来像“变形”。
@ -1541,7 +1640,7 @@ export function AgentPsdTemplateEditorDialog({
</div>
<div className="mt-2 flex items-center justify-between px-0.5 text-[10px] text-white/45">
<span>40%</span>
<span className="text-orange-200">100%</span>
<span className="text-emerald-200">100%</span>
<span>300%</span>
</div>
</div>
@ -1628,7 +1727,7 @@ export function AgentPsdTemplateEditorDialog({
</div>
</div>
) : selectedTextLayer ? (
<div className="space-y-4">
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="default">可编辑文字</Badge>
<div className="text-sm font-medium">{selectedTextLayer.layerName}</div>
@ -1638,9 +1737,10 @@ export function AgentPsdTemplateEditorDialog({
</div>
<div className="text-xs text-muted-foreground">{selectedTextLayer.layerPath}</div>
<Textarea
rows={5}
rows={4}
value={effectiveTextAssignments[selectedTextLayer.id] || ""}
placeholder={selectedTextLayer.placeholder || "请输入文字"}
className="min-h-[150px] resize-none xl:min-h-[132px]"
onChange={(event) =>
setTextAssignments((current) => ({
...current,
@ -1707,7 +1807,7 @@ export function AgentPsdTemplateEditorDialog({
) : null}
</div>
) : (
<div className="flex min-h-[200px] flex-col items-center justify-center text-center">
<div className="flex min-h-[160px] flex-1 flex-col items-center justify-center text-center">
<MousePointerClick className="h-8 w-8 text-white/35" />
<div className="mt-3 text-sm font-medium">点模板中的图层开始编辑</div>
<div className="mt-2 text-xs leading-6 text-muted-foreground">
@ -1715,13 +1815,13 @@ export function AgentPsdTemplateEditorDialog({
</div>
</div>
)}
</div>
</div>
<div className="min-h-0 flex-1 rounded-2xl border border-white/10 bg-white/[0.02] p-4">
<div className="flex min-h-0 flex-col rounded-2xl border border-white/10 bg-white/[0.02] p-3.5 xl:p-4">
<div className="mb-3 text-sm font-medium">导出结果</div>
{renderedResultUrl ? (
<div className="flex h-full min-h-0 flex-col justify-between gap-4">
<div className="mx-auto w-full max-w-[240px] overflow-hidden rounded-2xl border border-white/10 bg-white/5">
<div className="mx-auto w-full max-w-[210px] overflow-hidden rounded-2xl border border-white/10 bg-white/5">
<div
style={{
aspectRatio: selectedTemplate
@ -1743,23 +1843,22 @@ export function AgentPsdTemplateEditorDialog({
</Button>
</div>
) : (
<div className="flex h-full min-h-[220px] 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">
<div className="flex h-full min-h-[160px] flex-col items-center justify-center rounded-2xl border border-dashed border-white/15 bg-white/[0.02] px-5 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/5">
{selectedTextLayer ? (
<Type className="h-8 w-8 text-muted-foreground" />
<Type className="h-7 w-7 text-muted-foreground" />
) : (
<ImageIcon className="h-8 w-8 text-muted-foreground" />
<ImageIcon className="h-7 w-7 text-muted-foreground" />
)}
</div>
<div className="mt-4 text-sm font-medium">还未导出成品图</div>
<div className="mt-3 text-sm font-medium">还未导出成品图</div>
<div className="mt-2 text-xs leading-6 text-muted-foreground">
在模板画布里完成替换后,点击“应用模板并导出”,这里会显示最终图片。
</div>
</div>
)}
</div>
</div>
</ScrollArea>
</div>
</div>
</div>
</DialogContent>

View File

@ -1,4 +1,5 @@
import type { AgentPsdTextColorConfig } from "@/types/agent";
import type { AgentPsdTemplateSecondaryCategoryConfig } from "@/lib/agents/psd-template-secondary-category-config";
export const AGENT_PSD_TEMPLATES_KEY = "agent_psd_templates";
export const AGENT_PSD_TEMPLATE_ASSET_VERSION = 3;
@ -74,6 +75,7 @@ export interface AgentPsdTemplate {
id: string;
name: string;
brandName: string;
secondaryCategory?: string;
description?: string;
status: AgentPsdTemplateStatus;
parseStatus: AgentPsdTemplateParseStatus;
@ -101,6 +103,7 @@ export interface AgentPsdTemplateListResponse {
success: boolean;
templates: AgentPsdTemplate[];
textColorConfig?: AgentPsdTextColorConfig;
secondaryCategoryConfig?: AgentPsdTemplateSecondaryCategoryConfig;
error?: string;
}
@ -383,6 +386,7 @@ export function parseAgentPsdTemplates(value: unknown) {
id,
name,
brandName,
secondaryCategory: normalizeText(record.secondaryCategory) || undefined,
description: normalizeText(record.description) || undefined,
status: normalizeAgentPsdTemplateStatus(record.status),
parseStatus,

View File

@ -469,7 +469,7 @@ export async function renderAgentPsdTemplate(params: {
placement: imagePlacements[layer.id],
});
let overlayBuffer = await sharp(sourceBuffer)
const resizedOverlayBuffer = await sharp(sourceBuffer)
.rotate()
.resize({
width: Math.max(1, Math.round(placementFrame.width)),
@ -485,9 +485,49 @@ export async function renderAgentPsdTemplate(params: {
.png()
.toBuffer();
const slotOverlay = await fitOverlayBufferIntoCanvas({
input: resizedOverlayBuffer,
left: placementFrame.left,
top: placementFrame.top,
width: placementFrame.width,
height: placementFrame.height,
canvasWidth: layer.width,
canvasHeight: layer.height,
});
let overlayBuffer = await sharp({
create: {
width: Math.max(1, Math.round(layer.width)),
height: Math.max(1, Math.round(layer.height)),
channels: 4,
background: {
r: 0,
g: 0,
b: 0,
alpha: 0,
},
},
})
.composite(slotOverlay ? [slotOverlay] : [])
.png()
.toBuffer();
if (layer.maskUrl) {
try {
const maskBuffer = await readCachedBuffer(layer.maskUrl);
const maskBuffer = await sharp(await readCachedBuffer(layer.maskUrl))
.resize({
width: Math.max(1, Math.round(layer.width)),
height: Math.max(1, Math.round(layer.height)),
fit: "fill",
background: {
r: 0,
g: 0,
b: 0,
alpha: 0,
},
})
.png()
.toBuffer();
overlayBuffer = await sharp(overlayBuffer)
.composite([
{
@ -508,10 +548,10 @@ export async function renderAgentPsdTemplate(params: {
const didAppend = await appendBufferOverlay({
input: overlayBuffer,
left: layer.x + placementFrame.left,
top: layer.y + placementFrame.top,
width: placementFrame.width,
height: placementFrame.height,
left: layer.x,
top: layer.y,
width: layer.width,
height: layer.height,
});
if (!didAppend) continue;
continue;

View File

@ -0,0 +1,23 @@
export interface AgentPsdTemplateSecondaryCategoryConfig {
enabled: boolean;
}
export const AGENT_PSD_TEMPLATE_SECONDARY_CATEGORY_CONFIG_KEY =
"agent_psd_template_secondary_category_config";
export const defaultAgentPsdTemplateSecondaryCategoryConfig: AgentPsdTemplateSecondaryCategoryConfig = {
enabled: false,
};
export function normalizeAgentPsdTemplateSecondaryCategoryConfig(
value: unknown
): AgentPsdTemplateSecondaryCategoryConfig {
const raw =
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
return {
enabled: raw.enabled === true,
};
}