From 01748fe501df283cc812ee8a72da74b4f06bd44f Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 2 Apr 2026 00:08:53 +0800 Subject: [PATCH] fix: harden psd text workflow compatibility --- .../agent-workflow-settings-client.tsx | 100 ++++++++++++ app/api/agent-projects/[id]/route.ts | 68 +++----- app/api/agent-projects/route.ts | 27 +--- .../agent-workflow/material-upload/route.ts | 43 ++++- app/api/psd-templates/render/route.ts | 8 + app/api/psd-templates/route.ts | 22 ++- app/api/site-settings/route.ts | 73 +++++++++ .../agents/AgentPsdTemplateEditorDialog.tsx | 148 ++++++++++++++++-- .../AgentWorkflowMaterialSelectionPanel.tsx | 2 +- lib/agents/agent-project-compat.ts | 69 ++++++++ lib/agents/psd-template-config.ts | 3 + lib/agents/psd-template-renderer.ts | 25 ++- lib/agents/psd-text-color-config.ts | 35 +++++ lib/agents/psd-text-layout.ts | 21 ++- types/agent.ts | 5 + 15 files changed, 557 insertions(+), 92 deletions(-) create mode 100644 lib/agents/agent-project-compat.ts create mode 100644 lib/agents/psd-text-color-config.ts diff --git a/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx b/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx index ad3c9699d..2bd271fbc 100644 --- a/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx +++ b/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx @@ -63,6 +63,11 @@ import { defaultAgentWorkflowGoodsInfoApiConfig, normalizeAgentWorkflowGoodsInfoApiConfig, } from "@/lib/agents/workflow-goods-info-config"; +import { + defaultAgentPsdTextColorConfig, + normalizeAgentPsdTextColorConfig, +} from "@/lib/agents/psd-text-color-config"; +import { normalizeHexColor } from "@/lib/agents/psd-text-layout"; import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; @@ -84,6 +89,7 @@ import type { AgentWorkflowGoodsInfoApiConfig, AgentWorkflowMaterialApiConfig, AgentWorkflowOptionUiConfig, + AgentPsdTextColorConfig, AgentWorkflowShowcaseConfig, AgentWorkflowStepUiConfig, AgentWorkflowUiConfig, @@ -467,6 +473,7 @@ export function AgentWorkflowSettingsClient({ agentWorkflowGoodsInfoApiConfig?: AgentWorkflowGoodsInfoApiConfig; agentWorkflowBrandModelConfig?: AgentWorkflowBrandModelConfig; agentWorkflowShowcaseConfig?: AgentWorkflowShowcaseConfig; + agentPsdTextColorConfig?: AgentPsdTextColorConfig; }>("/api/site-settings", fetcher); const [presetLibraryConfig, setPresetLibraryConfig] = useState( defaultAgentPresetLibraryConfig @@ -481,6 +488,9 @@ export function AgentWorkflowSettingsClient({ const [showcaseConfig, setShowcaseConfig] = useState( defaultAgentWorkflowShowcaseConfig ); + const [psdTextColorConfig, setPsdTextColorConfig] = useState( + defaultAgentPsdTextColorConfig + ); const [brandModelConfig, setBrandModelConfig] = useState( defaultAgentWorkflowBrandModelConfig ); @@ -523,6 +533,9 @@ export function AgentWorkflowSettingsClient({ setShowcaseConfig( normalizeAgentWorkflowShowcaseConfig(data.agentWorkflowShowcaseConfig) ); + setPsdTextColorConfig( + normalizeAgentPsdTextColorConfig(data.agentPsdTextColorConfig) + ); setBrandModelConfig( normalizeAgentWorkflowBrandModelConfig(data.agentWorkflowBrandModelConfig) ); @@ -1023,6 +1036,18 @@ export function AgentWorkflowSettingsClient({ } }; + const updatePsdTextColor = (index: 0 | 1, value: string) => { + const normalized = normalizeHexColor(value, psdTextColorConfig.colors[index] || "#111111"); + setPsdTextColorConfig((prev) => { + const nextColors = [...prev.colors] as [string, string]; + nextColors[index] = normalized; + return { + ...prev, + colors: nextColors, + }; + }); + }; + const handleSave = async () => { try { setIsSaving(true); @@ -1036,6 +1061,7 @@ export function AgentWorkflowSettingsClient({ agentWorkflowGoodsInfoApiConfig: goodsInfoApiConfig, agentWorkflowBrandModelConfig: brandModelConfig, agentWorkflowShowcaseConfig: showcaseConfig, + agentPsdTextColorConfig: psdTextColorConfig, }), }); @@ -2618,6 +2644,80 @@ export function AgentWorkflowSettingsClient({ + + + + +
+
+ +
+
+ PSD 文字颜色选项 + + 控制套用 PSD 模板时,前台是否允许用户在两种预设文字颜色之间切换。 + +
+
+
+ +
+
+
启用 PSD 文字双色切换
+
+ 开启后,用户在 PSD 模板编辑弹窗里可为文字图层选择两种系统预设颜色。 +
+
+ + setPsdTextColorConfig((prev) => ({ ...prev, enabled: checked })) + } + /> +
+ +
+ {psdTextColorConfig.colors.map((color, index) => ( +
+
+
+ +
+ 用户前台可直接选择这组文字颜色。 +
+
+
+
+
+ + updatePsdTextColor(index as 0 | 1, event.target.value) + } + className="h-11 w-14 cursor-pointer rounded-lg border bg-transparent p-1" + /> + + updatePsdTextColor(index as 0 | 1, event.target.value) + } + placeholder="#FFFFFF" + className="font-mono uppercase" + /> +
+
+ ))} +
+ + + diff --git a/app/api/agent-projects/[id]/route.ts b/app/api/agent-projects/[id]/route.ts index 1082fa90b..5dba7bead 100644 --- a/app/api/agent-projects/[id]/route.ts +++ b/app/api/agent-projects/[id]/route.ts @@ -4,28 +4,22 @@ import { auth } from "@/app/(auth)/auth"; import { db } from "@/lib/db"; import { canvasProject } from "@/lib/db/schema"; import { - createEmptyAgentGenerationProjectPersistedState, - normalizeAgentGenerationProjectPersistedState, -} from "@/lib/agents/generation-project-state"; + extractAgentProjectStateFromCanvasData, + isAgentProjectRow, +} from "@/lib/agents/agent-project-compat"; +import { normalizeAgentGenerationProjectPersistedState } from "@/lib/agents/generation-project-state"; export const dynamic = "force-dynamic"; export const revalidate = 0; function mapAgentProjectRow(row: typeof canvasProject.$inferSelect) { - const canvasData = - row.canvasData && typeof row.canvasData === "object" && !Array.isArray(row.canvasData) - ? (row.canvasData as Record) - : {}; - return { id: row.id, userId: row.userId, title: row.title, description: row.description || null, thumbnail: row.thumbnail || null, - projectState: normalizeAgentGenerationProjectPersistedState( - canvasData.agentProjectState || createEmptyAgentGenerationProjectPersistedState() - ), + projectState: extractAgentProjectStateFromCanvasData(row.canvasData), createdAt: row.createdAt, updatedAt: row.updatedAt, }; @@ -46,15 +40,11 @@ export async function GET( .select() .from(canvasProject) .where( - and( - eq(canvasProject.id, id), - eq(canvasProject.userId, session.user.id), - eq(canvasProject.workspaceType, "agent") - ) + and(eq(canvasProject.id, id), eq(canvasProject.userId, session.user.id)) ) .limit(1); - if (!project) { + if (!project || !isAgentProjectRow(project)) { return NextResponse.json({ success: false, error: "生图项目不存在" }, { status: 404 }); } @@ -81,15 +71,11 @@ export async function PUT( .select() .from(canvasProject) .where( - and( - eq(canvasProject.id, id), - eq(canvasProject.userId, session.user.id), - eq(canvasProject.workspaceType, "agent") - ) + and(eq(canvasProject.id, id), eq(canvasProject.userId, session.user.id)) ) .limit(1); - if (!existingProject) { + if (!existingProject || !isAgentProjectRow(existingProject)) { return NextResponse.json({ success: false, error: "生图项目不存在" }, { status: 404 }); } @@ -99,19 +85,10 @@ export async function PUT( const thumbnail = body.thumbnail !== undefined ? String(body.thumbnail || "").trim() || null : undefined; - const existingCanvasData = - existingProject.canvasData && - typeof existingProject.canvasData === "object" && - !Array.isArray(existingProject.canvasData) - ? (existingProject.canvasData as Record) - : {}; - const nextProjectState = body.projectState !== undefined ? normalizeAgentGenerationProjectPersistedState(body.projectState) - : normalizeAgentGenerationProjectPersistedState( - existingCanvasData.agentProjectState || createEmptyAgentGenerationProjectPersistedState() - ); + : extractAgentProjectStateFromCanvasData(existingProject.canvasData); const [project] = await db .update(canvasProject) @@ -119,6 +96,7 @@ export async function PUT( title, description, thumbnail, + workspaceType: "agent", updatedAt: new Date(), canvasData: { nodes: [], @@ -130,11 +108,7 @@ export async function PUT( } as any, }) .where( - and( - eq(canvasProject.id, id), - eq(canvasProject.userId, session.user.id), - eq(canvasProject.workspaceType, "agent") - ) + and(eq(canvasProject.id, id), eq(canvasProject.userId, session.user.id)) ) .returning(); @@ -156,15 +130,19 @@ export async function DELETE( } const { id } = await params; + const [existingProject] = await db + .select() + .from(canvasProject) + .where(and(eq(canvasProject.id, id), eq(canvasProject.userId, session.user.id))) + .limit(1); + + if (!existingProject || !isAgentProjectRow(existingProject)) { + return NextResponse.json({ success: false, error: "生图项目不存在" }, { status: 404 }); + } + const [project] = await db .delete(canvasProject) - .where( - and( - eq(canvasProject.id, id), - eq(canvasProject.userId, session.user.id), - eq(canvasProject.workspaceType, "agent") - ) - ) + .where(and(eq(canvasProject.id, id), eq(canvasProject.userId, session.user.id))) .returning(); if (!project) { diff --git a/app/api/agent-projects/route.ts b/app/api/agent-projects/route.ts index 1c628ec85..6e4ed74dc 100644 --- a/app/api/agent-projects/route.ts +++ b/app/api/agent-projects/route.ts @@ -1,31 +1,25 @@ import { NextRequest, NextResponse } from "next/server"; -import { and, desc, eq } from "drizzle-orm"; +import { desc, eq } from "drizzle-orm"; import { auth } from "@/app/(auth)/auth"; import { db } from "@/lib/db"; import { canvasProject } from "@/lib/db/schema"; import { - createEmptyAgentGenerationProjectPersistedState, - normalizeAgentGenerationProjectPersistedState, -} from "@/lib/agents/generation-project-state"; + extractAgentProjectStateFromCanvasData, + isAgentProjectRow, +} from "@/lib/agents/agent-project-compat"; +import { normalizeAgentGenerationProjectPersistedState } from "@/lib/agents/generation-project-state"; export const dynamic = "force-dynamic"; export const revalidate = 0; function mapAgentProjectRow(row: typeof canvasProject.$inferSelect) { - const canvasData = - row.canvasData && typeof row.canvasData === "object" && !Array.isArray(row.canvasData) - ? (row.canvasData as Record) - : {}; - return { id: row.id, userId: row.userId, title: row.title, description: row.description || null, thumbnail: row.thumbnail || null, - projectState: normalizeAgentGenerationProjectPersistedState( - canvasData.agentProjectState || createEmptyAgentGenerationProjectPersistedState() - ), + projectState: extractAgentProjectStateFromCanvasData(row.canvasData), createdAt: row.createdAt, updatedAt: row.updatedAt, }; @@ -41,17 +35,12 @@ export async function GET() { const projects = await db .select() .from(canvasProject) - .where( - and( - eq(canvasProject.userId, session.user.id), - eq(canvasProject.workspaceType, "agent") - ) - ) + .where(eq(canvasProject.userId, session.user.id)) .orderBy(desc(canvasProject.updatedAt)); return NextResponse.json({ success: true, - data: projects.map(mapAgentProjectRow), + data: projects.filter(isAgentProjectRow).map(mapAgentProjectRow), }); } catch (error) { console.error("[AgentProjects API] 获取项目列表失败:", error); diff --git a/app/api/agent-workflow/material-upload/route.ts b/app/api/agent-workflow/material-upload/route.ts index eeb3b0b8f..c2b65740b 100644 --- a/app/api/agent-workflow/material-upload/route.ts +++ b/app/api/agent-workflow/material-upload/route.ts @@ -17,6 +17,14 @@ const payloadSchema = z.object({ productModel: z.string().trim().max(128).optional(), }); +function isUploadLikeFile(value: FormDataEntryValue | null) { + return ( + Boolean(value) && + typeof value !== "string" && + typeof (value as Blob).arrayBuffer === "function" + ); +} + function sanitizeFilenameStem(value: string) { return String(value || "") .trim() @@ -28,6 +36,23 @@ function sanitizeFilenameStem(value: string) { .slice(0, 80); } +function normalizeUploadedImageMimeType(file: Blob & { name?: string; type?: string }) { + const type = String(file.type || "").trim().toLowerCase(); + if (type.startsWith("image/")) { + return type; + } + + const fileName = String(file.name || "").trim().toLowerCase(); + if (/\.(png)$/i.test(fileName)) return "image/png"; + if (/\.(jpe?g)$/i.test(fileName)) return "image/jpeg"; + if (/\.(webp)$/i.test(fileName)) return "image/webp"; + if (/\.(gif)$/i.test(fileName)) return "image/gif"; + if (/\.(bmp)$/i.test(fileName)) return "image/bmp"; + if (/\.(heic)$/i.test(fileName)) return "image/heic"; + if (/\.(heif)$/i.test(fileName)) return "image/heif"; + return ""; +} + export async function POST(request: NextRequest) { try { const session = await auth(); @@ -36,12 +61,14 @@ export async function POST(request: NextRequest) { } const formData = await request.formData(); - const file = formData.get("file"); - if (!(file instanceof File)) { + const fileEntry = formData.get("file"); + if (!isUploadLikeFile(fileEntry)) { return NextResponse.json({ error: "缺少图片文件" }, { status: 400 }); } + const file = fileEntry as Blob & { name?: string; type?: string }; - if (!String(file.type || "").startsWith("image/")) { + const mimeType = normalizeUploadedImageMimeType(file); + if (!mimeType) { return NextResponse.json({ error: "请上传图片文件" }, { status: 400 }); } @@ -56,6 +83,10 @@ export async function POST(request: NextRequest) { const arrayBuffer = await file.arrayBuffer(); const imageBuffer = Buffer.from(arrayBuffer); + if (imageBuffer.byteLength <= 0) { + return NextResponse.json({ error: "图片文件为空,请重新上传" }, { status: 400 }); + } + const promptPrefix = payload.source === "secondary" ? "搭配SKU" : "主SPU"; const prompt = `${promptPrefix}素材抠图`; @@ -66,7 +97,8 @@ export async function POST(request: NextRequest) { skipGallerySave: true, }); - const originalStem = sanitizeFilenameStem(file.name) || `workflow-upload-${Date.now()}`; + const originalFileName = String(file.name || "").trim() || `workflow-upload-${Date.now()}.png`; + const originalStem = sanitizeFilenameStem(originalFileName) || `workflow-upload-${Date.now()}`; const displayName = `${originalStem}-rmbg.png`; const asset = await upsertMediaAsset({ userId: session.user.id, @@ -88,7 +120,8 @@ export async function POST(request: NextRequest) { matchedSku: payload.matchedSku || null, matchedSpu: payload.matchedSpu || null, productModel: payload.productModel || null, - originalFileName: file.name, + originalFileName, + originalMimeType: mimeType, provider: result.provider, }, }); diff --git a/app/api/psd-templates/render/route.ts b/app/api/psd-templates/render/route.ts index 7ca8e1abf..cc356d3e9 100644 --- a/app/api/psd-templates/render/route.ts +++ b/app/api/psd-templates/render/route.ts @@ -15,6 +15,7 @@ import { normalizeAgentPsdImagePlacement } from "@/lib/agents/psd-image-placemen import { repairAgentPsdTemplateAssetsIfNeeded } from "@/lib/agents/psd-template-repair"; import { renderAgentPsdTemplate } from "@/lib/agents/psd-template-renderer"; import { resolveAgentPsdTemplateTextStyles } from "@/lib/agents/psd-template-style-resolver"; +import { normalizeHexColor } from "@/lib/agents/psd-text-layout"; import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings"; export const runtime = "nodejs"; @@ -34,6 +35,7 @@ const renderSchema = z.object({ ) .default({}), textAssignments: z.record(z.string(), z.string()).default({}), + textColorAssignments: z.record(z.string(), z.string()).default({}), }); function jsonError(status: number, error: string) { @@ -107,6 +109,7 @@ export async function POST(request: NextRequest) { const imageAssignments: Record = {}; const imagePlacements: Record> = {}; const textAssignments: Record = {}; + const textColorAssignments: Record = {}; for (const layer of editableLayers) { if (isAgentPsdImageLayerSlot(layer)) { const explicitUrl = String(body.imageAssignments[layer.id] || "").trim(); @@ -127,6 +130,10 @@ export async function POST(request: NextRequest) { if (nextText) { textAssignments[layer.id] = nextText; } + textColorAssignments[layer.id] = normalizeHexColor( + body.textColorAssignments[layer.id] || layer.textColor, + layer.textColor + ); } } @@ -146,6 +153,7 @@ export async function POST(request: NextRequest) { imageAssignments, imagePlacements, textAssignments, + textColorAssignments, fonts, }); diff --git a/app/api/psd-templates/route.ts b/app/api/psd-templates/route.ts index 784c98266..67b2e7fbf 100644 --- a/app/api/psd-templates/route.ts +++ b/app/api/psd-templates/route.ts @@ -1,6 +1,9 @@ import { NextRequest, NextResponse } from "next/server"; import { upsertSystemSetting } from "@/lib/db/queries"; import { auth } from "@/app/(auth)/auth"; +import { db } from "@/lib/db"; +import { systemSettings } from "@/lib/db/schema"; +import { eq } from "drizzle-orm"; import { AGENT_PSD_TEMPLATES_KEY, getEditableAgentPsdTemplateLayers, @@ -8,6 +11,11 @@ import { isAgentPsdTemplateReady, type AgentPsdTemplateListResponse, } from "@/lib/agents/psd-template-config"; +import { + AGENT_PSD_TEXT_COLOR_CONFIG_KEY, + defaultAgentPsdTextColorConfig, + normalizeAgentPsdTextColorConfig, +} from "@/lib/agents/psd-text-color-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"; @@ -33,7 +41,16 @@ export async function GET(request: NextRequest) { try { const requestedBrand = String(request.nextUrl.searchParams.get("brand") || "").trim(); - const allTemplates = await readAgentPsdTemplates(); + const [allTemplates, textColorSetting] = await Promise.all([ + readAgentPsdTemplates(), + db + .select() + .from(systemSettings) + .where(eq(systemSettings.key, AGENT_PSD_TEXT_COLOR_CONFIG_KEY)) + .limit(1) + .then((rows) => rows[0]) + .catch(() => null), + ]); const repairedTemplateMap = new Map< string, Awaited>[number] @@ -112,6 +129,9 @@ export async function GET(request: NextRequest) { return NextResponse.json({ success: true, templates, + textColorConfig: textColorSetting + ? normalizeAgentPsdTextColorConfig(textColorSetting.value) + : defaultAgentPsdTextColorConfig, } satisfies AgentPsdTemplateListResponse); } catch (error) { console.error("[PSD Templates] list failed:", error); diff --git a/app/api/site-settings/route.ts b/app/api/site-settings/route.ts index 76f2aaa45..d2c01cbfa 100644 --- a/app/api/site-settings/route.ts +++ b/app/api/site-settings/route.ts @@ -57,6 +57,11 @@ import { defaultAgentWorkflowShowcaseConfig, normalizeAgentWorkflowShowcaseConfig, } from "@/lib/agents/workflow-showcase-config"; +import { + AGENT_PSD_TEXT_COLOR_CONFIG_KEY, + defaultAgentPsdTextColorConfig, + normalizeAgentPsdTextColorConfig, +} from "@/lib/agents/psd-text-color-config"; import { AGENT_PRESET_LIBRARY_CONFIG_KEY, defaultAgentPresetLibraryConfig, @@ -266,6 +271,7 @@ export async function GET() { agentWorkflowGoodsInfoApiConfigSetting, agentWorkflowBrandModelConfigSetting, agentWorkflowShowcaseConfigSetting, + agentPsdTextColorConfigSetting, ] = await Promise.all([ db.select().from(siteSettings).limit(1).then(rows => rows[0]), db @@ -418,6 +424,12 @@ export async function GET() { .where(eq(systemSettings.key, AGENT_WORKFLOW_SHOWCASE_CONFIG_KEY)) .limit(1) .then(rows => rows[0]), + db + .select() + .from(systemSettings) + .where(eq(systemSettings.key, AGENT_PSD_TEXT_COLOR_CONFIG_KEY)) + .limit(1) + .then(rows => rows[0]), ]); const notification = notificationSetting?.value || { enabled: false, title: "", content: "" }; @@ -484,6 +496,9 @@ export async function GET() { const agentWorkflowShowcaseConfig = normalizeAgentWorkflowShowcaseConfig( agentWorkflowShowcaseConfigSetting?.value ); + const agentPsdTextColorConfig = normalizeAgentPsdTextColorConfig( + agentPsdTextColorConfigSetting?.value + ); // 如果没有设置记录,返回默认值 const baseSettings = settings || { @@ -519,6 +534,7 @@ export async function GET() { agentWorkflowGoodsInfoApiConfig: defaultAgentWorkflowGoodsInfoApiConfig, agentWorkflowBrandModelConfig: defaultAgentWorkflowBrandModelConfig, agentWorkflowShowcaseConfig: defaultAgentWorkflowShowcaseConfig, + agentPsdTextColorConfig: defaultAgentPsdTextColorConfig, realtimeCanvasEnabled: true, realtimeCanvasServiceEnabled: true, storageFailoverConfig, @@ -569,6 +585,7 @@ export async function GET() { agentWorkflowGoodsInfoApiConfig, agentWorkflowBrandModelConfig, agentWorkflowShowcaseConfig, + agentPsdTextColorConfig, membershipConfig, membershipUserOverrides, pointsSystemConfig, @@ -790,6 +807,12 @@ const updateSchema = z.object({ footerText: z.string().max(64).optional(), }) .optional(), + agentPsdTextColorConfig: z + .object({ + enabled: z.boolean().optional(), + colors: z.array(z.string().trim().max(32)).length(2).optional(), + }) + .optional(), realtimeCanvasEnabled: z.boolean().optional(), aiDetailGenerationEnabled: z.boolean().optional(), canvasDefaultModelId: z.string().nullable().optional(), @@ -877,6 +900,7 @@ export async function PUT(request: NextRequest) { "agentWorkflowGoodsInfoApiConfig", "agentWorkflowBrandModelConfig", "agentWorkflowShowcaseConfig", + "agentPsdTextColorConfig", ]); const hasRestrictedKeys = Object.keys(body || {}).some((key) => !allowedKeys.has(key)); if (hasRestrictedKeys) { @@ -900,6 +924,7 @@ export async function PUT(request: NextRequest) { agentWorkflowGoodsInfoApiConfig, agentWorkflowBrandModelConfig, agentWorkflowShowcaseConfig, + agentPsdTextColorConfig, membershipConfig, membershipUserOverrides, pointsSystemConfig, @@ -1292,6 +1317,26 @@ export async function PUT(request: NextRequest) { }); } + if (agentPsdTextColorConfig !== undefined) { + const normalized = normalizeAgentPsdTextColorConfig(agentPsdTextColorConfig); + await db + .insert(systemSettings) + .values({ + key: AGENT_PSD_TEXT_COLOR_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 @@ -1490,6 +1535,10 @@ export async function PUT(request: NextRequest) { agentWorkflowShowcaseConfig !== undefined ? normalizeAgentWorkflowShowcaseConfig(agentWorkflowShowcaseConfig) : defaultAgentWorkflowShowcaseConfig, + agentPsdTextColorConfig: + agentPsdTextColorConfig !== undefined + ? normalizeAgentPsdTextColorConfig(agentPsdTextColorConfig) + : defaultAgentPsdTextColorConfig, membershipConfig: membershipConfig !== undefined ? normalizeMembershipConfig(membershipConfig) @@ -1870,6 +1919,26 @@ export async function PUT(request: NextRequest) { }); } + if (agentPsdTextColorConfig !== undefined) { + const normalized = normalizeAgentPsdTextColorConfig(agentPsdTextColorConfig); + await db + .insert(systemSettings) + .values({ + key: AGENT_PSD_TEXT_COLOR_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 @@ -2066,6 +2135,10 @@ export async function PUT(request: NextRequest) { agentWorkflowShowcaseConfig !== undefined ? normalizeAgentWorkflowShowcaseConfig(agentWorkflowShowcaseConfig) : defaultAgentWorkflowShowcaseConfig, + agentPsdTextColorConfig: + agentPsdTextColorConfig !== undefined + ? normalizeAgentPsdTextColorConfig(agentPsdTextColorConfig) + : defaultAgentPsdTextColorConfig, membershipConfig: membershipConfig !== undefined ? normalizeMembershipConfig(membershipConfig) diff --git a/components/agents/AgentPsdTemplateEditorDialog.tsx b/components/agents/AgentPsdTemplateEditorDialog.tsx index 112577785..feb209a06 100644 --- a/components/agents/AgentPsdTemplateEditorDialog.tsx +++ b/components/agents/AgentPsdTemplateEditorDialog.tsx @@ -39,6 +39,10 @@ import { type AgentPsdImagePlacement, type AgentPsdImageSize, } from "@/lib/agents/psd-image-placement"; +import { + defaultAgentPsdTextColorConfig, + normalizeAgentPsdTextColorConfig, +} from "@/lib/agents/psd-text-color-config"; import { isAgentPsdImageLayerSlot, isAgentPsdTextLayerSlot, @@ -50,7 +54,7 @@ import { type AgentPsdTemplateRenderResponse, type AgentPsdTemplateTextLayerSlot, } from "@/lib/agents/psd-template-config"; -import { resolveTextLayout } from "@/lib/agents/psd-text-layout"; +import { normalizeHexColor, resolveTextLayout } from "@/lib/agents/psd-text-layout"; const fetcher = async (url: string): Promise => { try { @@ -63,6 +67,7 @@ const fetcher = async (url: string): Promise => { return { success: false, templates: [], + textColorConfig: defaultAgentPsdTextColorConfig, error: typeof payload?.error === "string" ? payload.error : "PSD 模板加载失败", }; } @@ -70,11 +75,13 @@ const fetcher = async (url: string): Promise => { return { success: true, templates: parseAgentPsdTemplates(payload.templates), + textColorConfig: normalizeAgentPsdTextColorConfig(payload.textColorConfig), }; } catch { return { success: false, templates: [], + textColorConfig: defaultAgentPsdTextColorConfig, error: "PSD 模板加载失败", }; } @@ -231,6 +238,13 @@ function getResolvedTextPreview(layer: AgentPsdTemplateTextLayerSlot, value: str return value || layer.placeholder || layer.layerName; } +function getResolvedTextLayerColor( + layer: AgentPsdTemplateTextLayerSlot, + value?: string +) { + return normalizeHexColor(value || layer.textColor, layer.textColor); +} + function getTextLayerRenderFrame( layer: AgentPsdTemplateTextLayerSlot, value: string @@ -262,13 +276,14 @@ function getTextLayerRenderFrame( width, height, offsetX, - offsetY: 0, + offsetY: -resolvedLayout.visualOffsetY, }; } function getTextLayerContentStyle( layer: AgentPsdTemplateTextLayerSlot, - value: string + value: string, + color: string ): CSSProperties { const resolvedLayout = getTextLayerRenderFrame(layer, value); @@ -279,7 +294,7 @@ function getTextLayerContentStyle( fontSize: resolvedLayout.fontSize, lineHeight: layer.lineHeight, letterSpacing: `${layer.letterSpacing}px`, - color: layer.textColor, + color, fontWeight: layer.fontWeight, textAlign: layer.textAlign, paddingLeft: layer.paddingX, @@ -335,20 +350,32 @@ function hasEditedTextValue(layer: AgentPsdTemplateTextLayerSlot, value: string) return String(value || "").replace(/\r\n/g, "\n") !== String(layer.defaultText || "").replace(/\r\n/g, "\n"); } +function hasCustomizedTextLayerAppearance( + layer: AgentPsdTemplateTextLayerSlot, + value: string, + color: string +) { + return ( + hasEditedTextValue(layer, value) || + getResolvedTextLayerColor(layer, color) !== getResolvedTextLayerColor(layer) + ); +} + function getInlineTextEditorStyle( layer: AgentPsdTemplateTextLayerSlot, preserveSourceAppearance: boolean, - value: string + value: string, + color: string ): CSSProperties { - const baseStyle = getTextLayerContentStyle(layer, value); + const baseStyle = getTextLayerContentStyle(layer, value, color); return { ...baseStyle, background: "transparent", boxSizing: "border-box", - overflow: "hidden", - color: preserveSourceAppearance ? "transparent" : layer.textColor, - caretColor: layer.textColor, - WebkitTextFillColor: preserveSourceAppearance ? "transparent" : layer.textColor, + overflow: "visible", + color: preserveSourceAppearance ? "transparent" : color, + caretColor: color, + WebkitTextFillColor: preserveSourceAppearance ? "transparent" : color, }; } @@ -379,6 +406,10 @@ export function AgentPsdTemplateEditorDialog({ fetcher ); const templates = useMemo(() => (data?.success ? data.templates : []), [data]); + const textColorConfig = useMemo( + () => normalizeAgentPsdTextColorConfig(data?.textColorConfig), + [data?.textColorConfig] + ); const [selectedTemplateId, setSelectedTemplateId] = useState(""); const [selectedLayerId, setSelectedLayerId] = useState(""); @@ -386,6 +417,7 @@ export function AgentPsdTemplateEditorDialog({ const [imagePlacements, setImagePlacements] = useState>({}); const [imageSizes, setImageSizes] = useState>({}); const [textAssignments, setTextAssignments] = useState>({}); + const [textColorAssignments, setTextColorAssignments] = useState>({}); const [uploadingSlotId, setUploadingSlotId] = useState(null); const [isRendering, setIsRendering] = useState(false); const [renderedResultUrl, setRenderedResultUrl] = useState(""); @@ -495,6 +527,21 @@ export function AgentPsdTemplateEditorDialog({ return nextAssignments; }, [editableTextLayers, selectedTemplate, textAssignments]); + const effectiveTextColorAssignments = useMemo(() => { + const nextAssignments: Record = {}; + if (!selectedTemplate) { + return nextAssignments; + } + + for (const layer of editableTextLayers) { + nextAssignments[layer.id] = getResolvedTextLayerColor( + layer, + textColorAssignments[layer.id] + ); + } + + return nextAssignments; + }, [editableTextLayers, selectedTemplate, textColorAssignments]); const effectiveImagePlacements = useMemo(() => { const nextPlacements: Record = {}; @@ -521,6 +568,9 @@ export function AgentPsdTemplateEditorDialog({ const selectedImagePlacement = selectedImageLayer ? normalizeAgentPsdImagePlacement(effectiveImagePlacements[selectedImageLayer.id]) : getDefaultAgentPsdImagePlacement(); + const selectedTextColor = selectedTextLayer + ? effectiveTextColorAssignments[selectedTextLayer.id] || selectedTextLayer.textColor + : "#111111"; const fontFaceCss = useMemo(() => buildPreviewFontFaces(selectedTemplate), [selectedTemplate]); const selectedTemplateSelectValue = selectedTemplate?.id; const hasTemplateOptions = templates.length > 0; @@ -548,6 +598,7 @@ export function AgentPsdTemplateEditorDialog({ setImageAssignments(initialState.imageAssignments); setImagePlacements(initialState.imagePlacements); setTextAssignments(initialState.textAssignments); + setTextColorAssignments({}); setSelectedLayerId(getPreferredSelectedLayerId(selectedTemplate)); if (!selectedTemplate || typeof window === "undefined") { @@ -872,6 +923,7 @@ export function AgentPsdTemplateEditorDialog({ imageAssignments: effectiveImageAssignments, imagePlacements: effectiveImagePlacements, textAssignments: effectiveTextAssignments, + textColorAssignments: effectiveTextColorAssignments, }), }); @@ -1113,7 +1165,12 @@ export function AgentPsdTemplateEditorDialog({ } const value = effectiveTextAssignments[layer.id] || ""; - const shouldUseEditedPreview = hasEditedTextValue(layer, value); + const resolvedTextColor = effectiveTextColorAssignments[layer.id] || layer.textColor; + const shouldUseEditedPreview = hasCustomizedTextLayerAppearance( + layer, + value, + resolvedTextColor + ); if (!shouldUseEditedPreview && layer.sourceUrl) { return ( @@ -1153,7 +1210,11 @@ export function AgentPsdTemplateEditorDialog({ >
{value || layer.placeholder || layer.layerName}
@@ -1277,8 +1338,11 @@ export function AgentPsdTemplateEditorDialog({ const isSelected = selectedLayerId === layer.id; const frameStyle = getLayerFrameStyle(layer, isSelected); const currentValue = effectiveTextAssignments[layer.id] || ""; + const resolvedTextColor = + effectiveTextColorAssignments[layer.id] || layer.textColor; const preserveSourceAppearance = - !!layer.sourceUrl && !hasEditedTextValue(layer, currentValue); + !!layer.sourceUrl && + !hasCustomizedTextLayerAppearance(layer, currentValue, resolvedTextColor); const editedTextFrame = getTextLayerRenderFrame(layer, currentValue); if (isSelected) { @@ -1325,7 +1389,12 @@ export function AgentPsdTemplateEditorDialog({ })) } className="resize-none border-none bg-transparent outline-none" - style={getInlineTextEditorStyle(layer, preserveSourceAppearance, currentValue)} + style={getInlineTextEditorStyle( + layer, + preserveSourceAppearance, + currentValue, + resolvedTextColor + )} />
@@ -1585,6 +1654,57 @@ export function AgentPsdTemplateEditorDialog({ 行高:{selectedTextLayer.lineHeight} 字距:{selectedTextLayer.letterSpacing}px
+ {textColorConfig.enabled ? ( +
+
+
+
文字颜色
+
+ 当前模板允许在两种系统预设颜色之间切换。 +
+
+
+
+
+ {textColorConfig.colors.map((color, index) => { + const isActive = + getResolvedTextLayerColor(selectedTextLayer, color) === + getResolvedTextLayerColor(selectedTextLayer, selectedTextColor); + return ( + + ); + })} +
+
+ ) : null}
) : (
diff --git a/components/agents/AgentWorkflowMaterialSelectionPanel.tsx b/components/agents/AgentWorkflowMaterialSelectionPanel.tsx index b1ba1ca87..753eb13b0 100644 --- a/components/agents/AgentWorkflowMaterialSelectionPanel.tsx +++ b/components/agents/AgentWorkflowMaterialSelectionPanel.tsx @@ -405,7 +405,7 @@ export function AgentWorkflowMaterialSelectionPanel({ const payload = await response.json().catch(() => ({})); if (!response.ok || !payload?.success || !payload?.data?.imageUrl) { - throw new Error(payload?.error || payload?.detail || "图片上传失败"); + throw new Error(payload?.detail || payload?.error || "图片上传失败"); } const uploadedImage = payload.data as AgentWorkflowMaterialImage; diff --git a/lib/agents/agent-project-compat.ts b/lib/agents/agent-project-compat.ts new file mode 100644 index 000000000..7ff40cde1 --- /dev/null +++ b/lib/agents/agent-project-compat.ts @@ -0,0 +1,69 @@ +import { + createEmptyAgentGenerationProjectPersistedState, + normalizeAgentGenerationProjectPersistedState, +} from "@/lib/agents/generation-project-state"; +import type { canvasProject } from "@/lib/db/schema"; + +function getCanvasDataRecord(value: unknown) { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function hasLegacyWorkflowState(value: unknown) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + + const raw = value as Record; + return ( + "inputs" in raw || + "materials" in raw || + "selectedAgentPresetId" in raw || + "currentStage" in raw || + "agentModeState" in raw + ); +} + +export function isLegacyAgentProjectCanvasData(value: unknown) { + const raw = getCanvasDataRecord(value); + return ( + hasLegacyWorkflowState(raw.workflowState) || + hasLegacyWorkflowState(raw.agentProjectState) || + hasLegacyWorkflowState(raw.projectState) || + Array.isArray(raw.resultImageUrls) || + (raw.resultCollections && typeof raw.resultCollections === "object") || + (raw.deletedResultCollections && typeof raw.deletedResultCollections === "object") || + (raw.workspaceState && typeof raw.workspaceState === "object") + ); +} + +export function isAgentProjectRow(row: typeof canvasProject.$inferSelect) { + return row.workspaceType === "agent" || isLegacyAgentProjectCanvasData(row.canvasData); +} + +export function extractAgentProjectStateFromCanvasData(value: unknown) { + const raw = getCanvasDataRecord(value); + const explicitStateCandidate = + raw.agentProjectState && typeof raw.agentProjectState === "object" + ? raw.agentProjectState + : raw.projectState && typeof raw.projectState === "object" + ? raw.projectState + : null; + + if (explicitStateCandidate) { + return normalizeAgentGenerationProjectPersistedState(explicitStateCandidate); + } + + if (isLegacyAgentProjectCanvasData(raw)) { + return normalizeAgentGenerationProjectPersistedState({ + workflowState: raw.workflowState, + workspaceState: raw.workspaceState, + resultImageUrls: raw.resultImageUrls, + resultCollections: raw.resultCollections, + deletedResultCollections: raw.deletedResultCollections, + }); + } + + return createEmptyAgentGenerationProjectPersistedState(); +} diff --git a/lib/agents/psd-template-config.ts b/lib/agents/psd-template-config.ts index 11b09caf9..4e457e7a8 100644 --- a/lib/agents/psd-template-config.ts +++ b/lib/agents/psd-template-config.ts @@ -1,3 +1,5 @@ +import type { AgentPsdTextColorConfig } from "@/types/agent"; + export const AGENT_PSD_TEMPLATES_KEY = "agent_psd_templates"; export const AGENT_PSD_TEMPLATE_ASSET_VERSION = 3; @@ -98,6 +100,7 @@ export interface AgentPsdTemplate { export interface AgentPsdTemplateListResponse { success: boolean; templates: AgentPsdTemplate[]; + textColorConfig?: AgentPsdTextColorConfig; error?: string; } diff --git a/lib/agents/psd-template-renderer.ts b/lib/agents/psd-template-renderer.ts index 2e11a5861..d8e8a0be8 100644 --- a/lib/agents/psd-template-renderer.ts +++ b/lib/agents/psd-template-renderer.ts @@ -10,7 +10,7 @@ import { import type { AgentPsdFont } from "@/lib/agents/psd-font-config"; import { ensurePsdFontAvailableForRender } from "@/lib/agents/psd-font-runtime"; import { normalizeAgentPsdImagePlacement, resolveAgentPsdImagePlacementFrame } from "@/lib/agents/psd-image-placement"; -import { resolveTextLayout } from "@/lib/agents/psd-text-layout"; +import { normalizeHexColor, resolveTextLayout } from "@/lib/agents/psd-text-layout"; async function getSharp() { const sharp = await import("sharp"); @@ -125,6 +125,7 @@ function resolveTextOverlayFrame(params: { contentWidth: number; contentHeight: number; textAlign: "left" | "center" | "right"; + topOffset?: number; }) { const width = Math.max(params.layerWidth, params.contentWidth); const height = Math.max(params.layerHeight, params.contentHeight); @@ -139,7 +140,7 @@ function resolveTextOverlayFrame(params: { width, height, leftOffset, - topOffset: 0, + topOffset: Number.isFinite(Number(params.topOffset)) ? Number(params.topOffset) : 0, }; } @@ -326,9 +327,17 @@ export async function renderAgentPsdTemplate(params: { imageAssignments: Record; imagePlacements: Record>; textAssignments: Record; + textColorAssignments?: Record; fonts: AgentPsdFont[]; }) { - const { template, imageAssignments, imagePlacements, textAssignments, fonts } = params; + const { + template, + imageAssignments, + imagePlacements, + textAssignments, + textColorAssignments = {}, + fonts, + } = params; const sharp = await getSharp(); const canvasWidth = Math.max(1, Math.round(Number(template.width || 1))); const canvasHeight = Math.max(1, Math.round(Number(template.height || 1))); @@ -511,7 +520,12 @@ export async function renderAgentPsdTemplate(params: { if (isAgentPsdTextLayerSlot(layer)) { const text = String(textAssignments[layer.id] || layer.defaultText || "").trim(); const defaultText = String(layer.defaultText || "").trim(); - if (!text || text === defaultText) { + const textColor = normalizeHexColor( + textColorAssignments[layer.id] || layer.textColor, + layer.textColor + ); + const hasColorChange = normalizeHexColor(textColor, layer.textColor) !== normalizeHexColor(layer.textColor); + if (!text || (text === defaultText && !hasColorChange)) { await appendStoredLayer(layer); continue; } @@ -563,7 +577,7 @@ export async function renderAgentPsdTemplate(params: { fontWeight: layer.fontWeight, lineHeight: layer.lineHeight, letterSpacing: layer.letterSpacing, - textColor: layer.textColor, + textColor, textAlign: layer.textAlign, paddingX: layer.paddingX, paddingY: layer.paddingY, @@ -590,6 +604,7 @@ export async function renderAgentPsdTemplate(params: { contentWidth: overlayLayout.contentWidth, contentHeight: overlayLayout.contentHeight, textAlign: layer.textAlign, + topOffset: -overlayLayout.visualOffsetY, }); const didAppend = await appendBufferOverlay({ diff --git a/lib/agents/psd-text-color-config.ts b/lib/agents/psd-text-color-config.ts new file mode 100644 index 000000000..bb3824187 --- /dev/null +++ b/lib/agents/psd-text-color-config.ts @@ -0,0 +1,35 @@ +import type { AgentPsdTextColorConfig } from "@/types/agent"; +import { normalizeHexColor } from "@/lib/agents/psd-text-layout"; + +export const AGENT_PSD_TEXT_COLOR_CONFIG_KEY = "agent_psd_text_color_config"; + +export const defaultAgentPsdTextColorConfig: AgentPsdTextColorConfig = { + enabled: false, + colors: ["#ffffff", "#111111"], +}; + +export function normalizeAgentPsdTextColorConfig( + value: unknown +): AgentPsdTextColorConfig { + const raw = + value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; + + const colors = Array.isArray(raw.colors) + ? raw.colors + .map((item) => normalizeHexColor(String(item || ""), "")) + .filter(Boolean) + .slice(0, 2) + : []; + + const [firstColor, secondColor] = [ + colors[0] || defaultAgentPsdTextColorConfig.colors[0], + colors[1] || colors[0] || defaultAgentPsdTextColorConfig.colors[1], + ]; + + return { + enabled: raw.enabled === true, + colors: [firstColor, secondColor], + }; +} diff --git a/lib/agents/psd-text-layout.ts b/lib/agents/psd-text-layout.ts index 1f180ac46..10e0786af 100644 --- a/lib/agents/psd-text-layout.ts +++ b/lib/agents/psd-text-layout.ts @@ -124,6 +124,13 @@ export function resolveTextLayout(params: { const availableHeight = Math.max(8, height - paddingY * 2); const resolvedLineHeight = Math.max(1, Number(lineHeight || 1)); const resolvedMaxLines = Math.max(1, Number(maxLines || 1)); + const visualOffsetY = Number( + Math.max(2, Math.min(20, Math.round(Math.max(10, Number(fontSize || 10)) * 0.16))).toFixed(2) + ); + const visualBottomPadding = Number( + Math.max(3, Math.min(24, Math.round(Math.max(10, Number(fontSize || 10)) * 0.24))).toFixed(2) + ); + const minContentHeight = height + visualOffsetY + visualBottomPadding; let fittedFontSize = Math.max(10, Number(fontSize || 10)); let bestLines: string[] = []; @@ -153,8 +160,13 @@ export function resolveTextLayout(params: { maxLineWidth: Number(bestMaxLineWidth.toFixed(2)), contentWidth: Number(Math.max(width, bestMaxLineWidth + paddingX * 2).toFixed(2)), contentHeight: Number( - Math.max(height, Math.max(1, bestLines.length) * bestLineHeightPx + paddingY * 2).toFixed(2) + Math.max( + minContentHeight, + Math.max(1, bestLines.length) * bestLineHeightPx + paddingY * 2 + visualOffsetY + visualBottomPadding + ).toFixed(2) ), + visualOffsetY, + visualBottomPadding, }; } @@ -202,8 +214,13 @@ export function resolveTextLayout(params: { maxLineWidth: Number(bestMaxLineWidth.toFixed(2)), contentWidth: Number(Math.max(width, bestMaxLineWidth + paddingX * 2).toFixed(2)), contentHeight: Number( - Math.max(height, Math.max(1, bestLines.length) * bestLineHeightPx + paddingY * 2).toFixed(2) + Math.max( + minContentHeight, + Math.max(1, bestLines.length) * bestLineHeightPx + paddingY * 2 + visualOffsetY + visualBottomPadding + ).toFixed(2) ), + visualOffsetY, + visualBottomPadding, }; } diff --git a/types/agent.ts b/types/agent.ts index 58ef233b3..81bed8964 100644 --- a/types/agent.ts +++ b/types/agent.ts @@ -411,6 +411,11 @@ export interface AgentGenerationProject { updatedAt: Date | string; } +export interface AgentPsdTextColorConfig { + enabled: boolean; + colors: [string, string]; +} + export interface AgentCategoryTreeConfig { productCategories: string[]; audienceTags: string[];