Compare commits

...

26 Commits

Author SHA1 Message Date
Your Name
6937217634 fix: resolve detail reference sku to spu 2026-06-02 13:18:39 +08:00
Your Name
84f105d4c8 fix: streamline detail reference template lookup 2026-06-02 12:59:14 +08:00
Your Name
824806406a fix: show regenerating state for style batch results 2026-06-02 11:43:44 +08:00
Your Name
6b72771701 fix: keep PSD clipped replacements in target layer order 2026-06-01 20:57:25 +08:00
Your Name
97cc9e4a0a fix: anchor PSD replacements to clip targets 2026-06-01 20:38:45 +08:00
Your Name
154ec44a4c fix: avoid empty PSD replacement masks 2026-06-01 20:26:56 +08:00
Your Name
2f0c533b87 fix: support PSD clipping mask target layers 2026-06-01 20:03:11 +08:00
Your Name
3ac209d149 fix: improve PSD template management and clipping masks 2026-06-01 19:40:22 +08:00
Your Name
97d7604e7c feat: add PSD force clipping mask option 2026-06-01 18:43:42 +08:00
Your Name
641cd800b2 Revert "fix: preserve PSD super lock mask editing"
This reverts commit 6d60d53910cf8cb3d65aade75496cf514d3df617.
2026-06-01 18:37:26 +08:00
Your Name
f5ee1fe592 Revert "fix: clip PSD super lock replacements"
This reverts commit 6e647021cdc52a083cace27801044838f11a5864.
2026-06-01 18:37:26 +08:00
Your Name
674a8d143e Revert "fix: honor PSD super locked image slots"
This reverts commit b189ca1b58e92df28f46d0c99dd3ae86a7edb4bc.
2026-06-01 18:37:02 +08:00
Your Name
b189ca1b58 fix: honor PSD super locked image slots 2026-06-01 18:33:12 +08:00
Your Name
6e647021cd fix: clip PSD super lock replacements 2026-06-01 18:23:13 +08:00
Your Name
6d60d53910 fix: preserve PSD super lock mask editing 2026-06-01 17:59:37 +08:00
Your Name
f050c807a4 fix: precompile PSD template render segments 2026-06-01 17:52:25 +08:00
Your Name
c15f6d435f fix: proxy material card previews 2026-06-01 16:46:54 +08:00
Your Name
42adff8545 fix: polish ai wash layout and sidebar overflow 2026-06-01 16:37:09 +08:00
Your Name
a701ddde50 fix: preserve manual detail cutlines 2026-06-01 15:06:17 +08:00
Your Name
d14fc0c188 fix: stabilize material grid hook order 2026-06-01 14:51:37 +08:00
Your Name
f455b433ec fix: stabilize detail text edits and cutline deletion 2026-06-01 14:43:34 +08:00
Your Name
6e0e4a5454 fix: guard material search client crashes 2026-06-01 14:24:28 +08:00
Your Name
5761fa77b1 fix: load material previews from originals 2026-06-01 14:08:30 +08:00
Your Name
fab8826123 fix: improve material preview loading 2026-06-01 13:56:21 +08:00
Your Name
c83a16545c fix: avoid canvas redirect shell flash 2026-06-01 13:21:10 +08:00
Your Name
f7cae037d5 refactor: move ai wash toolbar right 2026-06-01 13:09:57 +08:00
25 changed files with 2104 additions and 991 deletions

View File

@ -95,6 +95,8 @@ const SYSTEM_FONT_OPTIONS = [
const EMPTY_CATEGORY_SELECT_VALUE = "__none__"; const EMPTY_CATEGORY_SELECT_VALUE = "__none__";
const EMPTY_BRAND_SELECT_VALUE = "__unassigned__"; const EMPTY_BRAND_SELECT_VALUE = "__unassigned__";
const ALL_TEMPLATE_BRANDS_SELECT_VALUE = "__all_template_brands__";
const SELF_CLIP_MASK_SELECT_VALUE = "__self_clip_mask__";
const UNASSIGNED_BRAND_LABEL = "未分类"; const UNASSIGNED_BRAND_LABEL = "未分类";
const MAX_PSD_TEMPLATE_FILE_SIZE = 80 * 1024 * 1024; const MAX_PSD_TEMPLATE_FILE_SIZE = 80 * 1024 * 1024;
const PSD_TEMPLATE_MODE_OPTIONS: Array<{ const PSD_TEMPLATE_MODE_OPTIONS: Array<{
@ -366,6 +368,7 @@ export function PsdTemplatesSettingsClient({
const [templateBrandDrafts, setTemplateBrandDrafts] = useState<Record<string, string>>({}); const [templateBrandDrafts, setTemplateBrandDrafts] = useState<Record<string, string>>({});
const [fontBrandDrafts, setFontBrandDrafts] = useState<Record<string, string>>({}); const [fontBrandDrafts, setFontBrandDrafts] = useState<Record<string, string>>({});
const [parsingTemplateIds, setParsingTemplateIds] = useState<string[]>([]); const [parsingTemplateIds, setParsingTemplateIds] = useState<string[]>([]);
const [templateBrandFilter, setTemplateBrandFilter] = useState(ALL_TEMPLATE_BRANDS_SELECT_VALUE);
const selectedTemplate = useMemo( const selectedTemplate = useMemo(
() => templates.find((template) => template.id === selectedTemplateId) || null, () => templates.find((template) => template.id === selectedTemplateId) || null,
@ -444,9 +447,42 @@ export function PsdTemplatesSettingsClient({
const currentBrand = normalizeManagedBrandName(draftTemplate?.brandName); const currentBrand = normalizeManagedBrandName(draftTemplate?.brandName);
return Array.from(new Set([...managedBrandOptions, ...(currentBrand ? [currentBrand] : [])])); return Array.from(new Set([...managedBrandOptions, ...(currentBrand ? [currentBrand] : [])]));
}, [draftTemplate?.brandName, managedBrandOptions]); }, [draftTemplate?.brandName, managedBrandOptions]);
const templateBrandFilterOptions = useMemo(() => {
const brands = new Set<string>();
let hasUnassigned = false;
templates.forEach((template) => {
const brand = normalizeManagedBrandName(template.brandName);
if (brand) {
brands.add(brand);
} else {
hasUnassigned = true;
}
});
return {
brands: Array.from(brands).sort((left, right) => left.localeCompare(right, "zh-CN")),
hasUnassigned,
};
}, [templates]);
const filteredTemplates = useMemo(() => {
if (templateBrandFilter === ALL_TEMPLATE_BRANDS_SELECT_VALUE) {
return templates;
}
if (templateBrandFilter === EMPTY_BRAND_SELECT_VALUE) {
return templates.filter((template) => !normalizeManagedBrandName(template.brandName));
}
return templates.filter(
(template) =>
normalizeManagedBrandName(template.brandName).toLowerCase() ===
templateBrandFilter.toLowerCase()
);
}, [templateBrandFilter, templates]);
const groupedTemplates = useMemo(() => { const groupedTemplates = useMemo(() => {
const grouped = new Map<string, AgentPsdTemplate[]>(); const grouped = new Map<string, AgentPsdTemplate[]>();
templates.forEach((template) => { filteredTemplates.forEach((template) => {
const label = normalizeManagedBrandName(template.brandName) || UNASSIGNED_BRAND_LABEL; const label = normalizeManagedBrandName(template.brandName) || UNASSIGNED_BRAND_LABEL;
const current = grouped.get(label) || []; const current = grouped.get(label) || [];
current.push(template); current.push(template);
@ -463,7 +499,7 @@ export function PsdTemplatesSettingsClient({
label, label,
items, items,
})); }));
}, [templates]); }, [filteredTemplates]);
const groupedFonts = useMemo(() => { const groupedFonts = useMemo(() => {
const grouped = new Map<string, AgentPsdFont[]>(); const grouped = new Map<string, AgentPsdFont[]>();
fonts.forEach((font) => { fonts.forEach((font) => {
@ -1644,7 +1680,22 @@ export function PsdTemplatesSettingsClient({
: "当前筛选条件下没有图层,请换个关键词试试。"} : "当前筛选条件下没有图层,请换个关键词试试。"}
</div> </div>
) : ( ) : (
filteredLayers.map((layer, index) => ( filteredLayers.map((layer, index) => {
const clipMaskLayerOptions =
layer.slotType === "image"
? draftTemplate.layers.filter(
(candidate) =>
candidate.id !== layer.id &&
(candidate.previewUrl || candidate.sourceUrl) &&
!(
candidate.slotType === "text" &&
!candidate.previewUrl &&
!candidate.sourceUrl
)
)
: [];
return (
<div key={layer.id} className="rounded-2xl border p-4"> <div key={layer.id} className="rounded-2xl border p-4">
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-3"> <div className="flex min-w-0 items-start gap-3">
@ -1798,6 +1849,12 @@ export function PsdTemplatesSettingsClient({
allowUserUploadOverride: checked allowUserUploadOverride: checked
? false ? false
: current.allowUserUploadOverride, : current.allowUserUploadOverride,
forceClipMask: checked
? false
: current.forceClipMask,
clipMaskLayerId: checked
? undefined
: current.clipMaskLayerId,
}; };
} }
@ -1834,6 +1891,12 @@ export function PsdTemplatesSettingsClient({
allowUserUploadOverride: checked allowUserUploadOverride: checked
? current.allowUserUploadOverride ? current.allowUserUploadOverride
: false, : false,
forceClipMask: checked
? current.forceClipMask
: false,
clipMaskLayerId: checked
? current.clipMaskLayerId
: undefined,
} }
: { : {
...current, ...current,
@ -1949,6 +2012,84 @@ export function PsdTemplatesSettingsClient({
/> />
</div> </div>
<div className="flex items-center justify-between rounded-xl border p-3">
<div className="space-y-0.5">
<div className="text-sm font-medium">强制剪切蒙版</div>
<div className="text-xs text-muted-foreground">
开启后替换图会按指定目标图层的透明区域剪切,适合替换图需要贴合超级锁或固定形状的图片槽位。
</div>
</div>
<Switch
checked={layer.isEditable && layer.forceClipMask === true}
disabled={!layer.isEditable || layer.isSuperLocked === true}
onCheckedChange={(checked) =>
updateDraftLayer(layer.id, (current) =>
current.slotType === "image"
? {
...current,
forceClipMask: current.isEditable
? checked
: false,
clipMaskLayerId:
current.isEditable && checked
? current.clipMaskLayerId
: undefined,
}
: current
)
}
/>
</div>
{layer.forceClipMask ? (
<div className="space-y-2 rounded-xl border p-3 md:col-span-2 xl:col-span-3">
<div className="text-sm font-medium">剪切蒙版目标图层</div>
<Select
value={
layer.clipMaskLayerId ||
SELF_CLIP_MASK_SELECT_VALUE
}
disabled={
!layer.isEditable ||
layer.isSuperLocked === true
}
onValueChange={(value) =>
updateDraftLayer(layer.id, (current) =>
current.slotType === "image"
? {
...current,
clipMaskLayerId:
value === SELF_CLIP_MASK_SELECT_VALUE
? undefined
: value,
}
: current
)
}
>
<SelectTrigger>
<SelectValue placeholder="选择用于裁切的目标图层" />
</SelectTrigger>
<SelectContent>
<SelectItem value={SELF_CLIP_MASK_SELECT_VALUE}>
使用当前替换图层自身透明区域
</SelectItem>
{clipMaskLayerOptions.map((candidate) => (
<SelectItem
key={`${layer.id}-clip-mask-${candidate.id}`}
value={candidate.id}
>
{candidate.layerName} · {candidate.width} x {candidate.height}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="text-xs text-muted-foreground">
如果 PSD 里目标形状是超级锁图层,请在这里选择那个目标图层;前台只能移动替换图,目标图仍保持锁定不可选中。
</div>
</div>
) : null}
<div className="space-y-2 rounded-xl border p-3"> <div className="space-y-2 rounded-xl border p-3">
<div className="text-sm font-medium">填充模式</div> <div className="text-sm font-medium">填充模式</div>
<Select <Select
@ -2261,7 +2402,8 @@ export function PsdTemplatesSettingsClient({
</div> </div>
)} )}
</div> </div>
)) );
})
)} )}
</div> </div>
</ScrollArea> </ScrollArea>
@ -2929,9 +3071,44 @@ export function PsdTemplatesSettingsClient({
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base">模板列表</CardTitle> <CardTitle className="text-base">模板列表</CardTitle>
<CardDescription>按品牌查看模板,选择一个模板继续配置槽位。</CardDescription> <CardDescription>按品牌筛选模板,选择一个模板继续配置槽位。</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<div className="flex flex-col gap-3 rounded-2xl border bg-muted/20 p-3 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-1">
<div className="text-sm font-medium">品牌筛选</div>
<div className="text-xs text-muted-foreground">
当前显示 {filteredTemplates.length} / {templates.length} 个模板
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Select value={templateBrandFilter} onValueChange={setTemplateBrandFilter}>
<SelectTrigger className="w-full sm:w-[260px]">
<SelectValue placeholder="选择品牌" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_TEMPLATE_BRANDS_SELECT_VALUE}>全部品牌</SelectItem>
{templateBrandFilterOptions.hasUnassigned ? (
<SelectItem value={EMPTY_BRAND_SELECT_VALUE}>未分类</SelectItem>
) : null}
{templateBrandFilterOptions.brands.map((brand) => (
<SelectItem key={`template-brand-filter-${brand}`} value={brand}>
{brand}
</SelectItem>
))}
</SelectContent>
</Select>
{templateBrandFilter !== ALL_TEMPLATE_BRANDS_SELECT_VALUE ? (
<Button
type="button"
variant="outline"
onClick={() => setTemplateBrandFilter(ALL_TEMPLATE_BRANDS_SELECT_VALUE)}
>
清除筛选
</Button>
) : null}
</div>
</div>
{templateListErrorMessage && templates.length > 0 ? ( {templateListErrorMessage && templates.length > 0 ? (
<div className="flex items-start gap-3 rounded-xl border border-amber-300/30 bg-amber-500/5 p-4 text-sm text-amber-100"> <div className="flex items-start gap-3 rounded-xl border border-amber-300/30 bg-amber-500/5 p-4 text-sm text-amber-100">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" /> <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
@ -2977,6 +3154,10 @@ export function PsdTemplatesSettingsClient({
<div className="rounded-xl border border-dashed p-4 text-sm text-muted-foreground"> <div className="rounded-xl border border-dashed p-4 text-sm text-muted-foreground">
还没有 PSD 模板,先上传一个模板开始配置。 还没有 PSD 模板,先上传一个模板开始配置。
</div> </div>
) : filteredTemplates.length === 0 ? (
<div className="rounded-xl border border-dashed p-4 text-sm text-muted-foreground">
当前品牌下没有 PSD 模板,可以切换品牌或清除筛选。
</div>
) : ( ) : (
groupedTemplates.map((group) => ( groupedTemplates.map((group) => (
<div key={group.label} className="space-y-3 rounded-2xl border bg-background/50 p-3"> <div key={group.label} className="space-y-3 rounded-2xl border bg-background/50 p-3">

View File

@ -18,6 +18,7 @@ import { normalizeAgentPsdTemplateCategoryTags } from "@/lib/agents/psd-template
import { resolveAgentPsdLocalCachePath } from "@/lib/agents/psd-local-cache"; import { resolveAgentPsdLocalCachePath } from "@/lib/agents/psd-local-cache";
import { import {
preparePsdTemplateRenderAssets, preparePsdTemplateRenderAssets,
uploadPreparedPsdTemplateRenderSegments,
} from "@/lib/agents/psd-template-parser"; } from "@/lib/agents/psd-template-parser";
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";
@ -222,6 +223,11 @@ export async function PUT(
preparedAssets.renderBaseBuffer.byteOffset + preparedAssets.renderBaseBuffer.byteLength preparedAssets.renderBaseBuffer.byteOffset + preparedAssets.renderBaseBuffer.byteLength
) as ArrayBuffer ) as ArrayBuffer
); );
const uploadedRenderSegments = await uploadPreparedPsdTemplateRenderSegments({
templateId: currentTemplate.id,
segments: preparedAssets.renderSegments,
uploadAsset,
});
const updatedTemplate = { const updatedTemplate = {
...currentTemplate, ...currentTemplate,
@ -234,6 +240,7 @@ export async function PUT(
status: nextStatus, status: nextStatus,
renderBaseUrl: uploadedRenderBase.url, renderBaseUrl: uploadedRenderBase.url,
renderBasePath: uploadedRenderBase.pathname, renderBasePath: uploadedRenderBase.pathname,
renderSegments: uploadedRenderSegments,
layers: nextLayers, layers: nextLayers,
layerCount: nextLayers.length, layerCount: nextLayers.length,
assetVersion: AGENT_PSD_TEMPLATE_ASSET_VERSION, assetVersion: AGENT_PSD_TEMPLATE_ASSET_VERSION,

View File

@ -34,6 +34,7 @@ type ExternalImageRecord = {
label?: string; label?: string;
fileName?: string; fileName?: string;
fileSize?: number; fileSize?: number;
previewUrl?: string;
downloadUrl?: string; downloadUrl?: string;
}; };
@ -81,6 +82,7 @@ type MaterialSearchImage = {
label: string; label: string;
fileName: string; fileName: string;
imageUrl: string; imageUrl: string;
previewUrl: string | null;
assetId: string | null; assetId: string | null;
mimeType: string; mimeType: string;
group: "fixed" | "other"; group: "fixed" | "other";
@ -611,6 +613,7 @@ function mapExternalImage(params: {
label: String(params.image.label || "").trim() || `图片${params.sortOrder + 1}`, label: String(params.image.label || "").trim() || `图片${params.sortOrder + 1}`,
fileName: filename, fileName: filename,
imageUrl: downloadUrl, imageUrl: downloadUrl,
previewUrl: String(params.image.previewUrl || "").trim() || null,
assetId: null, assetId: null,
mimeType: inferMimeTypeFromFilename(filename), mimeType: inferMimeTypeFromFilename(filename),
group: params.group, group: params.group,

View File

@ -33,6 +33,7 @@ const querySchema = z.object({
target: z.enum(["main", "detail", "sku"]), target: z.enum(["main", "detail", "sku"]),
brandName: z.string().trim().max(64).optional(), brandName: z.string().trim().max(64).optional(),
channel: productDetailChannelSchema, channel: productDetailChannelSchema,
strictQueryType: z.enum(["true", "false", "1", "0"]).optional(),
}); });
function jsonNoStore( function jsonNoStore(
@ -235,7 +236,35 @@ function getGoodsInfoBrandName(data: Record<string, unknown>) {
); );
} }
async function fetchGoodsBrandByKeyword(params: { function getGoodsInfoMatchedSpu(data: Record<string, unknown>) {
return (
sanitizeText(data["spu"], 64) ||
sanitizeText(data["SPU"], 64) ||
sanitizeText(data["productSpu"], 64) ||
sanitizeText(data["ProductSpu"], 64) ||
sanitizeText(data["商品SPU"], 64) ||
sanitizeText(data["商品spu"], 64) ||
sanitizeText(data["款号"], 64) ||
sanitizeText(data["商品款号"], 64) ||
null
);
}
function getGoodsInfoMatchedSku(data: Record<string, unknown>) {
return (
sanitizeText(data["sku"], 64) ||
sanitizeText(data["SKU"], 64) ||
sanitizeText(data["skuId"], 64) ||
sanitizeText(data["SkuId"], 64) ||
sanitizeText(data["skuoid"], 64) ||
sanitizeText(data["SKUOID"], 64) ||
sanitizeText(data["商品SKU"], 64) ||
sanitizeText(data["商品sku"], 64) ||
null
);
}
async function fetchGoodsLookupByKeyword(params: {
keyword: string; keyword: string;
config: ReturnType<typeof normalizeAgentWorkflowGoodsInfoApiConfig>; config: ReturnType<typeof normalizeAgentWorkflowGoodsInfoApiConfig>;
}) { }) {
@ -269,13 +298,20 @@ async function fetchGoodsBrandByKeyword(params: {
}); });
const result = (await response.json().catch(() => ({}))) as ExternalGoodsInfoResponse; const result = (await response.json().catch(() => ({}))) as ExternalGoodsInfoResponse;
if (response.ok && result.success === true && result.data) { if (response.ok && result.success === true && result.data) {
const brandName = getGoodsInfoBrandName(result.data); const data = result.data;
if (brandName) { const brandName = getGoodsInfoBrandName(data);
return brandName; const matchedSpu = getGoodsInfoMatchedSpu(data);
const matchedSku = getGoodsInfoMatchedSku(data);
if (brandName || matchedSpu || matchedSku) {
return {
brandName,
matchedSpu,
matchedSku,
};
} }
} }
} catch (error) { } catch (error) {
console.warn("[Agent Workflow Reference Search] Goods brand query failed:", { console.warn("[Agent Workflow Reference Search] Goods info lookup failed:", {
requestPath, requestPath,
error, error,
}); });
@ -854,6 +890,8 @@ export async function GET(request: NextRequest) {
target: request.nextUrl.searchParams.get("target"), target: request.nextUrl.searchParams.get("target"),
brandName: request.nextUrl.searchParams.get("brandName") || undefined, brandName: request.nextUrl.searchParams.get("brandName") || undefined,
channel: request.nextUrl.searchParams.get("channel") || undefined, channel: request.nextUrl.searchParams.get("channel") || undefined,
strictQueryType:
request.nextUrl.searchParams.get("strictQueryType") || undefined,
}); });
const setting = await getSystemSetting({ key: AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY }); const setting = await getSystemSetting({ key: AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY });
@ -907,15 +945,25 @@ export async function GET(request: NextRequest) {
const materialQueryType = queryType as AgentWorkflowMaterialQueryType; const materialQueryType = queryType as AgentWorkflowMaterialQueryType;
const requestedBrandName = String(parsed.brandName || "").trim(); const requestedBrandName = String(parsed.brandName || "").trim();
const verifiedGoodsBrandName = requestedBrandName const goodsLookup = await fetchGoodsLookupByKeyword({
? await fetchGoodsBrandByKeyword({ keyword: normalizedKeyword,
keyword: normalizedKeyword, config: goodsInfoConfig,
config: goodsInfoConfig, }).catch((error) => {
}).catch((error) => { console.warn("[Agent Workflow Reference Search] Goods info lookup failed:", error);
console.warn("[Agent Workflow Reference Search] Goods brand lookup failed:", error); return null;
return null; });
}) const verifiedGoodsBrandName = goodsLookup?.brandName || null;
: null; const resolvedMaterialKeyword =
materialQueryType === "spu" && goodsLookup?.matchedSpu
? goodsLookup.matchedSpu.trim().toUpperCase()
: normalizedKeyword;
if (materialQueryType === "spu" && goodsLookup?.matchedSpu) {
console.info("[Agent Workflow Reference Search] Resolved SKU input to SPU:", {
keyword: normalizedKeyword,
resolvedSpu: resolvedMaterialKeyword,
matchedSku: goodsLookup.matchedSku || null,
});
}
if ( if (
requestedBrandName && requestedBrandName &&
verifiedGoodsBrandName && verifiedGoodsBrandName &&
@ -928,12 +976,15 @@ export async function GET(request: NextRequest) {
}); });
} }
const queryTypesToTry = Array.from( const strictQueryType = parsed.strictQueryType === "true" || parsed.strictQueryType === "1";
new Set<AgentWorkflowMaterialQueryType>([ const queryTypesToTry = strictQueryType
materialQueryType, ? [materialQueryType]
materialQueryType === "spu" ? "sku" : "spu", : Array.from(
]) new Set<AgentWorkflowMaterialQueryType>([
); materialQueryType,
materialQueryType === "spu" ? "sku" : "spu",
])
);
let selectedAttempt: let selectedAttempt:
| Awaited<ReturnType<typeof requestTaskDetailAttempt>> | Awaited<ReturnType<typeof requestTaskDetailAttempt>>
@ -947,7 +998,7 @@ export async function GET(request: NextRequest) {
for (const currentQueryType of queryTypesToTry) { for (const currentQueryType of queryTypesToTry) {
const attempt = await requestTaskDetailAttempt({ const attempt = await requestTaskDetailAttempt({
keyword: normalizedKeyword, keyword: resolvedMaterialKeyword,
queryType: currentQueryType, queryType: currentQueryType,
target: parsed.target, target: parsed.target,
requestedBrandName, requestedBrandName,
@ -958,6 +1009,7 @@ export async function GET(request: NextRequest) {
if (requestedBrandName) { if (requestedBrandName) {
console.info("[Agent Workflow Reference Search] Brand gate:", { console.info("[Agent Workflow Reference Search] Brand gate:", {
keyword: normalizedKeyword, keyword: normalizedKeyword,
resolvedKeyword: resolvedMaterialKeyword,
queryType: currentQueryType, queryType: currentQueryType,
target: parsed.target, target: parsed.target,
requestedBrandName, requestedBrandName,
@ -1042,6 +1094,7 @@ export async function GET(request: NextRequest) {
if (selectedAttempt.queryType !== materialQueryType) { if (selectedAttempt.queryType !== materialQueryType) {
console.info("[Agent Workflow Reference Search] Query type fallback succeeded:", { console.info("[Agent Workflow Reference Search] Query type fallback succeeded:", {
keyword: normalizedKeyword, keyword: normalizedKeyword,
resolvedKeyword: resolvedMaterialKeyword,
requestedQueryType: materialQueryType, requestedQueryType: materialQueryType,
resolvedQueryType: selectedAttempt.queryType, resolvedQueryType: selectedAttempt.queryType,
target: parsed.target, target: parsed.target,
@ -1059,6 +1112,7 @@ export async function GET(request: NextRequest) {
success: true, success: true,
data: { data: {
query: normalizedKeyword, query: normalizedKeyword,
resolvedQuery: resolvedMaterialKeyword,
queryType: selectedAttempt.queryType, queryType: selectedAttempt.queryType,
target: parsed.target, target: parsed.target,
matchedSpu: String(selectedAttempt.upstreamResult.data?.spu || "").trim() || null, matchedSpu: String(selectedAttempt.upstreamResult.data?.spu || "").trim() || null,

View File

@ -16,11 +16,14 @@ import { createKrea } from "@/lib/ai/providers/krea";
import { createVolcanoVision } from "@/lib/ai/providers/volcano-vision"; import { createVolcanoVision } from "@/lib/ai/providers/volcano-vision";
import { isImageGenerationModelDescriptor } from "@/lib/ai/model-classification"; import { isImageGenerationModelDescriptor } from "@/lib/ai/model-classification";
import { createLogger } from "@/lib/observability/logger"; import { createLogger } from "@/lib/observability/logger";
import { uploadFile } from "@/lib/storage/service";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const maxDuration = 120; export const maxDuration = 120;
const logger = createLogger("canvas-text-edit"); const logger = createLogger("canvas-text-edit");
const TEXT_EDIT_SOURCE_ARCHIVE_TIMEOUT_MS = 20_000;
const TEXT_EDIT_MODEL_FALLBACK_LIMIT = 3;
const textItemSchema = z.object({ const textItemSchema = z.object({
id: z.string().min(1), id: z.string().min(1),
@ -50,8 +53,12 @@ const SUPPORTED_TEXT_EDIT_PROVIDER_TYPES = new Set([
"volcano-vision", "volcano-vision",
]); ]);
function pickTextEditModel(models: Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>) { type TextEditModelCandidate = Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>[number];
const candidates = models
function getTextEditModelCandidates(
models: Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>
) {
return models
.filter( .filter(
(item) => (item) =>
isImageGenerationModelDescriptor({ isImageGenerationModelDescriptor({
@ -77,8 +84,6 @@ function pickTextEditModel(models: Awaited<ReturnType<typeof getEnabledAiModelsW
(b.model.displayName?.includes("火山") ? 3 : 0); (b.model.displayName?.includes("火山") ? 3 : 0);
return bScore - aScore; return bScore - aScore;
}); });
return candidates[0];
} }
function createTextEditProvider( function createTextEditProvider(
@ -118,6 +123,85 @@ function createTextEditProvider(
} }
} }
function bufferToArrayBuffer(buffer: Buffer): ArrayBuffer {
return buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength
) as ArrayBuffer;
}
function inferImageExtension(contentType: string, imageUrl: string) {
const normalizedContentType = contentType.toLowerCase();
if (normalizedContentType.includes("image/jpeg")) return "jpg";
if (normalizedContentType.includes("image/png")) return "png";
if (normalizedContentType.includes("image/webp")) return "webp";
if (normalizedContentType.includes("image/gif")) return "gif";
const pathname = (() => {
try {
return new URL(imageUrl).pathname;
} catch {
return "";
}
})();
const match = pathname.match(/\.([a-z0-9]{2,5})$/i);
const ext = String(match?.[1] || "").toLowerCase();
return ["jpg", "jpeg", "png", "webp", "gif"].includes(ext) ? ext : "png";
}
async function archiveTextEditSourceImage(imageUrl: string) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), TEXT_EDIT_SOURCE_ARCHIVE_TIMEOUT_MS);
try {
const response = await fetch(imageUrl, {
cache: "no-store",
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`源图下载失败:${response.status} ${response.statusText}`);
}
const contentType = response.headers.get("content-type") || "";
if (contentType && !contentType.toLowerCase().startsWith("image/")) {
throw new Error(`源图类型异常:${contentType}`);
}
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.length === 0) {
throw new Error("源图为空文件");
}
const ext = inferImageExtension(contentType, imageUrl);
const uploaded = await uploadFile(
`canvas-text-edit-source-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`,
bufferToArrayBuffer(buffer)
);
return {
bytes: buffer.length,
url: uploaded.url,
};
} finally {
clearTimeout(timeoutId);
}
}
async function resolveTextEditSourceImageUrl(imageUrl: string) {
try {
const archived = await archiveTextEditSourceImage(imageUrl);
logger.info("source_image_archived", {
bytes: archived.bytes,
sourceHost: new URL(imageUrl).host,
targetHost: new URL(archived.url).host,
});
return archived.url;
} catch (error) {
logger.warn("source_image_archive_failed", {
error: error instanceof Error ? error.message : String(error),
});
return imageUrl;
}
}
function normalizeGeneratedImageUrl(value: unknown) { function normalizeGeneratedImageUrl(value: unknown) {
const raw = String(value || "").trim(); const raw = String(value || "").trim();
if (!raw) return ""; if (!raw) return "";
@ -271,6 +355,70 @@ function buildTextEditPrompt(
return sections.join("\n"); return sections.join("\n");
} }
async function generateTextEditImageWithFailover(params: {
candidates: TextEditModelCandidate[];
imageUrl: string;
prompt: string;
}) {
let lastError: unknown = null;
const candidates = params.candidates.slice(0, TEXT_EDIT_MODEL_FALLBACK_LIMIT);
for (const selected of candidates) {
try {
logger.info("selected_model", {
providerType: selected.provider.type,
providerName: selected.provider.name,
modelId: selected.model.modelId,
displayName: selected.model.displayName,
isDefault: selected.model.isDefault,
isDefaultTextEditModel: selected.model.isDefaultTextEditModel,
});
const provider = createTextEditProvider(selected.provider);
const result = await provider.chat(selected.model.modelId).doGenerate({
messages: [
{
role: "user",
content: [
{ type: "image_url", image_url: { url: params.imageUrl } },
{ type: "text", text: params.prompt },
],
},
],
imageParams: {
imageCount: 1,
},
});
const resultText = String(result?.text || "").trim();
const finishReason = String(result?.finishReason || "").toLowerCase();
if (!resultText) {
throw new Error("模型未返回结果");
}
if (finishReason === "error" || resultText.startsWith("图片生成失败")) {
throw new Error(resultText);
}
const parsed = parseImageFromGenerationText(resultText);
return {
...parsed,
model: selected.model.displayName,
};
} catch (error) {
lastError = error;
logger.warn("candidate_failed", {
providerType: selected.provider.type,
providerName: selected.provider.name,
modelId: selected.model.modelId,
displayName: selected.model.displayName,
error: error instanceof Error ? error.message : String(error),
});
}
}
throw lastError instanceof Error ? lastError : new Error("文字替换失败");
}
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const session = await auth(); const session = await auth();
@ -327,9 +475,9 @@ export async function POST(request: Request) {
} }
const models = await getEnabledAiModelsWithProvider(); const models = await getEnabledAiModelsWithProvider();
const selected = pickTextEditModel(models); const candidates = getTextEditModelCandidates(models);
if (!selected) { if (candidates.length === 0) {
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
@ -339,46 +487,20 @@ export async function POST(request: Request) {
); );
} }
logger.info("selected_model", {
providerType: selected.provider.type,
providerName: selected.provider.name,
modelId: selected.model.modelId,
displayName: selected.model.displayName,
isDefault: selected.model.isDefault,
isDefaultTextEditModel: selected.model.isDefaultTextEditModel,
});
const provider = createTextEditProvider(selected.provider);
const prompt = buildTextEditPrompt(replacements, deletions); const prompt = buildTextEditPrompt(replacements, deletions);
const generationSourceImageUrl = await resolveTextEditSourceImageUrl(imageUrl);
const result = await provider.chat(selected.model.modelId).doGenerate({ const { originalUrl, thumbnailUrl, model } = await generateTextEditImageWithFailover({
messages: [ candidates,
{ imageUrl: generationSourceImageUrl,
role: "user", prompt,
content: [
{ type: "image_url", image_url: { url: imageUrl } },
{ type: "text", text: prompt },
],
},
],
imageParams: {
imageCount: 1,
},
}); });
if (!result?.text) {
throw new Error("模型未返回结果");
}
const { originalUrl, thumbnailUrl } = parseImageFromGenerationText(result.text);
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
data: { data: {
originalUrl, originalUrl,
thumbnailUrl, thumbnailUrl,
model: selected.model.displayName, model,
}, },
}); });
} catch (error) { } catch (error) {

12
app/canvas/loading.tsx Normal file
View File

@ -0,0 +1,12 @@
import { Loader2 } from "lucide-react";
export default function CanvasLoading() {
return (
<div className="flex h-dvh w-screen items-center justify-center bg-[#050607] text-white/70">
<div className="flex flex-col items-center gap-4 rounded-[32px] border border-white/10 bg-white/[0.04] px-8 py-7 shadow-[0_24px_90px_rgba(0,0,0,0.42)]">
<Loader2 className="h-5 w-5 animate-spin text-emerald-300" />
<div className="text-sm tracking-[0.08em]">正在进入工作台...</div>
</div>
</div>
);
}

View File

@ -5,6 +5,8 @@ import { db } from "@/lib/db";
import { canvasProject } from "@/lib/db/schema"; import { canvasProject } from "@/lib/db/schema";
import { and, desc, eq } from "drizzle-orm"; import { and, desc, eq } from "drizzle-orm";
export const dynamic = "force-dynamic";
type CanvasRedirectPageProps = { type CanvasRedirectPageProps = {
searchParams?: Promise<Record<string, string | string[] | undefined>>; searchParams?: Promise<Record<string, string | string[] | undefined>>;
}; };

View File

@ -197,6 +197,12 @@ export function AgentDetailReferenceStitchDialog({
const [warningText, setWarningText] = useState<string | null>(null); const [warningText, setWarningText] = useState<string | null>(null);
const [previewImageLoadFailed, setPreviewImageLoadFailed] = useState(false); const [previewImageLoadFailed, setPreviewImageLoadFailed] = useState(false);
const [passthroughSegmentIndexes, setPassthroughSegmentIndexes] = useState<number[]>([]); const [passthroughSegmentIndexes, setPassthroughSegmentIndexes] = useState<number[]>([]);
const hasManualCutLineChangesRef = useRef(false);
const onPreparedRef = useRef(onPrepared);
useEffect(() => {
onPreparedRef.current = onPrepared;
}, [onPrepared]);
const validCards = useMemo( const validCards = useMemo(
() => () =>
@ -210,8 +216,8 @@ export function AgentDetailReferenceStitchDialog({
() => () =>
validCards validCards
.map( .map(
(card) => (card, index) =>
`${card.id}::${String(card.imageUrl || "").trim()}::${String(card.previewUrl || "").trim()}::${card.width || ""}x${card.height || ""}` `${card.id}::${String(card.imageUrl || "").trim()}::${card.sortOrder ?? index}`
) )
.join("|"), .join("|"),
[validCards] [validCards]
@ -265,12 +271,14 @@ export function AgentDetailReferenceStitchDialog({
setWarningText(null); setWarningText(null);
setPreviewImageLoadFailed(false); setPreviewImageLoadFailed(false);
setPassthroughSegmentIndexes([]); setPassthroughSegmentIndexes([]);
hasManualCutLineChangesRef.current = false;
return; return;
} }
if (setupCards.length === 0) { if (setupCards.length === 0) {
setSnapshot(null); setSnapshot(null);
setCutLines([]); setCutLines([]);
hasManualCutLineChangesRef.current = false;
setIsLoading(false); setIsLoading(false);
setIsPreparing(false); setIsPreparing(false);
setErrorText(`当前没有可拼合的${sourceLabel}`); setErrorText(`当前没有可拼合的${sourceLabel}`);
@ -282,6 +290,7 @@ export function AgentDetailReferenceStitchDialog({
setSnapshot(optimisticSnapshot); setSnapshot(optimisticSnapshot);
setCutLines(optimisticSnapshot.defaultCutLines); setCutLines(optimisticSnapshot.defaultCutLines);
hasManualCutLineChangesRef.current = false;
setPassthroughSegmentIndexes([]); setPassthroughSegmentIndexes([]);
setErrorText(null); setErrorText(null);
setWarningText(null); setWarningText(null);
@ -341,12 +350,15 @@ export function AgentDetailReferenceStitchDialog({
}; };
setSnapshot(preciseSnapshot); setSnapshot(preciseSnapshot);
setPreviewImageLoadFailed(!previewUrl); setPreviewImageLoadFailed(!previewUrl);
setCutLines((current) => setCutLines((current) => {
areCutLinesEqual(current, optimisticSnapshot.defaultCutLines) if (hasManualCutLineChangesRef.current) {
return scaleCutLines(current, optimisticSnapshot.height, height);
}
return areCutLinesEqual(current, optimisticSnapshot.defaultCutLines)
? defaultCutLines ? defaultCutLines
: scaleCutLines(current, optimisticSnapshot.height, height) : scaleCutLines(current, optimisticSnapshot.height, height);
); });
if (onPrepared) { if (onPreparedRef.current) {
const dimensionByCardId = new Map( const dimensionByCardId = new Map(
sourceSegments.map((segment) => [ sourceSegments.map((segment) => [
segment.card.id, segment.card.id,
@ -357,7 +369,7 @@ export function AgentDetailReferenceStitchDialog({
}, },
]) ])
); );
onPrepared( onPreparedRef.current(
setupCards.map((card, index) => { setupCards.map((card, index) => {
const dimensions = dimensionByCardId.get(card.id); const dimensions = dimensionByCardId.get(card.id);
return { return {
@ -394,7 +406,7 @@ export function AgentDetailReferenceStitchDialog({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [onPrepared, open, setupCards, sourceLabel]); }, [open, setupCards, sourceLabel]);
useEffect(() => { useEffect(() => {
const validIndexes = new Set(sliceSegments.map((segment) => segment.index)); const validIndexes = new Set(sliceSegments.map((segment) => segment.index));
@ -532,10 +544,12 @@ export function AgentDetailReferenceStitchDialog({
const handleResetCutLines = useCallback(() => { const handleResetCutLines = useCallback(() => {
if (!snapshot) return; if (!snapshot) return;
hasManualCutLineChangesRef.current = false;
setCutLines(snapshot.defaultCutLines); setCutLines(snapshot.defaultCutLines);
}, [snapshot]); }, [snapshot]);
const handleClearCutLines = useCallback(() => { const handleClearCutLines = useCallback(() => {
hasManualCutLineChangesRef.current = true;
setCutLines([]); setCutLines([]);
setDraggingLineIndex(null); setDraggingLineIndex(null);
setIsAddingCutLine(false); setIsAddingCutLine(false);
@ -543,6 +557,7 @@ export function AgentDetailReferenceStitchDialog({
}, []); }, []);
const handleRemoveCutLine = useCallback((index: number) => { const handleRemoveCutLine = useCallback((index: number) => {
hasManualCutLineChangesRef.current = true;
setCutLines((current) => current.filter((_, currentIndex) => currentIndex !== index)); setCutLines((current) => current.filter((_, currentIndex) => currentIndex !== index));
}, []); }, []);
@ -573,6 +588,7 @@ export function AgentDetailReferenceStitchDialog({
toast.error("当前位置无法添加切线,请换一个位置"); toast.error("当前位置无法添加切线,请换一个位置");
return; return;
} }
hasManualCutLineChangesRef.current = true;
setCutLines((current) => [...current, Math.round(nextY)].sort((left, right) => left - right)); setCutLines((current) => [...current, Math.round(nextY)].sort((left, right) => left - right));
setIsAddingCutLine(false); setIsAddingCutLine(false);
setPreviewCutLineY(null); setPreviewCutLineY(null);
@ -589,6 +605,7 @@ export function AgentDetailReferenceStitchDialog({
} catch { } catch {
// Older browsers may not support pointer capture for this target. // Older browsers may not support pointer capture for this target.
} }
hasManualCutLineChangesRef.current = true;
setDraggingLineIndex(index); setDraggingLineIndex(index);
}, },
[] []
@ -894,7 +911,13 @@ export function AgentDetailReferenceStitchDialog({
</div> </div>
<button <button
type="button" type="button"
onPointerDown={(event) => {
event.preventDefault();
event.stopPropagation();
setDraggingLineIndex(null);
}}
onClick={(event) => { onClick={(event) => {
event.preventDefault();
event.stopPropagation(); event.stopPropagation();
handleRemoveCutLine(index); handleRemoveCutLine(index);
}} }}

View File

@ -1609,6 +1609,14 @@ export function AgentGenerationWorkspaceDarkV2({
[getLatestWorkflowContext, workflowContext] [getLatestWorkflowContext, workflowContext]
); );
const workflowBrandName = getCurrentWorkflowBrandName(); const workflowBrandName = getCurrentWorkflowBrandName();
const getCurrentWorkflowPrimarySpu = useCallback(
() =>
String((getLatestWorkflowContext?.() ?? workflowContext)?.primarySku || "")
.trim(),
[getLatestWorkflowContext, workflowContext]
);
const workflowPrimarySpu = getCurrentWorkflowPrimarySpu();
const normalizedWorkflowPrimarySpu = workflowPrimarySpu.toUpperCase();
const shouldAllowModelOverride = const shouldAllowModelOverride =
!isWorkflowModelLocked && !isWorkflowModelLocked &&
(isDirectModelGenerationMode || !isSceneGenerationPreset || presetModeConfig.scene.allowModelOverride); (isDirectModelGenerationMode || !isSceneGenerationPreset || presetModeConfig.scene.allowModelOverride);
@ -4836,19 +4844,6 @@ export function AgentGenerationWorkspaceDarkV2({
setPendingGeneration(null); setPendingGeneration(null);
} }
} }
const handleDetailReplacementQueryTypeChange = useCallback(
(queryType: AgentWorkflowReferenceQueryType) => {
updateDetailReplacementState((current) => ({
...current,
queryType,
candidates: current.queryType === queryType ? current.candidates : [],
selectedCandidateId:
current.queryType === queryType ? current.selectedCandidateId : null,
references: current.queryType === queryType ? current.references : [],
}));
},
[updateDetailReplacementState]
);
const handleDetailReplacementProductDetailChannelChange = useCallback( const handleDetailReplacementProductDetailChannelChange = useCallback(
(channel: AgentWorkflowProductDetailChannel) => { (channel: AgentWorkflowProductDetailChannel) => {
updateDetailReplacementState((current) => ({ updateDetailReplacementState((current) => ({
@ -4960,25 +4955,22 @@ export function AgentGenerationWorkspaceDarkV2({
}, },
[detailReplacementReferenceCards.length, updateDetailReplacementState] [detailReplacementReferenceCards.length, updateDetailReplacementState]
); );
const handleDetailReplacementSearch = useCallback(async () => { const handleDetailReplacementSearch = useCallback(async (
queryType: Extract<AgentWorkflowReferenceQueryType, "spu" | "productId">
) => {
const rawPrimarySpu = getCurrentWorkflowPrimarySpu();
const normalized = const normalized =
detailReplacementQueryType === "productId" queryType === "productId" ? rawPrimarySpu.trim() : rawPrimarySpu.trim().toUpperCase();
? detailReplacementQueryInput.trim()
: detailReplacementQueryInput.trim().toUpperCase();
const currentWorkflowBrandName = getCurrentWorkflowBrandName(); const currentWorkflowBrandName = getCurrentWorkflowBrandName();
if (!currentWorkflowBrandName && detailReplacementQueryType !== "productId") { if (!currentWorkflowBrandName && queryType !== "productId") {
toast.error("请先在第一步完成品牌选择后再查询参考图"); toast.error("请先在第一步完成品牌选择后再检索可用模版");
return; return;
} }
if (!normalized) { if (!normalized) {
toast.error( toast.error(
`请输入${ queryType === "productId"
detailReplacementQueryType === "productId" ? "请先在第一步输入主商品市场ID后再查询详情"
? "商品ID" : "请先在第一步输入主商品SPU后再检索可用模版"
: detailReplacementQueryType === "sku"
? "SKU"
: "SPU"
}后再查询${detailReferenceSourceLabel}`
); );
return; return;
} }
@ -4991,14 +4983,16 @@ export function AgentGenerationWorkspaceDarkV2({
try { try {
const searchParams = new URLSearchParams({ const searchParams = new URLSearchParams({
keyword: normalized, keyword: normalized,
queryType: detailReplacementQueryType, queryType,
target: detailReferenceTarget, target: detailReferenceTarget,
}); });
if (currentWorkflowBrandName) { if (currentWorkflowBrandName) {
searchParams.set("brandName", currentWorkflowBrandName); searchParams.set("brandName", currentWorkflowBrandName);
} }
if (detailReplacementQueryType === "productId") { if (queryType === "productId") {
searchParams.set("channel", detailReplacementProductDetailChannel); searchParams.set("channel", detailReplacementProductDetailChannel);
} else {
searchParams.set("strictQueryType", "true");
} }
const response = await fetch( const response = await fetch(
`/api/agent-workflow/reference-search?${searchParams.toString()}`, `/api/agent-workflow/reference-search?${searchParams.toString()}`,
@ -5012,7 +5006,7 @@ export function AgentGenerationWorkspaceDarkV2({
updateDetailReplacementState({ updateDetailReplacementState({
query: normalized, query: normalized,
queryType: result.data.queryType || detailReplacementQueryType, queryType: result.data.queryType || queryType,
productDetailChannel: productDetailChannel:
result.data.channel || detailReplacementProductDetailChannel, result.data.channel || detailReplacementProductDetailChannel,
candidates: result.data.candidates || [], candidates: result.data.candidates || [],
@ -5036,8 +5030,7 @@ export function AgentGenerationWorkspaceDarkV2({
detailReferenceSourceLabel, detailReferenceSourceLabel,
detailReferenceTarget, detailReferenceTarget,
detailReplacementProductDetailChannel, detailReplacementProductDetailChannel,
detailReplacementQueryInput, getCurrentWorkflowPrimarySpu,
detailReplacementQueryType,
getCurrentWorkflowBrandName, getCurrentWorkflowBrandName,
updateDetailReplacementState, updateDetailReplacementState,
]); ]);
@ -6241,87 +6234,79 @@ export function AgentGenerationWorkspaceDarkV2({
</div> </div>
<div className="space-y-4 p-5"> <div className="space-y-4 p-5">
<div className="flex flex-wrap gap-2"> <input
{([ ref={detailReferenceUploadInputRef}
["spu", "按SPU查询"], type="file"
["sku", "按SKU查询"], accept="image/*"
["productId", "按商品ID查询"], multiple
] as Array<[AgentWorkflowReferenceQueryType, string]>).map(([queryType, label]) => ( className="hidden"
<button onChange={handleDetailReferenceUploadChange}
key={queryType} />
type="button" <div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
onClick={() => handleDetailReplacementQueryTypeChange(queryType)} <div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
className={cn( <div className="min-w-0">
"rounded-full border px-3 py-1.5 text-xs transition-colors", <div className="text-xs text-white/42">
detailReplacementQueryType === queryType 自动复用第一步主商品信息检索参考详情,不需要重复输入 SPU / SKU。
? "border-emerald-400/45 bg-emerald-500/[0.12] text-emerald-200" </div>
: "border-white/10 bg-white/[0.03] text-white/58 hover:border-white/18 hover:text-white" <div className="mt-2 flex flex-wrap gap-2">
)} <span className="rounded-full border border-emerald-400/20 bg-emerald-500/[0.08] px-3 py-1 text-xs text-emerald-100">
> 主 SPU / {normalizedWorkflowPrimarySpu || "未填写"}
{label} </span>
</button> <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs text-white/60">
))} 品牌 / {workflowBrandName || "未选择"}
</span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs text-white/60">
目标 / {detailReferenceSourceLabel}
</span>
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Select
value={detailReplacementProductDetailChannel}
onValueChange={(value) =>
handleDetailReplacementProductDetailChannelChange(
value as AgentWorkflowProductDetailChannel
)
}
>
<SelectTrigger className="h-12 rounded-[22px] border-white/10 bg-black/20 text-sm text-white focus:border-emerald-400/30 sm:w-[150px]">
<SelectValue placeholder="渠道" />
</SelectTrigger>
<SelectContent>
{PRODUCT_DETAIL_CHANNEL_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
onClick={() => handleDetailReplacementSearch("productId")}
disabled={detailReplacementSearchState.isLoading}
className="h-12 rounded-[22px] bg-white/[0.04] px-5 text-sm text-white hover:bg-white/[0.08]"
>
{detailReplacementSearchState.isLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
市场ID查询详情
</Button>
<Button
type="button"
onClick={() => handleDetailReplacementSearch("spu")}
disabled={detailReplacementSearchState.isLoading}
className="h-12 rounded-[22px] bg-emerald-500 px-5 font-semibold text-sm text-black shadow-[0_0_24px_rgba(16,185,129,0.22)] hover:bg-emerald-400"
>
{detailReplacementSearchState.isLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
检索可用模版
</Button>
</div>
</div>
</div> </div>
<div className="flex flex-col gap-2 lg:flex-row"> <div className="flex flex-col gap-2 lg:flex-row">
<input
ref={detailReferenceUploadInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={handleDetailReferenceUploadChange}
/>
{detailReplacementQueryType === "productId" ? (
<Select
value={detailReplacementProductDetailChannel}
onValueChange={(value) =>
handleDetailReplacementProductDetailChannelChange(
value as AgentWorkflowProductDetailChannel
)
}
>
<SelectTrigger className="h-12 rounded-[22px] border-white/10 bg-black/20 text-sm text-white focus:border-emerald-400/30 lg:w-[150px]">
<SelectValue placeholder="渠道" />
</SelectTrigger>
<SelectContent>
{PRODUCT_DETAIL_CHANNEL_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
<Input
value={detailReplacementQueryInput}
onChange={(e) =>
updateDetailReplacementState({
query: e.target.value,
})
}
placeholder={
detailReplacementQueryType === "productId"
? "输入商品ID,查询并拉取详情图片"
: `输入 ${detailReplacementQueryType === "sku" ? "SKU" : "SPU"},查询任务整套中的${detailReferenceSourceLabel}参考图`
}
className="h-12 rounded-[22px] border-white/10 bg-black/20 text-sm text-white placeholder:text-white/22 focus:border-emerald-400/30"
/>
<Button
type="button"
onClick={handleDetailReplacementSearch}
disabled={detailReplacementSearchState.isLoading}
className="h-12 rounded-[22px] bg-white/[0.04] px-5 text-sm text-white hover:bg-white/[0.08]"
>
{detailReplacementSearchState.isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
查询中
</>
) : (
`查询${detailReferenceSourceLabel}`
)}
</Button>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
@ -6897,7 +6882,7 @@ export function AgentGenerationWorkspaceDarkV2({
<div className="flex min-h-[220px] flex-col items-center justify-center rounded-[22px] border border-dashed border-white/12 bg-black/20 px-5 text-center"> <div className="flex min-h-[220px] flex-col items-center justify-center rounded-[22px] border border-dashed border-white/12 bg-black/20 px-5 text-center">
<div className="text-sm font-medium text-white/72">等待{detailReferenceSourceLabel}参考图</div> <div className="text-sm font-medium text-white/72">等待{detailReferenceSourceLabel}参考图</div>
<div className="mt-2 text-xs leading-6 text-white/40"> <div className="mt-2 text-xs leading-6 text-white/40">
输入 SPU 或 SKU 后,这里会展示任务整套里的真实{detailReferenceSourceLabel}参考图。 点击“检索可用模版”后,这里会展示当前品牌下主 SPU 对应的真实{detailReferenceSourceLabel}参考图。
删除不需要的参考图后,列表会自动上移补位。 删除不需要的参考图后,列表会自动上移补位。
</div> </div>
</div> </div>

View File

@ -18,6 +18,9 @@ type AgentPreviewImageProps = Omit<
previewWidth?: number; previewWidth?: number;
previewQuality?: number; previewQuality?: number;
previewFormat?: "webp" | "avif" | "jpeg" | "png"; previewFormat?: "webp" | "avif" | "jpeg" | "png";
delivery?: "display" | "proxy";
fallbackSrc?: string | null;
fallbackToOriginal?: boolean;
rootMargin?: string; rootMargin?: string;
eager?: boolean; eager?: boolean;
raw?: boolean; raw?: boolean;
@ -31,6 +34,7 @@ function buildPreviewUrl(params: {
width: number; width: number;
quality: number; quality: number;
format: "webp" | "avif" | "jpeg" | "png"; format: "webp" | "avif" | "jpeg" | "png";
delivery?: "display" | "proxy";
raw?: boolean; raw?: boolean;
}) { }) {
if (params.raw) return params.src; if (params.raw) return params.src;
@ -39,6 +43,7 @@ function buildPreviewUrl(params: {
width: params.width, width: params.width,
quality: params.quality, quality: params.quality,
format: params.format, format: params.format,
delivery: params.delivery,
}) || params.src }) || params.src
); );
} }
@ -48,6 +53,9 @@ function AgentPreviewImageBase({
previewWidth = 480, previewWidth = 480,
previewQuality = 78, previewQuality = 78,
previewFormat = "webp", previewFormat = "webp",
delivery = "display",
fallbackSrc,
fallbackToOriginal = true,
rootMargin = "640px 0px", rootMargin = "640px 0px",
eager = false, eager = false,
raw = false, raw = false,
@ -58,15 +66,22 @@ function AgentPreviewImageBase({
const imageRef = useRef<HTMLImageElement | null>(null); const imageRef = useRef<HTMLImageElement | null>(null);
const [shouldLoad, setShouldLoad] = useState(eager); const [shouldLoad, setShouldLoad] = useState(eager);
const [useOriginal, setUseOriginal] = useState(false); const [useOriginal, setUseOriginal] = useState(false);
const [useFallbackSource, setUseFallbackSource] = useState(false);
const normalizedSrc = String(src || "").trim(); const normalizedSrc = String(src || "").trim();
const normalizedFallbackSrc = String(fallbackSrc || "").trim();
const currentSrc =
useFallbackSource && normalizedFallbackSrc
? normalizedFallbackSrc
: normalizedSrc;
useEffect(() => { useEffect(() => {
setUseOriginal(false); setUseOriginal(false);
setUseFallbackSource(false);
setShouldLoad(eager); setShouldLoad(eager);
}, [eager, normalizedSrc]); }, [eager, normalizedFallbackSrc, normalizedSrc]);
useEffect(() => { useEffect(() => {
if (eager || shouldLoad || !normalizedSrc) return; if (eager || shouldLoad || !currentSrc) return;
const image = imageRef.current; const image = imageRef.current;
if (!image) return; if (!image) return;
@ -86,20 +101,22 @@ function AgentPreviewImageBase({
); );
observer.observe(image); observer.observe(image);
return () => observer.disconnect(); return () => observer.disconnect();
}, [eager, normalizedSrc, rootMargin, shouldLoad]); }, [currentSrc, eager, rootMargin, shouldLoad]);
const displaySrc = useMemo(() => { const displaySrc = useMemo(() => {
if (!normalizedSrc || !shouldLoad) return EMPTY_PIXEL; if (!currentSrc || !shouldLoad) return EMPTY_PIXEL;
if (useOriginal) return normalizedSrc; if (useOriginal) return currentSrc;
return buildPreviewUrl({ return buildPreviewUrl({
src: normalizedSrc, src: currentSrc,
width: previewWidth, width: previewWidth,
quality: previewQuality, quality: previewQuality,
format: previewFormat, format: previewFormat,
delivery,
raw, raw,
}); });
}, [ }, [
normalizedSrc, currentSrc,
delivery,
previewFormat, previewFormat,
previewQuality, previewQuality,
previewWidth, previewWidth,
@ -117,10 +134,20 @@ function AgentPreviewImageBase({
loading={eager ? "eager" : "lazy"} loading={eager ? "eager" : "lazy"}
decoding="async" decoding="async"
onError={(event) => { onError={(event) => {
if (normalizedSrc && !useOriginal && displaySrc !== normalizedSrc) { if (
fallbackToOriginal &&
currentSrc &&
!useOriginal &&
displaySrc !== currentSrc
) {
setUseOriginal(true); setUseOriginal(true);
return; return;
} }
if (!useFallbackSource && normalizedFallbackSrc) {
setUseFallbackSource(true);
setUseOriginal(false);
return;
}
onError?.(event); onError?.(event);
}} }}
onLoad={onLoad} onLoad={onLoad}

View File

@ -383,11 +383,34 @@ function buildPsdLayerPreviewUrl(
params?: { params?: {
width?: number; width?: number;
quality?: number; quality?: number;
delivery?: "proxy";
} }
) { ) {
return buildTemplatePreviewThumbUrl(imageUrl, { const width = Math.max(96, Math.min(1280, Math.round(params?.width || 720)));
width: Math.max(96, Math.min(1280, Math.round(params?.width || 720))), const quality = Math.max(52, Math.min(82, Math.round(params?.quality || 68)));
quality: Math.max(52, Math.min(82, Math.round(params?.quality || 68))), if (params?.delivery === "proxy") {
return buildImageProxyUrl(imageUrl, {
width,
quality,
format: "webp",
delivery: "proxy",
});
}
return buildTemplatePreviewThumbUrl(imageUrl, { width, quality });
}
function buildPsdLayerMaskUrl(maskUrl: string, layerWidth: number) {
const normalizedUrl = String(maskUrl || "").trim();
if (!normalizedUrl) {
return "";
}
return buildImageProxyUrl(normalizedUrl, {
width: Math.max(96, Math.min(1280, Math.round(layerWidth || 720))),
quality: 100,
format: "png",
delivery: "proxy",
}); });
} }
@ -1474,35 +1497,55 @@ function renderTextLayerPreviewContent(
} }
function getAssignedImageMaskStyle( function getAssignedImageMaskStyle(
layer: AgentPsdTemplateImageLayerSlot layer: AgentPsdTemplateImageLayerSlot,
frameLayer: AgentPsdTemplateLayerSlot,
layers: AgentPsdTemplateLayerSlot[]
): CSSProperties { ): CSSProperties {
const hasMask = Boolean(layer.maskUrl); const clipMaskLayer = layer.clipMaskLayerId
? layers.find((item) => item.id === layer.clipMaskLayerId)
: null;
const fallbackMaskUrl =
layer.forceClipMask && clipMaskLayer
? getPsdLayerPreviewSourceUrl(clipMaskLayer)
: "";
const normalizedMaskUrl = String(
layer.maskUrl || fallbackMaskUrl || ""
).trim();
const hasMask = Boolean(normalizedMaskUrl);
const maskImageUrl = hasMask
? `url(${buildPsdLayerMaskUrl(normalizedMaskUrl, frameLayer.width)})`
: undefined;
return { return {
WebkitMaskImage: hasMask ? `url(${layer.maskUrl})` : undefined, WebkitMaskImage: maskImageUrl,
maskImage: hasMask ? `url(${layer.maskUrl})` : undefined, maskImage: maskImageUrl,
WebkitMaskSize: hasMask ? "100% 100%" : undefined, WebkitMaskSize: hasMask ? "100% 100%" : undefined,
maskSize: hasMask ? "100% 100%" : undefined, maskSize: hasMask ? "100% 100%" : undefined,
WebkitMaskRepeat: hasMask ? "no-repeat" : undefined, WebkitMaskRepeat: hasMask ? "no-repeat" : undefined,
maskRepeat: hasMask ? "no-repeat" : undefined, maskRepeat: hasMask ? "no-repeat" : undefined,
WebkitMaskPosition: hasMask ? "center" : undefined, WebkitMaskPosition: hasMask ? "center" : undefined,
maskPosition: hasMask ? "center" : undefined, maskPosition: hasMask ? "center" : undefined,
maskMode: hasMask ? "alpha" : undefined,
}; };
} }
function getPlacedAssignedImageStyle(params: { function getPlacedAssignedImageStyle(params: {
layer: AgentPsdTemplateImageLayerSlot; layer: AgentPsdTemplateImageLayerSlot;
frameLayer?: AgentPsdTemplateLayerSlot | null;
imageSize: AgentPsdImageSize; imageSize: AgentPsdImageSize;
placement: AgentPsdImagePlacement; placement: AgentPsdImagePlacement;
}): CSSProperties { }): CSSProperties {
const { layer, imageSize, placement } = params; const { layer, imageSize, placement } = params;
const frameLayer = params.frameLayer || layer;
const normalizedPlacement = normalizeAgentPsdImagePlacement(placement); const normalizedPlacement = normalizeAgentPsdImagePlacement(placement);
const frameContentBounds =
isAgentPsdImageLayerSlot(frameLayer) ? frameLayer.contentBounds : undefined;
const baseFrame = resolveAgentPsdImageBaseFrame({ const baseFrame = resolveAgentPsdImageBaseFrame({
containerWidth: layer.width, containerWidth: frameLayer.width,
containerHeight: layer.height, containerHeight: frameLayer.height,
imageWidth: imageSize.width, imageWidth: imageSize.width,
imageHeight: imageSize.height, imageHeight: imageSize.height,
replaceMode: layer.replaceMode, replaceMode: layer.replaceMode,
contentBounds: layer.contentBounds, contentBounds: frameContentBounds,
}); });
return { return {
@ -1520,6 +1563,17 @@ function getPlacedAssignedImageStyle(params: {
}; };
} }
function getAssignedImageFrameLayer(
layer: AgentPsdTemplateImageLayerSlot,
layers: AgentPsdTemplateLayerSlot[]
) {
if (!layer.forceClipMask || !layer.clipMaskLayerId) {
return layer;
}
return layers.find((item) => item.id === layer.clipMaskLayerId) || layer;
}
function hasEditedTextValue( function hasEditedTextValue(
layer: AgentPsdTemplateTextLayerSlot, layer: AgentPsdTemplateTextLayerSlot,
value: string value: string
@ -3394,15 +3448,18 @@ export function AgentPsdTemplateEditorDialog({
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
setSelectedLayerId(layer.id); setSelectedLayerId(layer.id);
const shouldDragAssignedImageContent =
isAgentPsdImageLayerSlot(layer) &&
Boolean(String(effectiveImageAssignments[layer.id] || "").trim());
layerDragStateRef.current = { layerDragStateRef.current = {
layerId: layer.id, layerId: layer.id,
pointerId: event.pointerId, pointerId: event.pointerId,
startClientX: event.clientX, startClientX: event.clientX,
startClientY: event.clientY, startClientY: event.clientY,
startOffset: normalizeLayerPositionOffset( startOffset: shouldDragAssignedImageContent
effectiveLayerPositionOffsets[layer.id] ? normalizeLayerPositionOffset(effectiveImagePlacements[layer.id])
), : normalizeLayerPositionOffset(effectiveLayerPositionOffsets[layer.id]),
}; };
const nextTarget = event.currentTarget; const nextTarget = event.currentTarget;
@ -3421,6 +3478,19 @@ export function AgentPsdTemplateEditorDialog({
return; return;
} }
if (shouldDragAssignedImageContent) {
updateImagePlacement(layer.id, (current) => ({
...current,
offsetX:
dragState.startOffset.offsetX +
(moveEvent.clientX - dragState.startClientX) / stageScale,
offsetY:
dragState.startOffset.offsetY +
(moveEvent.clientY - dragState.startClientY) / stageScale,
}));
return;
}
updateLayerPositionOffset(layer.id, () => ({ updateLayerPositionOffset(layer.id, () => ({
offsetX: offsetX:
dragState.startOffset.offsetX + dragState.startOffset.offsetX +
@ -4146,12 +4216,45 @@ export function AgentPsdTemplateEditorDialog({
if (!layerImageUrl) { if (!layerImageUrl) {
return null; return null;
} }
const assignedFrameLayer = assignedUrl
? getAssignedImageFrameLayer(
layer,
selectedTemplateLayers
)
: layer;
const assignedFrameOffset =
effectiveLayerPositionOffsets[
assignedFrameLayer.id
] || normalizeLayerPositionOffset();
const assignedFrameBoxStyle = {
left:
assignedFrameLayer.x +
assignedFrameOffset.offsetX,
top:
assignedFrameLayer.y +
assignedFrameOffset.offsetY,
width: assignedFrameLayer.width,
height: assignedFrameLayer.height,
} satisfies CSSProperties;
const assignedImageContainerStyle = assignedUrl const assignedImageContainerStyle = assignedUrl
? { ? {
...layerBoxStyle, ...assignedFrameBoxStyle,
...getAssignedImageMaskStyle(layer), ...getAssignedImageMaskStyle(
layer,
assignedFrameLayer,
selectedTemplateLayers
),
} }
: layerBoxStyle; : layerBoxStyle;
const assignedFrameBaseZIndex =
layerStackZIndexById.get(
assignedFrameLayer.id
) || baseZIndex;
const previewZIndex = assignedUrl
? assignedFrameLayer.id === layer.id
? Math.max(baseZIndex, renderBaseZIndex + 1)
: assignedFrameBaseZIndex + 1
: baseZIndex;
return ( return (
<div <div
@ -4176,7 +4279,7 @@ export function AgentPsdTemplateEditorDialog({
} }
style={{ style={{
...assignedImageContainerStyle, ...assignedImageContainerStyle,
zIndex: baseZIndex, zIndex: previewZIndex,
cursor: cursor:
isLayerSuperLocked isLayerSuperLocked
? "default" ? "default"
@ -4201,11 +4304,13 @@ export function AgentPsdTemplateEditorDialog({
? Math.max(layer.width, layer.height) ? Math.max(layer.width, layer.height)
: layer.width, : layer.width,
quality: assignedUrl ? 74 : 68, quality: assignedUrl ? 74 : 68,
delivery: assignedUrl ? "proxy" : undefined,
})} })}
style={ style={
assignedUrl && imageSizes[layer.id] assignedUrl && imageSizes[layer.id]
? getPlacedAssignedImageStyle({ ? getPlacedAssignedImageStyle({
layer, layer,
frameLayer: assignedFrameLayer,
imageSize: imageSizes[layer.id], imageSize: imageSizes[layer.id],
placement: placement:
effectiveImagePlacements[ effectiveImagePlacements[

View File

@ -63,6 +63,40 @@ const MATERIAL_GRID_MIN_CARD_HEIGHT = 258;
const AGENT_STYLE_OPTIMIZED_RENDER_ENABLED = const AGENT_STYLE_OPTIMIZED_RENDER_ENABLED =
process.env.NEXT_PUBLIC_AGENT_STYLE_OPTIMIZED_RENDER !== "false"; process.env.NEXT_PUBLIC_AGENT_STYLE_OPTIMIZED_RENDER !== "false";
function normalizeImageIdList(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value.map((item) => String(item || "").trim()).filter(Boolean);
}
function normalizeMaterialImages(value: unknown): AgentWorkflowMaterialImage[] {
if (!Array.isArray(value)) {
return [];
}
return value.filter((item): item is AgentWorkflowMaterialImage => {
if (!item || typeof item !== "object" || Array.isArray(item)) {
return false;
}
const image = item as Partial<AgentWorkflowMaterialImage>;
return Boolean(String(image.id || "").trim());
});
}
function normalizeSkuOptions(
value: AgentWorkflowMaterialSearchResult["skuOptions"] | unknown
): NonNullable<AgentWorkflowMaterialSearchResult["skuOptions"]> {
if (!Array.isArray(value)) {
return [];
}
return value.filter((item): item is NonNullable<AgentWorkflowMaterialSearchResult["skuOptions"]>[number] => {
if (!item || typeof item !== "object" || Array.isArray(item)) {
return false;
}
return Boolean(String(item.skuName || "").trim());
});
}
const MaterialImageCard = memo(function MaterialImageCard({ const MaterialImageCard = memo(function MaterialImageCard({
image, image,
selected, selected,
@ -75,6 +109,15 @@ const MaterialImageCard = memo(function MaterialImageCard({
onToggle: (imageId: string) => void; onToggle: (imageId: string) => void;
}) { }) {
const isPlaceholder = !String(image.imageUrl || "").trim(); const isPlaceholder = !String(image.imageUrl || "").trim();
const displayImageUrl = String(image.imageUrl || "").trim();
const [imageStatus, setImageStatus] = useState<"loading" | "loaded" | "error">(
isPlaceholder ? "loaded" : "loading"
);
useEffect(() => {
setImageStatus(isPlaceholder ? "loaded" : "loading");
}, [displayImageUrl, isPlaceholder]);
return ( return (
<button <button
type="button" type="button"
@ -109,16 +152,39 @@ const MaterialImageCard = memo(function MaterialImageCard({
</div> </div>
) : ( ) : (
<AgentPreviewImage <AgentPreviewImage
src={image.imageUrl} src={displayImageUrl}
alt={image.label} alt={image.label}
className="h-full w-full object-cover" className={cn(
previewWidth={480} "h-full w-full object-contain transition-opacity duration-300",
imageStatus === "loaded" ? "opacity-100" : "opacity-0"
)}
delivery="proxy"
eager={selected || image.sortOrder < 4}
previewWidth={720}
previewQuality={74} previewQuality={74}
onError={(event) => { onError={() => {
event.currentTarget.style.display = "none"; setImageStatus("error");
}} }}
onLoad={() => setImageStatus("loaded")}
/> />
)} )}
{!isPlaceholder && imageStatus !== "loaded" ? (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-[radial-gradient(circle_at_top,rgba(16,185,129,0.16),transparent_58%),linear-gradient(180deg,rgba(255,255,255,0.07),rgba(255,255,255,0.02))] px-4 text-center text-white/70">
{imageStatus === "error" ? (
<ImageIcon className="h-7 w-7 text-white/45" />
) : (
<Loader2 className="h-6 w-6 animate-spin text-emerald-200/80" />
)}
<div className="text-xs font-medium">
{imageStatus === "error" ? "原图暂不可用" : "正在加载原图"}
</div>
<div className="max-w-[180px] text-[11px] leading-5 text-white/45">
{imageStatus === "error"
? "原图地址返回异常,可先稍后重试或联系素材服务检查。"
: "已命中素材,正在直接拉取原图。"}
</div>
</div>
) : null}
<div className="absolute inset-x-0 top-0 flex items-center justify-between px-2 py-2"> <div className="absolute inset-x-0 top-0 flex items-center justify-between px-2 py-2">
<span className="rounded-full bg-black/60 px-2 py-1 text-[11px] text-white"> <span className="rounded-full bg-black/60 px-2 py-1 text-[11px] text-white">
{isPlaceholder ? "加载中" : image.group === "fixed" ? "固定角度" : "其他图片"} {isPlaceholder ? "加载中" : image.group === "fixed" ? "固定角度" : "其他图片"}
@ -189,6 +255,19 @@ function MaterialImageGrid({
if (containerWidth >= 900) return 3; if (containerWidth >= 900) return 3;
return 2; return 2;
}, [containerWidth]); }, [containerWidth]);
const rowCount = Math.ceil(images.length / columnCount);
const cardWidth =
containerWidth > 0
? Math.floor((containerWidth - MATERIAL_GRID_GAP * (columnCount - 1)) / columnCount)
: 240;
const rowHeight = Math.max(MATERIAL_GRID_MIN_CARD_HEIGHT, cardWidth + 74);
// Keep hook order stable when SKU loading placeholders and final images cross the virtualization threshold.
const rowVirtualizer = useVirtualizer({
count: rowCount,
getScrollElement: () => parentRef.current,
estimateSize: () => rowHeight + MATERIAL_GRID_GAP,
overscan: 2,
});
if ( if (
!AGENT_STYLE_OPTIMIZED_RENDER_ENABLED || !AGENT_STYLE_OPTIMIZED_RENDER_ENABLED ||
@ -209,19 +288,6 @@ function MaterialImageGrid({
); );
} }
const rowCount = Math.ceil(images.length / columnCount);
const cardWidth =
containerWidth > 0
? Math.floor((containerWidth - MATERIAL_GRID_GAP * (columnCount - 1)) / columnCount)
: 240;
const rowHeight = Math.max(MATERIAL_GRID_MIN_CARD_HEIGHT, cardWidth + 74);
const rowVirtualizer = useVirtualizer({
count: rowCount,
getScrollElement: () => parentRef.current,
estimateSize: () => rowHeight + MATERIAL_GRID_GAP,
overscan: 2,
});
return ( return (
<div <div
ref={parentRef} ref={parentRef}
@ -296,10 +362,14 @@ export function AgentWorkflowMaterialPicker({
const canUploadMaterialImage = Boolean(onUploadImage); const canUploadMaterialImage = Boolean(onUploadImage);
const [previewDisplayMode, setPreviewDisplayMode] = useState<"selected" | "all">("all"); const [previewDisplayMode, setPreviewDisplayMode] = useState<"selected" | "all">("all");
const [hasManualSelectionInteraction, setHasManualSelectionInteraction] = useState(false); const [hasManualSelectionInteraction, setHasManualSelectionInteraction] = useState(false);
const selectedSet = useMemo( const normalizedSelectedImageIds = useMemo(
() => new Set((selectedImageIds || []).map((item) => String(item || "").trim()).filter(Boolean)), () => normalizeImageIdList(selectedImageIds),
[selectedImageIds] [selectedImageIds]
); );
const selectedSet = useMemo(
() => new Set(normalizedSelectedImageIds),
[normalizedSelectedImageIds]
);
const recommendedImageIds = useMemo( const recommendedImageIds = useMemo(
() => () =>
Array.isArray(result?.recommendedImageIds) Array.isArray(result?.recommendedImageIds)
@ -307,7 +377,14 @@ export function AgentWorkflowMaterialPicker({
: [], : [],
[result?.recommendedImageIds] [result?.recommendedImageIds]
); );
const allImages = result?.images || []; const allImages = useMemo(
() => normalizeMaterialImages(result?.images),
[result?.images]
);
const skuOptions = useMemo(
() => normalizeSkuOptions(result?.skuOptions),
[result?.skuOptions]
);
const uploadedImages = useMemo( const uploadedImages = useMemo(
() => () =>
allImages allImages
@ -427,7 +504,7 @@ export function AgentWorkflowMaterialPicker({
const handleToggleImage = useCallback((imageId: string) => { const handleToggleImage = useCallback((imageId: string) => {
setHasManualSelectionInteraction(true); setHasManualSelectionInteraction(true);
const next = new Set(selectedImageIds); const next = new Set(normalizedSelectedImageIds);
if (next.has(imageId)) { if (next.has(imageId)) {
next.delete(imageId); next.delete(imageId);
} else { } else {
@ -444,7 +521,7 @@ export function AgentWorkflowMaterialPicker({
}, [ }, [
onSelectedImageIdsChange, onSelectedImageIdsChange,
resolvedSelectionLimit, resolvedSelectionLimit,
selectedImageIds, normalizedSelectedImageIds,
selectionLimitLabel, selectionLimitLabel,
]); ]);
@ -1011,9 +1088,9 @@ export function AgentWorkflowMaterialPicker({
</div> </div>
) : null} ) : null}
{result.skuOptions?.length ? ( {skuOptions.length ? (
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2"> <div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
{result.skuOptions.map((option) => ( {skuOptions.map((option) => (
<div <div
key={`${option.skuName}-${option.skuId || "sku"}`} key={`${option.skuName}-${option.skuId || "sku"}`}
className={cn( className={cn(
@ -1152,7 +1229,7 @@ export function AgentWorkflowMaterialPicker({
</div> </div>
)} )}
{(result?.images?.length || result?.skuOptions?.length) ? ( {(allImages.length || skuOptions.length) ? (
<div <div
className={cn( className={cn(
"flex items-start gap-2 rounded-[20px] px-3 py-2 text-xs", "flex items-start gap-2 rounded-[20px] px-3 py-2 text-xs",
@ -1160,7 +1237,7 @@ export function AgentWorkflowMaterialPicker({
)} )}
> >
<RefreshCw className="mt-0.5 h-3.5 w-3.5 shrink-0" /> <RefreshCw className="mt-0.5 h-3.5 w-3.5 shrink-0" />
{result?.skuOptions?.length {skuOptions.length
? "当前是 SPU 命中结果,请先选择具体 SKU,再继续拉取白底图。" ? "当前是 SPU 命中结果,请先选择具体 SKU,再继续拉取白底图。"
: totalImageCount > 0 : totalImageCount > 0
? "白底图会先按权重自动选入槽位,如需微调仍可手动调整。" ? "白底图会先按权重自动选入槽位,如需微调仍可手动调整。"

View File

@ -1,8 +1,9 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Plus, Sparkles } from "lucide-react"; import { Plus, Sparkles } from "lucide-react";
import { AgentWorkflowMaterialPicker } from "@/components/agents/AgentWorkflowMaterialPicker"; import { AgentWorkflowMaterialPicker } from "@/components/agents/AgentWorkflowMaterialPicker";
import { ClientErrorBoundary } from "@/components/ui/client-error-boundary";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { createEmptyAgentWorkflowMaterialSelection } from "@/lib/agents/workflow-material-config"; import { createEmptyAgentWorkflowMaterialSelection } from "@/lib/agents/workflow-material-config";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -31,6 +32,58 @@ interface AgentWorkflowMaterialSelectionPanelProps {
theme?: "light" | "dark"; theme?: "light" | "dark";
} }
function normalizeImageIdList(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value.map((item) => String(item || "").trim()).filter(Boolean);
}
function normalizeMaterialImages(value: unknown): AgentWorkflowMaterialImage[] {
if (!Array.isArray(value)) {
return [];
}
return value.filter((item): item is AgentWorkflowMaterialImage => {
if (!item || typeof item !== "object" || Array.isArray(item)) {
return false;
}
const image = item as Partial<AgentWorkflowMaterialImage>;
return Boolean(String(image.id || "").trim());
});
}
function normalizeMaterialSearchResult(
result?: AgentWorkflowMaterialSearchResult | null
): AgentWorkflowMaterialSearchResult | null {
if (!result || typeof result !== "object" || Array.isArray(result)) {
return null;
}
return {
...result,
images: normalizeMaterialImages(result.images),
recommendedImageIds: normalizeImageIdList(result.recommendedImageIds),
skuOptions: Array.isArray(result.skuOptions)
? result.skuOptions.filter((item) => Boolean(String(item?.skuName || "").trim()))
: [],
};
}
function normalizeMaterialSelections(
value: AgentWorkflowMaterialSelections
): AgentWorkflowMaterialSelections {
return {
primary: {
result: normalizeMaterialSearchResult(value?.primary?.result),
selectedImageIds: normalizeImageIdList(value?.primary?.selectedImageIds),
},
secondary: {
result: normalizeMaterialSearchResult(value?.secondary?.result),
selectedImageIds: normalizeImageIdList(value?.secondary?.selectedImageIds),
},
};
}
function getUploadedWorkflowMaterialImages( function getUploadedWorkflowMaterialImages(
result?: AgentWorkflowMaterialSearchResult | null result?: AgentWorkflowMaterialSearchResult | null
): AgentWorkflowMaterialImage[] { ): AgentWorkflowMaterialImage[] {
@ -47,13 +100,15 @@ function getSelectedWorkflowMaterialImages(params: {
selectedImageIds: string[]; selectedImageIds: string[];
}): AgentWorkflowMaterialImage[] { }): AgentWorkflowMaterialImage[] {
const selectedIds = new Set( const selectedIds = new Set(
(params.selectedImageIds || []).map((item) => String(item || "").trim()).filter(Boolean) normalizeImageIdList(params.selectedImageIds)
); );
if (!params.result || selectedIds.size === 0) { if (!params.result || selectedIds.size === 0) {
return []; return [];
} }
return (params.result.images || []).filter((image) => selectedIds.has(image.id)); return normalizeMaterialImages(params.result.images).filter((image) =>
selectedIds.has(image.id)
);
} }
function syncWorkflowMaterialAppearanceSummary(params: { function syncWorkflowMaterialAppearanceSummary(params: {
@ -101,7 +156,7 @@ function syncWorkflowMaterialAppearanceSummary(params: {
} }
let changed = false; let changed = false;
const nextImages = result.images.map((image) => { const nextImages = normalizeMaterialImages(result.images).map((image) => {
if (image.id !== targetImage.id) { if (image.id !== targetImage.id) {
return image; return image;
} }
@ -217,7 +272,9 @@ function mergeSearchResultWithUploadedImages(params: {
currentResult?: AgentWorkflowMaterialSearchResult | null; currentResult?: AgentWorkflowMaterialSearchResult | null;
}): AgentWorkflowMaterialSearchResult { }): AgentWorkflowMaterialSearchResult {
const uploadedImages = getUploadedWorkflowMaterialImages(params.currentResult); const uploadedImages = getUploadedWorkflowMaterialImages(params.currentResult);
const searchImages = markSearchWorkflowMaterialImages(params.searchResult.images || []); const searchImages = markSearchWorkflowMaterialImages(
normalizeMaterialImages(params.searchResult.images)
);
return buildWorkflowMaterialResult({ return buildWorkflowMaterialResult({
currentResult: { currentResult: {
...params.searchResult, ...params.searchResult,
@ -229,10 +286,34 @@ function mergeSearchResultWithUploadedImages(params: {
}); });
} }
function MaterialPickerErrorFallback({
isDark,
title,
}: {
isDark: boolean;
title: string;
}) {
return (
<div
className={cn(
"rounded-[24px] border px-4 py-5 text-sm leading-6",
isDark
? "border-amber-300/20 bg-amber-300/[0.08] text-amber-50"
: "border-amber-200 bg-amber-50 text-amber-900"
)}
>
<div className="font-medium">{title}暂时无法渲染</div>
<div className={cn("mt-1 text-xs", isDark ? "text-amber-50/70" : "text-amber-800/75")}>
已拦截本次客户端异常,避免整个风格化工作台崩溃。请修改搜索词或刷新后重试。
</div>
</div>
);
}
export function AgentWorkflowMaterialSelectionPanel({ export function AgentWorkflowMaterialSelectionPanel({
inputs, inputs,
onInputsChange, onInputsChange,
materials, materials: rawMaterials,
onMaterialsChange, onMaterialsChange,
appearanceRecognitionEnabled = false, appearanceRecognitionEnabled = false,
onAppearanceRecognitionEnabledChange, onAppearanceRecognitionEnabledChange,
@ -245,6 +326,10 @@ export function AgentWorkflowMaterialSelectionPanel({
disabledHint = "请先在下方选择一个生图模式,再启用 SKU / SPU 搜图。", disabledHint = "请先在下方选择一个生图模式,再启用 SKU / SPU 搜图。",
theme = "light", theme = "light",
}: AgentWorkflowMaterialSelectionPanelProps) { }: AgentWorkflowMaterialSelectionPanelProps) {
const materials = useMemo(
() => normalizeMaterialSelections(rawMaterials),
[rawMaterials]
);
const hasSecondaryMaterialContent = const hasSecondaryMaterialContent =
Boolean(String(inputs.secondarySku || "").trim()) || Boolean(String(inputs.secondarySku || "").trim()) ||
Boolean(materials.secondary.result) || Boolean(materials.secondary.result) ||
@ -890,174 +975,75 @@ export function AgentWorkflowMaterialSelectionPanel({
</div> </div>
</div> </div>
<AgentWorkflowMaterialPicker <ClientErrorBoundary
value={inputs.primarySku} resetKeys={[
queryMode={materialSearchMode.primary} "primary",
placeholder={materialSearchMode.primary === "spu" ? "输入主 SPU" : "输入主 SKU"} inputs.primarySku,
buttonLabel={materialSearchMode.primary === "spu" ? "查SKU列表" : "搜索主图"} materials.primary.result?.matchedSku || "",
searchNote={ materials.primary.selectedImageIds.join("|"),
materialSearchMode.primary === "spu" ]}
? "按 SPU 搜索时会先返回该 SPU 下的 SKU 列表,点选某个 SKU 后再继续拉取白底图。" fallback={<MaterialPickerErrorFallback isDark={isDark} title="主商品素材搜索" />}
: "按 SKU 搜索会直接拉取白底图,适合你已经知道具体 SKU 的情况。" >
}
disabled={disabled}
disabledHint={disabledHint}
isLoading={materialSearchState.primary.isLoading}
errorText={materialSearchState.primary.errorText}
result={materials.primary.result}
selectedImageIds={materials.primary.selectedImageIds}
appearanceRecognitionEnabled={appearanceRecognitionEnabled}
selectionLimit={primarySelectionLimit}
selectionLimitLabel={selectionLimitLabel}
onValueChange={(value) => handleMaterialInputChange("primary", value)}
onQueryModeChange={(nextMode) =>
setMaterialSearchMode((prev) => ({
...prev,
primary: nextMode,
}))
}
onSearch={() => {
void handleSearchMaterials("primary");
}}
onUploadImage={(file, options) => handleUploadMaterialImage("primary", file, options)}
onRemoveUploadedImage={(imageId) =>
handleRemoveUploadedMaterialImage("primary", imageId)
}
isUploadingNewImage={materialUploadState.primary.isUploadingNew}
uploadingImageId={materialUploadState.primary.uploadingImageId}
onSkuOptionSelect={(skuName) => {
const selectedOption = materials.primary.result?.skuOptions?.find(
(option) => option.skuName === skuName
);
const placeholderCount = selectedOption?.whiteBackgroundCount || 8;
const uploadedImages = getUploadedWorkflowMaterialImages(materials.primary.result);
onInputsChange({
...inputs,
primarySku: skuName,
});
onMaterialsChange({
...materials,
primary: {
result: buildWorkflowMaterialResult({
currentResult: {
...buildLoadingMaterialResult({
keyword: skuName,
currentResult: materials.primary.result,
placeholderCount,
}),
images: [
...uploadedImages,
...buildLoadingMaterialResult({
keyword: skuName,
currentResult: materials.primary.result,
placeholderCount,
}).images.map((image) => ({
...image,
sourceType: "search" as const,
})),
],
},
images: [
...uploadedImages,
...buildLoadingMaterialResult({
keyword: skuName,
currentResult: materials.primary.result,
placeholderCount,
}).images.map((image) => ({
...image,
sourceType: "search" as const,
})),
],
fallbackQuery: skuName,
fallbackQueryType: "sku",
}),
selectedImageIds: materials.primary.selectedImageIds.filter((id) =>
uploadedImages.some((image) => image.id === id)
),
},
});
setMaterialSearchMode((prev) => ({
...prev,
primary: "sku",
}));
void handleSearchMaterials("primary", {
keyword: skuName,
queryType: "sku",
});
}}
theme={theme}
onSelectedImageIdsChange={(next) =>
onMaterialsChange({
...materials,
primary: {
...materials.primary,
selectedImageIds: next,
},
})
}
/>
{isSecondaryMaterialVisible ? (
<AgentWorkflowMaterialPicker <AgentWorkflowMaterialPicker
value={inputs.secondarySku} value={inputs.primarySku}
queryMode={materialSearchMode.secondary} queryMode={materialSearchMode.primary}
placeholder={materialSearchMode.secondary === "spu" ? "输入搭配 SPU" : "输入搭配 SKU"} placeholder={materialSearchMode.primary === "spu" ? "输入主 SPU" : "输入主 SKU"}
buttonLabel={materialSearchMode.secondary === "spu" ? "查SKU列表" : "搜索搭配图"} buttonLabel={materialSearchMode.primary === "spu" ? "查SKU列表" : "搜索主图"}
searchNote={ searchNote={
materialSearchMode.secondary === "spu" materialSearchMode.primary === "spu"
? "按 SPU 搜索时会先返回该 SPU 下的 SKU 列表,再选具体 SKU 获取白底图。" ? "按 SPU 搜索时会先返回该 SPU 下的 SKU 列表,点选某个 SKU 后再继续拉取白底图。"
: "选中的搭配图会进入第二个产品槽位,你也可以在下面继续手动替换。" : "按 SKU 搜索会直接拉取白底图,适合你已经知道具体 SKU 的情况。"
} }
disabled={disabled} disabled={disabled}
disabledHint={disabledHint} disabledHint={disabledHint}
isLoading={materialSearchState.secondary.isLoading} isLoading={materialSearchState.primary.isLoading}
errorText={materialSearchState.secondary.errorText} errorText={materialSearchState.primary.errorText}
result={materials.secondary.result} result={materials.primary.result}
selectedImageIds={materials.secondary.selectedImageIds} selectedImageIds={materials.primary.selectedImageIds}
appearanceRecognitionEnabled={appearanceRecognitionEnabled} appearanceRecognitionEnabled={appearanceRecognitionEnabled}
selectionLimit={secondarySelectionLimit} selectionLimit={primarySelectionLimit}
selectionLimitLabel={selectionLimitLabel} selectionLimitLabel={selectionLimitLabel}
onValueChange={(value) => handleMaterialInputChange("secondary", value)} onValueChange={(value) => handleMaterialInputChange("primary", value)}
onQueryModeChange={(nextMode) => onQueryModeChange={(nextMode) =>
setMaterialSearchMode((prev) => ({ setMaterialSearchMode((prev) => ({
...prev, ...prev,
secondary: nextMode, primary: nextMode,
})) }))
} }
onSearch={() => { onSearch={() => {
void handleSearchMaterials("secondary"); void handleSearchMaterials("primary");
}} }}
onUploadImage={(file, options) => handleUploadMaterialImage("secondary", file, options)} onUploadImage={(file, options) => handleUploadMaterialImage("primary", file, options)}
onRemoveUploadedImage={(imageId) => onRemoveUploadedImage={(imageId) =>
handleRemoveUploadedMaterialImage("secondary", imageId) handleRemoveUploadedMaterialImage("primary", imageId)
} }
isUploadingNewImage={materialUploadState.secondary.isUploadingNew} isUploadingNewImage={materialUploadState.primary.isUploadingNew}
uploadingImageId={materialUploadState.secondary.uploadingImageId} uploadingImageId={materialUploadState.primary.uploadingImageId}
onSkuOptionSelect={(skuName) => { onSkuOptionSelect={(skuName) => {
const selectedOption = materials.secondary.result?.skuOptions?.find( const selectedOption = materials.primary.result?.skuOptions?.find(
(option) => option.skuName === skuName (option) => option.skuName === skuName
); );
const placeholderCount = selectedOption?.whiteBackgroundCount || 8; const placeholderCount = selectedOption?.whiteBackgroundCount || 8;
const uploadedImages = getUploadedWorkflowMaterialImages(materials.secondary.result); const uploadedImages = getUploadedWorkflowMaterialImages(materials.primary.result);
onInputsChange({ onInputsChange({
...inputs, ...inputs,
secondarySku: skuName, primarySku: skuName,
}); });
onMaterialsChange({ onMaterialsChange({
...materials, ...materials,
secondary: { primary: {
result: buildWorkflowMaterialResult({ result: buildWorkflowMaterialResult({
currentResult: { currentResult: {
...buildLoadingMaterialResult({ ...buildLoadingMaterialResult({
keyword: skuName, keyword: skuName,
currentResult: materials.secondary.result, currentResult: materials.primary.result,
placeholderCount, placeholderCount,
}), }),
images: [ images: [
...uploadedImages, ...uploadedImages,
...buildLoadingMaterialResult({ ...buildLoadingMaterialResult({
keyword: skuName, keyword: skuName,
currentResult: materials.secondary.result, currentResult: materials.primary.result,
placeholderCount, placeholderCount,
}).images.map((image) => ({ }).images.map((image) => ({
...image, ...image,
@ -1069,7 +1055,7 @@ export function AgentWorkflowMaterialSelectionPanel({
...uploadedImages, ...uploadedImages,
...buildLoadingMaterialResult({ ...buildLoadingMaterialResult({
keyword: skuName, keyword: skuName,
currentResult: materials.secondary.result, currentResult: materials.primary.result,
placeholderCount, placeholderCount,
}).images.map((image) => ({ }).images.map((image) => ({
...image, ...image,
@ -1079,16 +1065,16 @@ export function AgentWorkflowMaterialSelectionPanel({
fallbackQuery: skuName, fallbackQuery: skuName,
fallbackQueryType: "sku", fallbackQueryType: "sku",
}), }),
selectedImageIds: materials.secondary.selectedImageIds.filter((id) => selectedImageIds: materials.primary.selectedImageIds.filter((id) =>
uploadedImages.some((image) => image.id === id) uploadedImages.some((image) => image.id === id)
), ),
}, },
}); });
setMaterialSearchMode((prev) => ({ setMaterialSearchMode((prev) => ({
...prev, ...prev,
secondary: "sku", primary: "sku",
})); }));
void handleSearchMaterials("secondary", { void handleSearchMaterials("primary", {
keyword: skuName, keyword: skuName,
queryType: "sku", queryType: "sku",
}); });
@ -1097,13 +1083,132 @@ export function AgentWorkflowMaterialSelectionPanel({
onSelectedImageIdsChange={(next) => onSelectedImageIdsChange={(next) =>
onMaterialsChange({ onMaterialsChange({
...materials, ...materials,
secondary: { primary: {
...materials.secondary, ...materials.primary,
selectedImageIds: next, selectedImageIds: next,
}, },
}) })
} }
/> />
</ClientErrorBoundary>
{isSecondaryMaterialVisible ? (
<ClientErrorBoundary
resetKeys={[
"secondary",
inputs.secondarySku,
materials.secondary.result?.matchedSku || "",
materials.secondary.selectedImageIds.join("|"),
]}
fallback={<MaterialPickerErrorFallback isDark={isDark} title="搭配商品素材搜索" />}
>
<AgentWorkflowMaterialPicker
value={inputs.secondarySku}
queryMode={materialSearchMode.secondary}
placeholder={materialSearchMode.secondary === "spu" ? "输入搭配 SPU" : "输入搭配 SKU"}
buttonLabel={materialSearchMode.secondary === "spu" ? "查SKU列表" : "搜索搭配图"}
searchNote={
materialSearchMode.secondary === "spu"
? "按 SPU 搜索时会先返回该 SPU 下的 SKU 列表,再选具体 SKU 获取白底图。"
: "选中的搭配图会进入第二个产品槽位,你也可以在下面继续手动替换。"
}
disabled={disabled}
disabledHint={disabledHint}
isLoading={materialSearchState.secondary.isLoading}
errorText={materialSearchState.secondary.errorText}
result={materials.secondary.result}
selectedImageIds={materials.secondary.selectedImageIds}
appearanceRecognitionEnabled={appearanceRecognitionEnabled}
selectionLimit={secondarySelectionLimit}
selectionLimitLabel={selectionLimitLabel}
onValueChange={(value) => handleMaterialInputChange("secondary", value)}
onQueryModeChange={(nextMode) =>
setMaterialSearchMode((prev) => ({
...prev,
secondary: nextMode,
}))
}
onSearch={() => {
void handleSearchMaterials("secondary");
}}
onUploadImage={(file, options) => handleUploadMaterialImage("secondary", file, options)}
onRemoveUploadedImage={(imageId) =>
handleRemoveUploadedMaterialImage("secondary", imageId)
}
isUploadingNewImage={materialUploadState.secondary.isUploadingNew}
uploadingImageId={materialUploadState.secondary.uploadingImageId}
onSkuOptionSelect={(skuName) => {
const selectedOption = materials.secondary.result?.skuOptions?.find(
(option) => option.skuName === skuName
);
const placeholderCount = selectedOption?.whiteBackgroundCount || 8;
const uploadedImages = getUploadedWorkflowMaterialImages(materials.secondary.result);
onInputsChange({
...inputs,
secondarySku: skuName,
});
onMaterialsChange({
...materials,
secondary: {
result: buildWorkflowMaterialResult({
currentResult: {
...buildLoadingMaterialResult({
keyword: skuName,
currentResult: materials.secondary.result,
placeholderCount,
}),
images: [
...uploadedImages,
...buildLoadingMaterialResult({
keyword: skuName,
currentResult: materials.secondary.result,
placeholderCount,
}).images.map((image) => ({
...image,
sourceType: "search" as const,
})),
],
},
images: [
...uploadedImages,
...buildLoadingMaterialResult({
keyword: skuName,
currentResult: materials.secondary.result,
placeholderCount,
}).images.map((image) => ({
...image,
sourceType: "search" as const,
})),
],
fallbackQuery: skuName,
fallbackQueryType: "sku",
}),
selectedImageIds: materials.secondary.selectedImageIds.filter((id) =>
uploadedImages.some((image) => image.id === id)
),
},
});
setMaterialSearchMode((prev) => ({
...prev,
secondary: "sku",
}));
void handleSearchMaterials("secondary", {
keyword: skuName,
queryType: "sku",
});
}}
theme={theme}
onSelectedImageIdsChange={(next) =>
onMaterialsChange({
...materials,
secondary: {
...materials.secondary,
selectedImageIds: next,
},
})
}
/>
</ClientErrorBoundary>
) : ( ) : (
<button <button
type="button" type="button"

View File

@ -24,7 +24,6 @@ import {
} from "react"; } from "react";
import useSWR from "swr"; import useSWR from "swr";
import { toast } from "sonner"; import { toast } from "sonner";
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Slider } from "@/components/ui/slider"; import { Slider } from "@/components/ui/slider";
import { import {
@ -53,8 +52,6 @@ const fetcher = (url: string) => fetch(url).then((res) => res.json());
const PANEL_CLASS = const PANEL_CLASS =
"rounded-[28px] border border-[#e5e5e5] bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.03),0_20px_40px_rgba(0,0,0,0.06)]"; "rounded-[28px] border border-[#e5e5e5] bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.03),0_20px_40px_rgba(0,0,0,0.06)]";
const SUBTLE_PANEL_CLASS =
"rounded-[22px] border border-[#ececec] bg-[#fbfbfb] shadow-[inset_0_1px_0_rgba(255,255,255,0.95)]";
const PRIMARY_ACTION_BUTTON_CLASS = const PRIMARY_ACTION_BUTTON_CLASS =
"border border-[#111111] bg-[#111111] text-white shadow-[0_16px_30px_rgba(0,0,0,0.18)] transition hover:bg-black hover:border-black disabled:border-[#e5e5e5] disabled:bg-[#f1f1f1] disabled:text-[#9a9a9a] disabled:shadow-none"; "border border-[#111111] bg-[#111111] text-white shadow-[0_16px_30px_rgba(0,0,0,0.18)] transition hover:bg-black hover:border-black disabled:border-[#e5e5e5] disabled:bg-[#f1f1f1] disabled:text-[#9a9a9a] disabled:shadow-none";
const SECONDARY_ACTION_BUTTON_CLASS = const SECONDARY_ACTION_BUTTON_CLASS =
@ -933,6 +930,13 @@ export function AiWashWorkspace({
? comparePositionById[selectedPreviewResult.presetId] ?? 50 ? comparePositionById[selectedPreviewResult.presetId] ?? 50
: 50; : 50;
const previewHasImage = Boolean(selectedPreviewResult?.imageUrl); const previewHasImage = Boolean(selectedPreviewResult?.imageUrl);
const selectedExecutionState =
presetExecutionStates.find(
(item) => item.presetId === selectedPreviewResult?.presetId
) ||
presetExecutionStates.find((item) => item.preset.isDefault) ||
presetExecutionStates[0] ||
null;
return ( return (
<div className="relative flex h-full w-full bg-[#ffffff] text-[#111111]"> <div className="relative flex h-full w-full bg-[#ffffff] text-[#111111]">
@ -940,8 +944,13 @@ export function AiWashWorkspace({
<div className="pattern-grid-lg pointer-events-none absolute inset-0 opacity-[0.025]" /> <div className="pattern-grid-lg pointer-events-none absolute inset-0 opacity-[0.025]" />
<div className="relative z-10 flex h-full w-full pt-24"> <div className="relative z-10 flex h-full w-full pt-24">
<aside className="relative z-20 h-full w-[420px] max-w-[42vw] shrink-0 py-6 pl-6"> <aside className="relative z-20 order-2 -mt-16 h-[calc(100%+4rem)] w-[430px] max-w-[42vw] shrink-0 pb-6 pr-6">
<div className={cn("h-full space-y-3 overflow-y-auto pb-3", SIDEBAR_SCROLL_CLASS)}> <div
className={cn(
"h-full space-y-4 overflow-y-auto rounded-[36px] border border-[#e7e7e7] bg-white/72 p-4 shadow-[0_24px_70px_rgba(0,0,0,0.08)] backdrop-blur-xl",
SIDEBAR_SCROLL_CLASS
)}
>
{isLoading ? ( {isLoading ? (
<div <div
className={cn( className={cn(
@ -966,42 +975,36 @@ export function AiWashWorkspace({
</div> </div>
) : null} ) : null}
<div className="space-y-3"> <div className="space-y-4">
<div className={cn(PANEL_CLASS, "space-y-3 p-3.5")}> <div className="rounded-[30px] border border-[#e8e8e8] bg-[#fbfbfb]/90 p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.9)]">
<div className="flex items-start justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[16px] border border-[#ececec] bg-white text-[#111111] shadow-[0_10px_22px_rgba(0,0,0,0.05)]"> <div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-[22px] border border-[#ececec] bg-white text-[#111111] shadow-[0_12px_24px_rgba(0,0,0,0.06)]">
<CanvasToolbarIcon <SlidersHorizontal className="h-6 w-6" />
className="h-5 w-5"
fallback={Sparkles}
iconUrl={config.modeIconUrl}
/>
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate font-semibold text-[22px] text-[#111111]"> <div className="inline-flex rounded-full border border-[#e6e6e6] bg-white px-3 py-1 font-semibold text-[11px] text-[#777777] tracking-[0.18em]">
{config.modeLabel || "AI洗图"} STUDIO SETUP
</div>
<div className="mt-2 font-semibold text-[20px] text-[#111111]">
设置
</div> </div>
</div> </div>
</div> </div>
<div className="rounded-full border border-[#ebebeb] bg-white px-2.5 py-1 text-[11px] text-[#666666]"> <div className="flex shrink-0 items-center gap-2">
{enabledPresets.length} 个预设 <span className="rounded-full border border-[#e6e6e6] bg-white px-3 py-1.5 text-xs text-[#666666]">
Redefine
</span>
<ChevronDown className="h-4 w-4 rotate-180 text-[#666666]" />
</div> </div>
</div> </div>
</div> </div>
<div className={cn(SUBTLE_PANEL_CLASS, "space-y-2 p-3.5")}> <div className="rounded-[26px] border border-[#ececec] bg-[#fbfbfb]/92 p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.95)]">
<div className="flex min-w-0 items-center gap-3"> <div className="mb-3 font-semibold text-[17px] text-[#111111]">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-[14px] border border-[#ececec] bg-white text-[#111111] shadow-[0_8px_18px_rgba(0,0,0,0.04)]"> 增强预设
<SlidersHorizontal className="h-5 w-5" />
</div>
<div className="min-w-0">
<div className="font-semibold text-[15px] text-[#111111]">
渲染目标
</div>
</div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2.5">
{presetExecutionStates.map((item) => { {presetExecutionStates.map((item) => {
const targetResult = results.find( const targetResult = results.find(
(result) => result.presetId === item.preset.id (result) => result.presetId === item.preset.id
@ -1014,72 +1017,46 @@ export function AiWashWorkspace({
<button <button
aria-pressed={isActive} aria-pressed={isActive}
className={cn( className={cn(
"w-full rounded-[16px] border px-3 py-2.5 text-left transition", "w-full rounded-[18px] border px-4 py-3 text-left transition",
isActive isActive
? "border-[#111111] bg-[#111111] text-white shadow-[0_12px_24px_rgba(0,0,0,0.12)]" ? "border-[#111111] bg-white text-[#111111] shadow-[0_10px_22px_rgba(0,0,0,0.08)]"
: "border-[#ececec] bg-white text-[#111111] hover:border-[#111111]" : "border-[#e8e8e8] bg-white text-[#666666] hover:border-[#111111] hover:text-[#111111]"
)} )}
key={item.preset.id} key={item.preset.id}
onClick={() => setSelectedResultId(item.preset.id)} onClick={() => setSelectedResultId(item.preset.id)}
type="button" type="button"
> >
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="min-w-0 truncate font-medium text-sm"> <div className="min-w-0 truncate font-medium text-[15px]">
{item.preset.title} {item.preset.title}
</div> </div>
<span <span
className={cn( className={cn(
"shrink-0 rounded-full border px-2 py-1 text-[11px]", "shrink-0 rounded-full border px-2 py-1 text-[11px]",
isActive isActive
? "border-white/20 bg-white/10 text-white" ? "border-[#111111] bg-[#111111] text-white"
: "border-[#ebebeb] bg-[#fafafa] text-[#666666]" : "border-[#ebebeb] bg-[#fafafa] text-[#666666]"
)} )}
> >
{getAiWashStatusLabel(targetResult?.status)} {getAiWashStatusLabel(targetResult?.status)}
</span> </span>
</div> </div>
<div className="mt-2 flex min-w-0 items-center gap-2 text-[11px]">
<span
className={cn(
"min-w-0 truncate rounded-full border px-2 py-1",
isActive
? "border-white/15 bg-white/10 text-white/80"
: "border-[#ebebeb] bg-[#fafafa] text-[#666666]"
)}
>
{item.resolvedModel?.displayName ||
item.resolvedModel?.modelId ||
(item.usesUserSelectableModel
? "前台自选模型"
: "待配置模型")}
</span>
<span
className={cn(
"shrink-0 rounded-full border px-2 py-1",
isActive
? "border-white/15 bg-white/10 text-white/80"
: "border-[#ebebeb] bg-[#fafafa] text-[#666666]"
)}
>
{item.unitCost > 0 ? `${item.unitCost} 积分/张` : "未计费"}
</span>
</div>
</button> </button>
); );
})} })}
</div> </div>
<div className="mt-3 max-h-24 overflow-hidden rounded-[18px] border border-[#e9e9e9] bg-white px-4 py-3 text-sm leading-6 text-[#666666]">
{selectedExecutionState?.resolvedPrompt ||
"选择预设后会使用管理员配置的提示词进行 AI洗图。"}
</div>
</div> </div>
<div className={cn(SUBTLE_PANEL_CLASS, "space-y-3 p-3.5")}> <div className="rounded-[26px] border border-[#ececec] bg-[#fbfbfb]/92 p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.95)]">
<div className="flex min-w-0 items-center gap-3"> <div className="mb-3 flex min-w-0 items-center justify-between gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-[14px] border border-[#ececec] bg-white text-[#111111] shadow-[0_8px_18px_rgba(0,0,0,0.04)]"> <div className="font-semibold text-[17px] text-[#111111]">
<Settings2 className="h-5 w-5" /> 参数设置
</div>
<div className="min-w-0">
<div className="font-semibold text-[15px] text-[#111111]">
参数设置
</div>
</div> </div>
<Settings2 className="h-4 w-4 text-[#777777]" />
</div> </div>
{shouldShowUserModelSelector ? ( {shouldShowUserModelSelector ? (
@ -1148,7 +1125,7 @@ export function AiWashWorkspace({
null; null;
return ( return (
<div <div
className="rounded-[16px] border border-[#ececec] bg-white px-3.5 py-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.94)]" className="rounded-[20px] border border-[#ececec] bg-white px-4 py-3.5 shadow-[0_8px_18px_rgba(0,0,0,0.03)]"
key={control.id} key={control.id}
> >
<div className="mb-2 flex items-center justify-between gap-3"> <div className="mb-2 flex items-center justify-between gap-3">
@ -1184,361 +1161,288 @@ export function AiWashWorkspace({
</div> </div>
</aside> </aside>
<main className="flex min-w-0 flex-1 flex-col gap-3 overflow-hidden px-6 pb-6 pt-6"> <main className="order-1 relative min-w-0 flex-1 overflow-hidden px-8 pb-7 pt-6">
<section <div className="relative h-full w-full">
className={cn( {compareSourceUrl ? (
PANEL_CLASS, <button
"flex min-h-0 flex-1 flex-col overflow-hidden p-3" className={cn(
)} "absolute right-4 top-2 z-20 inline-flex h-10 items-center rounded-full px-4 text-sm font-medium",
> SECONDARY_ACTION_BUTTON_CLASS
<div className="relative min-h-0 flex-1 overflow-hidden rounded-[28px] border border-[#e9e9e9] bg-[radial-gradient(circle_at_50%_0%,#ffffff_0%,#f8f8f8_52%,#f2f2f2_100%)]"> )}
{compareSourceUrl ? ( onClick={() => onRequestAddImage?.()}
<button type="button"
className={cn( >
"absolute right-4 top-4 z-20 inline-flex h-10 items-center rounded-full px-4 text-sm font-medium", <Upload className="mr-2 h-4 w-4" />
SECONDARY_ACTION_BUTTON_CLASS 更换图片
)} </button>
onClick={() => onRequestAddImage?.()} ) : null}
type="button"
> <div className="absolute inset-0 flex items-center justify-center px-8 pb-44 pt-8">
<Upload className="mr-2 h-4 w-4" /> {selectedPreviewResult && previewHasImage ? (
更换图片 <div className="relative max-h-full max-w-full overflow-hidden rounded-[24px] shadow-[0_18px_52px_rgba(0,0,0,0.08)]">
</button> <img
) : null} alt={selectedPreviewResult.title}
{selectedPreviewResult ? ( className="block max-h-[calc(100vh-320px)] max-w-[min(64vw,1120px)] object-contain"
previewHasImage ? ( src={getDisplayImageUrl(selectedPreviewResult.imageUrl || "", 1800)}
<> />
<img {compareSourceUrl ? (
alt={selectedPreviewResult.title} <>
className="absolute inset-0 h-full w-full object-contain" <div
src={getDisplayImageUrl( className="pointer-events-none absolute inset-0 overflow-hidden"
selectedPreviewResult.imageUrl || "", style={{
1800 clipPath: `inset(0 ${100 - previewComparePosition}% 0 0)`,
)} }}
/> >
{compareSourceUrl ? ( <img
<> alt={`${selectedPreviewResult.title} 原图对比`}
<div className="h-full w-full object-contain"
className="pointer-events-none absolute inset-0 overflow-hidden" src={getDisplayImageUrl(compareSourceUrl, 1800)}
style={{
clipPath: `inset(0 ${100 - previewComparePosition}% 0 0)`,
}}
>
<img
alt={`${selectedPreviewResult.title} 原图对比`}
className="h-full w-full object-contain"
src={getDisplayImageUrl(compareSourceUrl, 1800)}
/>
</div>
<div
className="pointer-events-none absolute inset-y-0 w-px bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.22),0_0_20px_rgba(0,0,0,0.18)]"
style={{ left: `${previewComparePosition}%` }}
/> />
</div>
<div
className="pointer-events-none absolute inset-y-0 w-px bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.22),0_0_20px_rgba(0,0,0,0.18)]"
style={{ left: `${previewComparePosition}%` }}
/>
<div
className="absolute inset-0 cursor-col-resize touch-none"
onPointerCancel={(event) =>
finishCompareDrag(selectedPreviewResult.presetId, event)
}
onPointerDown={(event) =>
handleComparePointerDown(
selectedPreviewResult.presetId,
event
)
}
onPointerMove={(event) =>
handleComparePointerMove(
selectedPreviewResult.presetId,
event
)
}
onPointerUp={(event) =>
finishCompareDrag(selectedPreviewResult.presetId, event)
}
>
<div <div
className="absolute inset-0 cursor-col-resize touch-none" className="pointer-events-none absolute inset-y-0 w-12 -translate-x-1/2"
onPointerCancel={(event) => style={{ left: `${previewComparePosition}%` }}
finishCompareDrag(selectedPreviewResult.presetId, event)
}
onPointerDown={(event) =>
handleComparePointerDown(
selectedPreviewResult.presetId,
event
)
}
onPointerMove={(event) =>
handleComparePointerMove(
selectedPreviewResult.presetId,
event
)
}
onPointerUp={(event) =>
finishCompareDrag(selectedPreviewResult.presetId, event)
}
> >
<div <div className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-white" />
className="pointer-events-none absolute inset-y-0 w-12 -translate-x-1/2" <div className="absolute left-1/2 top-1/2 flex h-12 w-12 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border border-white bg-black/72 text-white shadow-[0_14px_28px_rgba(0,0,0,0.24)]">
style={{ left: `${previewComparePosition}%` }} <RefreshCcw className="h-4 w-4" />
>
<div className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-white" />
<div className="absolute left-1/2 top-1/2 flex h-12 w-12 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border border-white bg-black/72 text-white shadow-[0_14px_28px_rgba(0,0,0,0.24)]">
<RefreshCcw className="h-4 w-4" />
</div>
</div> </div>
</div> </div>
<div className="pointer-events-none absolute left-4 top-4 rounded-full bg-black/65 px-3 py-1.5 text-[11px] text-white">
原图
</div>
<div className="pointer-events-none absolute right-4 top-4 rounded-full bg-black/65 px-3 py-1.5 text-[11px] text-white">
洗后
</div>
</>
) : null}
</>
) : compareSourceUrl ? (
<>
<img
alt="AI洗图底图"
className="absolute inset-0 h-full w-full object-contain"
src={getDisplayImageUrl(compareSourceUrl, 1800)}
/>
<div className="pointer-events-none absolute left-4 top-4 rounded-full bg-black/65 px-3 py-1.5 text-[11px] text-white">
原图
</div>
{(selectedPreviewResult.status === "running" ||
selectedPreviewResult.status === "submitting" ||
selectedPreviewResult.status === "failed") ? (
<div className="absolute inset-0 flex items-center justify-center bg-white/58 px-6 text-center backdrop-blur-[1px]">
<div className="max-w-sm rounded-[24px] border border-[#ececec] bg-white/92 px-6 py-5 shadow-[0_18px_40px_rgba(0,0,0,0.08)]">
{selectedPreviewResult.status === "running" ||
selectedPreviewResult.status === "submitting" ? (
<>
<Loader2 className="mx-auto mb-3 h-8 w-8 animate-spin text-[#666666]" />
<div className="text-base font-medium text-[#111111]">
{selectedPreviewResult.status === "submitting"
? "正在提交模型任务"
: "模型正在生成结果"}
</div>
<div className="mt-2 text-sm leading-6 text-[#777777]">
结果完成后会在同一区域切换为可拖动对比图。
</div>
</>
) : (
<>
<div className="text-base font-medium text-[#9b2c2c]">
该目标生成失败
</div>
<div className="mt-2 text-sm leading-6 text-[#9b2c2c]">
{selectedPreviewResult.error || "请调整配置后重新生成。"}
</div>
</>
)}
</div>
</div> </div>
) : ( <div className="pointer-events-none absolute left-4 top-4 rounded-full bg-black/65 px-3 py-1.5 text-[11px] text-white">
<div className="pointer-events-none absolute bottom-5 left-1/2 -translate-x-1/2 rounded-full border border-[#e5e5e5] bg-white/92 px-4 py-2 text-xs text-[#666666] shadow-[0_12px_24px_rgba(0,0,0,0.06)]"> 原图
原图已就绪,点击下方开始 AI洗图
</div> </div>
)} <div className="pointer-events-none absolute right-4 top-4 rounded-full bg-black/65 px-3 py-1.5 text-[11px] text-white">
</> 洗后
) : (
<div className="absolute inset-0 flex items-center justify-center px-6 text-center">
<div>
<button
className="inline-flex h-16 items-center rounded-[26px] bg-[#111111] px-10 text-[18px] font-semibold text-white shadow-[0_18px_40px_rgba(0,0,0,0.16)] transition hover:bg-black"
onClick={() => onRequestAddImage?.()}
type="button"
>
<ImageIcon className="mr-3 h-5 w-5" />
添加图片
</button>
<div className="mt-4 text-sm text-[#777777]">
选择本地图后即可开始 AI洗图
</div> </div>
</div> </>
</div> ) : null}
) </div>
) : compareSourceUrl ? ( ) : compareSourceUrl ? (
<> <div className="relative max-h-full max-w-full">
<img <img
alt="AI洗图底图" alt="AI洗图底图"
className="absolute inset-0 h-full w-full object-contain" className="block max-h-[calc(100vh-320px)] max-w-[min(64vw,1120px)] rounded-[24px] object-contain shadow-[0_18px_52px_rgba(0,0,0,0.08)]"
src={getDisplayImageUrl(compareSourceUrl, 1800)} src={getDisplayImageUrl(compareSourceUrl, 1800)}
/> />
<div className="pointer-events-none absolute left-4 top-4 rounded-full bg-black/65 px-3 py-1.5 text-[11px] text-white"> <div className="pointer-events-none absolute left-4 top-4 rounded-full bg-black/65 px-3 py-1.5 text-[11px] text-white">
原图 原图
</div> </div>
<div className="pointer-events-none absolute bottom-5 left-1/2 -translate-x-1/2 rounded-full border border-[#e5e5e5] bg-white/92 px-4 py-2 text-xs text-[#666666] shadow-[0_12px_24px_rgba(0,0,0,0.06)]"> {selectedPreviewResult?.status === "running" ||
原图已就绪,点击下方开始 AI洗图 selectedPreviewResult?.status === "submitting" ||
</div> selectedPreviewResult?.status === "failed" ? (
</> <div className="absolute inset-0 flex items-center justify-center rounded-[24px] bg-white/58 px-6 text-center backdrop-blur-[1px]">
) : ( <div className="max-w-sm rounded-[24px] border border-[#ececec] bg-white/92 px-6 py-5 shadow-[0_18px_40px_rgba(0,0,0,0.08)]">
<div className="absolute inset-0 flex items-center justify-center px-6 text-center"> {selectedPreviewResult.status === "running" ||
<div> selectedPreviewResult.status === "submitting" ? (
<button <>
className="inline-flex h-16 items-center rounded-[26px] bg-[#111111] px-10 text-[18px] font-semibold text-white shadow-[0_18px_40px_rgba(0,0,0,0.16)] transition hover:bg-black" <Loader2 className="mx-auto mb-3 h-8 w-8 animate-spin text-[#666666]" />
onClick={() => onRequestAddImage?.()} <div className="text-base font-medium text-[#111111]">
type="button" {selectedPreviewResult.status === "submitting"
> ? "正在提交模型任务"
<ImageIcon className="mr-3 h-5 w-5" /> : "模型正在生成结果"}
添加图片 </div>
</button> <div className="mt-2 text-sm leading-6 text-[#777777]">
<div className="mt-4 text-sm text-[#777777]"> 结果完成后会在同一区域切换为可拖动对比图。
选择本地图后即可开始 AI洗图 </div>
</>
) : (
<>
<div className="text-base font-medium text-[#9b2c2c]">
该目标生成失败
</div>
<div className="mt-2 text-sm leading-6 text-[#9b2c2c]">
{selectedPreviewResult.error || "请调整配置后重新生成。"}
</div>
</>
)}
</div>
</div> </div>
) : null}
</div>
) : (
<div className="text-center">
<button
className="inline-flex h-[92px] items-center rounded-[28px] bg-[#111111] px-12 text-[22px] font-semibold text-white shadow-[0_22px_56px_rgba(0,0,0,0.16)] transition hover:bg-black"
onClick={() => onRequestAddImage?.()}
type="button"
>
<ImageIcon className="mr-4 h-6 w-6" />
添加图片
</button>
<div className="mt-5 text-[15px] text-[#777777]">
选择本地图后即可开始 {config.modeLabel || "AI洗图"}
</div> </div>
</div> </div>
)} )}
</div> </div>
</section>
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="absolute bottom-0 left-1/2 z-30 flex -translate-x-1/2 flex-col items-center gap-2">
<div className="rounded-full border border-[#e5e5e5] bg-white px-4 py-2 text-xs text-[#666666] shadow-[0_0_0_1px_rgba(0,0,0,0.02),0_10px_20px_rgba(0,0,0,0.03)]"> <div className="rounded-full border border-[#e5e5e5] bg-white/92 px-5 py-2 text-xs text-[#666666] shadow-[0_10px_24px_rgba(0,0,0,0.06)] backdrop-blur">
{selectedResult 生成后可直接发送回画布
? `已选中 ${selectedResult.title},可直接发送回画布` </div>
: selectedPreviewResult <div className="rounded-full border border-[#e5e5e5] bg-white/92 px-5 py-2 text-xs text-[#333333] shadow-[0_10px_24px_rgba(0,0,0,0.06)] backdrop-blur">
? compareSourceUrl {totalEstimatedCost > 0
? `当前目标 ${selectedPreviewResult.title},生成完成后可发送回画布` ? `预计消耗 ${totalEstimatedCost} 积分`
: "请先添加一张底图" : "预计消耗按模型配置计算"}
: compareSourceUrl </div>
? "原图已上传,点击开始 AI洗图生成结果" <div className="flex items-center gap-3 rounded-[34px] border border-[#e6e6e6] bg-white/88 p-3 shadow-[0_18px_46px_rgba(0,0,0,0.08)] backdrop-blur-xl">
: "请先添加一张底图"} <Button
className={cn(
"h-14 min-w-[220px] rounded-[26px] px-8 text-[15px] font-semibold",
PRIMARY_ACTION_BUTTON_CLASS
)}
disabled={
isSubmitting ||
!compareSourceUrl ||
enabledPresets.length === 0 ||
(shouldShowUserModelSelector && !selectedUserModel)
}
onClick={handleSubmit}
type="button"
>
{isSubmitting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
{isSubmitting ? "正在洗图" : "开始 AI洗图"}
</Button>
<Button
className={cn(
"h-14 min-w-[220px] rounded-[26px] px-8 text-[15px] font-medium",
SECONDARY_ACTION_BUTTON_CLASS
)}
disabled={!selectedResult?.imageUrl}
onClick={handleApply}
type="button"
variant="outline"
>
<Send className="mr-2 h-4 w-4" />
发送到画布
</Button>
</div>
</div> </div>
<div className="flex flex-wrap items-center gap-2">
<Button
className={cn(
"h-11 rounded-[18px] px-5 text-sm font-semibold",
PRIMARY_ACTION_BUTTON_CLASS
)}
disabled={
isSubmitting ||
!compareSourceUrl ||
enabledPresets.length === 0 ||
(shouldShowUserModelSelector && !selectedUserModel)
}
onClick={handleSubmit}
type="button"
>
{isSubmitting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
{isSubmitting
? "正在洗图"
: totalEstimatedCost > 0
? `开始 AI洗图 · ${totalEstimatedCost} 积分`
: "开始 AI洗图"}
</Button>
<Button
className={cn(
"h-11 rounded-[18px] px-5 text-sm font-medium",
SECONDARY_ACTION_BUTTON_CLASS
)}
disabled={!selectedResult?.imageUrl}
onClick={handleApply}
type="button"
variant="outline"
>
<Send className="mr-2 h-4 w-4" />
发送到画布
</Button>
</div>
</div>
<div className={cn(PANEL_CLASS, "overflow-hidden p-3")}>
<button <button
className="flex w-full items-center justify-between gap-3 rounded-[22px] px-1 py-1 text-left transition hover:bg-[#fafafa]" className="absolute bottom-2 left-2 z-30 inline-flex h-10 items-center gap-2 rounded-full border border-[#e5e5e5] bg-white/90 px-4 text-xs text-[#666666] shadow-[0_10px_24px_rgba(0,0,0,0.06)] backdrop-blur transition hover:border-[#111111] hover:text-[#111111]"
onClick={() => setIsHistoryExpanded((current) => !current)} onClick={() => setIsHistoryExpanded((current) => !current)}
type="button" type="button"
> >
<div className="flex items-center gap-3"> <HistoryIcon className="h-4 w-4" />
<div className="flex h-11 w-11 items-center justify-center rounded-[16px] border border-[#ececec] bg-white shadow-[0_10px_22px_rgba(0,0,0,0.05)]"> 历史 {visibleHistoryBatches.length}
<HistoryIcon className="h-5 w-5 text-[#111111]" /> <ChevronDown
</div> className={cn(
<div> "h-3.5 w-3.5 transition-transform",
<div className="text-lg font-semibold text-[#111111]"> isHistoryExpanded && "rotate-180"
历史洗图记录 )}
</div> />
<div className="text-sm text-[#666666]">
保留最近的 AI洗图批次,支持一键回看并继续发送结果到画布。
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<div className="rounded-full border border-[#ebebeb] bg-[#fafafa] px-3 py-1 text-xs text-[#666666]">
最近 {visibleHistoryBatches.length} 条
</div>
<div className="inline-flex items-center gap-1 rounded-full border border-[#ebebeb] bg-white px-3 py-1 text-xs text-[#666666]">
{isHistoryExpanded ? "收起" : "展开"}
<ChevronDown
className={cn(
"h-3.5 w-3.5 transition-transform",
isHistoryExpanded && "rotate-180"
)}
/>
</div>
</div>
</button> </button>
{isHistoryExpanded ? ( {isHistoryExpanded ? (
visibleHistoryBatches.length > 0 ? ( <div className="absolute bottom-16 left-2 z-40 w-[380px] rounded-[28px] border border-[#e5e5e5] bg-white/94 p-3 shadow-[0_24px_60px_rgba(0,0,0,0.12)] backdrop-blur-xl">
<div className="mt-3 flex gap-3 overflow-x-auto border-t border-[#eeeeee] pt-3 pb-1"> {visibleHistoryBatches.length > 0 ? (
{visibleHistoryBatches.map((entry) => { <div className="flex max-h-[360px] flex-col gap-3 overflow-y-auto pr-1">
const cover = entry.items[0]; {visibleHistoryBatches.map((entry) => {
return ( const cover = entry.items[0];
<div return (
className="min-w-[260px] max-w-[260px] rounded-[22px] border border-[#ececec] bg-[#fbfbfb] p-3" <div
key={entry.id} className="rounded-[20px] border border-[#ececec] bg-[#fbfbfb] p-3"
> key={entry.id}
<button
className="w-full text-left"
onClick={() => handleLoadHistoryBatch(entry)}
type="button"
>
<div className="overflow-hidden rounded-[18px] border border-[#ececec] bg-white">
<img
alt={cover?.title || "历史洗图结果"}
className="aspect-[4/3] w-full object-cover"
src={getDisplayImageUrl(
cover?.thumbnailUrl || cover?.imageUrl || "",
520
)}
/>
</div>
<div className="mt-3 flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate font-medium text-sm text-[#111111]">
{cover?.title || "AI洗图结果"}
</div>
<div className="mt-1 flex items-center gap-2 text-[11px] text-[#666666]">
<Clock3 className="h-3.5 w-3.5" />
{new Date(entry.createdAt).toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})}
</div>
</div>
<div className="rounded-full border border-[#ebebeb] bg-white px-2 py-1 text-[11px] text-[#666666]">
{entry.items.length} 张
</div>
</div>
</button>
<div className="mt-3 flex items-center justify-between">
<div className="flex flex-wrap items-center gap-2">
{cover?.modelId ? (
<span className="rounded-full border border-[#ebebeb] bg-white px-2 py-1 text-[11px] text-[#666666]">
{cover.modelId}
</span>
) : null}
{entry.sourceImageUrl ? (
<span className="rounded-full border border-[#dcebdd] bg-[#f4fbf5] px-2 py-1 text-[11px] text-[#2f6f3b]">
可对比
</span>
) : null}
</div>
<button
className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-[#ececec] bg-white text-[#666666] transition hover:border-[#111111] hover:text-[#111111]"
onClick={(event) => {
event.stopPropagation();
handleDeleteHistoryBatch(entry.id);
}}
title="删除历史记录"
type="button"
> >
<Trash2 className="h-4 w-4" /> <button
</button> className="flex w-full items-center gap-3 text-left"
</div> onClick={() => handleLoadHistoryBatch(entry)}
</div> type="button"
); >
})} <div className="h-16 w-20 shrink-0 overflow-hidden rounded-[14px] border border-[#ececec] bg-white">
</div> <img
) : ( alt={cover?.title || "历史洗图结果"}
<div className="mt-3 rounded-[22px] border border-dashed border-[#dcdcdc] bg-[#fafafa] px-5 py-8 text-center text-sm text-[#8a8a8a]"> className="h-full w-full object-cover"
还没有历史洗图记录,生成一次后会自动出现在这里。 src={getDisplayImageUrl(
</div> cover?.thumbnailUrl || cover?.imageUrl || "",
) 360
)}
/>
</div>
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-sm text-[#111111]">
{cover?.title || "AI洗图结果"}
</div>
<div className="mt-1 flex items-center gap-2 text-[11px] text-[#666666]">
<Clock3 className="h-3.5 w-3.5" />
{new Date(entry.createdAt).toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})}
</div>
</div>
<div className="rounded-full border border-[#ebebeb] bg-white px-2 py-1 text-[11px] text-[#666666]">
{entry.items.length} 张
</div>
</button>
<div className="mt-2 flex items-center justify-between">
<div className="flex min-w-0 flex-wrap items-center gap-2">
{cover?.modelId ? (
<span className="max-w-[240px] truncate rounded-full border border-[#ebebeb] bg-white px-2 py-1 text-[11px] text-[#666666]">
{cover.modelId}
</span>
) : null}
{entry.sourceImageUrl ? (
<span className="rounded-full border border-[#dcebdd] bg-[#f4fbf5] px-2 py-1 text-[11px] text-[#2f6f3b]">
可对比
</span>
) : null}
</div>
<button
className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-[#ececec] bg-white text-[#666666] transition hover:border-[#111111] hover:text-[#111111]"
onClick={(event) => {
event.stopPropagation();
handleDeleteHistoryBatch(entry.id);
}}
title="删除历史记录"
type="button"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
);
})}
</div>
) : (
<div className="rounded-[20px] border border-dashed border-[#dcdcdc] bg-[#fafafa] px-5 py-8 text-center text-sm text-[#8a8a8a]">
还没有历史洗图记录,生成一次后会自动出现在这里。
</div>
)}
</div>
) : null} ) : null}
</div> </div>
</main> </main>

View File

@ -122,7 +122,7 @@ export function FloatingSidebarRail({
: "border border-white/8 bg-[#17181d]/94 text-white shadow-[0_22px_60px_rgba(6,8,14,0.34),inset_0_1px_0_rgba(255,255,255,0.05)]", : "border border-white/8 bg-[#17181d]/94 text-white shadow-[0_22px_60px_rgba(6,8,14,0.34),inset_0_1px_0_rgba(255,255,255,0.05)]",
isHorizontal isHorizontal
? "h-[56px] w-auto flex-row overflow-hidden rounded-[22px] px-3 py-2" ? "h-[56px] w-auto flex-row overflow-hidden rounded-[22px] px-3 py-2"
: "w-[56px] flex-col overflow-y-auto rounded-[24px] px-2 py-2", : "w-[56px] flex-col overflow-x-hidden overflow-y-auto overscroll-contain rounded-[24px] px-2 py-2 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
className className
)} )}
> >

View File

@ -119,6 +119,20 @@ interface ProcessItem {
}; };
} }
function markProcessItemRegenerating(
item: ProcessItem,
status: Extract<ItemStatus, "pending" | "processing">
): ProcessItem {
return {
...item,
status,
result: undefined,
originalUrl: undefined,
queueStatus: undefined,
error: undefined,
};
}
interface SceneFusionViewProps { interface SceneFusionViewProps {
initialProductImage?: string | null; initialProductImage?: string | null;
initialProductName?: string; initialProductName?: string;
@ -1081,11 +1095,12 @@ export function SceneFusionView({
setBatchItems((prev) => setBatchItems((prev) =>
prev.map((item) => prev.map((item) =>
ids.includes(item.id) ids.includes(item.id)
? { ? status === "pending" || status === "processing"
...item, ? markProcessItemRegenerating(item, status)
status, : {
error: status === "processing" ? undefined : item.error, ...item,
} status,
}
: item : item
) )
); );
@ -1099,11 +1114,12 @@ export function SceneFusionView({
setter((prev) => setter((prev) =>
prev.map((item) => prev.map((item) =>
ids.includes(item.id) ids.includes(item.id)
? { ? status === "pending" || status === "processing"
...item, ? markProcessItemRegenerating(item, status)
status, : {
error: status === "processing" ? undefined : item.error, ...item,
} status,
}
: item : item
) )
); );
@ -1217,12 +1233,17 @@ export function SceneFusionView({
setter((prev) => setter((prev) =>
prev.map((item) => prev.map((item) =>
item.id === data.id item.id === data.id
? { ? data.status === "pending" || data.status === "processing"
...item, ? {
status: data.status, ...markProcessItemRegenerating(item, data.status),
queueStatus: data.queueStatus || undefined, queueStatus: data.queueStatus || undefined,
error: data.status === "failed" ? item.error : undefined, }
} : {
...item,
status: data.status,
queueStatus: data.queueStatus || undefined,
error: data.status === "failed" ? item.error : undefined,
}
: item : item
) )
); );
@ -1454,12 +1475,21 @@ export function SceneFusionView({
setMultiProductItems((prev) => setMultiProductItems((prev) =>
prev.map((item) => prev.map((item) =>
item.id === params.itemId item.id === params.itemId
? { ? msg.data?.status === "pending" ||
...item, msg.data?.status === "processing"
status: msg.data?.status || item.status, ? {
queueStatus: msg.data?.queueStatus || undefined, ...markProcessItemRegenerating(
error: undefined, item,
} msg.data.status
),
queueStatus: msg.data.queueStatus || undefined,
}
: {
...item,
status: msg.data?.status || item.status,
queueStatus: msg.data?.queueStatus || undefined,
error: undefined,
}
: item : item
) )
); );
@ -1556,7 +1586,7 @@ export function SceneFusionView({
setMultiProductItems((prev) => setMultiProductItems((prev) =>
prev.map((item) => prev.map((item) =>
itemsToProcess.some((target) => target.id === item.id) itemsToProcess.some((target) => target.id === item.id)
? { ...item, status: "pending", queueStatus: undefined, error: undefined } ? markProcessItemRegenerating(item, "pending")
: item : item
) )
); );
@ -1577,7 +1607,7 @@ export function SceneFusionView({
setMultiProductItems((prev) => setMultiProductItems((prev) =>
prev.map((item) => prev.map((item) =>
item.id === current.id item.id === current.id
? { ...item, status: "processing", error: undefined } ? markProcessItemRegenerating(item, "processing")
: item : item
) )
); );
@ -1743,7 +1773,7 @@ export function SceneFusionView({
setBatchItems((prev) => setBatchItems((prev) =>
prev.map((item) => prev.map((item) =>
itemsToProcess.some((target) => target.id === item.id) itemsToProcess.some((target) => target.id === item.id)
? { ...item, status: "pending", error: undefined } ? markProcessItemRegenerating(item, "pending")
: item : item
) )
); );
@ -1763,7 +1793,7 @@ export function SceneFusionView({
setBatchItems((prev) => setBatchItems((prev) =>
prev.map((item) => prev.map((item) =>
item.id === current.id item.id === current.id
? { ...item, status: "processing", error: undefined } ? markProcessItemRegenerating(item, "processing")
: item : item
) )
); );
@ -1825,7 +1855,7 @@ export function SceneFusionView({
setBatchItems((prev) => setBatchItems((prev) =>
prev.map((batchItem) => prev.map((batchItem) =>
batchItem.id === item.id batchItem.id === item.id
? { ...batchItem, status: "processing", error: undefined } ? markProcessItemRegenerating(batchItem, "processing")
: batchItem : batchItem
) )
); );
@ -1955,7 +1985,7 @@ export function SceneFusionView({
setMultiProductItems((prev) => setMultiProductItems((prev) =>
prev.map((productItem) => prev.map((productItem) =>
productItem.id === item.id productItem.id === item.id
? { ...productItem, status: "pending", error: undefined } ? markProcessItemRegenerating(productItem, "pending")
: productItem : productItem
) )
); );
@ -2915,6 +2945,12 @@ export function SceneFusionView({
) : ( ) : (
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 xl:grid-cols-4"> <div className="grid grid-cols-2 gap-4 md:grid-cols-3 xl:grid-cols-4">
{resultItems.map((item, index) => ( {resultItems.map((item, index) => (
(() => {
const completedResultUrl =
item.status === "completed"
? item.result || item.originalUrl || ""
: "";
return (
<div <div
className="flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm" className="flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm"
key={item.id} key={item.id}
@ -2947,15 +2983,12 @@ export function SceneFusionView({
<div <div
className={cn( className={cn(
"group relative aspect-[3/4] bg-muted/20", "group relative aspect-[3/4] bg-muted/20",
item.status === "completed" && completedResultUrl && "cursor-zoom-in"
(item.result || item.originalUrl) &&
"cursor-zoom-in"
)} )}
onClick={() => { onClick={() => {
const previewUrl = item.originalUrl || item.result; if (completedResultUrl) {
if (item.status === "completed" && previewUrl) {
openResultPreview( openResultPreview(
previewUrl, completedResultUrl,
`任务 ${index + 1} 预览` `任务 ${index + 1} 预览`
); );
} }
@ -2963,28 +2996,24 @@ export function SceneFusionView({
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") if (event.key !== "Enter" && event.key !== " ")
return; return;
const previewUrl = item.originalUrl || item.result; if (completedResultUrl) {
if (item.status === "completed" && previewUrl) {
event.preventDefault(); event.preventDefault();
openResultPreview( openResultPreview(
previewUrl, completedResultUrl,
`任务 ${index + 1} 预览` `任务 ${index + 1} 预览`
); );
} }
}} }}
role="button" role="button"
tabIndex={ tabIndex={
item.status === "completed" && completedResultUrl ? 0 : -1
(item.result || item.originalUrl)
? 0
: -1
} }
> >
{item.result ? ( {completedResultUrl ? (
<img <img
alt="处理结果" alt="处理结果"
className="h-full w-full object-cover" className="h-full w-full object-cover"
src={item.result} src={completedResultUrl}
/> />
) : ( ) : (
(() => { (() => {
@ -3080,7 +3109,7 @@ export function SceneFusionView({
})() })()
)} )}
{item.status === "completed" && ( {completedResultUrl && (
<> <>
<button <button
type="button" type="button"
@ -3107,7 +3136,7 @@ export function SceneFusionView({
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
downloadImage( downloadImage(
item.originalUrl || item.result!, completedResultUrl,
`batch-${index + 1}.png` `batch-${index + 1}.png`
); );
}} }}
@ -3131,6 +3160,8 @@ export function SceneFusionView({
)} )}
</div> </div>
</div> </div>
);
})()
))} ))}
</div> </div>
)} )}

View File

@ -271,7 +271,7 @@ export function TopNavbar({
}; };
const navContent = ( const navContent = (
<nav className="fixed top-0 bottom-0 left-0 z-[100] flex w-16 flex-col items-center border-gray-200 border-r bg-white/90 text-gray-900 shadow-sm backdrop-blur-sm dark:border-white/10 dark:bg-black/90 dark:text-white"> <nav className="fixed top-0 bottom-0 left-0 z-[100] flex w-16 flex-col items-center overflow-x-hidden border-gray-200 border-r bg-white/90 text-gray-900 shadow-sm backdrop-blur-sm dark:border-white/10 dark:bg-black/90 dark:text-white">
{/* 顶部:LOGO + 网站名称 */} {/* 顶部:LOGO + 网站名称 */}
<a <a
className="mt-3 flex flex-col items-center gap-1 transition-opacity hover:opacity-80" className="mt-3 flex flex-col items-center gap-1 transition-opacity hover:opacity-80"
@ -294,7 +294,7 @@ export function TopNavbar({
</a> </a>
{/* 侧边功能区 */} {/* 侧边功能区 */}
<div className="mt-12 flex w-full flex-1 flex-col items-center gap-3 overflow-y-auto px-2 pb-4"> <div className="mt-12 flex w-full min-w-0 flex-1 flex-col items-center gap-3 overflow-x-hidden overflow-y-auto overscroll-contain px-2 pb-4 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{/* 工具箱1 (如果启用且有链接) */} {/* 工具箱1 (如果启用且有链接) */}
{toolbox1Enabled && {toolbox1Enabled &&
customLinks.filter((l) => (l.toolboxGroup || 1) === 1).length > 0 && ( customLinks.filter((l) => (l.toolboxGroup || 1) === 1).length > 0 && (

View File

@ -10,7 +10,10 @@ import {
type AgentPsdTemplateLayerSlot, type AgentPsdTemplateLayerSlot,
} from "@/lib/agents/psd-template-config"; } from "@/lib/agents/psd-template-config";
import { resolveAgentPsdLocalCachePath } from "@/lib/agents/psd-local-cache"; import { resolveAgentPsdLocalCachePath } from "@/lib/agents/psd-local-cache";
import { preparePsdTemplateRenderAssets } from "@/lib/agents/psd-template-parser"; import {
preparePsdTemplateRenderAssets,
uploadPreparedPsdTemplateRenderSegments,
} from "@/lib/agents/psd-template-parser";
import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings"; import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings";
import { createStorageUploadSession } from "@/lib/storage/service"; import { createStorageUploadSession } from "@/lib/storage/service";
@ -181,7 +184,7 @@ async function backfillTemplateAssetsOnce(params: {
...currentTemplate, ...currentTemplate,
parseStatus: "processing", parseStatus: "processing",
parseProgress: Math.max(88, Number(currentTemplate.parseProgress || 0)), parseProgress: Math.max(88, Number(currentTemplate.parseProgress || 0)),
parseStage: "正在后台生成模板底图", parseStage: "正在后台生成模板底图和快速导出分层",
parseError: undefined, parseError: undefined,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}), }),
@ -191,6 +194,11 @@ async function backfillTemplateAssetsOnce(params: {
`agent-psd-base-${template.id}-${Date.now()}.png`, `agent-psd-base-${template.id}-${Date.now()}.png`,
toArrayBuffer(preparedAssets.renderBaseBuffer) toArrayBuffer(preparedAssets.renderBaseBuffer)
); );
const uploadedRenderSegments = await uploadPreparedPsdTemplateRenderSegments({
templateId: template.id,
segments: preparedAssets.renderSegments,
uploadAsset,
});
await updateTemplateById({ await updateTemplateById({
templateId: template.id, templateId: template.id,
@ -205,6 +213,7 @@ async function backfillTemplateAssetsOnce(params: {
...currentTemplate, ...currentTemplate,
renderBaseUrl: uploadedRenderBase.url, renderBaseUrl: uploadedRenderBase.url,
renderBasePath: uploadedRenderBase.pathname, renderBasePath: uploadedRenderBase.pathname,
renderSegments: uploadedRenderSegments,
layers: mergedLayers, layers: mergedLayers,
layerCount: mergedLayers.length, layerCount: mergedLayers.length,
assetVersion: AGENT_PSD_TEMPLATE_ASSET_VERSION, assetVersion: AGENT_PSD_TEMPLATE_ASSET_VERSION,

View File

@ -4,7 +4,7 @@ import type { AgentPsdTemplateCategoryTagConfig } from "@/lib/agents/psd-templat
import { normalizeAgentPsdTemplateCategoryTags } from "@/lib/agents/psd-template-category-tag-config"; import { normalizeAgentPsdTemplateCategoryTags } from "@/lib/agents/psd-template-category-tag-config";
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 = 6; export const AGENT_PSD_TEMPLATE_ASSET_VERSION = 7;
export type AgentPsdTemplateStatus = "active" | "disabled"; export type AgentPsdTemplateStatus = "active" | "disabled";
export type AgentPsdTemplateParseStatus = export type AgentPsdTemplateParseStatus =
@ -49,6 +49,8 @@ export interface AgentPsdTemplateImageLayerSlot
slotType: "image"; slotType: "image";
isDefaultResultSource: boolean; isDefaultResultSource: boolean;
allowUserUploadOverride: boolean; allowUserUploadOverride: boolean;
forceClipMask?: boolean;
clipMaskLayerId?: string;
replaceMode: AgentPsdTemplateReplaceMode; replaceMode: AgentPsdTemplateReplaceMode;
contentBounds?: AgentPsdTemplateImageContentBounds; contentBounds?: AgentPsdTemplateImageContentBounds;
maskUrl?: string; maskUrl?: string;
@ -83,6 +85,13 @@ export type AgentPsdTemplateLayerSlot =
| AgentPsdTemplateImageLayerSlot | AgentPsdTemplateImageLayerSlot
| AgentPsdTemplateTextLayerSlot; | AgentPsdTemplateTextLayerSlot;
export interface AgentPsdTemplateRenderSegment {
id: string;
beforeLayerId?: string;
url: string;
path?: string;
}
export interface AgentPsdTemplate { export interface AgentPsdTemplate {
id: string; id: string;
name: string; name: string;
@ -103,6 +112,7 @@ export interface AgentPsdTemplate {
previewPath?: string; previewPath?: string;
renderBaseUrl?: string; renderBaseUrl?: string;
renderBasePath?: string; renderBasePath?: string;
renderSegments?: AgentPsdTemplateRenderSegment[];
width: number; width: number;
height: number; height: number;
layerCount: number; layerCount: number;
@ -407,6 +417,11 @@ export function parseAgentPsdTemplateLayers(value: unknown) {
isEditable && !isSuperLocked && record.isDefaultResultSource === true, isEditable && !isSuperLocked && record.isDefaultResultSource === true,
allowUserUploadOverride: allowUserUploadOverride:
isEditable && !isSuperLocked ? record.allowUserUploadOverride !== false : false, isEditable && !isSuperLocked ? record.allowUserUploadOverride !== false : false,
forceClipMask: isEditable && !isSuperLocked && record.forceClipMask === true,
clipMaskLayerId:
isEditable && !isSuperLocked && record.forceClipMask === true
? normalizeText(record.clipMaskLayerId) || undefined
: undefined,
replaceMode: normalizeAgentPsdTemplateReplaceMode(record.replaceMode), replaceMode: normalizeAgentPsdTemplateReplaceMode(record.replaceMode),
contentBounds: normalizeAgentPsdTemplateImageContentBounds( contentBounds: normalizeAgentPsdTemplateImageContentBounds(
record.contentBounds, record.contentBounds,
@ -443,6 +458,36 @@ export function parseAgentPsdTemplateLayers(value: unknown) {
}); });
} }
function parseAgentPsdTemplateRenderSegments(value: unknown) {
if (!Array.isArray(value)) return [] as AgentPsdTemplateRenderSegment[];
const segments: AgentPsdTemplateRenderSegment[] = [];
const seenIds = new Set<string>();
for (const entry of value) {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
continue;
}
const record = entry as Record<string, unknown>;
const id = normalizeText(record.id);
const url = normalizeUrl(record.url);
if (!id || !url || seenIds.has(id)) {
continue;
}
seenIds.add(id);
segments.push({
id,
beforeLayerId: normalizeText(record.beforeLayerId) || undefined,
url,
path: normalizeText(record.path) || undefined,
});
}
return segments;
}
export function parseAgentPsdTemplates(value: unknown) { export function parseAgentPsdTemplates(value: unknown) {
if (!Array.isArray(value)) return [] as AgentPsdTemplate[]; if (!Array.isArray(value)) return [] as AgentPsdTemplate[];
@ -512,6 +557,7 @@ export function parseAgentPsdTemplates(value: unknown) {
renderBaseUrl: renderBaseUrl:
normalizeOptionalUrl(record.renderBaseUrl) || normalizeOptionalUrl(record.previewUrl), normalizeOptionalUrl(record.renderBaseUrl) || normalizeOptionalUrl(record.previewUrl),
renderBasePath: normalizeText(record.renderBasePath) || undefined, renderBasePath: normalizeText(record.renderBasePath) || undefined,
renderSegments: parseAgentPsdTemplateRenderSegments(record.renderSegments),
width, width,
height, height,
layerCount, layerCount,
@ -547,6 +593,14 @@ export function hasAgentPsdTemplateStoredLayerSource(
); );
} }
export function hasAgentPsdTemplateRenderSegments(
template: Pick<AgentPsdTemplate, "renderSegments">
) {
return Boolean(
template.renderSegments?.some((segment) => String(segment.url || "").trim())
);
}
export function hasAgentPsdTemplateDedicatedRenderBase( export function hasAgentPsdTemplateDedicatedRenderBase(
template: Pick<AgentPsdTemplate, "renderBaseUrl" | "renderBasePath" | "previewUrl"> template: Pick<AgentPsdTemplate, "renderBaseUrl" | "renderBasePath" | "previewUrl">
) { ) {

View File

@ -11,6 +11,7 @@ import {
isAgentPsdTextLayerSlot, isAgentPsdTextLayerSlot,
type AgentPsdTemplateImageContentBounds, type AgentPsdTemplateImageContentBounds,
type AgentPsdTemplateLayerSlot, type AgentPsdTemplateLayerSlot,
type AgentPsdTemplateRenderSegment,
} from "@/lib/agents/psd-template-config"; } from "@/lib/agents/psd-template-config";
const DEFAULT_PSD_TEMPLATE_ASSET_CONCURRENCY = 4; const DEFAULT_PSD_TEMPLATE_ASSET_CONCURRENCY = 4;
@ -119,6 +120,12 @@ export interface PsdTemplateSourceLayerEntry {
composite: () => Promise<Uint8ClampedArray>; composite: () => Promise<Uint8ClampedArray>;
} }
export interface PreparedPsdTemplateRenderSegment {
id: string;
beforeLayerId?: string;
buffer: Buffer;
}
function resolveVisibleContentBounds(params: { function resolveVisibleContentBounds(params: {
rgba: Uint8ClampedArray; rgba: Uint8ClampedArray;
width: number; width: number;
@ -666,6 +673,35 @@ function toArrayBuffer(buffer: Buffer) {
) as ArrayBuffer; ) as ArrayBuffer;
} }
export async function uploadPreparedPsdTemplateRenderSegments(params: {
templateId: string;
segments: PreparedPsdTemplateRenderSegment[];
uploadAsset: StorageUploadFunction;
}) {
const uploadedSegments = await mapWithConcurrency(
params.segments,
DEFAULT_PSD_TEMPLATE_ASSET_CONCURRENCY,
async (segment) => {
const uploaded = await params.uploadAsset(
`agent-psd-segment-${params.templateId}-${sanitizeFileSegment(
segment.id,
"segment"
)}-${Date.now()}.png`,
toArrayBuffer(segment.buffer)
);
return {
id: segment.id,
beforeLayerId: segment.beforeLayerId,
url: uploaded.url,
path: uploaded.pathname,
} satisfies AgentPsdTemplateRenderSegment;
}
);
return uploadedSegments;
}
async function mapWithConcurrency<T, R>( async function mapWithConcurrency<T, R>(
items: T[], items: T[],
concurrency: number, concurrency: number,
@ -727,8 +763,13 @@ export async function preparePsdTemplateRenderAssets(params: {
fileBuffer: params.fileBuffer, fileBuffer: params.fileBuffer,
}); });
const psd = await parsePsdBuffer(psdBuffer); const psd = await parsePsdBuffer(psdBuffer);
const canvasWidth = Math.max(1, Math.round(Number(psd.width || 0)));
const canvasHeight = Math.max(1, Math.round(Number(psd.height || 0)));
const layerEntries = collectPsdLayerEntries(Array.isArray(psd.children) ? psd.children : []); const layerEntries = collectPsdLayerEntries(Array.isArray(psd.children) ? psd.children : []);
const nodeMap = new Map(layerEntries.map((entry) => [entry.layer.layerId, entry])); const nodeMap = new Map(layerEntries.map((entry) => [entry.layer.layerId, entry]));
const layerSlotByPsdLayerId = new Map(
params.layers.map((layer) => [layer.layerId, layer])
);
const editableLayerIdSet = new Set( const editableLayerIdSet = new Set(
params.layers params.layers
.filter(isAgentPsdTemplateLayerUserEditable) .filter(isAgentPsdTemplateLayerUserEditable)
@ -900,19 +941,28 @@ export async function preparePsdTemplateRenderAssets(params: {
nextLayers[index] = layer; nextLayers[index] = layer;
}); });
const baseEntries = [...layerEntries] const drawableEntries = [...layerEntries]
.reverse() .reverse()
.filter( .filter((entry) => entry.node && typeof entry.node.composite === "function");
(entry) =>
!editableLayerIdSet.has(entry.layer.layerId) &&
entry.node &&
typeof entry.node.composite === "function"
);
const baseCompositeResults = await mapWithConcurrency( const layerCompositeResults = await mapWithConcurrency(
baseEntries, drawableEntries,
assetConcurrency, assetConcurrency,
async (entry, index) => { async (entry, index) => {
const layerSlot = layerSlotByPsdLayerId.get(entry.layer.layerId);
const isEditableBoundary = layerSlot
? isAgentPsdTemplateLayerUserEditable(layerSlot)
: editableLayerIdSet.has(entry.layer.layerId);
if (isEditableBoundary) {
return {
index,
input: null,
beforeLayerId: layerSlot?.id || "",
editableBoundary: true,
};
}
try { try {
const rgba = await entry.node.composite!(); const rgba = await entry.node.composite!();
const overlayBuffer = await rgbaToPngBuffer( const overlayBuffer = await rgbaToPngBuffer(
@ -926,8 +976,8 @@ export async function preparePsdTemplateRenderAssets(params: {
top: entry.layer.y, top: entry.layer.y,
width: entry.layer.width, width: entry.layer.width,
height: entry.layer.height, height: entry.layer.height,
canvasWidth: Math.max(1, Math.round(Number(psd.width || 0))), canvasWidth,
canvasHeight: Math.max(1, Math.round(Number(psd.height || 0))), canvasHeight,
}); });
if (!fittedOverlay) { if (!fittedOverlay) {
return null; return null;
@ -936,6 +986,8 @@ export async function preparePsdTemplateRenderAssets(params: {
return { return {
index, index,
input: fittedOverlay, input: fittedOverlay,
beforeLayerId: "",
editableBoundary: false,
}; };
} catch (error) { } catch (error) {
console.warn("[PSD Template] Failed to compose base layer:", { console.warn("[PSD Template] Failed to compose base layer:", {
@ -947,27 +999,77 @@ export async function preparePsdTemplateRenderAssets(params: {
} }
); );
const baseCompositeInputs = baseCompositeResults const sortedLayerCompositeResults = layerCompositeResults
.filter(
(
result
): result is {
index: number;
input: { input: Buffer; left: number; top: number } | null;
beforeLayerId: string;
editableBoundary: boolean;
} => Boolean(result)
)
.sort((left, right) => left.index - right.index);
const baseCompositeInputs = sortedLayerCompositeResults
.filter( .filter(
( (
result result
): result is { ): result is {
index: number; index: number;
input: { input: Buffer; left: number; top: number }; input: { input: Buffer; left: number; top: number };
} => Boolean(result) beforeLayerId: string;
editableBoundary: false;
} => Boolean(result.input) && !result.editableBoundary
) )
.sort((left, right) => left.index - right.index)
.map((result) => result.input); .map((result) => result.input);
const sharp = await getSharp(); const sharp = await getSharp();
const transparentBaseBuffer = await createTransparentCanvasBuffer(psd.width, psd.height); const transparentBaseBuffer = await createTransparentCanvasBuffer(
canvasWidth,
canvasHeight
);
const composeSegmentBuffer = (
inputs: Array<{ input: Buffer; left: number; top: number }>
) => sharp(transparentBaseBuffer).composite(inputs).png().toBuffer();
const renderBaseBuffer = await sharp(transparentBaseBuffer) const renderBaseBuffer = await sharp(transparentBaseBuffer)
.composite(baseCompositeInputs) .composite(baseCompositeInputs)
.png() .png()
.toBuffer(); .toBuffer();
const renderSegments: PreparedPsdTemplateRenderSegment[] = [];
let pendingSegmentInputs: Array<{ input: Buffer; left: number; top: number }> = [];
for (const result of sortedLayerCompositeResults) {
if (result.editableBoundary) {
if (pendingSegmentInputs.length > 0) {
renderSegments.push({
id: `before-${sanitizeFileSegment(
result.beforeLayerId,
`layer-${result.index}`
)}`,
beforeLayerId: result.beforeLayerId,
buffer: await composeSegmentBuffer(pendingSegmentInputs),
});
pendingSegmentInputs = [];
}
continue;
}
if (result.input) {
pendingSegmentInputs.push(result.input);
}
}
if (pendingSegmentInputs.length > 0) {
renderSegments.push({
id: "above-editable-layers",
buffer: await composeSegmentBuffer(pendingSegmentInputs),
});
}
return { return {
layers: nextLayers, layers: nextLayers,
renderBaseBuffer, renderBaseBuffer,
renderSegments,
}; };
} }

View File

@ -2,9 +2,11 @@ import "server-only";
import { uploadFile } from "@/lib/storage/service"; import { uploadFile } from "@/lib/storage/service";
import { fetchRemoteAssetBuffer } from "@/lib/agents/psd-remote-asset"; import { fetchRemoteAssetBuffer } from "@/lib/agents/psd-remote-asset";
import { loadPsdTemplateSource } from "@/lib/agents/psd-template-parser";
import { import {
hasAgentPsdTemplateStoredLayerSource, hasAgentPsdTemplateStoredLayerSource,
isAgentPsdImageLayerSlot, isAgentPsdImageLayerSlot,
isAgentPsdLayerSuperLocked,
isAgentPsdTemplateLayerUserEditable, isAgentPsdTemplateLayerUserEditable,
isAgentPsdTextLayerSlot, isAgentPsdTextLayerSlot,
resolveAgentPsdTextLayerEffectiveAlign, resolveAgentPsdTextLayerEffectiveAlign,
@ -181,8 +183,7 @@ function getDefaultSourceImageLayer(
) { ) {
const editableImageLayers = layers.filter( const editableImageLayers = layers.filter(
(layer): layer is AgentPsdTemplateImageLayerSlot => (layer): layer is AgentPsdTemplateImageLayerSlot =>
isAgentPsdTemplateLayerUserEditable(layer) && layer.isEditable && isAgentPsdImageLayerSlot(layer)
isAgentPsdImageLayerSlot(layer)
); );
return ( return (
@ -462,18 +463,68 @@ export async function renderAgentPsdTemplate(params: {
const hasDedicatedRenderBase = const hasDedicatedRenderBase =
Boolean(renderBaseUrl) && Boolean(renderBaseUrl) &&
renderBaseUrl !== String(template.previewUrl || "").trim(); renderBaseUrl !== String(template.previewUrl || "").trim();
const templateLayerOrderIds = template.layers.map((layer) => layer.id);
const isUsingTemplateLayerOrder =
orderedTemplateLayers.length === templateLayerOrderIds.length &&
orderedTemplateLayers.every(
(layer, index) => layer.id === templateLayerOrderIds[index]
);
const defaultSourceImageLayerId = const defaultSourceImageLayerId =
getDefaultSourceImageLayer(orderedTemplateLayers)?.id || ""; getDefaultSourceImageLayer(orderedTemplateLayers)?.id || "";
const hasImageAssignment = (layer: AgentPsdTemplate["layers"][number]) =>
isAgentPsdImageLayerSlot(layer) &&
Boolean(String(imageAssignments[layer.id] || "").trim());
const shouldRenderAsStaticLayer = (
layer: AgentPsdTemplate["layers"][number]
) => !isAgentPsdTemplateLayerUserEditable(layer) && !hasImageAssignment(layer);
const hasHiddenStaticLayer = orderedTemplateLayers.some( const hasHiddenStaticLayer = orderedTemplateLayers.some(
(layer) => (layer) =>
!isAgentPsdTemplateLayerUserEditable(layer) && shouldRenderAsStaticLayer(layer) &&
hiddenLayerIdSet.has(layer.id) hiddenLayerIdSet.has(layer.id)
); );
const hasVisibleAssignedImageLayer = orderedTemplateLayers.some(
(layer) =>
!hiddenLayerIdSet.has(layer.id) && hasImageAssignment(layer)
);
const hasStaticLayerPositionOffset = orderedTemplateLayers.some((layer) => {
if (
isAgentPsdTemplateLayerUserEditable(layer) ||
isAgentPsdLayerSuperLocked(layer)
) {
return false;
}
const offset = normalizeTextPositionOffset(layerPositionOffsets[layer.id]);
return Math.abs(offset.offsetX) > 0.01 || Math.abs(offset.offsetY) > 0.01;
});
const renderSegments = (template.renderSegments || []).filter((segment) =>
Boolean(String(segment.url || "").trim())
);
const shouldUseRenderSegments =
hasVisibleAssignedImageLayer &&
renderSegments.length > 0 &&
isUsingTemplateLayerOrder &&
!hasHiddenStaticLayer &&
!hasStaticLayerPositionOffset;
const shouldUseRenderBaseFallback = const shouldUseRenderBaseFallback =
hasDedicatedRenderBase && hasDedicatedRenderBase &&
!hasHiddenStaticLayer; !hasHiddenStaticLayer &&
!hasVisibleAssignedImageLayer;
const shouldHydrateLayerSourcesFromPsd =
hasVisibleAssignedImageLayer && !shouldUseRenderSegments;
const renderSegmentByBeforeLayerId = new Map(
renderSegments
.filter((segment) => String(segment.beforeLayerId || "").trim())
.map((segment) => [String(segment.beforeLayerId || "").trim(), segment])
);
const topRenderSegments = renderSegments.filter(
(segment) => !String(segment.beforeLayerId || "").trim()
);
function getLayerPositionOffset(layerId: string) { function getLayerPositionOffset(layerId: string) {
const layer = layerMap.get(layerId);
if (layer && isAgentPsdLayerSuperLocked(layer)) {
return normalizeTextPositionOffset();
}
return normalizeTextPositionOffset(layerPositionOffsets[layerId]); return normalizeTextPositionOffset(layerPositionOffsets[layerId]);
} }
@ -516,6 +567,11 @@ export async function renderAgentPsdTemplate(params: {
if (shouldUseRenderBaseFallback) { if (shouldUseRenderBaseFallback) {
pushAsset(renderBaseUrl, "PSD 模板底图"); pushAsset(renderBaseUrl, "PSD 模板底图");
} }
if (shouldUseRenderSegments) {
for (const segment of renderSegments) {
pushAsset(segment.url, "PSD 模板快速导出分层");
}
}
for (const layer of orderedTemplateLayers) { for (const layer of orderedTemplateLayers) {
if (hiddenLayerIdSet.has(layer.id)) { if (hiddenLayerIdSet.has(layer.id)) {
@ -523,8 +579,8 @@ export async function renderAgentPsdTemplate(params: {
} }
const storedLayerSourceUrl = getStoredLayerSourceUrl(layer); const storedLayerSourceUrl = getStoredLayerSourceUrl(layer);
if (!isAgentPsdTemplateLayerUserEditable(layer)) { if (shouldRenderAsStaticLayer(layer)) {
if (shouldUseRenderBaseFallback) { if (shouldUseRenderBaseFallback || shouldUseRenderSegments) {
continue; continue;
} }
pushAsset( pushAsset(
@ -540,6 +596,13 @@ export async function renderAgentPsdTemplate(params: {
if (assignmentUrl) { if (assignmentUrl) {
pushAsset(assignmentUrl, "PSD 套版素材"); pushAsset(assignmentUrl, "PSD 套版素材");
pushAsset(layer.maskUrl, "PSD 蒙版资源", true); pushAsset(layer.maskUrl, "PSD 蒙版资源", true);
if (layer.forceClipMask && layer.clipMaskLayerId) {
pushAsset(
getStoredLayerSourceUrl(layerMap.get(layer.clipMaskLayerId) || layer),
"PSD 剪切蒙版目标资源",
true
);
}
} else { } else {
pushAsset( pushAsset(
storedLayerSourceUrl, storedLayerSourceUrl,
@ -601,6 +664,111 @@ export async function renderAgentPsdTemplate(params: {
const compositeInputs: Array<{ input: Buffer; left: number; top: number }> = const compositeInputs: Array<{ input: Buffer; left: number; top: number }> =
[]; [];
let psdSourceLayerEntryMapPromise: Promise<
Map<
string,
{
layer: { width: number; height: number };
composite: () => Promise<Uint8ClampedArray>;
}
>
> | null = null;
const psdSourceLayerBufferPromiseMap = new Map<string, Promise<Buffer | null>>();
async function getPsdSourceLayerEntryMap() {
if (!shouldHydrateLayerSourcesFromPsd) {
return null;
}
if (!psdSourceLayerEntryMapPromise) {
psdSourceLayerEntryMapPromise = loadPsdTemplateSource({
fileUrl: template.fileUrl,
filePath: template.filePath,
}).then((source) => {
const entryMap = new Map<
string,
{
layer: { width: number; height: number };
composite: () => Promise<Uint8ClampedArray>;
}
>();
for (const entry of source.layers) {
entryMap.set(entry.layer.layerId, {
layer: {
width: entry.layer.width,
height: entry.layer.height,
},
composite: entry.composite,
});
}
return entryMap;
});
}
return psdSourceLayerEntryMapPromise;
}
async function readPsdSourceLayerBuffer(
layer: AgentPsdTemplate["layers"][number]
) {
if (!shouldHydrateLayerSourcesFromPsd) {
return null;
}
if (!psdSourceLayerBufferPromiseMap.has(layer.layerId)) {
psdSourceLayerBufferPromiseMap.set(
layer.layerId,
(async () => {
try {
const entryMap = await getPsdSourceLayerEntryMap();
const entry = entryMap?.get(layer.layerId);
if (!entry) {
return null;
}
const rgba = await entry.composite();
return sharp(Buffer.from(rgba), {
raw: {
width: Math.max(1, Math.round(entry.layer.width || layer.width)),
height: Math.max(1, Math.round(entry.layer.height || layer.height)),
channels: 4,
},
})
.png(PSD_RENDER_PNG_OPTIONS)
.toBuffer();
} catch (error) {
console.warn("[PSD Template] failed to hydrate layer from PSD source:", {
templateId: template.id,
layerId: layer.layerId,
error,
});
return null;
}
})()
);
}
return psdSourceLayerBufferPromiseMap.get(layer.layerId)!;
}
async function readReusableLayerBuffer(
layer: AgentPsdTemplate["layers"][number]
) {
const sourceUrl = getStoredLayerSourceUrl(layer);
if (sourceUrl) {
try {
return await readCachedBuffer(sourceUrl, "PSD 图层素材");
} catch (error) {
const hydratedBuffer = await readPsdSourceLayerBuffer(layer);
if (hydratedBuffer) {
return hydratedBuffer;
}
throw error;
}
}
return readPsdSourceLayerBuffer(layer);
}
async function appendBufferOverlay(params: { async function appendBufferOverlay(params: {
input: Buffer; input: Buffer;
@ -631,10 +799,19 @@ export async function renderAgentPsdTemplate(params: {
options: { optional?: boolean } = {} options: { optional?: boolean } = {}
) { ) {
const sourceUrl = getStoredLayerSourceUrl(layer); const sourceUrl = getStoredLayerSourceUrl(layer);
if ( if ((!hasAgentPsdTemplateStoredLayerSource(layer) && !sourceUrl) || !sourceUrl) {
(!hasAgentPsdTemplateStoredLayerSource(layer) && !sourceUrl) || const hydratedBuffer = await readPsdSourceLayerBuffer(layer);
!sourceUrl if (hydratedBuffer) {
) { const layerPositionOffset = getLayerPositionOffset(layer.id);
return appendBufferOverlay({
input: hydratedBuffer,
left: layer.x + layerPositionOffset.offsetX,
top: layer.y + layerPositionOffset.offsetY,
width: layer.width,
height: layer.height,
});
}
if (options.optional) { if (options.optional) {
return false; return false;
} }
@ -645,7 +822,13 @@ export async function renderAgentPsdTemplate(params: {
const layerPositionOffset = getLayerPositionOffset(layer.id); const layerPositionOffset = getLayerPositionOffset(layer.id);
try { try {
const overlayBuffer = await readCachedBuffer(sourceUrl, "PSD 图层素材"); const overlayBuffer = await readReusableLayerBuffer(layer);
if (!overlayBuffer) {
if (options.optional) {
return false;
}
throw new Error("Missing PSD layer asset");
}
const didAppend = await appendBufferOverlay({ const didAppend = await appendBufferOverlay({
input: overlayBuffer, input: overlayBuffer,
left: layer.x + layerPositionOffset.offsetX, left: layer.x + layerPositionOffset.offsetX,
@ -693,6 +876,231 @@ export async function renderAgentPsdTemplate(params: {
}); });
} }
const appendedRenderSegmentIds = new Set<string>();
async function appendRenderSegmentOverlay(
segment: (typeof renderSegments)[number] | undefined
) {
if (
!shouldUseRenderSegments ||
!segment ||
appendedRenderSegmentIds.has(segment.id)
) {
return;
}
appendedRenderSegmentIds.add(segment.id);
const segmentBuffer = await readCachedBuffer(
segment.url,
"PSD 模板快速导出分层"
);
await appendBufferOverlay({
input: segmentBuffer,
left: 0,
top: 0,
width: canvasWidth,
height: canvasHeight,
});
}
async function appendRenderSegmentBeforeLayer(layerId: string) {
await appendRenderSegmentOverlay(renderSegmentByBeforeLayerId.get(layerId));
}
async function appendTopRenderSegments() {
for (const segment of topRenderSegments) {
await appendRenderSegmentOverlay(segment);
}
}
function getAssignedImageRenderFrameLayer(layer: AgentPsdTemplateImageLayerSlot) {
if (!layer.forceClipMask || !layer.clipMaskLayerId) {
return layer;
}
return layerMap.get(layer.clipMaskLayerId) || layer;
}
const assignedImageLayersByFrameId = new Map<string, AgentPsdTemplateImageLayerSlot[]>();
for (const layer of orderedTemplateLayers) {
if (
hiddenLayerIdSet.has(layer.id) ||
!isAgentPsdImageLayerSlot(layer) ||
!String(imageAssignments[layer.id] || "").trim()
) {
continue;
}
const frameLayer = getAssignedImageRenderFrameLayer(layer);
const currentLayers = assignedImageLayersByFrameId.get(frameLayer.id) || [];
currentLayers.push(layer);
assignedImageLayersByFrameId.set(frameLayer.id, currentLayers);
}
const appendedImageAssignmentLayerIds = new Set<string>();
async function appendAssignedImageLayer(layer: AgentPsdTemplateImageLayerSlot) {
if (appendedImageAssignmentLayerIds.has(layer.id)) {
return;
}
const isDefaultSourceImageLayer = layer.id === defaultSourceImageLayerId;
const assignmentUrl = String(imageAssignments[layer.id] || "").trim();
if (!assignmentUrl || hiddenLayerIdSet.has(layer.id)) {
return;
}
appendedImageAssignmentLayerIds.add(layer.id);
const sourceBuffer = await readCachedBuffer(assignmentUrl, "PSD 套版素材");
const clipMaskLayer =
layer.forceClipMask && layer.clipMaskLayerId
? layerMap.get(layer.clipMaskLayerId)
: null;
const renderFrameLayer = clipMaskLayer || layer;
const renderFrameContentBounds =
clipMaskLayer && isAgentPsdImageLayerSlot(clipMaskLayer)
? clipMaskLayer.contentBounds
: layer.contentBounds;
const sourceImage = sharp(sourceBuffer, { failOn: "none" }).rotate();
const metadata = await sourceImage.clone().metadata();
const placementFrame = resolveAgentPsdImagePlacementFrame({
containerWidth: renderFrameLayer.width,
containerHeight: renderFrameLayer.height,
imageWidth: Math.max(1, Number(metadata.width || 1)),
imageHeight: Math.max(1, Number(metadata.height || 1)),
replaceMode: layer.replaceMode,
contentBounds: renderFrameContentBounds,
placement: imagePlacements[layer.id],
});
const resizedOverlayBuffer = await sourceImage
.clone()
.resize({
width: Math.max(1, Math.round(placementFrame.width)),
height: Math.max(1, Math.round(placementFrame.height)),
fit:
layer.replaceMode === "contain"
? "contain"
: layer.replaceMode === "stretch"
? "fill"
: "cover",
background: {
r: 0,
g: 0,
b: 0,
alpha: 0,
},
})
.png(PSD_RENDER_PNG_OPTIONS)
.toBuffer();
const slotOverlay = await fitOverlayBufferIntoCanvas({
input: resizedOverlayBuffer,
left: placementFrame.left,
top: placementFrame.top,
width: placementFrame.width,
height: placementFrame.height,
canvasWidth: renderFrameLayer.width,
canvasHeight: renderFrameLayer.height,
});
if (!slotOverlay) {
return;
}
const layerPositionOffset = getLayerPositionOffset(renderFrameLayer.id);
const shouldApplyMask = Boolean(layer.maskUrl || clipMaskLayer);
if (!shouldApplyMask) {
if (isDefaultSourceImageLayer) {
await appendRenderBaseOverlay();
}
await appendBufferOverlay({
input: slotOverlay.input,
left: renderFrameLayer.x + layerPositionOffset.offsetX + slotOverlay.left,
top: renderFrameLayer.y + layerPositionOffset.offsetY + slotOverlay.top,
width: slotOverlay.width,
height: slotOverlay.height,
});
return;
}
let overlayBuffer = await sharp({
create: {
width: Math.max(1, Math.round(renderFrameLayer.width)),
height: Math.max(1, Math.round(renderFrameLayer.height)),
channels: 4,
background: {
r: 0,
g: 0,
b: 0,
alpha: 0,
},
},
})
.composite([slotOverlay])
.png(PSD_RENDER_PNG_OPTIONS)
.toBuffer();
try {
const rawMaskBuffer = layer.maskUrl
? await readCachedBuffer(layer.maskUrl, "PSD 蒙版资源")
: await readReusableLayerBuffer(clipMaskLayer!);
if (!rawMaskBuffer) {
throw new Error("Missing PSD layer mask source");
}
const maskBuffer = await sharp(rawMaskBuffer)
.resize({
width: Math.max(1, Math.round(renderFrameLayer.width)),
height: Math.max(1, Math.round(renderFrameLayer.height)),
fit: "fill",
background: {
r: 0,
g: 0,
b: 0,
alpha: 0,
},
})
.png(PSD_RENDER_PNG_OPTIONS)
.toBuffer();
overlayBuffer = await sharp(overlayBuffer)
.composite([
{
input: maskBuffer,
blend: "dest-in",
},
])
.png(PSD_RENDER_PNG_OPTIONS)
.toBuffer();
} catch (error) {
console.warn(
"[PSD Template] failed to apply layer mask during render:",
{
templateId: template.id,
layerId: layer.layerId,
error,
}
);
}
if (isDefaultSourceImageLayer) {
await appendRenderBaseOverlay();
}
await appendBufferOverlay({
input: overlayBuffer,
left: renderFrameLayer.x + layerPositionOffset.offsetX,
top: renderFrameLayer.y + layerPositionOffset.offsetY,
width: renderFrameLayer.width,
height: renderFrameLayer.height,
});
}
async function appendAssignedImagesForFrameLayer(frameLayerId: string) {
for (const assignedLayer of assignedImageLayersByFrameId.get(frameLayerId) || []) {
if (assignedLayer.id !== frameLayerId) {
await appendAssignedImageLayer(assignedLayer);
}
}
}
if ( if (
shouldUseRenderBaseFallback && shouldUseRenderBaseFallback &&
(!defaultSourceImageLayerId || hiddenLayerIdSet.has(defaultSourceImageLayerId)) (!defaultSourceImageLayerId || hiddenLayerIdSet.has(defaultSourceImageLayerId))
@ -701,15 +1109,21 @@ export async function renderAgentPsdTemplate(params: {
} }
for (const layer of [...orderedTemplateLayers].reverse()) { for (const layer of [...orderedTemplateLayers].reverse()) {
if (shouldUseRenderSegments && isAgentPsdTemplateLayerUserEditable(layer)) {
await appendRenderSegmentBeforeLayer(layer.id);
}
if (hiddenLayerIdSet.has(layer.id)) { if (hiddenLayerIdSet.has(layer.id)) {
continue; continue;
} }
if (!isAgentPsdTemplateLayerUserEditable(layer)) { if (!isAgentPsdTemplateLayerUserEditable(layer)) {
if (shouldUseRenderBaseFallback) { if (shouldUseRenderBaseFallback || shouldUseRenderSegments) {
await appendAssignedImagesForFrameLayer(layer.id);
continue; continue;
} }
await appendStoredLayer(layer, { optional: shouldUseRenderBaseFallback }); await appendStoredLayer(layer, { optional: shouldUseRenderBaseFallback });
await appendAssignedImagesForFrameLayer(layer.id);
continue; continue;
} }
@ -723,149 +1137,15 @@ export async function renderAgentPsdTemplate(params: {
if (isDefaultSourceImageLayer) { if (isDefaultSourceImageLayer) {
await appendRenderBaseOverlay(); await appendRenderBaseOverlay();
} }
await appendAssignedImagesForFrameLayer(layer.id);
continue; continue;
} }
const sourceBuffer = await readCachedBuffer( const renderFrameLayer = getAssignedImageRenderFrameLayer(layer);
assignmentUrl, if (renderFrameLayer.id === layer.id) {
"PSD 套版素材" await appendAssignedImageLayer(layer);
);
const sourceImage = sharp(sourceBuffer, { failOn: "none" }).rotate();
const metadata = await sourceImage.clone().metadata();
const placementFrame = resolveAgentPsdImagePlacementFrame({
containerWidth: layer.width,
containerHeight: layer.height,
imageWidth: Math.max(1, Number(metadata.width || 1)),
imageHeight: Math.max(1, Number(metadata.height || 1)),
replaceMode: layer.replaceMode,
contentBounds: layer.contentBounds,
placement: imagePlacements[layer.id],
});
const resizedOverlayBuffer = await sourceImage
.clone()
.resize({
width: Math.max(1, Math.round(placementFrame.width)),
height: Math.max(1, Math.round(placementFrame.height)),
fit:
layer.replaceMode === "contain"
? "contain"
: layer.replaceMode === "stretch"
? "fill"
: "cover",
background: {
r: 0,
g: 0,
b: 0,
alpha: 0,
},
})
.png(PSD_RENDER_PNG_OPTIONS)
.toBuffer();
const slotOverlay = await fitOverlayBufferIntoCanvas({
input: resizedOverlayBuffer,
left: placementFrame.left,
top: placementFrame.top,
width: placementFrame.width,
height: placementFrame.height,
canvasWidth: layer.width,
canvasHeight: layer.height,
});
if (!slotOverlay) {
continue;
} }
await appendAssignedImagesForFrameLayer(layer.id);
const layerPositionOffset = getLayerPositionOffset(layer.id);
if (!layer.maskUrl) {
const didAppend = await appendBufferOverlay({
input: slotOverlay.input,
left:
layer.x +
layerPositionOffset.offsetX +
slotOverlay.left,
top:
layer.y +
layerPositionOffset.offsetY +
slotOverlay.top,
width: slotOverlay.width,
height: slotOverlay.height,
});
if (isDefaultSourceImageLayer) {
await appendRenderBaseOverlay();
}
if (!didAppend) continue;
continue;
}
let overlayBuffer = await sharp({
create: {
width: Math.max(1, Math.round(layer.width)),
height: Math.max(1, Math.round(layer.height)),
channels: 4,
background: {
r: 0,
g: 0,
b: 0,
alpha: 0,
},
},
})
.composite([slotOverlay])
.png(PSD_RENDER_PNG_OPTIONS)
.toBuffer();
if (layer.maskUrl) {
try {
const maskBuffer = await sharp(
await readCachedBuffer(layer.maskUrl, "PSD 蒙版资源")
)
.resize({
width: Math.max(1, Math.round(layer.width)),
height: Math.max(1, Math.round(layer.height)),
fit: "fill",
background: {
r: 0,
g: 0,
b: 0,
alpha: 0,
},
})
.png(PSD_RENDER_PNG_OPTIONS)
.toBuffer();
overlayBuffer = await sharp(overlayBuffer)
.composite([
{
input: maskBuffer,
blend: "dest-in",
},
])
.png(PSD_RENDER_PNG_OPTIONS)
.toBuffer();
} catch (error) {
console.warn(
"[PSD Template] failed to apply layer mask during render:",
{
templateId: template.id,
layerId: layer.layerId,
error,
}
);
}
}
const didAppend = await appendBufferOverlay({
input: overlayBuffer,
left: layer.x + layerPositionOffset.offsetX,
top: layer.y + layerPositionOffset.offsetY,
width: layer.width,
height: layer.height,
});
if (isDefaultSourceImageLayer) {
await appendRenderBaseOverlay();
}
if (!didAppend) continue;
continue; continue;
} }
@ -889,6 +1169,7 @@ export async function renderAgentPsdTemplate(params: {
optional: shouldUseRenderBaseFallback, optional: shouldUseRenderBaseFallback,
}); });
} }
await appendAssignedImagesForFrameLayer(layer.id);
continue; continue;
} }
const textAlign = resolveAgentPsdTextLayerEffectiveAlign(layer); const textAlign = resolveAgentPsdTextLayerEffectiveAlign(layer);
@ -998,9 +1279,12 @@ export async function renderAgentPsdTemplate(params: {
height: overlayFrame.height, height: overlayFrame.height,
}); });
if (!didAppend) continue; if (!didAppend) continue;
await appendAssignedImagesForFrameLayer(layer.id);
} }
} }
await appendTopRenderSegments();
const transparentCanvas = await sharp({ const transparentCanvas = await sharp({
create: { create: {
width: canvasWidth, width: canvasWidth,

View File

@ -8,6 +8,7 @@ import {
import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings"; import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings";
import { import {
AGENT_PSD_TEMPLATE_ASSET_VERSION, AGENT_PSD_TEMPLATE_ASSET_VERSION,
hasAgentPsdTemplateRenderSegments,
isAgentPsdImageLayerSlot, isAgentPsdImageLayerSlot,
isAgentPsdLayerSuperLocked, isAgentPsdLayerSuperLocked,
isAgentPsdTemplateLayerUserEditable, isAgentPsdTemplateLayerUserEditable,
@ -21,6 +22,7 @@ import {
import { import {
parsePsdTemplateFileFromSource, parsePsdTemplateFileFromSource,
preparePsdTemplateRenderAssets, preparePsdTemplateRenderAssets,
uploadPreparedPsdTemplateRenderSegments,
} from "@/lib/agents/psd-template-parser"; } from "@/lib/agents/psd-template-parser";
import { resolveAgentPsdLocalCachePath } from "@/lib/agents/psd-local-cache"; import { resolveAgentPsdLocalCachePath } from "@/lib/agents/psd-local-cache";
import { createStorageUploadSession } from "@/lib/storage/service"; import { createStorageUploadSession } from "@/lib/storage/service";
@ -66,6 +68,10 @@ function hasDedicatedRenderBase(template: AgentPsdTemplate) {
return Boolean(renderBaseUrl) && renderBaseUrl !== previewUrl; return Boolean(renderBaseUrl) && renderBaseUrl !== previewUrl;
} }
function hasStaticTemplateLayers(template: AgentPsdTemplate) {
return template.layers.some((layer) => !isAgentPsdTemplateLayerUserEditable(layer));
}
function repairEditableImageLayerSelection(template: AgentPsdTemplate) { function repairEditableImageLayerSelection(template: AgentPsdTemplate) {
const imageLayers = template.layers.filter(isAgentPsdImageLayerSlot); const imageLayers = template.layers.filter(isAgentPsdImageLayerSlot);
const unlockedImageLayers = imageLayers.filter( const unlockedImageLayers = imageLayers.filter(
@ -255,6 +261,8 @@ export async function repairAgentPsdTemplateAssetsIfNeeded(
Number(template.assetVersion || 0) !== AGENT_PSD_TEMPLATE_ASSET_VERSION; Number(template.assetVersion || 0) !== AGENT_PSD_TEMPLATE_ASSET_VERSION;
const needsRepair = needsAssetVersionRefresh || hasMissingLayerAssets(template); const needsRepair = needsAssetVersionRefresh || hasMissingLayerAssets(template);
const needsRenderBaseRepair = !hasDedicatedRenderBase(template); const needsRenderBaseRepair = !hasDedicatedRenderBase(template);
const needsRenderSegmentRepair =
hasStaticTemplateLayers(template) && !hasAgentPsdTemplateRenderSegments(template);
const fonts = await readAgentPsdFonts().catch(() => []); const fonts = await readAgentPsdFonts().catch(() => []);
const needsFontDetection = needsTextMetadataRepair(template); const needsFontDetection = needsTextMetadataRepair(template);
@ -311,7 +319,12 @@ export async function repairAgentPsdTemplateAssetsIfNeeded(
(layer, index) => JSON.stringify(layer) !== JSON.stringify(template.layers[index]) (layer, index) => JSON.stringify(layer) !== JSON.stringify(template.layers[index])
); );
if (!needsRepair && !hasFontRepair && !needsRenderBaseRepair) { if (
!needsRepair &&
!hasFontRepair &&
!needsRenderBaseRepair &&
!needsRenderSegmentRepair
) {
return template; return template;
} }
@ -335,11 +348,20 @@ export async function repairAgentPsdTemplateAssetsIfNeeded(
toArrayBuffer(preparedAssets.renderBaseBuffer) toArrayBuffer(preparedAssets.renderBaseBuffer)
) )
: null; : null;
const uploadedRenderSegments =
needsRenderSegmentRepair || needsAssetVersionRefresh
? await uploadPreparedPsdTemplateRenderSegments({
templateId: template.id,
segments: preparedAssets.renderSegments,
uploadAsset,
})
: null;
return { return {
...template, ...template,
renderBaseUrl: uploadedRenderBase?.url || template.renderBaseUrl, renderBaseUrl: uploadedRenderBase?.url || template.renderBaseUrl,
renderBasePath: uploadedRenderBase?.pathname || template.renderBasePath, renderBasePath: uploadedRenderBase?.pathname || template.renderBasePath,
renderSegments: uploadedRenderSegments || template.renderSegments,
layers: preparedAssets.layers, layers: preparedAssets.layers,
assetVersion: AGENT_PSD_TEMPLATE_ASSET_VERSION, assetVersion: AGENT_PSD_TEMPLATE_ASSET_VERSION,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),

View File

@ -26,6 +26,7 @@ export async function tryDeleteAgentPsdTemplateAssets(template: AgentPsdTemplate
template.filePath, template.filePath,
template.previewPath, template.previewPath,
template.renderBasePath, template.renderBasePath,
...(template.renderSegments || []).map((segment) => segment.path),
...template.layers.map((layer) => layer.previewPath), ...template.layers.map((layer) => layer.previewPath),
...template.layers ...template.layers
.filter(isAgentPsdImageLayerSlot) .filter(isAgentPsdImageLayerSlot)

View File

@ -41,6 +41,7 @@ function normalizeMaterialImage(
const id = sanitizeText(raw.id, 128) || `image-${index + 1}`; const id = sanitizeText(raw.id, 128) || `image-${index + 1}`;
const group = raw.group === "other" ? "other" : "fixed"; const group = raw.group === "other" ? "other" : "fixed";
const previewUrl = sanitizeText(raw.previewUrl, 2000) || null;
return { return {
id, id,
@ -48,6 +49,7 @@ function normalizeMaterialImage(
label: sanitizeText(raw.label, 128) || `图片${index + 1}`, label: sanitizeText(raw.label, 128) || `图片${index + 1}`,
fileName: sanitizeText(raw.fileName, 255) || `image-${index + 1}`, fileName: sanitizeText(raw.fileName, 255) || `image-${index + 1}`,
imageUrl, imageUrl,
previewUrl,
assetId: sanitizeText(raw.assetId, 128) || null, assetId: sanitizeText(raw.assetId, 128) || null,
mimeType: sanitizeText(raw.mimeType, 128) || null, mimeType: sanitizeText(raw.mimeType, 128) || null,
appearanceSummary: sanitizeText(raw.appearanceSummary, 255) || null, appearanceSummary: sanitizeText(raw.appearanceSummary, 255) || null,

View File

@ -272,6 +272,7 @@ export interface AgentWorkflowMaterialImage {
label: string; label: string;
fileName: string; fileName: string;
imageUrl: string; imageUrl: string;
previewUrl?: string | null;
assetId?: string | null; assetId?: string | null;
mimeType?: string | null; mimeType?: string | null;
appearanceSummary?: string | null; appearanceSummary?: string | null;