refactor(core): 抽出共享 ProductCreateDrawer · 商品/项目新建复用同一流程
- 新增 components/product-create-drawer.tsx,products/projects 改用同一右侧 Drawer 组件(products -188 / projects -116) - ai-tools 配套调整;附 perf-report 产物 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -311,6 +311,8 @@
|
||||
width: 36px; height: 36px;
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
.image-workbench .iw-prod-item .thumb.has-real-media { overflow: hidden; background: var(--background-lighter); }
|
||||
.image-workbench .iw-prod-item .thumb.has-real-media img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.image-workbench .iw-prod-item .body { flex: 1; min-width: 0; }
|
||||
.image-workbench .iw-prod-item .nm {
|
||||
font-size: 13px; color: var(--accent-black); font-weight: 500;
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ChangeEvent } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useBodyScrollLock } from "./overlays";
|
||||
import type { Product } from "../types";
|
||||
import "../product-create-page.css";
|
||||
|
||||
export const PC_CAT_OPTIONS = ["美妆个护", "服饰内衣", "食品饮料", "家居家电", "数码 3C", "个护清洁", "运动户外", "母婴亲子"];
|
||||
|
||||
export type ProductCreatePayload = {
|
||||
title: string;
|
||||
category: string;
|
||||
target_audience?: string;
|
||||
selling_points?: Array<{ title: string; detail: string; sort_order: number }>;
|
||||
};
|
||||
|
||||
// 新建商品抽屉 · 商品库与新建项目向导共用同一实现,保证两处「新建商品」是同一流程。
|
||||
// onCreate 落库(商品库),onUploadImage 把主图随商品持久化,onCreated 让调用方接管成功后行为
|
||||
// (商品库弹「继续创建/去新建项目」,向导自动选中新商品)。
|
||||
export function ProductCreateDrawer({ open, close, onCreate, onUploadImage, onCreated, catOptions = PC_CAT_OPTIONS }: {
|
||||
open: boolean;
|
||||
close: () => void;
|
||||
onCreate: (payload: ProductCreatePayload) => Promise<Product | null | undefined> | void;
|
||||
onUploadImage?: (productId: string, formData: FormData) => Promise<unknown> | void;
|
||||
onCreated?: (product: Product) => void;
|
||||
catOptions?: string[];
|
||||
}) {
|
||||
useBodyScrollLock(open);
|
||||
const [title, setTitle] = useState("");
|
||||
const [category, setCategory] = useState("");
|
||||
const [target, setTarget] = useState("");
|
||||
const [bullets, setBullets] = useState<string[]>([]);
|
||||
const [bulletDraft, setBulletDraft] = useState("");
|
||||
const [imagePreview, setImagePreview] = useState("");
|
||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||
const [titleError, setTitleError] = useState(false);
|
||||
const [showGuide, setShowGuide] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const imgInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
function resetForm() {
|
||||
setTitle("");
|
||||
setCategory("");
|
||||
setTarget("");
|
||||
setBullets([]);
|
||||
setBulletDraft("");
|
||||
setImagePreview("");
|
||||
setImageFile(null);
|
||||
setTitleError(false);
|
||||
}
|
||||
// 每次打开抽屉时复位表单(含商品库「继续创建商品」二次打开)
|
||||
useEffect(() => { if (open) resetForm(); }, [open]);
|
||||
|
||||
function pickImage(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) { setImagePreview(URL.createObjectURL(file)); setImageFile(file); }
|
||||
event.target.value = "";
|
||||
}
|
||||
function addBullet() {
|
||||
const value = bulletDraft.trim();
|
||||
if (!value) return;
|
||||
setBullets((list) => [...list, value]);
|
||||
setBulletDraft("");
|
||||
}
|
||||
function removeBullet(index: number) {
|
||||
setBullets((list) => list.filter((_, position) => position !== index));
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const name = title.trim();
|
||||
if (!name) { setTitleError(true); return; } // 空名:必填校验,不静默无反应
|
||||
setSaving(true);
|
||||
try {
|
||||
const file = imageFile;
|
||||
const created = await onCreate({
|
||||
title: name,
|
||||
category: category || catOptions[0],
|
||||
target_audience: target.trim() || undefined,
|
||||
selling_points: bullets.map((item, index) => ({ title: item, detail: item, sort_order: index }))
|
||||
});
|
||||
close();
|
||||
if (!created) return;
|
||||
// 把抽屉里选的主图随商品一起上传持久化(否则卡片/详情看不到图)
|
||||
if (file && onUploadImage) {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
fd.append("name", `${name}-主图`);
|
||||
await onUploadImage(created.id, fd);
|
||||
}
|
||||
onCreated?.(created);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<div className={`drawer-bg${open ? " show" : ""}`} onClick={close} />
|
||||
<aside className={`drawer pc-drawer${open ? " show" : ""}`} role="dialog" aria-label="新建商品" aria-hidden={!open}>
|
||||
<div className="drawer-h">
|
||||
<h3>新建商品</h3>
|
||||
<button className="x" type="button" onClick={close} aria-label="关闭">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M6 18L18 6" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="drawer-b">
|
||||
<div className="form-card">
|
||||
<div className="field">
|
||||
<label className="field-label">商品名称<span className="req">*</span></label>
|
||||
<input className="input" value={title} onChange={(event) => { setTitle(event.target.value); if (titleError) setTitleError(false); }} placeholder="请输入商品名称(必填)" maxLength={100} aria-invalid={titleError} style={titleError ? { borderColor: "var(--accent-crimson, #c43d3d)" } : undefined} />
|
||||
{titleError && <div style={{ color: "var(--accent-crimson, #c43d3d)", fontSize: 12, marginTop: 4 }}>请先填写商品名称</div>}
|
||||
</div>
|
||||
|
||||
<div className="field-row">
|
||||
<div>
|
||||
<label className="field-label">品类<span className="req">*</span></label>
|
||||
<select className="select" value={category} onChange={(event) => setCategory(event.target.value)}>
|
||||
{catOptions.map((option) => <option key={option}>{option}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">目标人群<span className="opt">(选填)</span></label>
|
||||
<input className="input" value={target} onChange={(event) => setTarget(event.target.value)} placeholder="例: 22-32 岁女性、敏感肌、办公室通勤" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="field-label">商品主图<span className="req">*</span></label>
|
||||
<div className="pf-upload-row">
|
||||
<div className="pf-upload-zone" role="button" tabIndex={0} onClick={() => imgInputRef.current?.click()} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); imgInputRef.current?.click(); } }}>
|
||||
<input ref={imgInputRef} type="file" accept="image/*" hidden onChange={pickImage} />
|
||||
{imagePreview ? (
|
||||
<img src={imagePreview} alt="商品主图预览" style={{ maxWidth: "100%", maxHeight: 120, borderRadius: 8, objectFit: "cover" }} />
|
||||
) : (
|
||||
<>
|
||||
<div className="uz-ic">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12" /></svg>
|
||||
</div>
|
||||
<div className="uz-t">点击上传或<strong>拖拽图片</strong>到此处</div>
|
||||
<div className="uz-d">// 支持 JPG、PNG 格式,建议尺寸 800×800 以上,大小不超过 10MB</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="pf-example">
|
||||
<div className="ex-h">示例图</div>
|
||||
<div className="ex-grid">
|
||||
<div className="ex-thumb ex-thumb--img"><img src="/exact/assets/mock/product-earbuds.png" alt="示例:蓝牙耳机" loading="lazy" /></div>
|
||||
<div className="ex-thumb ex-thumb--img"><img src="/exact/assets/mock/product-mask.png" alt="示例:面膜" loading="lazy" /></div>
|
||||
<div className="ex-thumb ex-thumb--img"><img src="/exact/assets/mock/product-air-fryer.png" alt="示例:空气炸锅" loading="lazy" /></div>
|
||||
</div>
|
||||
<div className="ex-d">优质的商品图有助于生成更好的素材效果</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pf-grid" />
|
||||
</div>
|
||||
|
||||
<div className="field" style={{ marginBottom: 0 }}>
|
||||
<label className="field-label">核心卖点<span className="req">*</span></label>
|
||||
<ul className="bullet-list">
|
||||
{bullets.map((bullet, index) => (
|
||||
<li className="bl-item" key={`${bullet}-${index}`}>
|
||||
<span className="num">{index + 1}</span>
|
||||
<span className="bl-text">{bullet}</span>
|
||||
<button className="bl-x" type="button" onClick={() => removeBullet(index)} aria-label="删除卖点">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M6 18L18 6" /></svg>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
<li className="bl-add">
|
||||
<span className="num">+</span>
|
||||
<input className="bl-input" value={bulletDraft} onChange={(event) => setBulletDraft(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); addBullet(); } }} placeholder="添加新卖点 · 回车确认" />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showGuide && (
|
||||
<div className="pc-guide-note" style={{ padding: "10px 14px", margin: "0 16px 8px", background: "var(--black-alpha-4)", borderRadius: 8, fontSize: 13, lineHeight: 1.7, color: "var(--black-alpha-72)" }}>
|
||||
<strong>// 建好商品的 3 步</strong><br />
|
||||
① 填写商品名称 + 品类(必填,用于脚本/素材生成)<br />
|
||||
② 上传清晰主图(800×800 以上),便于 AI 出图更准<br />
|
||||
③ 写 2–4 条核心卖点,越具体生成效果越好
|
||||
</div>
|
||||
)}
|
||||
<div className="drawer-f">
|
||||
<button className="btn-guide" type="button" aria-expanded={showGuide} onClick={() => setShowGuide((v) => !v)}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><path d="M9.5 9a2.5 2.5 0 015 0c0 1.5-2.5 2-2.5 4M12 17h.01" /></svg>
|
||||
使用指南
|
||||
</button>
|
||||
<button className="btn" type="button" onClick={close}>取消</button>
|
||||
<button className="btn btn-primary" type="button" disabled={saving} 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 ? "创建中…" : "创建商品"}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -523,6 +523,19 @@ export function ImageWorkbenchPage({
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
// 模特网格默认只露 6 个;「全部模特 →」展开后露全部,再点收起(超过 6 个时第 7 个之后本来选不到)
|
||||
const [showAllModels, setShowAllModels] = useState(false);
|
||||
// 左侧商品空间搜索(原 input 无 state/无过滤=死框,输入无效);按名称/分类过滤
|
||||
const [prodQuery, setProdQuery] = useState("");
|
||||
const visibleProducts = products.filter((item) => {
|
||||
const q = prodQuery.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
return `${item.title} ${item.category || ""}`.toLowerCase().includes(q);
|
||||
});
|
||||
// 商品主图:用后端内嵌的 preview_url(cover_preview_url / images[].preview_url),不再反查全局 assets
|
||||
const productCoverUrl = (p: Product): string => {
|
||||
const sorted = [...(p.images || [])].sort((a, b) => a.sort_order - b.sort_order);
|
||||
const primary = p.images?.find((img) => img.is_primary) || sorted[0];
|
||||
return p.cover_preview_url || primary?.preview_url || "";
|
||||
};
|
||||
function pickReference(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
@@ -1052,7 +1065,7 @@ export function ImageWorkbenchPage({
|
||||
</div>
|
||||
<div className="iw-ps-search">
|
||||
<Search size={13} />
|
||||
<input placeholder="搜索商品 / 分类" />
|
||||
<input placeholder="搜索商品 / 分类" value={prodQuery} onChange={(event) => setProdQuery(event.target.value)} />
|
||||
</div>
|
||||
<div className="iw-list-h">
|
||||
<span className="mono">// 商品空间</span>
|
||||
@@ -1070,23 +1083,36 @@ export function ImageWorkbenchPage({
|
||||
<br />
|
||||
// NO PRODUCTS
|
||||
</div>
|
||||
) : visibleProducts.length === 0 ? (
|
||||
<div className="iw-ps-empty">
|
||||
没有匹配的商品
|
||||
<br />
|
||||
// NO MATCH
|
||||
</div>
|
||||
) : (
|
||||
products.slice(0, 12).map((item) => (
|
||||
<button
|
||||
className={`iw-prod-item ${productId === item.id ? "active" : ""}`}
|
||||
type="button"
|
||||
key={item.id}
|
||||
onClick={() => setProductId(item.id)}
|
||||
>
|
||||
<div className="placeholder thumb">
|
||||
<span className="ph-frame">{item.title.slice(0, 2)}</span>
|
||||
</div>
|
||||
<div className="body">
|
||||
<div className="nm">{item.title}</div>
|
||||
<div className="sub">// {item.category || "未分类"}</div>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
visibleProducts.slice(0, 12).map((item) => {
|
||||
const cover = productCoverUrl(item);
|
||||
return (
|
||||
<button
|
||||
className={`iw-prod-item ${productId === item.id ? "active" : ""}`}
|
||||
type="button"
|
||||
key={item.id}
|
||||
onClick={() => setProductId(item.id)}
|
||||
>
|
||||
{cover ? (
|
||||
<div className="thumb has-real-media"><img src={cover} alt={item.title} loading="lazy" /></div>
|
||||
) : (
|
||||
<div className="placeholder thumb">
|
||||
<span className="ph-frame">{item.title.slice(0, 2)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="body">
|
||||
<div className="nm">{item.title}</div>
|
||||
<div className="sub">// {item.category || "未分类"}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ChangeEvent, CSSProperties, FormEvent, KeyboardEvent } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { ArrowLeft, Trash2, X } from "lucide-react";
|
||||
import { ConfirmModal, MediaLightbox, SuccessModal, useBodyScrollLock } from "../components/overlays";
|
||||
import { ConfirmModal, MediaLightbox, SuccessModal } from "../components/overlays";
|
||||
import { ProductCreateDrawer, PC_CAT_OPTIONS } from "../components/product-create-drawer";
|
||||
import { Pager } from "../components/pager";
|
||||
import { api } from "../api";
|
||||
|
||||
@@ -12,7 +12,6 @@ import type { NavigateFn, Page } from "./route-config";
|
||||
import "../product-create-page.css";
|
||||
|
||||
const PC_PHOTO_SLOTS = ["主图", "细节 02", "细节 03", "细节 04", "细节 05"];
|
||||
const PC_CAT_OPTIONS = ["美妆个护", "服饰内衣", "食品饮料", "家居家电", "数码 3C", "个护清洁", "运动户外", "母婴亲子"];
|
||||
|
||||
type ProductPayload = {
|
||||
title?: string;
|
||||
@@ -89,25 +88,8 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o
|
||||
}
|
||||
};
|
||||
const [drawer, setDrawer] = useState(Boolean(autoOpenCreate));
|
||||
useBodyScrollLock(drawer);
|
||||
// 创建成功弹窗:存刚创建的商品名,非空即展示「继续创建 / 去新建项目」选择
|
||||
const [createdName, setCreatedName] = useState<string | null>(null);
|
||||
const [title, setTitle] = useState("");
|
||||
const [category, setCategory] = useState("");
|
||||
const [target, setTarget] = useState("");
|
||||
const [bullets, setBullets] = useState<string[]>([]);
|
||||
const [bulletDraft, setBulletDraft] = useState("");
|
||||
// 新建抽屉:主图选择(选图即预览,创建成功后随商品一起上传持久化)+ 使用指南面板
|
||||
const [imagePreview, setImagePreview] = useState<string>("");
|
||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||
const imgInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [showGuide, setShowGuide] = useState(false);
|
||||
const [titleError, setTitleError] = useState(false);
|
||||
function pickImage(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) { setImagePreview(URL.createObjectURL(file)); setImageFile(file); }
|
||||
event.target.value = "";
|
||||
}
|
||||
const [openChip, setOpenChip] = useState<"" | "cat" | "date">("");
|
||||
const [catFilter, setCatFilter] = useState("");
|
||||
const [dateFilter, setDateFilter] = useState<"all" | "7" | "30" | "90">("all");
|
||||
@@ -155,52 +137,6 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o
|
||||
const curPage = Math.min(page, totalPages);
|
||||
const pageItems = filtered.slice((curPage - 1) * PROD_PAGE_SIZE, curPage * PROD_PAGE_SIZE);
|
||||
|
||||
function addBullet(event: KeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key !== "Enter") return;
|
||||
event.preventDefault();
|
||||
const value = bulletDraft.trim();
|
||||
if (!value) return;
|
||||
setBullets((list) => [...list, value]);
|
||||
setBulletDraft("");
|
||||
}
|
||||
function removeBullet(index: number) {
|
||||
setBullets((list) => list.filter((_, position) => position !== index));
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
setTitle("");
|
||||
setCategory("");
|
||||
setTarget("");
|
||||
setBullets([]);
|
||||
setBulletDraft("");
|
||||
setImagePreview("");
|
||||
setImageFile(null);
|
||||
setTitleError(false);
|
||||
}
|
||||
async function submit() {
|
||||
const name = title.trim();
|
||||
if (!name) { setTitleError(true); return; } // 空名:给必填校验提示(不再静默无反应)
|
||||
const file = imageFile;
|
||||
const created = await onCreate({
|
||||
title: name,
|
||||
category: category || PC_CAT_OPTIONS[0],
|
||||
target_audience: target,
|
||||
selling_points: bullets.map((item, index) => ({ title: item, detail: item, sort_order: index }))
|
||||
});
|
||||
setDrawer(false);
|
||||
resetForm();
|
||||
if (!created) return;
|
||||
// 把抽屉里选的主图随商品一起上传持久化(原先只做了本地预览 → 卡片/详情看不到图)
|
||||
if (file && onUploadImage) {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
fd.append("name", `${name}-主图`);
|
||||
await onUploadImage(created.id, fd);
|
||||
}
|
||||
// 创建成功 → 弹「继续创建商品 / 去新建项目」选择弹窗(替代纯 toast)
|
||||
setCreatedName(name);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="products-page">
|
||||
<div className="page-head">
|
||||
@@ -213,7 +149,7 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m3 7 2 2 4-4" /><path d="m3 17 2 2 4-4" /><path d="M13 6h8" /><path d="M13 12h8" /><path d="M13 18h8" /></svg>
|
||||
<span className="btn-edit-label">{editMode ? "完成" : "管理商品"}</span>
|
||||
</button>
|
||||
<button className="btn btn-primary btn-create" type="button" id="open-new-product" onClick={() => { resetForm(); setDrawer(true); }}>
|
||||
<button className="btn btn-primary btn-create" type="button" id="open-new-product" onClick={() => setDrawer(true)}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 22V12" /><path d="M16 17h6" /><path d="M19 14v6" /><path d="M21 10.5V8a2 2 0 0 0-1-1.7l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.7l7 4a2 2 0 0 0 2 0l1.7-1" /><path d="m3.3 7 8.7 5 8.7-5" /><path d="m7.5 4.3 9 5.1" /></svg>
|
||||
新建商品
|
||||
</button>
|
||||
@@ -320,119 +256,21 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o
|
||||
close={() => setCreatedName(null)}
|
||||
actions={
|
||||
<>
|
||||
<button className="btn" type="button" onClick={() => { setCreatedName(null); resetForm(); setDrawer(true); }}>继续创建商品</button>
|
||||
<button className="btn" type="button" onClick={() => { setCreatedName(null); setDrawer(true); }}>继续创建商品</button>
|
||||
<button className="btn btn-primary" type="button" onClick={() => { setCreatedName(null); navigate("projectWizard"); }}>去新建项目</button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 新建商品 · 右侧 Drawer · portal 到 body,脱离 .content(z-index:1)层叠上下文,遮罩才能盖住头部/侧栏(转写自 products.html #pc-drawer) */}
|
||||
{createPortal(
|
||||
<>
|
||||
<div className={`drawer-bg${drawer ? " show" : ""}`} onClick={() => setDrawer(false)} />
|
||||
<aside className={`drawer pc-drawer${drawer ? " show" : ""}`} role="dialog" aria-label="新建商品" aria-hidden={!drawer}>
|
||||
<div className="drawer-h">
|
||||
<h3>新建商品</h3>
|
||||
<button className="x" type="button" onClick={() => setDrawer(false)} aria-label="关闭">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M6 18L18 6" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="drawer-b">
|
||||
<div className="form-card">
|
||||
<div className="field">
|
||||
<label className="field-label">商品名称<span className="req">*</span></label>
|
||||
<input className="input" value={title} onChange={(event) => { setTitle(event.target.value); if (titleError) setTitleError(false); }} placeholder="请输入商品名称(必填)" maxLength={100} aria-invalid={titleError} style={titleError ? { borderColor: "var(--accent-crimson, #c43d3d)" } : undefined} />
|
||||
{titleError && <div style={{ color: "var(--accent-crimson, #c43d3d)", fontSize: 12, marginTop: 4 }}>请先填写商品名称</div>}
|
||||
</div>
|
||||
|
||||
<div className="field-row">
|
||||
<div>
|
||||
<label className="field-label">品类<span className="req">*</span></label>
|
||||
<select className="select" value={category} onChange={(event) => setCategory(event.target.value)}>
|
||||
{PC_CAT_OPTIONS.map((option) => <option key={option}>{option}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">目标人群<span className="opt">(选填)</span></label>
|
||||
<input className="input" value={target} onChange={(event) => setTarget(event.target.value)} placeholder="例: 22-32 岁女性、敏感肌、办公室通勤" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="field-label">商品主图<span className="req">*</span></label>
|
||||
<div className="pf-upload-row">
|
||||
<div className="pf-upload-zone" role="button" tabIndex={0} onClick={() => imgInputRef.current?.click()} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); imgInputRef.current?.click(); } }}>
|
||||
<input ref={imgInputRef} type="file" accept="image/*" hidden onChange={pickImage} />
|
||||
{imagePreview ? (
|
||||
<img src={imagePreview} alt="商品主图预览" style={{ maxWidth: "100%", maxHeight: 120, borderRadius: 8, objectFit: "cover" }} />
|
||||
) : (
|
||||
<>
|
||||
<div className="uz-ic">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12" /></svg>
|
||||
</div>
|
||||
<div className="uz-t">点击上传或<strong>拖拽图片</strong>到此处</div>
|
||||
<div className="uz-d">// 支持 JPG、PNG 格式,建议尺寸 800×800 以上,大小不超过 10MB</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="pf-example">
|
||||
<div className="ex-h">示例图</div>
|
||||
<div className="ex-grid">
|
||||
<div className="ex-thumb ex-thumb--img"><img src="/exact/assets/mock/product-earbuds.png" alt="示例:蓝牙耳机" loading="lazy" /></div>
|
||||
<div className="ex-thumb ex-thumb--img"><img src="/exact/assets/mock/product-mask.png" alt="示例:面膜" loading="lazy" /></div>
|
||||
<div className="ex-thumb ex-thumb--img"><img src="/exact/assets/mock/product-air-fryer.png" alt="示例:空气炸锅" loading="lazy" /></div>
|
||||
</div>
|
||||
<div className="ex-d">优质的商品图有助于生成更好的素材效果</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pf-grid" />
|
||||
</div>
|
||||
|
||||
<div className="field" style={{ marginBottom: 0 }}>
|
||||
<label className="field-label">核心卖点<span className="req">*</span></label>
|
||||
<ul className="bullet-list">
|
||||
{bullets.map((bullet, index) => (
|
||||
<li className="bl-item" key={`${bullet}-${index}`}>
|
||||
<span className="num">{index + 1}</span>
|
||||
<span className="bl-text">{bullet}</span>
|
||||
<button className="bl-x" type="button" onClick={() => removeBullet(index)} aria-label="删除卖点">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M6 18L18 6" /></svg>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
<li className="bl-add">
|
||||
<span className="num">+</span>
|
||||
<input className="bl-input" value={bulletDraft} onChange={(event) => setBulletDraft(event.target.value)} onKeyDown={addBullet} placeholder="添加新卖点 · 回车确认" />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showGuide && (
|
||||
<div className="pc-guide-note" style={{ padding: "10px 14px", margin: "0 16px 8px", background: "var(--black-alpha-4)", borderRadius: 8, fontSize: 13, lineHeight: 1.7, color: "var(--black-alpha-72)" }}>
|
||||
<strong>// 建好商品的 3 步</strong><br />
|
||||
① 填写商品名称 + 品类(必填,用于脚本/素材生成)<br />
|
||||
② 上传清晰主图(800×800 以上),便于 AI 出图更准<br />
|
||||
③ 写 2–4 条核心卖点,越具体生成效果越好
|
||||
</div>
|
||||
)}
|
||||
<div className="drawer-f">
|
||||
<button className="btn-guide" type="button" aria-expanded={showGuide} onClick={() => setShowGuide((v) => !v)}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><path d="M9.5 9a2.5 2.5 0 015 0c0 1.5-2.5 2-2.5 4M12 17h.01" /></svg>
|
||||
使用指南
|
||||
</button>
|
||||
<button className="btn" type="button" onClick={() => setDrawer(false)}>取消</button>
|
||||
<button className="btn btn-primary" type="button" 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>
|
||||
创建商品
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</>,
|
||||
document.body,
|
||||
)}
|
||||
{/* 新建商品 · 右侧 Drawer · 与新建项目向导共用同一组件,保证两处「新建商品」是同一流程 */}
|
||||
<ProductCreateDrawer
|
||||
open={drawer}
|
||||
close={() => setDrawer(false)}
|
||||
onCreate={onCreate}
|
||||
onUploadImage={onUploadImage}
|
||||
// 创建成功 → 弹「继续创建商品 / 去新建项目」选择弹窗(替代纯 toast)
|
||||
onCreated={(product) => setCreatedName(product.title)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import type { CSSProperties, FormEvent } from "react";
|
||||
import type { Asset, Product, Project } from "../types";
|
||||
import type { Page } from "./route-config";
|
||||
import { ConfirmModal, Drawer, EmptyPanel } from "../components/overlays";
|
||||
import { ConfirmModal, EmptyPanel } from "../components/overlays";
|
||||
import { ProductCreateDrawer } from "../components/product-create-drawer";
|
||||
import { Pager } from "../components/pager";
|
||||
import "../project-wizard-page.css";
|
||||
|
||||
@@ -18,8 +19,6 @@ const WIZ_DURATIONS = [
|
||||
];
|
||||
|
||||
const WIZ_PAGE_SIZE = 7; // 4 列 × 2 行 = 8 格,首格为「创建新商品」→ 每页 7 商品
|
||||
// 新建商品抽屉品类选项(与 products.tsx 一致;本组件自洽,不依赖 products.tsx)
|
||||
const WIZ_PC_CATS = ["美妆个护", "服饰内衣", "食品饮料", "家居家电", "数码 3C", "个护清洁", "运动户外", "母婴亲子"];
|
||||
|
||||
type WizProductPayload = {
|
||||
title: string;
|
||||
@@ -28,15 +27,17 @@ type WizProductPayload = {
|
||||
selling_points?: Array<{ title: string; detail: string; sort_order: number }>;
|
||||
};
|
||||
|
||||
export function ProjectWizardPage({ products, projects = [], preselectProductId, onBack, onCreate, onCreateProduct }: {
|
||||
export function ProjectWizardPage({ products, projects = [], preselectProductId, onBack, onCreate, onCreateProduct, onUploadImage }: {
|
||||
products: Product[];
|
||||
projects?: Project[];
|
||||
// 从商品详情页「立即生成」进入时,预勾选该商品(而非默认第一个/耳机);id 不存在则回落默认。
|
||||
preselectProductId?: string;
|
||||
onBack: () => void;
|
||||
onCreate: (payload: { name: string; product: string; metadata?: Record<string, unknown> }) => Promise<unknown> | void;
|
||||
// 「创建新商品」抽屉提交时调用;返回新建商品(含 id)以便自动选中。可选,未接线时抽屉退化为「去商品库」。
|
||||
// 「创建新商品」抽屉提交时调用 → 落库到商品库;返回新建商品(含 id)以便自动选中。可选,未接线时抽屉退化为「去商品库」。
|
||||
onCreateProduct?: (payload: WizProductPayload) => Promise<Product | null | undefined> | void;
|
||||
// 新建商品的主图随商品一起持久化(与商品库新建商品同一流程)
|
||||
onUploadImage?: (productId: string, formData: FormData) => Promise<unknown> | void;
|
||||
}) {
|
||||
// 预选 id 命中商品库时用它,否则默认第一个商品
|
||||
const initialProductId =
|
||||
@@ -56,15 +57,8 @@ export function ProjectWizardPage({ products, projects = [], preselectProductId,
|
||||
const [pickView, setPickView] = useState<"grid" | "list">("grid");
|
||||
const [pickPage, setPickPage] = useState(1);
|
||||
|
||||
// 新建商品 · 右侧抽屉本地态(自洽实现,不依赖 products.tsx)
|
||||
// 新建商品 · 右侧抽屉开关(抽屉实现与商品库共用 ProductCreateDrawer,保证同一流程)
|
||||
const [npOpen, setNpOpen] = useState(false);
|
||||
const [npTitle, setNpTitle] = useState("");
|
||||
const [npCat, setNpCat] = useState(WIZ_PC_CATS[0]);
|
||||
const [npAudience, setNpAudience] = useState("");
|
||||
const [npPoints, setNpPoints] = useState<string[]>([]);
|
||||
const [npDraft, setNpDraft] = useState("");
|
||||
const [npTitleError, setNpTitleError] = useState(false);
|
||||
const [npSaving, setNpSaving] = useState(false);
|
||||
|
||||
// Step 2 · 配置(基础配置只保留项目名 + 成片时长;脚本风格/人物设定已移到流水线第 1 步脚本助手)
|
||||
const [duration, setDuration] = useState<string | null>("0-15");
|
||||
@@ -131,44 +125,9 @@ export function ProjectWizardPage({ products, projects = [], preselectProductId,
|
||||
// 点击「创建新商品」:有 onCreateProduct 接线则开右侧抽屉,否则回落到旧行为(返回去商品库)
|
||||
function openCreateProduct() {
|
||||
if (!onCreateProduct) { onBack(); return; }
|
||||
setNpTitle("");
|
||||
setNpCat(WIZ_PC_CATS[0]);
|
||||
setNpAudience("");
|
||||
setNpPoints([]);
|
||||
setNpDraft("");
|
||||
setNpTitleError(false);
|
||||
setNpOpen(true);
|
||||
}
|
||||
|
||||
function addNpPoint() {
|
||||
const v = npDraft.trim();
|
||||
if (!v || npPoints.includes(v)) { setNpDraft(""); return; }
|
||||
setNpPoints((prev) => [...prev, v]);
|
||||
setNpDraft("");
|
||||
}
|
||||
|
||||
async function submitNewProduct() {
|
||||
if (!onCreateProduct) return;
|
||||
const name = npTitle.trim();
|
||||
if (!name) { setNpTitleError(true); return; } // 空名:必填校验,不静默无反应
|
||||
setNpSaving(true);
|
||||
try {
|
||||
const created = await onCreateProduct({
|
||||
title: name,
|
||||
category: npCat,
|
||||
target_audience: npAudience.trim() || undefined,
|
||||
selling_points: npPoints.map((item, index) => ({ title: item, detail: item, sort_order: index }))
|
||||
});
|
||||
setNpOpen(false);
|
||||
// 新建成功且拿到 id → 自动选中该商品进入第 1 步选中态
|
||||
if (created && typeof created === "object" && "id" in created) {
|
||||
setProductId((created as Product).id);
|
||||
}
|
||||
} finally {
|
||||
setNpSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const durObj = WIZ_DURATIONS.find((d) => d.id === duration);
|
||||
|
||||
const product1Done = !!productId;
|
||||
@@ -386,56 +345,17 @@ export function ProjectWizardPage({ products, projects = [], preselectProductId,
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* 新建商品 · 右侧抽屉(复用 overlays Drawer;portal 到 body,遮罩盖住头部/侧栏) */}
|
||||
<Drawer title="新建商品" open={npOpen} close={() => setNpOpen(false)}>
|
||||
<div className="field">
|
||||
<label className="field-label">商品名称<span className="req">*</span></label>
|
||||
<input
|
||||
className={`input${npTitleError ? " error" : ""}`}
|
||||
value={npTitle}
|
||||
placeholder="例:南卡 Lite Pro 蓝牙耳机"
|
||||
onChange={(event) => { setNpTitle(event.target.value); if (npTitleError) setNpTitleError(false); }}
|
||||
/>
|
||||
{npTitleError && <div className="field-hint err">// 商品名称不能为空</div>}
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label">商品品类</label>
|
||||
<select className="select" value={npCat} onChange={(event) => setNpCat(event.target.value)}>
|
||||
{WIZ_PC_CATS.map((c) => <option value={c} key={c}>{c}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label">目标人群</label>
|
||||
<input className="input" value={npAudience} placeholder="例:25-30 岁都市白领" onChange={(event) => setNpAudience(event.target.value)} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label">关键卖点</label>
|
||||
<div className="np-point-add">
|
||||
<input
|
||||
className="input"
|
||||
value={npDraft}
|
||||
placeholder="输入一个卖点后回车添加"
|
||||
onChange={(event) => setNpDraft(event.target.value)}
|
||||
onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); addNpPoint(); } }}
|
||||
/>
|
||||
<button className="btn btn-sm" type="button" onClick={addNpPoint}>添加</button>
|
||||
</div>
|
||||
{npPoints.length > 0 && (
|
||||
<div className="theme-pill-row" style={{ marginTop: 10 }}>
|
||||
{npPoints.map((p) => (
|
||||
<button className="theme-pill active" type="button" key={p} onClick={() => setNpPoints((prev) => prev.filter((x) => x !== p))}>
|
||||
<span>{p}</span>
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M4 4l8 8M12 4l-8 8" /></svg>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="np-drawer-foot">
|
||||
<button className="btn" type="button" onClick={() => setNpOpen(false)}>取消</button>
|
||||
<button className="btn btn-primary" type="button" disabled={npSaving} onClick={submitNewProduct}>{npSaving ? "创建中…" : "创建商品"}</button>
|
||||
</div>
|
||||
</Drawer>
|
||||
{/* 新建商品 · 右侧抽屉 · 与商品库新建商品共用同一组件 → 落库到商品库,同一流程 */}
|
||||
{onCreateProduct && (
|
||||
<ProductCreateDrawer
|
||||
open={npOpen}
|
||||
close={() => setNpOpen(false)}
|
||||
onCreate={onCreateProduct}
|
||||
onUploadImage={onUploadImage}
|
||||
// 新建成功 → 自动选中该商品进入第 1 步选中态
|
||||
onCreated={(created) => setProductId(created.id)}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user