fix: harden psd text workflow compatibility
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 00:08:53 +08:00
parent 3e935fae37
commit 01748fe501
15 changed files with 557 additions and 92 deletions

View File

@ -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<AgentPresetLibraryConfig>(
defaultAgentPresetLibraryConfig
@ -481,6 +488,9 @@ export function AgentWorkflowSettingsClient({
const [showcaseConfig, setShowcaseConfig] = useState<AgentWorkflowShowcaseConfig>(
defaultAgentWorkflowShowcaseConfig
);
const [psdTextColorConfig, setPsdTextColorConfig] = useState<AgentPsdTextColorConfig>(
defaultAgentPsdTextColorConfig
);
const [brandModelConfig, setBrandModelConfig] = useState<AgentWorkflowBrandModelConfig>(
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({
</CardContent>
</Card>
</motion.div>
<motion.div variants={itemVariants} className="lg:col-span-2">
<Card>
<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-violet-500/10">
<Settings2 className="h-4 w-4 text-violet-600" />
</div>
<div>
<CardTitle className="text-base">PSD 文字颜色选项</CardTitle>
<CardDescription>
控制套用 PSD 模板时,前台是否允许用户在两种预设文字颜色之间切换。
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<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">
开启后,用户在 PSD 模板编辑弹窗里可为文字图层选择两种系统预设颜色。
</div>
</div>
<Switch
checked={psdTextColorConfig.enabled}
onCheckedChange={(checked) =>
setPsdTextColorConfig((prev) => ({ ...prev, enabled: checked }))
}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
{psdTextColorConfig.colors.map((color, index) => (
<div
key={`psd-text-color-${index + 1}`}
className="rounded-xl border bg-muted/20 p-4"
>
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<Label className="text-xs">{`颜色 ${index + 1}`}</Label>
<div className="mt-1 text-xs text-muted-foreground">
用户前台可直接选择这组文字颜色。
</div>
</div>
<div
className="h-10 w-10 rounded-full border shadow-sm"
style={{ backgroundColor: color }}
/>
</div>
<div className="flex items-center gap-3">
<input
type="color"
value={color}
onChange={(event) =>
updatePsdTextColor(index as 0 | 1, event.target.value)
}
className="h-11 w-14 cursor-pointer rounded-lg border bg-transparent p-1"
/>
<Input
value={color}
onChange={(event) =>
updatePsdTextColor(index as 0 | 1, event.target.value)
}
placeholder="#FFFFFF"
className="font-mono uppercase"
/>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</motion.div>
</motion.div>
</TabsContent>

View File

@ -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<string, unknown>)
: {};
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<string, unknown>)
: {};
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) {

View File

@ -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<string, unknown>)
: {};
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);

View File

@ -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,
},
});

View File

@ -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<string, string> = {};
const imagePlacements: Record<string, ReturnType<typeof normalizeAgentPsdImagePlacement>> = {};
const textAssignments: Record<string, string> = {};
const textColorAssignments: Record<string, string> = {};
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,
});

View File

@ -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<ReturnType<typeof readAgentPsdTemplates>>[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);

View File

@ -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)

View File

@ -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<AgentPsdTemplateListResponse> => {
try {
@ -63,6 +67,7 @@ const fetcher = async (url: string): Promise<AgentPsdTemplateListResponse> => {
return {
success: false,
templates: [],
textColorConfig: defaultAgentPsdTextColorConfig,
error: typeof payload?.error === "string" ? payload.error : "PSD 模板加载失败",
};
}
@ -70,11 +75,13 @@ const fetcher = async (url: string): Promise<AgentPsdTemplateListResponse> => {
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<string>("");
const [selectedLayerId, setSelectedLayerId] = useState<string>("");
@ -386,6 +417,7 @@ export function AgentPsdTemplateEditorDialog({
const [imagePlacements, setImagePlacements] = useState<Record<string, AgentPsdImagePlacement>>({});
const [imageSizes, setImageSizes] = useState<Record<string, AgentPsdImageSize>>({});
const [textAssignments, setTextAssignments] = useState<Record<string, string>>({});
const [textColorAssignments, setTextColorAssignments] = useState<Record<string, string>>({});
const [uploadingSlotId, setUploadingSlotId] = useState<string | null>(null);
const [isRendering, setIsRendering] = useState(false);
const [renderedResultUrl, setRenderedResultUrl] = useState<string>("");
@ -495,6 +527,21 @@ export function AgentPsdTemplateEditorDialog({
return nextAssignments;
}, [editableTextLayers, selectedTemplate, textAssignments]);
const effectiveTextColorAssignments = useMemo(() => {
const nextAssignments: Record<string, string> = {};
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<string, AgentPsdImagePlacement> = {};
@ -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({
>
<div
className="whitespace-pre"
style={getTextLayerContentStyle(layer, value || layer.placeholder || layer.layerName)}
style={getTextLayerContentStyle(
layer,
value || layer.placeholder || layer.layerName,
resolvedTextColor
)}
>
{value || layer.placeholder || layer.layerName}
</div>
@ -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
)}
/>
</div>
<div className={getTextLayerNameBadgeClassName()}>
@ -1585,6 +1654,57 @@ export function AgentPsdTemplateEditorDialog({
<span>行高:{selectedTextLayer.lineHeight}</span>
<span>字距:{selectedTextLayer.letterSpacing}px</span>
</div>
{textColorConfig.enabled ? (
<div className="space-y-3 rounded-xl border border-white/10 bg-white/[0.03] p-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">文字颜色</div>
<div className="text-xs text-muted-foreground">
当前模板允许在两种系统预设颜色之间切换。
</div>
</div>
<div
className="h-8 w-8 rounded-full border border-white/15"
style={{ backgroundColor: selectedTextColor }}
/>
</div>
<div className="grid grid-cols-2 gap-2">
{textColorConfig.colors.map((color, index) => {
const isActive =
getResolvedTextLayerColor(selectedTextLayer, color) ===
getResolvedTextLayerColor(selectedTextLayer, selectedTextColor);
return (
<button
key={`text-color-option-${index + 1}`}
type="button"
onClick={() =>
setTextColorAssignments((current) => ({
...current,
[selectedTextLayer.id]: color,
}))
}
className={`flex items-center gap-3 rounded-xl border px-3 py-3 text-left transition ${
isActive
? "border-emerald-400/55 bg-emerald-500/[0.12] text-white"
: "border-white/10 bg-white/[0.02] text-white/80 hover:bg-white/[0.06]"
}`}
>
<span
className="h-6 w-6 rounded-full border border-white/20"
style={{ backgroundColor: color }}
/>
<span className="flex min-w-0 flex-col">
<span className="text-xs font-medium">{`颜色 ${index + 1}`}</span>
<span className="font-mono text-[11px] uppercase text-white/55">
{color}
</span>
</span>
</button>
);
})}
</div>
</div>
) : null}
</div>
) : (
<div className="flex min-h-[200px] flex-col items-center justify-center text-center">

View File

@ -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;

View File

@ -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<string, unknown>)
: {};
}
function hasLegacyWorkflowState(value: unknown) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
}
const raw = value as Record<string, unknown>;
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();
}

View File

@ -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;
}

View File

@ -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<string, string>;
imagePlacements: Record<string, ReturnType<typeof normalizeAgentPsdImagePlacement>>;
textAssignments: Record<string, string>;
textColorAssignments?: Record<string, string>;
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({

View File

@ -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<string, unknown>)
: {};
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],
};
}

View File

@ -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,
};
}

View File

@ -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[];