Compare commits
26 Commits
main
...
codex/yedi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6937217634 | ||
|
|
84f105d4c8 | ||
|
|
824806406a | ||
|
|
6b72771701 | ||
|
|
97cc9e4a0a | ||
|
|
154ec44a4c | ||
|
|
2f0c533b87 | ||
|
|
3ac209d149 | ||
|
|
97d7604e7c | ||
|
|
641cd800b2 | ||
|
|
f5ee1fe592 | ||
|
|
674a8d143e | ||
|
|
b189ca1b58 | ||
|
|
6e647021cd | ||
|
|
6d60d53910 | ||
|
|
f050c807a4 | ||
|
|
c15f6d435f | ||
|
|
42adff8545 | ||
|
|
a701ddde50 | ||
|
|
d14fc0c188 | ||
|
|
f455b433ec | ||
|
|
6e0e4a5454 | ||
|
|
5761fa77b1 | ||
|
|
fab8826123 | ||
|
|
c83a16545c | ||
|
|
f7cae037d5 |
@ -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">
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
12
app/canvas/loading.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>>;
|
||||||
};
|
};
|
||||||
@ -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);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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[
|
||||||
|
|||||||
@ -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
|
||||||
? "白底图会先按权重自动选入槽位,如需微调仍可手动调整。"
|
? "白底图会先按权重自动选入槽位,如需微调仍可手动调整。"
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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 && (
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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">
|
||||||
) {
|
) {
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user