feat(core): Wave 3 batch4 — 外壳账户菜单 + 设置dirty引擎 + 商品创建抽屉多图
全局外壳(sub-agent):侧栏 .user 点击弹账户菜单(头像+店名+用户名[非邮箱,认证定调]+
个人设置/消息/团队/消费/退出),复用现成 .shell-account-menu CSS,点外部/ESC 收起
设置页(sub-agent):dirty-state 引擎(baseline+draft diff 13字段,顶栏「保存所有变更」
无改动 disabled、改动激活+N项计数、统一保存、beforeunload、nav dirty-dot);修一处类型错(|| {} → 可选链)
商品创建抽屉(sub-agent):主图多图≤5+.pf-grid缩略网格+单张删、submit主图/卖点必填校验+收编bulletDraft、
上传区拖拽变橙、卖点字重对齐;内联 style 抽进 css
验证: tsc 0 · build 0 · 走查 账户菜单5项+ESC、设置保存按钮无改动disabled、抽屉multiple+pf-grid 全过 · 0 console error
⚠️ 外壳:顶栏头像菜单接线需改 App.tsx/pipeline.tsx(超出单文件范围),侧栏入口已全可用。移交监督。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
da8c6a70a7
commit
378b0a350c
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import type { MouseEvent as ReactMouseEvent } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { AlertCircle, Check, Info } from "lucide-react";
|
||||
import { AlertCircle, Check, Info, LogOut } from "lucide-react";
|
||||
import { IconKitSvg } from "./IconKitSvg";
|
||||
import { useBodyScrollLock } from "./overlays";
|
||||
import { api, setToken } from "../api";
|
||||
import type { Product, Project, Team, User } from "../types";
|
||||
import type { Notice, Page } from "../routes/route-config";
|
||||
|
||||
@@ -96,6 +98,93 @@ function CommandPalette({ open, onClose, navigate }: { open: boolean; onClose: (
|
||||
|
||||
type Navigate = (page: Page) => void;
|
||||
|
||||
// 账户下拉菜单 —— 忠实搬设计稿 shell.js 的 accountMenuHtml + toggleAccountMenu 定位逻辑。
|
||||
// 触发器(侧栏 .user / 顶栏头像)传入自己的 DOMRect 当锚点;菜单 fixed 定位、右对齐锚点、
|
||||
// 越界自动翻到锚点上方,并钳进视口。点外部 / Esc 收起。各项 navigate,退出走 logout。
|
||||
// 认证定调:头像下方第二行展示「用户名」(user.username),本项目不用邮箱。
|
||||
const ACCOUNT_ITEMS: { act: Page; icon: string; label: string }[] = [
|
||||
{ act: "settings", icon: "settings", label: "个人设置" },
|
||||
{ act: "messages", icon: "bell", label: "消息中心" },
|
||||
{ act: "team", icon: "users", label: "团队管理" },
|
||||
{ act: "account", icon: "creditCard", label: "消费与余额" }
|
||||
];
|
||||
|
||||
function AccountMenu({ anchorRect, onClose, navigate, logout, user, team }: {
|
||||
anchorRect: DOMRect;
|
||||
onClose: () => void;
|
||||
navigate: Navigate;
|
||||
logout: () => void;
|
||||
user: User;
|
||||
team: Team | null;
|
||||
}) {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState<{ left: number; top: number }>({ left: anchorRect.left, top: anchorRect.bottom + 8 });
|
||||
|
||||
// 量出真实菜单尺寸后再定位(右对齐锚点、上下翻转防溢出),与 V1 toggleAccountMenu 一致
|
||||
useLayoutEffect(() => {
|
||||
const menu = menuRef.current;
|
||||
const width = menu?.offsetWidth || 232;
|
||||
const height = menu?.offsetHeight || 260;
|
||||
let left = anchorRect.right - width;
|
||||
if (left < 12) left = anchorRect.left;
|
||||
left = Math.min(Math.max(12, left), window.innerWidth - width - 12);
|
||||
let top = anchorRect.bottom + 8;
|
||||
if (top + height > window.innerHeight - 12) top = Math.max(12, anchorRect.top - height - 8);
|
||||
setPos({ left, top });
|
||||
}, [anchorRect]);
|
||||
|
||||
// 点菜单外部 / Esc → 收起(锚点本身由触发器的 toggle 处理,避免点锚点立刻又被关掉)
|
||||
useEffect(() => {
|
||||
const onDown = (event: MouseEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (menuRef.current?.contains(target)) return;
|
||||
if ((target as Element).closest?.(".user, .topbar-avatar")) return;
|
||||
onClose();
|
||||
};
|
||||
const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") onClose(); };
|
||||
document.addEventListener("mousedown", onDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const avatar = (team?.name || user.username || "A").slice(0, 1).toUpperCase();
|
||||
const go = (page: Page) => { onClose(); navigate(page); };
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="shell-account-menu show"
|
||||
id="shell-account-menu"
|
||||
role="menu"
|
||||
aria-label="账户菜单"
|
||||
style={{ left: pos.left, top: pos.top }}
|
||||
>
|
||||
<div className="shell-account-head">
|
||||
<span className="av">{avatar}</span>
|
||||
<span>
|
||||
<span className="nm">{team?.name || user.username}</span>
|
||||
<span className="mail">{user.username}</span>
|
||||
</span>
|
||||
</div>
|
||||
{ACCOUNT_ITEMS.map((item) => (
|
||||
<button key={item.act} type="button" role="menuitem" onClick={() => go(item.act)}>
|
||||
<IconKitSvg name={item.icon} />
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="sep" />
|
||||
<button type="button" role="menuitem" onClick={() => { onClose(); logout(); }}>
|
||||
<LogOut size={14} />
|
||||
退出登录
|
||||
</button>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
type NavDef = { id: string; page: Page; label: string; icon: string; badge?: number };
|
||||
|
||||
const NAV: NavDef[] = [
|
||||
@@ -130,7 +219,7 @@ const PAGE_TO_NAV: Partial<Record<Page, Page>> = {
|
||||
settingsNotify: "settings"
|
||||
};
|
||||
|
||||
export function Sidebar({ page, navigate, user, team, products, projects, productTotal, projectTotal }: {
|
||||
export function Sidebar({ page, navigate, user, team, products, projects, productTotal, projectTotal, logout }: {
|
||||
page: Page;
|
||||
navigate: Navigate;
|
||||
user: User;
|
||||
@@ -139,6 +228,9 @@ export function Sidebar({ page, navigate, user, team, products, projects, produc
|
||||
projects: Project[];
|
||||
productTotal?: number;
|
||||
projectTotal?: number;
|
||||
// 退出登录。父级(App.tsx)有完整 logout 闭包时传入;未传时用自带兜底
|
||||
// (best-effort 调登出接口 + 清 token + 跳 /login),保证侧栏账户菜单的"退出"今天就可用。
|
||||
logout?: () => void;
|
||||
}) {
|
||||
const activeNav = PAGE_TO_NAV[page];
|
||||
// 徽标用后端真实总数(分页 count),不用已加载的页内条数
|
||||
@@ -164,6 +256,20 @@ export function Sidebar({ page, navigate, user, team, products, projects, produc
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [mobileNavOpen]);
|
||||
|
||||
// 账户下拉菜单:点侧栏 .user 用其 DOMRect 当锚点展开,再次点击收起(toggle)。
|
||||
const [accountAnchor, setAccountAnchor] = useState<DOMRect | null>(null);
|
||||
const toggleAccountMenu = (event: ReactMouseEvent<HTMLElement>) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
setAccountAnchor((prev) => (prev ? null : rect));
|
||||
};
|
||||
// 退出兜底:父级未传 logout 时,自行 best-effort 调登出接口 + 清 token + 跳登录页。
|
||||
const handleLogout = () => {
|
||||
if (logout) { logout(); return; }
|
||||
void api.logout().catch(() => undefined);
|
||||
setToken(null);
|
||||
window.location.href = "/login";
|
||||
};
|
||||
|
||||
// 命令面板:Ctrl/Cmd K 开关,点搜索框打开
|
||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||
const openCommandPalette = () => setPaletteOpen(true);
|
||||
@@ -225,13 +331,32 @@ export function Sidebar({ page, navigate, user, team, products, projects, produc
|
||||
))}
|
||||
</nav>
|
||||
<div className="aside-foot">
|
||||
<div className="user">
|
||||
<div
|
||||
className="user"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={accountAnchor !== null}
|
||||
title="账户"
|
||||
onClick={toggleAccountMenu}
|
||||
onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); toggleAccountMenu(event as unknown as ReactMouseEvent<HTMLElement>); } }}
|
||||
>
|
||||
<div className="av">{avatar}</div>
|
||||
<div className="em">{team?.name || user.username}</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} navigate={navigate} />
|
||||
{accountAnchor && (
|
||||
<AccountMenu
|
||||
anchorRect={accountAnchor}
|
||||
onClose={() => setAccountAnchor(null)}
|
||||
navigate={navigate}
|
||||
logout={handleLogout}
|
||||
user={user}
|
||||
team={team}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ChangeEvent } from "react";
|
||||
import type { ChangeEvent, DragEvent } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useBodyScrollLock } from "./overlays";
|
||||
import type { Product } from "../types";
|
||||
@@ -7,6 +7,9 @@ import "../product-create-page.css";
|
||||
|
||||
export const PC_CAT_OPTIONS = ["美妆个护", "服饰内衣", "食品饮料", "家居家电", "数码 3C", "个护清洁", "运动户外", "母婴亲子"];
|
||||
|
||||
// 主图上限(对齐 V1 #pc-drawer:PF_MAX = 5)
|
||||
const PF_MAX = 5;
|
||||
|
||||
export type ProductCreatePayload = {
|
||||
title: string;
|
||||
category: string;
|
||||
@@ -14,6 +17,11 @@ export type ProductCreatePayload = {
|
||||
selling_points?: Array<{ title: string; detail: string; sort_order: number }>;
|
||||
};
|
||||
|
||||
// 抽屉内主图条目:保留原始 File 逐张随商品上传,url 仅作缩略预览
|
||||
type PfImage = { id: string; file: File; url: string };
|
||||
|
||||
const pfUid = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
|
||||
|
||||
// 新建商品抽屉 · 商品库与新建项目向导共用同一实现,保证两处「新建商品」是同一流程。
|
||||
// onCreate 落库(商品库),onUploadImage 把主图随商品持久化,onCreated 让调用方接管成功后行为
|
||||
// (商品库弹「继续创建/去新建项目」,向导自动选中新商品)。
|
||||
@@ -31,12 +39,25 @@ export function ProductCreateDrawer({ open, close, onCreate, onUploadImage, onCr
|
||||
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 [images, setImages] = useState<PfImage[]>([]);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [titleError, setTitleError] = useState(false);
|
||||
const [showGuide, setShowGuide] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
// 抽屉内就地 toast(组件无全局 Shell.toast,这里自给一条,镜像 V1 反馈文案)
|
||||
const [toast, setToast] = useState<{ title: string; sub: string } | null>(null);
|
||||
const imgInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const toastTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
function flash(title: string, sub: string) {
|
||||
if (toastTimer.current) clearTimeout(toastTimer.current);
|
||||
setToast({ title, sub });
|
||||
toastTimer.current = setTimeout(() => setToast(null), 2600);
|
||||
}
|
||||
// 卸载时清掉 toast 定时器 + 释放残留的预览 objectURL
|
||||
useEffect(() => () => {
|
||||
if (toastTimer.current) clearTimeout(toastTimer.current);
|
||||
}, []);
|
||||
|
||||
function resetForm() {
|
||||
setTitle("");
|
||||
@@ -44,18 +65,51 @@ export function ProductCreateDrawer({ open, close, onCreate, onUploadImage, onCr
|
||||
setTarget("");
|
||||
setBullets([]);
|
||||
setBulletDraft("");
|
||||
setImagePreview("");
|
||||
setImageFile(null);
|
||||
setImages((list) => { list.forEach((item) => URL.revokeObjectURL(item.url)); return []; });
|
||||
setDragOver(false);
|
||||
setTitleError(false);
|
||||
}
|
||||
// 每次打开抽屉时复位表单(含商品库「继续创建商品」二次打开)
|
||||
useEffect(() => { if (open) resetForm(); }, [open]);
|
||||
|
||||
// 多图收编:超限/计数 toast 对齐 V1 pfAdd()
|
||||
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;
|
||||
});
|
||||
}
|
||||
function pickImage(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) { setImagePreview(URL.createObjectURL(file)); setImageFile(file); }
|
||||
addImages(event.target.files);
|
||||
event.target.value = "";
|
||||
}
|
||||
function removeImage(id: string) {
|
||||
setImages((list) => {
|
||||
const hit = list.find((item) => item.id === id);
|
||||
if (hit) URL.revokeObjectURL(hit.url);
|
||||
return list.filter((item) => item.id !== id);
|
||||
});
|
||||
}
|
||||
function openPicker() {
|
||||
if (images.length >= PF_MAX) { flash("已达上限", `${PF_MAX} / ${PF_MAX} 张`); return; }
|
||||
imgInputRef.current?.click();
|
||||
}
|
||||
|
||||
// 拖拽上传:dragover 高亮边框,drop 收图(对齐 V1 pfZone 事件)
|
||||
function onZoneDragOver(event: DragEvent<HTMLDivElement>) { event.preventDefault(); setDragOver(true); }
|
||||
function onZoneDragLeave(event: DragEvent<HTMLDivElement>) { event.preventDefault(); setDragOver(false); }
|
||||
function onZoneDrop(event: DragEvent<HTMLDivElement>) {
|
||||
event.preventDefault();
|
||||
setDragOver(false);
|
||||
if (event.dataTransfer?.files?.length) addImages(event.dataTransfer.files);
|
||||
}
|
||||
|
||||
function addBullet() {
|
||||
const value = bulletDraft.trim();
|
||||
if (!value) return;
|
||||
@@ -68,24 +122,32 @@ export function ProductCreateDrawer({ open, close, onCreate, onUploadImage, onCr
|
||||
|
||||
async function submit() {
|
||||
const name = title.trim();
|
||||
if (!name) { setTitleError(true); return; } // 空名:必填校验,不静默无反应
|
||||
if (!name) { setTitleError(true); flash("请填写商品名称", "必填项"); return; } // 空名:必填校验,不静默无反应
|
||||
if (images.length === 0) { flash("请上传商品主图", "至少 1 张 · 必填"); return; }
|
||||
// 提交时自动收编未回车的 bulletDraft,避免用户输入了卖点却被丢弃
|
||||
const pending = bulletDraft.trim();
|
||||
const finalBullets = pending ? [...bullets, pending] : bullets;
|
||||
if (finalBullets.length === 0) { flash("请填写核心卖点", "至少 1 条 · 回车确认"); return; }
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const file = imageFile;
|
||||
const files = images.map((item) => item.file);
|
||||
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 }))
|
||||
selling_points: finalBullets.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);
|
||||
// 把抽屉里选的主图逐张随商品一起上传持久化(否则卡片/详情看不到图)
|
||||
if (files.length && onUploadImage) {
|
||||
for (let i = 0; i < files.length; i += 1) {
|
||||
const fd = new FormData();
|
||||
fd.append("file", files[i]);
|
||||
fd.append("name", `${name}-主图${files.length > 1 ? `-${i + 1}` : ""}`);
|
||||
await onUploadImage(created.id, fd);
|
||||
}
|
||||
}
|
||||
onCreated?.(created);
|
||||
} finally {
|
||||
@@ -108,8 +170,8 @@ export function ProductCreateDrawer({ open, close, onCreate, onUploadImage, onCr
|
||||
<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>}
|
||||
<input className={`input${titleError ? " is-error" : ""}`} value={title} onChange={(event) => { setTitle(event.target.value); if (titleError) setTitleError(false); }} placeholder="请输入商品名称(必填)" maxLength={100} aria-invalid={titleError} />
|
||||
{titleError && <div className="pc-err-note">请先填写商品名称</div>}
|
||||
</div>
|
||||
|
||||
<div className="field-row">
|
||||
@@ -127,20 +189,23 @@ export function ProductCreateDrawer({ open, close, onCreate, onUploadImage, onCr
|
||||
|
||||
<div className="field">
|
||||
<label className="field-label">商品主图<span className="req">*</span></label>
|
||||
<input ref={imgInputRef} type="file" accept="image/*" multiple hidden onChange={pickImage} />
|
||||
<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
|
||||
className={`pf-upload-zone${dragOver ? " is-dragover" : ""}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={openPicker}
|
||||
onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openPicker(); } }}
|
||||
onDragOver={onZoneDragOver}
|
||||
onDragLeave={onZoneDragLeave}
|
||||
onDrop={onZoneDrop}
|
||||
>
|
||||
<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>
|
||||
@@ -152,10 +217,19 @@ export function ProductCreateDrawer({ open, close, onCreate, onUploadImage, onCr
|
||||
<div className="ex-d">优质的商品图有助于生成更好的素材效果</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pf-grid" />
|
||||
<div className="pf-grid">
|
||||
{images.map((item) => (
|
||||
<div className="pf-thumb" key={item.id}>
|
||||
<img src={item.url} alt="商品主图预览" />
|
||||
<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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field" style={{ marginBottom: 0 }}>
|
||||
<div className="field pc-field-last">
|
||||
<label className="field-label">核心卖点<span className="req">*</span></label>
|
||||
<ul className="bullet-list">
|
||||
{bullets.map((bullet, index) => (
|
||||
@@ -177,7 +251,7 @@ export function ProductCreateDrawer({ open, close, onCreate, onUploadImage, onCr
|
||||
</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)" }}>
|
||||
<div className="pc-guide-note">
|
||||
<strong>// 建好商品的 3 步</strong><br />
|
||||
① 填写商品名称 + 品类(必填,用于脚本/素材生成)<br />
|
||||
② 上传清晰主图(800×800 以上),便于 AI 出图更准<br />
|
||||
@@ -195,6 +269,13 @@ export function ProductCreateDrawer({ open, close, onCreate, onUploadImage, onCr
|
||||
{saving ? "创建中…" : "创建商品"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{toast && (
|
||||
<div className="pc-toast" role="status" aria-live="polite">
|
||||
<span className="ic-t"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12l5 5L20 6" /></svg></span>
|
||||
<div className="txt">{toast.title}<span className="mono">// {toast.sub}</span></div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</>,
|
||||
document.body,
|
||||
|
||||
Reference in New Issue
Block a user