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:
seaislee1209
2026-06-23 16:28:53 +08:00
co-authored by Claude Opus 4.8
parent 6916f9c877
commit f02c8053a1
4 changed files with 78 additions and 54 deletions
+3
View File
@@ -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>
+14
View File
@@ -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,