fix: 平台套图/新建商品 YYX 第二轮(清单4 未完成项)

平台套图:
- 商品列表无真封面时回退 mock 图(与商品库一致显图)
- 商品分类去掉前缀「//」
- 左侧商品栏可折叠(收起只剩商品图)
- 结果搜索框加 × 清空 + 平台筛选悬浮弹窗(可清空)
- 结果卡精简:去容器/引号/平台/小标题,提示词可折叠展开、张数灰色
- 删悬浮「更多」里重复的加入资产库入口
- 去掉加入资产库后的中央遮罩,只保留左上角已入库标签
- 重跑/单图再生成的新批次置底并自动滚动到底

新建商品抽屉:
- 上传失败:黑色遮罩 + 感叹号 + 提示 + 重新上传胶囊
- 填了内容中途退出:二次确认(复用 ConfirmModal)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-27 16:51:49 +08:00
co-authored by Claude Opus 4.8
parent 217d24f316
commit 98b6ba948e
5 changed files with 265 additions and 74 deletions
@@ -2,7 +2,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 { useBodyScrollLock, ConfirmModal } from "./overlays";
import type { Product } from "../types";
import "../product-create-page.css";
@@ -51,6 +51,8 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
const [saving, setSaving] = useState(false);
// 抽屉内就地 toast(组件无全局 Shell.toast,这里自给一条,镜像 V1 反馈文案)
const [toast, setToast] = useState<{ title: string; sub: string } | null>(null);
// YYX#24:填了内容中途退出 → 二次确认
const [confirmExit, setConfirmExit] = useState(false);
const imgInputRef = useRef<HTMLInputElement | null>(null);
const toastTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// 始终指向最新 images:addImages 里同步算余量 + 发上传,不靠会滞后的 effect/闭包
@@ -80,6 +82,16 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
// 每次打开抽屉时复位表单(含商品库「继续创建商品」二次打开)
useEffect(() => { if (open) resetForm(); }, [open]);
// YYX#24:表单是否已填内容(有任一内容即视为「编辑中」)
function isDirty() {
return Boolean(title.trim() || target.trim() || bulletDraft.trim() || bullets.length || images.length);
}
// 关闭入口统一走这里:有内容则先二次确认,否则直接关
function guardedClose() {
if (isDirty()) setConfirmExit(true);
else close();
}
// upload-first:任一图处于「uploading」且未在途 → 立刻传进桶(/assets/upload/),拿到 assetId 标 done
async function uploadOne(entry: PfImage) {
try {
@@ -119,6 +131,13 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
return list.filter((item) => item.id !== id);
});
}
// YYX#22:上传失败后「重新上传」—— 把该图置回 uploading 再发一次
function retryUpload(id: string) {
const hit = imagesRef.current.find((item) => item.id === id);
if (!hit) return;
setImages((list) => list.map((it) => (it.id === id ? { ...it, status: "uploading" } : it)));
void uploadOne({ ...hit, status: "uploading" });
}
function openPicker() {
if (images.length >= PF_MAX) { flash("已达上限", `${PF_MAX} / ${PF_MAX}`); return; }
imgInputRef.current?.click();
@@ -176,11 +195,11 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
return createPortal(
<>
<div className={`drawer-bg${open ? " show" : ""}`} onClick={close} />
<div className={`drawer-bg${open ? " show" : ""}`} onClick={guardedClose} />
<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="关闭">
<button className="x" type="button" onClick={guardedClose} 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>
@@ -242,7 +261,19 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
<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>}
{/* YYX#22:上传失败 —— 黑色遮罩 + 感叹号 + 提示 + 重新上传胶囊 */}
{item.status === "error" && (
<div className="pf-fail-mask">
<span className="pf-fail-ic" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><path d="M12 8v4" /><path d="M12 16h.01" /></svg>
</span>
<span className="pf-fail-t"></span>
<button className="pf-retry" type="button" onClick={() => retryUpload(item.id)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36" /><path d="M21 3v6h-6" /></svg>
</button>
</div>
)}
<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>
@@ -285,7 +316,7 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
<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" type="button" onClick={guardedClose}></button>
<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 ? "创建中…" : images.some((im) => im.status === "uploading") ? "图片上传中…" : "创建商品"}
@@ -299,6 +330,16 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
</div>
)}
</aside>
{/* YYX#24:填了内容中途退出 → 二次确认(复用通用 ConfirmModal) */}
<ConfirmModal
open={confirmExit}
title="商品未保存"
subtitle="// UNSAVED"
detail="当前商品信息尚未保存,确定要退出编辑吗?"
confirmText="退出编辑"
onCancel={() => setConfirmExit(false)}
onConfirm={() => { setConfirmExit(false); close(); }}
/>
</>,
document.body,
);