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, defaultAgentWorkflowGoodsInfoApiConfig,
normalizeAgentWorkflowGoodsInfoApiConfig, normalizeAgentWorkflowGoodsInfoApiConfig,
} from "@/lib/agents/workflow-goods-info-config"; } 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 { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@ -84,6 +89,7 @@ import type {
AgentWorkflowGoodsInfoApiConfig, AgentWorkflowGoodsInfoApiConfig,
AgentWorkflowMaterialApiConfig, AgentWorkflowMaterialApiConfig,
AgentWorkflowOptionUiConfig, AgentWorkflowOptionUiConfig,
AgentPsdTextColorConfig,
AgentWorkflowShowcaseConfig, AgentWorkflowShowcaseConfig,
AgentWorkflowStepUiConfig, AgentWorkflowStepUiConfig,
AgentWorkflowUiConfig, AgentWorkflowUiConfig,
@ -467,6 +473,7 @@ export function AgentWorkflowSettingsClient({
agentWorkflowGoodsInfoApiConfig?: AgentWorkflowGoodsInfoApiConfig; agentWorkflowGoodsInfoApiConfig?: AgentWorkflowGoodsInfoApiConfig;
agentWorkflowBrandModelConfig?: AgentWorkflowBrandModelConfig; agentWorkflowBrandModelConfig?: AgentWorkflowBrandModelConfig;
agentWorkflowShowcaseConfig?: AgentWorkflowShowcaseConfig; agentWorkflowShowcaseConfig?: AgentWorkflowShowcaseConfig;
agentPsdTextColorConfig?: AgentPsdTextColorConfig;
}>("/api/site-settings", fetcher); }>("/api/site-settings", fetcher);
const [presetLibraryConfig, setPresetLibraryConfig] = useState<AgentPresetLibraryConfig>( const [presetLibraryConfig, setPresetLibraryConfig] = useState<AgentPresetLibraryConfig>(
defaultAgentPresetLibraryConfig defaultAgentPresetLibraryConfig
@ -481,6 +488,9 @@ export function AgentWorkflowSettingsClient({
const [showcaseConfig, setShowcaseConfig] = useState<AgentWorkflowShowcaseConfig>( const [showcaseConfig, setShowcaseConfig] = useState<AgentWorkflowShowcaseConfig>(
defaultAgentWorkflowShowcaseConfig defaultAgentWorkflowShowcaseConfig
); );
const [psdTextColorConfig, setPsdTextColorConfig] = useState<AgentPsdTextColorConfig>(
defaultAgentPsdTextColorConfig
);
const [brandModelConfig, setBrandModelConfig] = useState<AgentWorkflowBrandModelConfig>( const [brandModelConfig, setBrandModelConfig] = useState<AgentWorkflowBrandModelConfig>(
defaultAgentWorkflowBrandModelConfig defaultAgentWorkflowBrandModelConfig
); );
@ -523,6 +533,9 @@ export function AgentWorkflowSettingsClient({
setShowcaseConfig( setShowcaseConfig(
normalizeAgentWorkflowShowcaseConfig(data.agentWorkflowShowcaseConfig) normalizeAgentWorkflowShowcaseConfig(data.agentWorkflowShowcaseConfig)
); );
setPsdTextColorConfig(
normalizeAgentPsdTextColorConfig(data.agentPsdTextColorConfig)
);
setBrandModelConfig( setBrandModelConfig(
normalizeAgentWorkflowBrandModelConfig(data.agentWorkflowBrandModelConfig) 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 () => { const handleSave = async () => {
try { try {
setIsSaving(true); setIsSaving(true);
@ -1036,6 +1061,7 @@ export function AgentWorkflowSettingsClient({
agentWorkflowGoodsInfoApiConfig: goodsInfoApiConfig, agentWorkflowGoodsInfoApiConfig: goodsInfoApiConfig,
agentWorkflowBrandModelConfig: brandModelConfig, agentWorkflowBrandModelConfig: brandModelConfig,
agentWorkflowShowcaseConfig: showcaseConfig, agentWorkflowShowcaseConfig: showcaseConfig,
agentPsdTextColorConfig: psdTextColorConfig,
}), }),
}); });
@ -2618,6 +2644,80 @@ export function AgentWorkflowSettingsClient({
</CardContent> </CardContent>
</Card> </Card>
</motion.div> </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> </motion.div>
</TabsContent> </TabsContent>

View File

@ -4,28 +4,22 @@ import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { canvasProject } from "@/lib/db/schema"; import { canvasProject } from "@/lib/db/schema";
import { import {
createEmptyAgentGenerationProjectPersistedState, extractAgentProjectStateFromCanvasData,
normalizeAgentGenerationProjectPersistedState, isAgentProjectRow,
} from "@/lib/agents/generation-project-state"; } from "@/lib/agents/agent-project-compat";
import { normalizeAgentGenerationProjectPersistedState } from "@/lib/agents/generation-project-state";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export const revalidate = 0; export const revalidate = 0;
function mapAgentProjectRow(row: typeof canvasProject.$inferSelect) { 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 { return {
id: row.id, id: row.id,
userId: row.userId, userId: row.userId,
title: row.title, title: row.title,
description: row.description || null, description: row.description || null,
thumbnail: row.thumbnail || null, thumbnail: row.thumbnail || null,
projectState: normalizeAgentGenerationProjectPersistedState( projectState: extractAgentProjectStateFromCanvasData(row.canvasData),
canvasData.agentProjectState || createEmptyAgentGenerationProjectPersistedState()
),
createdAt: row.createdAt, createdAt: row.createdAt,
updatedAt: row.updatedAt, updatedAt: row.updatedAt,
}; };
@ -46,15 +40,11 @@ export async function GET(
.select() .select()
.from(canvasProject) .from(canvasProject)
.where( .where(
and( and(eq(canvasProject.id, id), eq(canvasProject.userId, session.user.id))
eq(canvasProject.id, id),
eq(canvasProject.userId, session.user.id),
eq(canvasProject.workspaceType, "agent")
)
) )
.limit(1); .limit(1);
if (!project) { if (!project || !isAgentProjectRow(project)) {
return NextResponse.json({ success: false, error: "生图项目不存在" }, { status: 404 }); return NextResponse.json({ success: false, error: "生图项目不存在" }, { status: 404 });
} }
@ -81,15 +71,11 @@ export async function PUT(
.select() .select()
.from(canvasProject) .from(canvasProject)
.where( .where(
and( and(eq(canvasProject.id, id), eq(canvasProject.userId, session.user.id))
eq(canvasProject.id, id),
eq(canvasProject.userId, session.user.id),
eq(canvasProject.workspaceType, "agent")
)
) )
.limit(1); .limit(1);
if (!existingProject) { if (!existingProject || !isAgentProjectRow(existingProject)) {
return NextResponse.json({ success: false, error: "生图项目不存在" }, { status: 404 }); return NextResponse.json({ success: false, error: "生图项目不存在" }, { status: 404 });
} }
@ -99,19 +85,10 @@ export async function PUT(
const thumbnail = const thumbnail =
body.thumbnail !== undefined ? String(body.thumbnail || "").trim() || null : undefined; 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 = const nextProjectState =
body.projectState !== undefined body.projectState !== undefined
? normalizeAgentGenerationProjectPersistedState(body.projectState) ? normalizeAgentGenerationProjectPersistedState(body.projectState)
: normalizeAgentGenerationProjectPersistedState( : extractAgentProjectStateFromCanvasData(existingProject.canvasData);
existingCanvasData.agentProjectState || createEmptyAgentGenerationProjectPersistedState()
);
const [project] = await db const [project] = await db
.update(canvasProject) .update(canvasProject)
@ -119,6 +96,7 @@ export async function PUT(
title, title,
description, description,
thumbnail, thumbnail,
workspaceType: "agent",
updatedAt: new Date(), updatedAt: new Date(),
canvasData: { canvasData: {
nodes: [], nodes: [],
@ -130,11 +108,7 @@ export async function PUT(
} as any, } as any,
}) })
.where( .where(
and( and(eq(canvasProject.id, id), eq(canvasProject.userId, session.user.id))
eq(canvasProject.id, id),
eq(canvasProject.userId, session.user.id),
eq(canvasProject.workspaceType, "agent")
)
) )
.returning(); .returning();
@ -156,15 +130,19 @@ export async function DELETE(
} }
const { id } = await params; 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 const [project] = await db
.delete(canvasProject) .delete(canvasProject)
.where( .where(and(eq(canvasProject.id, id), eq(canvasProject.userId, session.user.id)))
and(
eq(canvasProject.id, id),
eq(canvasProject.userId, session.user.id),
eq(canvasProject.workspaceType, "agent")
)
)
.returning(); .returning();
if (!project) { if (!project) {

View File

@ -1,31 +1,25 @@
import { NextRequest, NextResponse } from "next/server"; 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 { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { canvasProject } from "@/lib/db/schema"; import { canvasProject } from "@/lib/db/schema";
import { import {
createEmptyAgentGenerationProjectPersistedState, extractAgentProjectStateFromCanvasData,
normalizeAgentGenerationProjectPersistedState, isAgentProjectRow,
} from "@/lib/agents/generation-project-state"; } from "@/lib/agents/agent-project-compat";
import { normalizeAgentGenerationProjectPersistedState } from "@/lib/agents/generation-project-state";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export const revalidate = 0; export const revalidate = 0;
function mapAgentProjectRow(row: typeof canvasProject.$inferSelect) { 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 { return {
id: row.id, id: row.id,
userId: row.userId, userId: row.userId,
title: row.title, title: row.title,
description: row.description || null, description: row.description || null,
thumbnail: row.thumbnail || null, thumbnail: row.thumbnail || null,
projectState: normalizeAgentGenerationProjectPersistedState( projectState: extractAgentProjectStateFromCanvasData(row.canvasData),
canvasData.agentProjectState || createEmptyAgentGenerationProjectPersistedState()
),
createdAt: row.createdAt, createdAt: row.createdAt,
updatedAt: row.updatedAt, updatedAt: row.updatedAt,
}; };
@ -41,17 +35,12 @@ export async function GET() {
const projects = await db const projects = await db
.select() .select()
.from(canvasProject) .from(canvasProject)
.where( .where(eq(canvasProject.userId, session.user.id))
and(
eq(canvasProject.userId, session.user.id),
eq(canvasProject.workspaceType, "agent")
)
)
.orderBy(desc(canvasProject.updatedAt)); .orderBy(desc(canvasProject.updatedAt));
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
data: projects.map(mapAgentProjectRow), data: projects.filter(isAgentProjectRow).map(mapAgentProjectRow),
}); });
} catch (error) { } catch (error) {
console.error("[AgentProjects API] 获取项目列表失败:", error); console.error("[AgentProjects API] 获取项目列表失败:", error);

View File

@ -17,6 +17,14 @@ const payloadSchema = z.object({
productModel: z.string().trim().max(128).optional(), 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) { function sanitizeFilenameStem(value: string) {
return String(value || "") return String(value || "")
.trim() .trim()
@ -28,6 +36,23 @@ function sanitizeFilenameStem(value: string) {
.slice(0, 80); .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) { export async function POST(request: NextRequest) {
try { try {
const session = await auth(); const session = await auth();
@ -36,12 +61,14 @@ export async function POST(request: NextRequest) {
} }
const formData = await request.formData(); const formData = await request.formData();
const file = formData.get("file"); const fileEntry = formData.get("file");
if (!(file instanceof File)) { if (!isUploadLikeFile(fileEntry)) {
return NextResponse.json({ error: "缺少图片文件" }, { status: 400 }); 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 }); return NextResponse.json({ error: "请上传图片文件" }, { status: 400 });
} }
@ -56,6 +83,10 @@ export async function POST(request: NextRequest) {
const arrayBuffer = await file.arrayBuffer(); const arrayBuffer = await file.arrayBuffer();
const imageBuffer = Buffer.from(arrayBuffer); const imageBuffer = Buffer.from(arrayBuffer);
if (imageBuffer.byteLength <= 0) {
return NextResponse.json({ error: "图片文件为空,请重新上传" }, { status: 400 });
}
const promptPrefix = payload.source === "secondary" ? "搭配SKU" : "主SPU"; const promptPrefix = payload.source === "secondary" ? "搭配SKU" : "主SPU";
const prompt = `${promptPrefix}素材抠图`; const prompt = `${promptPrefix}素材抠图`;
@ -66,7 +97,8 @@ export async function POST(request: NextRequest) {
skipGallerySave: true, 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 displayName = `${originalStem}-rmbg.png`;
const asset = await upsertMediaAsset({ const asset = await upsertMediaAsset({
userId: session.user.id, userId: session.user.id,
@ -88,7 +120,8 @@ export async function POST(request: NextRequest) {
matchedSku: payload.matchedSku || null, matchedSku: payload.matchedSku || null,
matchedSpu: payload.matchedSpu || null, matchedSpu: payload.matchedSpu || null,
productModel: payload.productModel || null, productModel: payload.productModel || null,
originalFileName: file.name, originalFileName,
originalMimeType: mimeType,
provider: result.provider, 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 { repairAgentPsdTemplateAssetsIfNeeded } from "@/lib/agents/psd-template-repair";
import { renderAgentPsdTemplate } from "@/lib/agents/psd-template-renderer"; import { renderAgentPsdTemplate } from "@/lib/agents/psd-template-renderer";
import { resolveAgentPsdTemplateTextStyles } from "@/lib/agents/psd-template-style-resolver"; 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"; import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings";
export const runtime = "nodejs"; export const runtime = "nodejs";
@ -34,6 +35,7 @@ const renderSchema = z.object({
) )
.default({}), .default({}),
textAssignments: z.record(z.string(), z.string()).default({}), textAssignments: z.record(z.string(), z.string()).default({}),
textColorAssignments: z.record(z.string(), z.string()).default({}),
}); });
function jsonError(status: number, error: string) { function jsonError(status: number, error: string) {
@ -107,6 +109,7 @@ export async function POST(request: NextRequest) {
const imageAssignments: Record<string, string> = {}; const imageAssignments: Record<string, string> = {};
const imagePlacements: Record<string, ReturnType<typeof normalizeAgentPsdImagePlacement>> = {}; const imagePlacements: Record<string, ReturnType<typeof normalizeAgentPsdImagePlacement>> = {};
const textAssignments: Record<string, string> = {}; const textAssignments: Record<string, string> = {};
const textColorAssignments: Record<string, string> = {};
for (const layer of editableLayers) { for (const layer of editableLayers) {
if (isAgentPsdImageLayerSlot(layer)) { if (isAgentPsdImageLayerSlot(layer)) {
const explicitUrl = String(body.imageAssignments[layer.id] || "").trim(); const explicitUrl = String(body.imageAssignments[layer.id] || "").trim();
@ -127,6 +130,10 @@ export async function POST(request: NextRequest) {
if (nextText) { if (nextText) {
textAssignments[layer.id] = 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, imageAssignments,
imagePlacements, imagePlacements,
textAssignments, textAssignments,
textColorAssignments,
fonts, fonts,
}); });

View File

@ -1,6 +1,9 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { upsertSystemSetting } from "@/lib/db/queries"; import { upsertSystemSetting } from "@/lib/db/queries";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db";
import { systemSettings } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
import { import {
AGENT_PSD_TEMPLATES_KEY, AGENT_PSD_TEMPLATES_KEY,
getEditableAgentPsdTemplateLayers, getEditableAgentPsdTemplateLayers,
@ -8,6 +11,11 @@ import {
isAgentPsdTemplateReady, isAgentPsdTemplateReady,
type AgentPsdTemplateListResponse, type AgentPsdTemplateListResponse,
} from "@/lib/agents/psd-template-config"; } 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 { readAgentPsdFonts } from "@/lib/agents/psd-font-settings";
import { repairAgentPsdTemplateAssetsIfNeeded } from "@/lib/agents/psd-template-repair"; import { repairAgentPsdTemplateAssetsIfNeeded } from "@/lib/agents/psd-template-repair";
import { resolveAgentPsdTemplateTextStyles } from "@/lib/agents/psd-template-style-resolver"; import { resolveAgentPsdTemplateTextStyles } from "@/lib/agents/psd-template-style-resolver";
@ -33,7 +41,16 @@ export async function GET(request: NextRequest) {
try { try {
const requestedBrand = String(request.nextUrl.searchParams.get("brand") || "").trim(); 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< const repairedTemplateMap = new Map<
string, string,
Awaited<ReturnType<typeof readAgentPsdTemplates>>[number] Awaited<ReturnType<typeof readAgentPsdTemplates>>[number]
@ -112,6 +129,9 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
templates, templates,
textColorConfig: textColorSetting
? normalizeAgentPsdTextColorConfig(textColorSetting.value)
: defaultAgentPsdTextColorConfig,
} satisfies AgentPsdTemplateListResponse); } satisfies AgentPsdTemplateListResponse);
} catch (error) { } catch (error) {
console.error("[PSD Templates] list failed:", error); console.error("[PSD Templates] list failed:", error);

View File

@ -57,6 +57,11 @@ import {
defaultAgentWorkflowShowcaseConfig, defaultAgentWorkflowShowcaseConfig,
normalizeAgentWorkflowShowcaseConfig, normalizeAgentWorkflowShowcaseConfig,
} from "@/lib/agents/workflow-showcase-config"; } from "@/lib/agents/workflow-showcase-config";
import {
AGENT_PSD_TEXT_COLOR_CONFIG_KEY,
defaultAgentPsdTextColorConfig,
normalizeAgentPsdTextColorConfig,
} from "@/lib/agents/psd-text-color-config";
import { import {
AGENT_PRESET_LIBRARY_CONFIG_KEY, AGENT_PRESET_LIBRARY_CONFIG_KEY,
defaultAgentPresetLibraryConfig, defaultAgentPresetLibraryConfig,
@ -266,6 +271,7 @@ export async function GET() {
agentWorkflowGoodsInfoApiConfigSetting, agentWorkflowGoodsInfoApiConfigSetting,
agentWorkflowBrandModelConfigSetting, agentWorkflowBrandModelConfigSetting,
agentWorkflowShowcaseConfigSetting, agentWorkflowShowcaseConfigSetting,
agentPsdTextColorConfigSetting,
] = await Promise.all([ ] = await Promise.all([
db.select().from(siteSettings).limit(1).then(rows => rows[0]), db.select().from(siteSettings).limit(1).then(rows => rows[0]),
db db
@ -418,6 +424,12 @@ export async function GET() {
.where(eq(systemSettings.key, AGENT_WORKFLOW_SHOWCASE_CONFIG_KEY)) .where(eq(systemSettings.key, AGENT_WORKFLOW_SHOWCASE_CONFIG_KEY))
.limit(1) .limit(1)
.then(rows => rows[0]), .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: "" }; const notification = notificationSetting?.value || { enabled: false, title: "", content: "" };
@ -484,6 +496,9 @@ export async function GET() {
const agentWorkflowShowcaseConfig = normalizeAgentWorkflowShowcaseConfig( const agentWorkflowShowcaseConfig = normalizeAgentWorkflowShowcaseConfig(
agentWorkflowShowcaseConfigSetting?.value agentWorkflowShowcaseConfigSetting?.value
); );
const agentPsdTextColorConfig = normalizeAgentPsdTextColorConfig(
agentPsdTextColorConfigSetting?.value
);
// 如果没有设置记录,返回默认值 // 如果没有设置记录,返回默认值
const baseSettings = settings || { const baseSettings = settings || {
@ -519,6 +534,7 @@ export async function GET() {
agentWorkflowGoodsInfoApiConfig: defaultAgentWorkflowGoodsInfoApiConfig, agentWorkflowGoodsInfoApiConfig: defaultAgentWorkflowGoodsInfoApiConfig,
agentWorkflowBrandModelConfig: defaultAgentWorkflowBrandModelConfig, agentWorkflowBrandModelConfig: defaultAgentWorkflowBrandModelConfig,
agentWorkflowShowcaseConfig: defaultAgentWorkflowShowcaseConfig, agentWorkflowShowcaseConfig: defaultAgentWorkflowShowcaseConfig,
agentPsdTextColorConfig: defaultAgentPsdTextColorConfig,
realtimeCanvasEnabled: true, realtimeCanvasEnabled: true,
realtimeCanvasServiceEnabled: true, realtimeCanvasServiceEnabled: true,
storageFailoverConfig, storageFailoverConfig,
@ -569,6 +585,7 @@ export async function GET() {
agentWorkflowGoodsInfoApiConfig, agentWorkflowGoodsInfoApiConfig,
agentWorkflowBrandModelConfig, agentWorkflowBrandModelConfig,
agentWorkflowShowcaseConfig, agentWorkflowShowcaseConfig,
agentPsdTextColorConfig,
membershipConfig, membershipConfig,
membershipUserOverrides, membershipUserOverrides,
pointsSystemConfig, pointsSystemConfig,
@ -790,6 +807,12 @@ const updateSchema = z.object({
footerText: z.string().max(64).optional(), footerText: z.string().max(64).optional(),
}) })
.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(), realtimeCanvasEnabled: z.boolean().optional(),
aiDetailGenerationEnabled: z.boolean().optional(), aiDetailGenerationEnabled: z.boolean().optional(),
canvasDefaultModelId: z.string().nullable().optional(), canvasDefaultModelId: z.string().nullable().optional(),
@ -877,6 +900,7 @@ export async function PUT(request: NextRequest) {
"agentWorkflowGoodsInfoApiConfig", "agentWorkflowGoodsInfoApiConfig",
"agentWorkflowBrandModelConfig", "agentWorkflowBrandModelConfig",
"agentWorkflowShowcaseConfig", "agentWorkflowShowcaseConfig",
"agentPsdTextColorConfig",
]); ]);
const hasRestrictedKeys = Object.keys(body || {}).some((key) => !allowedKeys.has(key)); const hasRestrictedKeys = Object.keys(body || {}).some((key) => !allowedKeys.has(key));
if (hasRestrictedKeys) { if (hasRestrictedKeys) {
@ -900,6 +924,7 @@ export async function PUT(request: NextRequest) {
agentWorkflowGoodsInfoApiConfig, agentWorkflowGoodsInfoApiConfig,
agentWorkflowBrandModelConfig, agentWorkflowBrandModelConfig,
agentWorkflowShowcaseConfig, agentWorkflowShowcaseConfig,
agentPsdTextColorConfig,
membershipConfig, membershipConfig,
membershipUserOverrides, membershipUserOverrides,
pointsSystemConfig, 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) { if (membershipConfig !== undefined) {
const normalized = normalizeMembershipConfig(membershipConfig); const normalized = normalizeMembershipConfig(membershipConfig);
await db await db
@ -1490,6 +1535,10 @@ export async function PUT(request: NextRequest) {
agentWorkflowShowcaseConfig !== undefined agentWorkflowShowcaseConfig !== undefined
? normalizeAgentWorkflowShowcaseConfig(agentWorkflowShowcaseConfig) ? normalizeAgentWorkflowShowcaseConfig(agentWorkflowShowcaseConfig)
: defaultAgentWorkflowShowcaseConfig, : defaultAgentWorkflowShowcaseConfig,
agentPsdTextColorConfig:
agentPsdTextColorConfig !== undefined
? normalizeAgentPsdTextColorConfig(agentPsdTextColorConfig)
: defaultAgentPsdTextColorConfig,
membershipConfig: membershipConfig:
membershipConfig !== undefined membershipConfig !== undefined
? normalizeMembershipConfig(membershipConfig) ? 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) { if (membershipConfig !== undefined) {
const normalized = normalizeMembershipConfig(membershipConfig); const normalized = normalizeMembershipConfig(membershipConfig);
await db await db
@ -2066,6 +2135,10 @@ export async function PUT(request: NextRequest) {
agentWorkflowShowcaseConfig !== undefined agentWorkflowShowcaseConfig !== undefined
? normalizeAgentWorkflowShowcaseConfig(agentWorkflowShowcaseConfig) ? normalizeAgentWorkflowShowcaseConfig(agentWorkflowShowcaseConfig)
: defaultAgentWorkflowShowcaseConfig, : defaultAgentWorkflowShowcaseConfig,
agentPsdTextColorConfig:
agentPsdTextColorConfig !== undefined
? normalizeAgentPsdTextColorConfig(agentPsdTextColorConfig)
: defaultAgentPsdTextColorConfig,
membershipConfig: membershipConfig:
membershipConfig !== undefined membershipConfig !== undefined
? normalizeMembershipConfig(membershipConfig) ? normalizeMembershipConfig(membershipConfig)

View File

@ -39,6 +39,10 @@ import {
type AgentPsdImagePlacement, type AgentPsdImagePlacement,
type AgentPsdImageSize, type AgentPsdImageSize,
} from "@/lib/agents/psd-image-placement"; } from "@/lib/agents/psd-image-placement";
import {
defaultAgentPsdTextColorConfig,
normalizeAgentPsdTextColorConfig,
} from "@/lib/agents/psd-text-color-config";
import { import {
isAgentPsdImageLayerSlot, isAgentPsdImageLayerSlot,
isAgentPsdTextLayerSlot, isAgentPsdTextLayerSlot,
@ -50,7 +54,7 @@ import {
type AgentPsdTemplateRenderResponse, type AgentPsdTemplateRenderResponse,
type AgentPsdTemplateTextLayerSlot, type AgentPsdTemplateTextLayerSlot,
} from "@/lib/agents/psd-template-config"; } 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> => { const fetcher = async (url: string): Promise<AgentPsdTemplateListResponse> => {
try { try {
@ -63,6 +67,7 @@ const fetcher = async (url: string): Promise<AgentPsdTemplateListResponse> => {
return { return {
success: false, success: false,
templates: [], templates: [],
textColorConfig: defaultAgentPsdTextColorConfig,
error: typeof payload?.error === "string" ? payload.error : "PSD 模板加载失败", error: typeof payload?.error === "string" ? payload.error : "PSD 模板加载失败",
}; };
} }
@ -70,11 +75,13 @@ const fetcher = async (url: string): Promise<AgentPsdTemplateListResponse> => {
return { return {
success: true, success: true,
templates: parseAgentPsdTemplates(payload.templates), templates: parseAgentPsdTemplates(payload.templates),
textColorConfig: normalizeAgentPsdTextColorConfig(payload.textColorConfig),
}; };
} catch { } catch {
return { return {
success: false, success: false,
templates: [], templates: [],
textColorConfig: defaultAgentPsdTextColorConfig,
error: "PSD 模板加载失败", error: "PSD 模板加载失败",
}; };
} }
@ -231,6 +238,13 @@ function getResolvedTextPreview(layer: AgentPsdTemplateTextLayerSlot, value: str
return value || layer.placeholder || layer.layerName; return value || layer.placeholder || layer.layerName;
} }
function getResolvedTextLayerColor(
layer: AgentPsdTemplateTextLayerSlot,
value?: string
) {
return normalizeHexColor(value || layer.textColor, layer.textColor);
}
function getTextLayerRenderFrame( function getTextLayerRenderFrame(
layer: AgentPsdTemplateTextLayerSlot, layer: AgentPsdTemplateTextLayerSlot,
value: string value: string
@ -262,13 +276,14 @@ function getTextLayerRenderFrame(
width, width,
height, height,
offsetX, offsetX,
offsetY: 0, offsetY: -resolvedLayout.visualOffsetY,
}; };
} }
function getTextLayerContentStyle( function getTextLayerContentStyle(
layer: AgentPsdTemplateTextLayerSlot, layer: AgentPsdTemplateTextLayerSlot,
value: string value: string,
color: string
): CSSProperties { ): CSSProperties {
const resolvedLayout = getTextLayerRenderFrame(layer, value); const resolvedLayout = getTextLayerRenderFrame(layer, value);
@ -279,7 +294,7 @@ function getTextLayerContentStyle(
fontSize: resolvedLayout.fontSize, fontSize: resolvedLayout.fontSize,
lineHeight: layer.lineHeight, lineHeight: layer.lineHeight,
letterSpacing: `${layer.letterSpacing}px`, letterSpacing: `${layer.letterSpacing}px`,
color: layer.textColor, color,
fontWeight: layer.fontWeight, fontWeight: layer.fontWeight,
textAlign: layer.textAlign, textAlign: layer.textAlign,
paddingLeft: layer.paddingX, 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"); 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( function getInlineTextEditorStyle(
layer: AgentPsdTemplateTextLayerSlot, layer: AgentPsdTemplateTextLayerSlot,
preserveSourceAppearance: boolean, preserveSourceAppearance: boolean,
value: string value: string,
color: string
): CSSProperties { ): CSSProperties {
const baseStyle = getTextLayerContentStyle(layer, value); const baseStyle = getTextLayerContentStyle(layer, value, color);
return { return {
...baseStyle, ...baseStyle,
background: "transparent", background: "transparent",
boxSizing: "border-box", boxSizing: "border-box",
overflow: "hidden", overflow: "visible",
color: preserveSourceAppearance ? "transparent" : layer.textColor, color: preserveSourceAppearance ? "transparent" : color,
caretColor: layer.textColor, caretColor: color,
WebkitTextFillColor: preserveSourceAppearance ? "transparent" : layer.textColor, WebkitTextFillColor: preserveSourceAppearance ? "transparent" : color,
}; };
} }
@ -379,6 +406,10 @@ export function AgentPsdTemplateEditorDialog({
fetcher fetcher
); );
const templates = useMemo(() => (data?.success ? data.templates : []), [data]); const templates = useMemo(() => (data?.success ? data.templates : []), [data]);
const textColorConfig = useMemo(
() => normalizeAgentPsdTextColorConfig(data?.textColorConfig),
[data?.textColorConfig]
);
const [selectedTemplateId, setSelectedTemplateId] = useState<string>(""); const [selectedTemplateId, setSelectedTemplateId] = useState<string>("");
const [selectedLayerId, setSelectedLayerId] = useState<string>(""); const [selectedLayerId, setSelectedLayerId] = useState<string>("");
@ -386,6 +417,7 @@ export function AgentPsdTemplateEditorDialog({
const [imagePlacements, setImagePlacements] = useState<Record<string, AgentPsdImagePlacement>>({}); const [imagePlacements, setImagePlacements] = useState<Record<string, AgentPsdImagePlacement>>({});
const [imageSizes, setImageSizes] = useState<Record<string, AgentPsdImageSize>>({}); const [imageSizes, setImageSizes] = useState<Record<string, AgentPsdImageSize>>({});
const [textAssignments, setTextAssignments] = useState<Record<string, string>>({}); const [textAssignments, setTextAssignments] = useState<Record<string, string>>({});
const [textColorAssignments, setTextColorAssignments] = useState<Record<string, string>>({});
const [uploadingSlotId, setUploadingSlotId] = useState<string | null>(null); const [uploadingSlotId, setUploadingSlotId] = useState<string | null>(null);
const [isRendering, setIsRendering] = useState(false); const [isRendering, setIsRendering] = useState(false);
const [renderedResultUrl, setRenderedResultUrl] = useState<string>(""); const [renderedResultUrl, setRenderedResultUrl] = useState<string>("");
@ -495,6 +527,21 @@ export function AgentPsdTemplateEditorDialog({
return nextAssignments; return nextAssignments;
}, [editableTextLayers, selectedTemplate, textAssignments]); }, [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 effectiveImagePlacements = useMemo(() => {
const nextPlacements: Record<string, AgentPsdImagePlacement> = {}; const nextPlacements: Record<string, AgentPsdImagePlacement> = {};
@ -521,6 +568,9 @@ export function AgentPsdTemplateEditorDialog({
const selectedImagePlacement = selectedImageLayer const selectedImagePlacement = selectedImageLayer
? normalizeAgentPsdImagePlacement(effectiveImagePlacements[selectedImageLayer.id]) ? normalizeAgentPsdImagePlacement(effectiveImagePlacements[selectedImageLayer.id])
: getDefaultAgentPsdImagePlacement(); : getDefaultAgentPsdImagePlacement();
const selectedTextColor = selectedTextLayer
? effectiveTextColorAssignments[selectedTextLayer.id] || selectedTextLayer.textColor
: "#111111";
const fontFaceCss = useMemo(() => buildPreviewFontFaces(selectedTemplate), [selectedTemplate]); const fontFaceCss = useMemo(() => buildPreviewFontFaces(selectedTemplate), [selectedTemplate]);
const selectedTemplateSelectValue = selectedTemplate?.id; const selectedTemplateSelectValue = selectedTemplate?.id;
const hasTemplateOptions = templates.length > 0; const hasTemplateOptions = templates.length > 0;
@ -548,6 +598,7 @@ export function AgentPsdTemplateEditorDialog({
setImageAssignments(initialState.imageAssignments); setImageAssignments(initialState.imageAssignments);
setImagePlacements(initialState.imagePlacements); setImagePlacements(initialState.imagePlacements);
setTextAssignments(initialState.textAssignments); setTextAssignments(initialState.textAssignments);
setTextColorAssignments({});
setSelectedLayerId(getPreferredSelectedLayerId(selectedTemplate)); setSelectedLayerId(getPreferredSelectedLayerId(selectedTemplate));
if (!selectedTemplate || typeof window === "undefined") { if (!selectedTemplate || typeof window === "undefined") {
@ -872,6 +923,7 @@ export function AgentPsdTemplateEditorDialog({
imageAssignments: effectiveImageAssignments, imageAssignments: effectiveImageAssignments,
imagePlacements: effectiveImagePlacements, imagePlacements: effectiveImagePlacements,
textAssignments: effectiveTextAssignments, textAssignments: effectiveTextAssignments,
textColorAssignments: effectiveTextColorAssignments,
}), }),
}); });
@ -1113,7 +1165,12 @@ export function AgentPsdTemplateEditorDialog({
} }
const value = effectiveTextAssignments[layer.id] || ""; 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) { if (!shouldUseEditedPreview && layer.sourceUrl) {
return ( return (
@ -1153,7 +1210,11 @@ export function AgentPsdTemplateEditorDialog({
> >
<div <div
className="whitespace-pre" 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} {value || layer.placeholder || layer.layerName}
</div> </div>
@ -1277,8 +1338,11 @@ export function AgentPsdTemplateEditorDialog({
const isSelected = selectedLayerId === layer.id; const isSelected = selectedLayerId === layer.id;
const frameStyle = getLayerFrameStyle(layer, isSelected); const frameStyle = getLayerFrameStyle(layer, isSelected);
const currentValue = effectiveTextAssignments[layer.id] || ""; const currentValue = effectiveTextAssignments[layer.id] || "";
const resolvedTextColor =
effectiveTextColorAssignments[layer.id] || layer.textColor;
const preserveSourceAppearance = const preserveSourceAppearance =
!!layer.sourceUrl && !hasEditedTextValue(layer, currentValue); !!layer.sourceUrl &&
!hasCustomizedTextLayerAppearance(layer, currentValue, resolvedTextColor);
const editedTextFrame = getTextLayerRenderFrame(layer, currentValue); const editedTextFrame = getTextLayerRenderFrame(layer, currentValue);
if (isSelected) { if (isSelected) {
@ -1325,7 +1389,12 @@ export function AgentPsdTemplateEditorDialog({
})) }))
} }
className="resize-none border-none bg-transparent outline-none" className="resize-none border-none bg-transparent outline-none"
style={getInlineTextEditorStyle(layer, preserveSourceAppearance, currentValue)} style={getInlineTextEditorStyle(
layer,
preserveSourceAppearance,
currentValue,
resolvedTextColor
)}
/> />
</div> </div>
<div className={getTextLayerNameBadgeClassName()}> <div className={getTextLayerNameBadgeClassName()}>
@ -1585,6 +1654,57 @@ export function AgentPsdTemplateEditorDialog({
<span>行高:{selectedTextLayer.lineHeight}</span> <span>行高:{selectedTextLayer.lineHeight}</span>
<span>字距:{selectedTextLayer.letterSpacing}px</span> <span>字距:{selectedTextLayer.letterSpacing}px</span>
</div> </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>
) : ( ) : (
<div className="flex min-h-[200px] flex-col items-center justify-center text-center"> <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(() => ({})); const payload = await response.json().catch(() => ({}));
if (!response.ok || !payload?.success || !payload?.data?.imageUrl) { 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; 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_TEMPLATES_KEY = "agent_psd_templates";
export const AGENT_PSD_TEMPLATE_ASSET_VERSION = 3; export const AGENT_PSD_TEMPLATE_ASSET_VERSION = 3;
@ -98,6 +100,7 @@ export interface AgentPsdTemplate {
export interface AgentPsdTemplateListResponse { export interface AgentPsdTemplateListResponse {
success: boolean; success: boolean;
templates: AgentPsdTemplate[]; templates: AgentPsdTemplate[];
textColorConfig?: AgentPsdTextColorConfig;
error?: string; error?: string;
} }

View File

@ -10,7 +10,7 @@ import {
import type { AgentPsdFont } from "@/lib/agents/psd-font-config"; import type { AgentPsdFont } from "@/lib/agents/psd-font-config";
import { ensurePsdFontAvailableForRender } from "@/lib/agents/psd-font-runtime"; import { ensurePsdFontAvailableForRender } from "@/lib/agents/psd-font-runtime";
import { normalizeAgentPsdImagePlacement, resolveAgentPsdImagePlacementFrame } from "@/lib/agents/psd-image-placement"; 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() { async function getSharp() {
const sharp = await import("sharp"); const sharp = await import("sharp");
@ -125,6 +125,7 @@ function resolveTextOverlayFrame(params: {
contentWidth: number; contentWidth: number;
contentHeight: number; contentHeight: number;
textAlign: "left" | "center" | "right"; textAlign: "left" | "center" | "right";
topOffset?: number;
}) { }) {
const width = Math.max(params.layerWidth, params.contentWidth); const width = Math.max(params.layerWidth, params.contentWidth);
const height = Math.max(params.layerHeight, params.contentHeight); const height = Math.max(params.layerHeight, params.contentHeight);
@ -139,7 +140,7 @@ function resolveTextOverlayFrame(params: {
width, width,
height, height,
leftOffset, 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>; imageAssignments: Record<string, string>;
imagePlacements: Record<string, ReturnType<typeof normalizeAgentPsdImagePlacement>>; imagePlacements: Record<string, ReturnType<typeof normalizeAgentPsdImagePlacement>>;
textAssignments: Record<string, string>; textAssignments: Record<string, string>;
textColorAssignments?: Record<string, string>;
fonts: AgentPsdFont[]; fonts: AgentPsdFont[];
}) { }) {
const { template, imageAssignments, imagePlacements, textAssignments, fonts } = params; const {
template,
imageAssignments,
imagePlacements,
textAssignments,
textColorAssignments = {},
fonts,
} = params;
const sharp = await getSharp(); const sharp = await getSharp();
const canvasWidth = Math.max(1, Math.round(Number(template.width || 1))); const canvasWidth = Math.max(1, Math.round(Number(template.width || 1)));
const canvasHeight = Math.max(1, Math.round(Number(template.height || 1))); const canvasHeight = Math.max(1, Math.round(Number(template.height || 1)));
@ -511,7 +520,12 @@ export async function renderAgentPsdTemplate(params: {
if (isAgentPsdTextLayerSlot(layer)) { if (isAgentPsdTextLayerSlot(layer)) {
const text = String(textAssignments[layer.id] || layer.defaultText || "").trim(); const text = String(textAssignments[layer.id] || layer.defaultText || "").trim();
const defaultText = String(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); await appendStoredLayer(layer);
continue; continue;
} }
@ -563,7 +577,7 @@ export async function renderAgentPsdTemplate(params: {
fontWeight: layer.fontWeight, fontWeight: layer.fontWeight,
lineHeight: layer.lineHeight, lineHeight: layer.lineHeight,
letterSpacing: layer.letterSpacing, letterSpacing: layer.letterSpacing,
textColor: layer.textColor, textColor,
textAlign: layer.textAlign, textAlign: layer.textAlign,
paddingX: layer.paddingX, paddingX: layer.paddingX,
paddingY: layer.paddingY, paddingY: layer.paddingY,
@ -590,6 +604,7 @@ export async function renderAgentPsdTemplate(params: {
contentWidth: overlayLayout.contentWidth, contentWidth: overlayLayout.contentWidth,
contentHeight: overlayLayout.contentHeight, contentHeight: overlayLayout.contentHeight,
textAlign: layer.textAlign, textAlign: layer.textAlign,
topOffset: -overlayLayout.visualOffsetY,
}); });
const didAppend = await appendBufferOverlay({ 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 availableHeight = Math.max(8, height - paddingY * 2);
const resolvedLineHeight = Math.max(1, Number(lineHeight || 1)); const resolvedLineHeight = Math.max(1, Number(lineHeight || 1));
const resolvedMaxLines = Math.max(1, Number(maxLines || 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 fittedFontSize = Math.max(10, Number(fontSize || 10));
let bestLines: string[] = []; let bestLines: string[] = [];
@ -153,8 +160,13 @@ export function resolveTextLayout(params: {
maxLineWidth: Number(bestMaxLineWidth.toFixed(2)), maxLineWidth: Number(bestMaxLineWidth.toFixed(2)),
contentWidth: Number(Math.max(width, bestMaxLineWidth + paddingX * 2).toFixed(2)), contentWidth: Number(Math.max(width, bestMaxLineWidth + paddingX * 2).toFixed(2)),
contentHeight: Number( 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)), maxLineWidth: Number(bestMaxLineWidth.toFixed(2)),
contentWidth: Number(Math.max(width, bestMaxLineWidth + paddingX * 2).toFixed(2)), contentWidth: Number(Math.max(width, bestMaxLineWidth + paddingX * 2).toFixed(2)),
contentHeight: Number( 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; updatedAt: Date | string;
} }
export interface AgentPsdTextColorConfig {
enabled: boolean;
colors: [string, string];
}
export interface AgentCategoryTreeConfig { export interface AgentCategoryTreeConfig {
productCategories: string[]; productCategories: string[];
audienceTags: string[]; audienceTags: string[];