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:
zyc
2026-06-17 20:13:40 +08:00
co-authored by Claude Opus 4.8
parent b714976d38
commit c9fb7139ac
6 changed files with 308 additions and 325 deletions
@@ -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,
);
}