diff --git a/core/backend/apps/products/serializers.py b/core/backend/apps/products/serializers.py index f3d068f..f0d237f 100644 --- a/core/backend/apps/products/serializers.py +++ b/core/backend/apps/products/serializers.py @@ -61,11 +61,11 @@ class ProductSerializer(serializers.ModelSerializer): return _asset_preview_url(obj.cover_asset) def validate(self, attrs): - # 创建商品必须至少有一张图:无图商品会让下游"商品参考图"彻底落空(主图兜底也没得兜), - # 导致故事板 / 视频退化纯文生图。前端抽屉已校验,这里在后端堵死 API / 向导路径。 - if self.instance is None: # 仅创建时强制(编辑已有商品不阻断) - if not attrs.get("cover_asset") and not attrs.get("images"): - raise serializers.ValidationError({"images": "创建商品至少需要上传一张商品图片"}) + # 创建商品必须至少有一张图:无图商品会让下游"商品参考图"彻底落空,故事板/视频退化纯文生图。 + # 前端抽屉走 upload-first(拖入即传 /assets/upload/ 拿 asset → 创建时带 cover_asset/images 进来), + # 所以这道校验是可满足的、且正确。只在创建时强制,编辑已有商品不阻断。 + if self.instance is None and not attrs.get("cover_asset") and not attrs.get("images"): + raise serializers.ValidationError({"images": "创建商品至少需要上传一张商品图片"}) return attrs def create(self, validated_data): diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index ad98678..4705858 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -217,6 +217,9 @@ export const api = { description?: string; specs?: Record; selling_points?: Array<{ title: string; detail: string; sort_order: number }>; + // upload-first:创建时带上已传进桶的图(cover_asset = 主图 asset id;images = 全部图) + cover_asset?: string; + images?: Array<{ asset: string; sort_order: number; is_primary?: boolean }>; }) { return request("/api/products/", { method: "POST", body: JSON.stringify(payload) }); }, diff --git a/core/frontend/src/components/product-create-drawer.tsx b/core/frontend/src/components/product-create-drawer.tsx index 4cc90e0..8bfd4f5 100644 --- a/core/frontend/src/components/product-create-drawer.tsx +++ b/core/frontend/src/components/product-create-drawer.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from "react"; import type { ChangeEvent, DragEvent } from "react"; import { createPortal } from "react-dom"; +import { api } from "../api"; import { useBodyScrollLock } from "./overlays"; import type { Product } from "../types"; import "../product-create-page.css"; @@ -15,20 +16,24 @@ export type ProductCreatePayload = { category: string; target_audience?: string; selling_points?: Array<{ title: string; detail: string; sort_order: number }>; + // upload-first:图先进桶,创建时带 asset id 一块进来(cover_asset=主图,images=全部) + cover_asset?: string; + images?: Array<{ asset: string; sort_order: number; is_primary?: boolean }>; }; -// 抽屉内主图条目:保留原始 File 逐张随商品上传,url 仅作缩略预览 -type PfImage = { id: string; file: File; url: string }; +// 抽屉内主图条目:拖入即传 /assets/upload/ 进桶,assetId 是返回的素材 id;url 仅作本地缩略预览 +type PfImage = { id: string; file: File; url: string; assetId?: string; status: "uploading" | "done" | "error" }; const pfUid = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 5); // 新建商品抽屉 · 商品库与新建项目向导共用同一实现,保证两处「新建商品」是同一流程。 // onCreate 落库(商品库),onUploadImage 把主图随商品持久化,onCreated 让调用方接管成功后行为 // (商品库弹「继续创建/去新建项目」,向导自动选中新商品)。 -export function ProductCreateDrawer({ open, close, onCreate, onUploadImage, onCreated, catOptions = PC_CAT_OPTIONS }: { +export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptions = PC_CAT_OPTIONS }: { open: boolean; close: () => void; onCreate: (payload: ProductCreatePayload) => Promise | void; + /** @deprecated upload-first 后不再用:图在创建时已带 asset 进库,不再二次传图。保留以兼容调用方。 */ onUploadImage?: (productId: string, formData: FormData) => Promise | void; onCreated?: (product: Product) => void; catOptions?: string[]; @@ -48,8 +53,9 @@ export function ProductCreateDrawer({ open, close, onCreate, onUploadImage, onCr const [toast, setToast] = useState<{ title: string; sub: string } | null>(null); const imgInputRef = useRef(null); const toastTimer = useRef | null>(null); - // 已建商品句柄:onCreate 成功但图片上传失败时暂存,重试只补传图不重复建商品 - const createdRef = useRef(null); + // 始终指向最新 images:addImages 里同步算余量 + 发上传,不靠会滞后的 effect/闭包 + const imagesRef = useRef([]); + imagesRef.current = images; function flash(title: string, sub: string) { if (toastTimer.current) clearTimeout(toastTimer.current); @@ -70,23 +76,37 @@ export function ProductCreateDrawer({ open, close, onCreate, onUploadImage, onCr setImages((list) => { list.forEach((item) => URL.revokeObjectURL(item.url)); return []; }); setDragOver(false); setTitleError(false); - createdRef.current = null; } // 每次打开抽屉时复位表单(含商品库「继续创建商品」二次打开) useEffect(() => { if (open) resetForm(); }, [open]); - // 多图收编:超限/计数 toast 对齐 V1 pfAdd() + // upload-first:任一图处于「uploading」且未在途 → 立刻传进桶(/assets/upload/),拿到 assetId 标 done + async function uploadOne(entry: PfImage) { + try { + const fd = new FormData(); + fd.append("file", entry.file); + fd.append("asset_type", "image"); + fd.append("category", "product_image"); + fd.append("name", entry.file.name); + const asset = await api.uploadAsset(fd); + setImages((list) => list.map((it) => (it.id === entry.id ? { ...it, assetId: asset.id, status: "done" } : it))); + } catch { + setImages((list) => list.map((it) => (it.id === entry.id ? { ...it, status: "error" } : it))); + } + } + + // 多图收编:超限/计数 toast 对齐 V1 pfAdd()。★ 必须同步读出 File(Array.from 立刻取), + // 否则 pickImage 紧跟着的 input.value="" 会清空 input.files,延迟读就成空 → 第一次点没反应。 function addImages(fileList: FileList | File[] | null) { if (!fileList) return; - setImages((list) => { - const room = PF_MAX - list.length; - if (room <= 0) { flash("已达上限", `${PF_MAX} / ${PF_MAX} 张`); return list; } - const incoming = Array.from(fileList).filter((file) => file.type.startsWith("image/")).slice(0, room); - if (incoming.length === 0) return list; - const next = [...list, ...incoming.map((file) => ({ id: pfUid(), file, url: URL.createObjectURL(file) }))]; - flash("已上传", `+ ${incoming.length} 张 · 共 ${next.length} / ${PF_MAX}`); - return next; - }); + const picked = Array.from(fileList).filter((file) => file.type.startsWith("image/")); + if (picked.length === 0) return; + const room = PF_MAX - imagesRef.current.length; + if (room <= 0) { flash("已达上限", `${PF_MAX} / ${PF_MAX} 张`); return; } + const incoming = picked.slice(0, room).map((file) => ({ id: pfUid(), file, url: URL.createObjectURL(file), status: "uploading" as const })); + setImages((list) => [...list, ...incoming]); + flash("上传中", `+ ${incoming.length} 张 · 共 ${imagesRef.current.length + incoming.length} / ${PF_MAX}`); + incoming.forEach((entry) => void uploadOne(entry)); // 立即发起上传,不靠 effect 时序 } function pickImage(event: ChangeEvent) { addImages(event.target.files); @@ -127,6 +147,9 @@ export function ProductCreateDrawer({ open, close, onCreate, onUploadImage, onCr const name = title.trim(); if (!name) { setTitleError(true); flash("请填写商品名称", "必填项"); return; } // 空名:必填校验,不静默无反应 if (images.length === 0) { flash("请上传商品主图", "至少 1 张 · 必填"); return; } + // upload-first:图必须先全部传完进桶(拿到 assetId)才能带进创建 + if (images.some((im) => im.status === "uploading")) { flash("图片上传中", "请等图片传完再创建"); return; } + if (images.some((im) => im.status === "error" || !im.assetId)) { flash("有图片未传成功", "请移除失败的图后重试"); return; } // 提交时自动收编未回车的 bulletDraft,避免用户输入了卖点却被丢弃 const pending = bulletDraft.trim(); const finalBullets = pending ? [...bullets, pending] : bullets; @@ -134,35 +157,16 @@ export function ProductCreateDrawer({ open, close, onCreate, onUploadImage, onCr setSaving(true); try { - const files = images.map((item) => item.file); - // 若上次已建好商品(仅图片上传失败),本次重试只补传图,不重复建商品 - let created = createdRef.current; - if (!created) { - created = (await onCreate({ - title: name, - category: category || catOptions[0], - target_audience: target.trim() || undefined, - selling_points: finalBullets.map((item, index) => ({ title: item, detail: item, sort_order: index })) - })) || null; - if (!created) { flash("商品创建失败", "请稍后重试"); return; } // 不静默:建库失败也给提示 - createdRef.current = created; - } - // 主图随商品上传持久化:等全部传完再关抽屉(原先 close 在上传前 + 无 catch → 图悄悄传失败,商品却已建) - if (files.length && onUploadImage) { - try { - await Promise.all(files.map((file, i) => { - const fd = new FormData(); - fd.append("file", file); - fd.append("name", `${name}-主图${files.length > 1 ? `-${i + 1}` : ""}`); - return onUploadImage(created!.id, fd); - })); - } catch { - // 不关抽屉、不重复建商品:留着让用户点「创建商品」重试上传(网络恢复后即成) - flash("图片上传失败", "已保留 · 点「创建商品」可重试上传"); - return; - } - } - createdRef.current = null; + // 图已在桶里,创建时直接带上 cover_asset(主图)+ images(全部),一步落库、不再二次传图 + const created = (await onCreate({ + title: name, + category: category || catOptions[0], + target_audience: target.trim() || undefined, + selling_points: finalBullets.map((item, index) => ({ title: item, detail: item, sort_order: index })), + cover_asset: images[0].assetId, + images: images.map((im, i) => ({ asset: im.assetId as string, sort_order: i, is_primary: i === 0 })) + })) || null; + if (!created) { flash("商品创建失败", "请稍后重试"); return; } // 不静默:建库失败也给提示 close(); onCreated?.(created); } finally { @@ -233,9 +237,12 @@ export function ProductCreateDrawer({ open, close, onCreate, onUploadImage, onCr
- {images.map((item) => ( -
+ {images.map((item, idx) => ( +
商品主图预览 + {idx === 0 && 主图} + {item.status === "uploading" && } + {item.status === "error" && 上传失败} @@ -279,9 +286,9 @@ export function ProductCreateDrawer({ open, close, onCreate, onUploadImage, onCr 使用指南 -
diff --git a/core/frontend/src/product-create-page.css b/core/frontend/src/product-create-page.css index fb78e48..fbd0315 100644 --- a/core/frontend/src/product-create-page.css +++ b/core/frontend/src/product-create-page.css @@ -461,6 +461,20 @@ .pc-drawer .form-card .pf-thumb:hover .pf-x, .pc-drawer .form-card .pf-thumb:focus-within .pf-x { opacity: 1; } .pc-drawer .form-card .pf-thumb .pf-x svg { width: 11px; height: 11px; } +/* upload-first 状态:上传中遮罩转圈 / 失败红底提示 / 主图角标(第一张=cover_asset) */ +.pc-drawer .form-card .pf-thumb .pf-state { + position: absolute; inset: 0; display: grid; place-items: center; + background: color-mix(in srgb, var(--surface) 58%, transparent); + font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-56); +} +.pc-drawer .form-card .pf-thumb .pf-state-err { background: var(--crimson-bg); color: var(--accent-crimson); font-weight: 600; } +.pc-drawer .form-card .pf-thumb.pf-error { border-color: var(--crimson-bd); } +.pc-drawer .form-card .pf-thumb .pf-cover { + position: absolute; left: 4px; bottom: 4px; z-index: 1; + background: var(--heat); color: var(--accent-white); + font-family: var(--font-mono); font-size: 10px; line-height: 1; + padding: 3px 6px; border-radius: var(--r-sm); +} /* ─── 校验态 · 必填红字 / 输入框红框(原内联 style 抽成类) ─── */ .pc-drawer .form-card .input.is-error,