feat(product-create): upload-first 建商品 —— 图拖入即进桶,创建时带 asset 一块落库
- 抽屉拖入/选图 → 立刻 POST /api/assets/upload/ 传进 TOS 桶拿 asset id(每张带上传中/完成/失败态, 首图标「主图」=cover_asset);上传未完成禁用「创建商品」 - 点创建 → POST /api/products/ 直接带 cover_asset + images inline 落库,一步到位,不再「先建后传图」 (那条因传图接口需先有商品 ID,与「创建必须带图」校验天然冲突 → 建商品恒 400「商品创建失败」) - 同步读取 File:addImages 一进来就 Array.from 取文件,避免 pickImage 紧接的 input.value="" 清空 input.files 导致「第一次点没反应、第二次才上传」;选好即发起上传,不靠会滞后的 effect - 后端保留「创建必须带图」校验(upload-first 下可满足、且正确) 真实接口验:独立上传 201 → 带 cover+images 创建 201(落库)→ 无图创建 400;tsc/build/后端测试通过。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
6916f9c877
commit
f02c8053a1
@@ -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):
|
||||
|
||||
@@ -217,6 +217,9 @@ export const api = {
|
||||
description?: string;
|
||||
specs?: Record<string, unknown>;
|
||||
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<Product>("/api/products/", { method: "POST", body: JSON.stringify(payload) });
|
||||
},
|
||||
|
||||
@@ -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<Product | null | undefined> | void;
|
||||
/** @deprecated upload-first 后不再用:图在创建时已带 asset 进库,不再二次传图。保留以兼容调用方。 */
|
||||
onUploadImage?: (productId: string, formData: FormData) => Promise<unknown> | 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<HTMLInputElement | null>(null);
|
||||
const toastTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// 已建商品句柄:onCreate 成功但图片上传失败时暂存,重试只补传图不重复建商品
|
||||
const createdRef = useRef<Product | null>(null);
|
||||
// 始终指向最新 images:addImages 里同步算余量 + 发上传,不靠会滞后的 effect/闭包
|
||||
const imagesRef = useRef<PfImage[]>([]);
|
||||
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<HTMLInputElement>) {
|
||||
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
|
||||
</div>
|
||||
</div>
|
||||
<div className="pf-grid">
|
||||
{images.map((item) => (
|
||||
<div className="pf-thumb" key={item.id}>
|
||||
{images.map((item, idx) => (
|
||||
<div className={`pf-thumb pf-${item.status}`} key={item.id}>
|
||||
<img src={item.url} alt="商品主图预览" />
|
||||
{idx === 0 && <span className="pf-cover">主图</span>}
|
||||
{item.status === "uploading" && <span className="pf-state"><span className="spinner" aria-hidden="true" /></span>}
|
||||
{item.status === "error" && <span className="pf-state pf-state-err">上传失败</span>}
|
||||
<button className="pf-x" type="button" title="删除" aria-label="删除该图" onClick={() => removeImage(item.id)}>
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><path d="M4 4l8 8M12 4l-8 8" /></svg>
|
||||
</button>
|
||||
@@ -279,9 +286,9 @@ export function ProductCreateDrawer({ open, close, onCreate, onUploadImage, onCr
|
||||
使用指南
|
||||
</button>
|
||||
<button className="btn" type="button" onClick={close}>取消</button>
|
||||
<button className="btn btn-primary" type="button" disabled={saving} onClick={submit}>
|
||||
<button className="btn btn-primary" type="button" disabled={saving || images.some((im) => im.status === "uploading")} onClick={submit}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12l5 5L20 6" /></svg>
|
||||
{saving ? "创建中…" : "创建商品"}
|
||||
{saving ? "创建中…" : images.some((im) => im.status === "uploading") ? "图片上传中…" : "创建商品"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user