diff --git a/.gitignore b/.gitignore index 65b2764..bdd479f 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,9 @@ tmp/ *.swo coverage/ 屏幕截图 *.png + +# Ecommerce template manifests are runtime/API data, not source (see AGENTS.md rule 4) +ecommerce-template-manifest.local.json +ecommerce-template-manifest.local.md +ecommerce-template-manifest.oss.json +ecommerce-template-manifest.oss.md diff --git a/src/api/ecommerceTemplateClient.ts b/src/api/ecommerceTemplateClient.ts new file mode 100644 index 0000000..8ca87eb --- /dev/null +++ b/src/api/ecommerceTemplateClient.ts @@ -0,0 +1,58 @@ +import { serverRequest } from "./serverConnection"; + +export interface EcommerceTemplateAsset { + fileName?: string; + extension?: string; + sizeBytes?: number; + assetIndex?: number; + ossKey?: string; + url?: string; +} + +export interface EcommerceTemplatePreview { + fileName?: string; + extension?: string; + sizeBytes?: number; + ossKey?: string; + url?: string; +} + +export interface EcommerceTemplateManifestItem { + id: string; + category?: string; + categorySlug?: string; + templateName?: string; + templateSlug?: string; + preview?: EcommerceTemplatePreview; + prompt?: string; + assets?: EcommerceTemplateAsset[]; +} + +export interface EcommerceTemplateListResult { + version?: number; + ossPrefix?: string; + generatedAt?: string; + templates: EcommerceTemplateManifestItem[]; + total: number; +} + +export async function listEcommerceTemplates(category?: string): Promise { + const search = new URLSearchParams(); + if (category) search.set("category", category); + const suffix = search.toString(); + + const response = await serverRequest( + `ai/ecommerce/templates${suffix ? `?${suffix}` : ""}`, + { + method: "GET", + maxRetries: 1, + fallbackMessage: "Failed to load ecommerce templates", + }, + ); + + return { + ...response, + templates: Array.isArray(response.templates) ? response.templates : [], + total: Number.isFinite(response.total) ? response.total : Array.isArray(response.templates) ? response.templates.length : 0, + }; +} diff --git a/src/features/ecommerce/EcommercePage.tsx b/src/features/ecommerce/EcommercePage.tsx index 9390eec..b81e3bd 100644 --- a/src/features/ecommerce/EcommercePage.tsx +++ b/src/features/ecommerce/EcommercePage.tsx @@ -224,6 +224,7 @@ const buildInspirationPrompt = (title: string, meta: string): string => { }; import { aiGenerationClient } from "../../api/aiGenerationClient"; +import { listEcommerceTemplates, type EcommerceTemplateManifestItem } from "../../api/ecommerceTemplateClient"; import { ServerRequestError } from "../../api/serverConnection"; import { waitForTask } from "../../api/taskSubscription"; import { toast } from "../../components/toast/toastStore"; @@ -277,6 +278,13 @@ type CloneTemplateAsset = { title: string; prompt: string; mediaUrl: string; + mediaType?: "image" | "video"; + sourceAssets?: Array<{ + url: string; + name: string; + ossKey?: string; + mimeType?: string; + }>; }; interface CommerceScenarioTemplate extends CloneTemplateAsset { scenario: Exclude; @@ -416,6 +424,56 @@ const commerceScenarioOutputMap: Record, salesVideo: "video", }; +const ecommerceTemplateCategoryMap: Record> = { + poster: "poster", + "main-image": "mainImage", + "scene-image": "scene", + "festival-image": "festival", + "model-image": "model", + "background-replace": "background", + retouch: "retouch", + "sales-video": "salesVideo", +}; + +const getTemplateMediaType = (template: EcommerceTemplateManifestItem): "image" | "video" => { + const extension = template.preview?.extension?.toLowerCase() || template.preview?.url?.split("?")[0].split(".").pop()?.toLowerCase() || ""; + return extension.includes("mp4") || extension.includes("webm") || extension.includes("mov") ? "video" : "image"; +}; + +const mapRemoteTemplateToScenarioTemplate = (template: EcommerceTemplateManifestItem): CommerceScenarioTemplate | null => { + const scenario = ecommerceTemplateCategoryMap[String(template.categorySlug || "").trim()]; + const mediaUrl = template.preview?.url?.trim(); + if (!scenario || !template.id || !mediaUrl) return null; + + const title = template.templateName?.trim() || template.templateSlug?.trim() || template.id; + const prompt = template.prompt?.trim() || title; + const sourceAssets = (template.assets || []) + .filter((asset) => typeof asset.url === "string" && asset.url.trim()) + .map((asset, index) => { + const url = asset.url!.trim(); + const extension = asset.extension?.replace(/^\./, "") || url.split("?")[0].split(".").pop() || "png"; + return { + url, + name: asset.fileName?.trim() || `${title}-素材${asset.assetIndex || index + 1}.${extension}`, + ossKey: asset.ossKey, + mimeType: extension.toLowerCase() === "jpg" || extension.toLowerCase() === "jpeg" ? "image/jpeg" : "image/png", + }; + }); + + return { + id: template.id, + scenario, + output: commerceScenarioOutputMap[scenario], + title, + desc: template.category?.trim() || commerceScenarioOptions.find((option) => option.key === scenario)?.desc || "", + badge: template.category?.trim() || commerceScenarioOptions.find((option) => option.key === scenario)?.label || title, + prompt, + mediaUrl, + mediaType: getTemplateMediaType(template), + sourceAssets, + }; +}; + const defaultCommerceIntentFallback: CommerceDefaultIntent = { kind: "image", scenario: "mainImage" }; const normalizeDefaultCommerceIntent = (value: unknown): CommerceDefaultIntent => { @@ -795,10 +853,6 @@ const commerceScenarioTemplates: CommerceScenarioTemplate[] = [ mediaUrl: ossAssets.ecommerce.inspiration.nightLightUnboxingDouyin, }, ]; -const popularCommerceScenarioTemplates = commerceScenarioOptions - .filter((option): option is { key: Exclude; label: string; desc: string; icon: ReactNode } => option.key !== "popular") - .map((option) => commerceScenarioTemplates.find((template) => template.scenario === option.key)) - .filter((template): template is CommerceScenarioTemplate => Boolean(template)); const cloneSetCountOptions: Array<{ key: CloneSetCountKey; title: string; @@ -1181,6 +1235,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const [isProductUploadDragging, setIsProductUploadDragging] = useState(false); const [activeCommerceScenario, setActiveCommerceScenario] = useState(null); const [isCommerceScenarioMoreOpen, setIsCommerceScenarioMoreOpen] = useState(false); + const [remoteCommerceScenarioTemplates, setRemoteCommerceScenarioTemplates] = useState(null); const [cloneOutput, setCloneOutput] = useState(defaultCloneOutput); const [isCloneTemplateStripVisible, setIsCloneTemplateStripVisible] = useState(false); const [videoHistoryVisible, setVideoHistoryVisible] = useState(false); @@ -1675,6 +1730,24 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const [hotStatus, setHotStatus] = useState("idle"); const [hotResultUrl, setHotResultUrl] = useState(null); const [hotProgress, setHotProgress] = useState(0); + useEffect(() => { + let cancelled = false; + listEcommerceTemplates() + .then((response) => { + if (cancelled) return; + const templates = response.templates + .map(mapRemoteTemplateToScenarioTemplate) + .filter((template): template is CommerceScenarioTemplate => Boolean(template)); + setRemoteCommerceScenarioTemplates(templates.length ? templates : null); + }) + .catch(() => { + if (!cancelled) setRemoteCommerceScenarioTemplates(null); + }); + + return () => { + cancelled = true; + }; + }, []); const productSetRatioOptions = useMemo( () => getPlatformRatioOptions(productSetPlatform, productSetOutput), [productSetOutput, productSetPlatform], @@ -1736,11 +1809,22 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { : commerceScenarioOptions.filter((option) => primaryCommerceScenarioKeys.includes(option.key)), [isCommerceScenarioMoreOpen], ); + const effectiveCommerceScenarioTemplates = remoteCommerceScenarioTemplates?.length + ? remoteCommerceScenarioTemplates + : commerceScenarioTemplates; + const popularCommerceScenarioTemplates = useMemo( + () => + commerceScenarioOptions + .filter((option): option is { key: Exclude; label: string; desc: string; icon: ReactNode } => option.key !== "popular") + .map((option) => effectiveCommerceScenarioTemplates.find((template) => template.scenario === option.key)) + .filter((template): template is CommerceScenarioTemplate => Boolean(template)), + [effectiveCommerceScenarioTemplates], + ); const activeCommerceScenarioTemplates = activeCommerceScenario === null ? [] : activeCommerceScenario === "popular" ? popularCommerceScenarioTemplates - : commerceScenarioTemplates.filter((template) => template.scenario === activeCommerceScenario); + : effectiveCommerceScenarioTemplates.filter((template) => template.scenario === activeCommerceScenario); const shouldShowScenarioSettings = activeCommerceScenario !== null && scenarioSettingsKeys.includes(activeCommerceScenario); useEffect(() => { templateStripRef.current?.scrollTo({ left: 0, behavior: "auto" }); @@ -5509,7 +5593,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { } else if (composerAssetTab === "recipe") { content = (
- {commerceScenarioTemplates.slice(0, 4).map((template) => ( + {effectiveCommerceScenarioTemplates.slice(0, 4).map((template) => (
-
{productImages.length ? (
@@ -6435,13 +6552,20 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { }} > {card.badge} {card.title} {card.desc} + ))} diff --git a/src/styles/ecommerce-standalone.css b/src/styles/ecommerce-standalone.css index 9a3e49c..dbbdae7 100644 --- a/src/styles/ecommerce-standalone.css +++ b/src/styles/ecommerce-standalone.css @@ -18702,6 +18702,144 @@ html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[d } } +/* Keep template cards fully readable inside narrow command workspaces. */ +html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"]:not(.is-history-detail) .ecom-command-template-carousel .ecom-command-template-card { + position: relative !important; + flex: 0 0 min(100%, clamp(252px, 24vw, 328px)) !important; + grid-template-columns: 1fr !important; + grid-template-rows: auto minmax(0, 1fr) !important; + gap: 8px !important; + box-sizing: border-box !important; + overflow: hidden !important; +} + +html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"]:not(.is-history-detail) .ecom-command-template-carousel .ecom-command-template-card__media { + width: 100% !important; + min-width: 0 !important; + height: auto !important; + aspect-ratio: 16 / 9 !important; +} + +html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"]:not(.is-history-detail) .ecom-command-template-card__media video { + display: block !important; + width: 100% !important; + height: 100% !important; + object-fit: contain !important; +} + +html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"]:not(.is-history-detail) .ecom-command-template-carousel .ecom-command-template-card__media img, +html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"]:not(.is-history-detail) .ecom-command-template-carousel .ecom-command-template-card__media video { + object-fit: contain !important; +} + +html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"]:not(.is-history-detail) .ecom-command-template-carousel .ecom-command-template-card:hover .ecom-command-template-card__media img { + transform: none !important; +} + +html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"]:not(.is-history-detail) .ecom-command-template-card__body strong { + display: -webkit-box !important; + white-space: normal !important; + overflow-wrap: anywhere !important; + -webkit-line-clamp: 2 !important; + -webkit-box-orient: vertical !important; +} + +html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"]:not(.is-history-detail) .ecom-command-template-card__prompt { + position: absolute !important; + right: 10px !important; + left: 10px !important; + top: 10px !important; + z-index: 3 !important; + display: -webkit-box !important; + max-height: 86px !important; + padding: 2px 4px !important; + overflow: hidden !important; + border: 0 !important; + border-radius: 0 !important; + background: transparent !important; + box-shadow: none !important; + color: rgba(16, 32, 44, 0.72) !important; + font-size: 12px !important; + font-weight: 650 !important; + line-height: 1.45 !important; + text-align: center !important; + text-shadow: 0 1px 2px rgba(255, 255, 255, 0.86) !important; + opacity: 0 !important; + pointer-events: none !important; + transform: translateY(-12px) scale(0.98) !important; + transition: + opacity 180ms ease, + transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), + box-shadow 220ms ease !important; + -webkit-box-orient: vertical !important; + -webkit-line-clamp: 4 !important; +} + +html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"]:not(.is-history-detail) .ecom-command-template-card:hover .ecom-command-template-card__prompt, +html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"]:not(.is-history-detail) .ecom-command-template-card:focus-visible .ecom-command-template-card__prompt { + opacity: 1 !important; + transform: translateY(0) scale(1) !important; +} + +@media (max-width: 640px) { + html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"]:not(.is-history-detail) .ecom-command-template-carousel .ecom-command-template-card { + flex-basis: min(100%, 300px) !important; + grid-template-columns: 1fr !important; + } +} + +/* Apply the same 16:9 preview treatment to the generated/history compact template rail. */ +html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-template-carousel .ecom-command-template-card { + aspect-ratio: 16 / 9 !important; +} + +html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-template-carousel .ecom-command-template-card__media { + position: absolute !important; + inset: 0 !important; + width: 100% !important; + min-width: 0 !important; + height: 100% !important; + aspect-ratio: 16 / 9 !important; + border: 0 !important; + border-radius: inherit !important; +} + +html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-template-carousel .ecom-command-template-card__media img, +html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-template-carousel .ecom-command-template-card__media video { + width: 100% !important; + height: 100% !important; + object-fit: contain !important; +} + +html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-template-carousel .ecom-command-template-card__body { + position: absolute !important; + right: 0 !important; + bottom: 0 !important; + left: 0 !important; + z-index: 2 !important; + display: grid !important; + gap: 2px !important; + padding: 18px 8px 8px !important; + background: linear-gradient(180deg, rgba(255, 255, 255, 0), rgba(246, 252, 254, 0.72)) !important; + text-align: center !important; +} + +html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-template-carousel .ecom-command-template-card__badge, +html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-template-carousel .ecom-command-template-card__body em { + display: none !important; +} + +html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-template-carousel .ecom-command-template-card__body strong { + display: block !important; + overflow: hidden !important; + color: rgba(85, 111, 126, 0.74) !important; + font-size: 11px !important; + font-weight: 760 !important; + line-height: 1.2 !important; + text-overflow: ellipsis !important; + white-space: nowrap !important; +} + /* Restore the colorful scenario feedback while keeping the compact responsive layout. */ html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"]:not(.is-history-detail) .ecom-command-scenario-shell .ecom-command-scenario-tabs button:has(.ecom-command-mode-icon--popular) { --mode-accent: #c04468 !important;