fix: 测试清单(7) 第7轮修复 — ZWQ/PMC/团队 8处真bug

- ZWQ-1 商品库: 编辑删图真生效 + 刷新后悬停垃圾桶图标恢复 (products.tsx/css)
- ZWQ-2 工作台: 最近项目进度条 5→4 段对齐视频项目, 完成态末段红→绿 (dashboard.tsx)
- PMC-4 消息中心: 收件箱文字错位修正 (messages-page.css)
- PMC-5 图片生成: 平台套图费用预估漏乘平台数, 改为 平台数×候选数×单价 对齐实扣(仅改展示, 不动扣费) (ai-tools.tsx)
- PMC-10 火山生图: 失败不再吞 response body, 错误体上抛便于定位 (volcano.py)
- PMC-11 团队: 创建账户弹窗禁止点遮罩误关 (overlays.tsx + team.tsx)

构建: npm run build EXIT=0 (1754 模块); volcano.py AST OK
注: PMC-10/YYX-8角标 等后端项需部署生效

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-29 11:41:00 +08:00
co-authored by Claude Opus 4.8
parent 6e7425048c
commit f3b89f3d49
9 changed files with 150 additions and 32 deletions
+3 -1
View File
@@ -176,7 +176,9 @@ class VolcanoArkProvider:
json=body,
timeout=180,
)
response.raise_for_status()
# 非 2xx 抽出火山真实报错(error.code/message)而非裸 HTTP 400 —— 否则 Seedream 的
# 内容审核 / 尺寸过小(历史 4:5 套图 raise_for_status 吞 body 的踩坑)等真因看不见。
_raise_volcano_error(response)
return response.json()
def create_video_task(
+5
View File
@@ -627,6 +627,11 @@
.image-workbench .p-logo-sph { background: #07c160; }
.image-workbench .p-logo-amz { background: #ff9900; }
.image-workbench .p-logo-al { background: #2c4af1; }
/* YYX#row10:真平台 logo 图 —— 填满 logo 容器(平台卡 32 / 筛选 20 / 分组头 24);
has-img 时容器底色改中性,真 logo 自带配色覆盖在上;图加载失败回退品牌色块字符 */
.image-workbench .p-logo.has-img,
.image-workbench .cg-logo.has-img { background: var(--surface); overflow: hidden; }
.image-workbench .p-logo-img { width: 100%; height: 100%; object-fit: cover; border-radius: inherit; display: block; }
/* ── 提示词框(YYX#7 加大)+ 框内生图模型胶囊(YYX#10 靠左置底)── */
.image-workbench .iw-prompt-box { position: relative; }
+4 -2
View File
@@ -100,7 +100,7 @@ export function SettingRow({ title, desc, action, toggle, checked }: { title: st
);
}
export function TeamModal({ open, title, subtitle, icon, close, children, footer }: {
export function TeamModal({ open, title, subtitle, icon, close, children, footer, dismissable = true }: {
open: boolean;
title: string;
subtitle: string;
@@ -108,13 +108,15 @@ export function TeamModal({ open, title, subtitle, icon, close, children, footer
close: () => void;
children: ReactNode;
footer?: ReactNode;
/** 是否允许点击遮罩关闭弹窗。默认 true(保持原有行为);传 false 则点遮罩不响应。 */
dismissable?: boolean;
}) {
useBodyScrollLock(open);
const { mounted, show } = useOverlayTransition(open, close);
if (!mounted) return null;
// 挂到 body:脱离 .content(z-index:1)层叠上下文,遮罩才能盖住头部/侧栏
return createPortal(
<div className={`modal-bg${show ? " show" : ""}`} onClick={close}>
<div className={`modal-bg${show ? " show" : ""}`} onClick={dismissable ? close : undefined}>
<div className="modal invite-modal" onClick={(event) => event.stopPropagation()}>
<span className="corner-tr">+</span><span className="corner-bl">+</span>
<div className="modal-h"><div className="ic-m">{icon}</div><div className="ti">{title}<span>{subtitle}</span></div><button className="x modal-x" type="button" onClick={close} aria-label="关闭"><X size={14} /></button></div>
+3 -1
View File
@@ -181,7 +181,9 @@
.msg-type-ic.team { background: var(--black-alpha-4); color: var(--accent-black); }
.msg-type-ic.billing { background: var(--honey-bg); border-color: var(--honey-bd); color: var(--accent-honey); }
.msg-type-ic.system { background: var(--black-alpha-7); color: var(--black-alpha-72); }
.msg-item-main { min-width: 0; }
/* display:block span 提升为块级容器,确保 flex 子行(.msg-item-row/.msg-brief/.msg-item-foot)
能正确换行堆叠;否则父 inline-span grid 结合时文字在框内错位 */
.msg-item-main { min-width: 0; display: block; }
.msg-item-row {
display: flex;
align-items: center;
+25
View File
@@ -112,3 +112,28 @@ body.edit-mode .bulk-bar { display: inline-flex; }
.pd-role-foot .mono { font-size: 11px; color: var(--black-alpha-48); letter-spacing: .02em; }
/* 角色卡「含三视图」小标(右下角) */
.pd-role-tri { position: absolute; right: 8px; bottom: 8px; padding: 1px 7px; border-radius: var(--r-pill); background: var(--black-alpha-56); color: var(--accent-white); font-size: 10.5px; letter-spacing: .02em; }
/* 商品详情·商品图片网格·编辑态悬停删除按钮
.img-del-btn:悬停在可删图片上时显示垃圾桶图标按钮(右上角),
点整张图仍走放大预览,只有点垃圾桶才触发删除,避免误操作
使用 design token:8px 圆角单橙 hover无裸 hex
同时覆盖 product-detail-page.css 里遗留的 ::after 叉号(已换成真实按钮,隐藏旧伪元素) */
.ov-card.editing .ov-images-sub .thumb.is-del::after { display: none; }
.ov-images-sub .thumb.is-del { position: relative; }
.ov-images-sub .thumb.is-del .img-del-btn {
position: absolute;
top: 5px; right: 5px;
width: 26px; height: 26px;
display: none;
align-items: center; justify-content: center;
background: rgba(0,0,0,.60);
border: 0;
border-radius: var(--r-sm);
color: var(--accent-white);
cursor: pointer;
z-index: 4;
transition: background var(--t-base);
padding: 0;
}
.ov-images-sub .thumb.is-del:hover .img-del-btn { display: flex; }
.ov-images-sub .thumb.is-del .img-del-btn:hover { background: var(--heat); }
+32 -15
View File
@@ -550,20 +550,34 @@ const FALLBACK_MODELS = [
{ id: "m4", name: "Zoe", tag: "亚洲·30岁·健身" }
];
/* 平台套图 · 平台卡(基线 platform-cover · logo 配色用 token 化 className) */
/* · (YYX#row10: logo, public/assets/svg;
logo ,img logo) */
const PLATFORM_OPTIONS = [
{ id: "dy", name: "抖音电商", logo: "抖" },
{ id: "tb", name: "淘宝", logo: "淘" },
{ id: "tm", name: "天猫", logo: "猫" },
{ id: "jd", name: "京东", logo: "京" },
{ id: "pdd", name: "拼多多", logo: "拼" },
{ id: "xhs", name: "小红书", logo: "红" },
{ id: "ks", name: "快手", logo: "快" },
{ id: "sph", name: "视频号", logo: "视" },
{ id: "amz", name: "亚马逊", logo: "a" },
{ id: "al", name: "1688", logo: "阿" }
{ id: "dy", name: "抖音电商", logo: "抖", img: "/assets/svg/icon-platform-douyin.svg" },
{ id: "tb", name: "淘宝", logo: "淘", img: "/assets/svg/icon-platform-taobao.svg" },
{ id: "tm", name: "天猫", logo: "猫", img: "/assets/svg/icon-platform-tmall.svg" },
{ id: "jd", name: "京东", logo: "京", img: "/assets/svg/icon-platform-jd.svg" },
{ id: "pdd", name: "拼多多", logo: "拼", img: "/assets/svg/icon-platform-pdd.svg" },
{ id: "xhs", name: "小红书", logo: "红", img: "/assets/svg/icon-platform-xiaohongshu.svg" },
{ id: "ks", name: "快手", logo: "快", img: "/assets/svg/icon-platform-kuaishou.svg" },
{ id: "sph", name: "视频号", logo: "视", img: "/assets/svg/icon-platform-wechat-video.svg" },
{ id: "amz", name: "亚马逊", logo: "a", img: "/assets/svg/icon-platform-amazon.svg" },
{ id: "al", name: "1688", logo: "阿", img: "/assets/svg/icon-platform-1688.svg" }
];
/* YYX#row10: logo logo (public/assets/svg),退+
className p-logo(/), cg-logo */
function PlatformLogo({ p, className }: { p: { id: string; name: string; logo: string; img?: string }; className?: string }) {
const [err, setErr] = useState(false);
return (
<span className={`${className || "p-logo"} p-logo-${p.id}${p.img && !err ? " has-img" : ""}`}>
{p.img && !err
? <img className="p-logo-img" src={p.img} alt={p.name} loading="lazy" decoding="async" onError={() => setErr(true)} />
: p.logo}
</span>
);
}
/* / = :;
31() / 32( + + /) / 33() */
type GenBatch = {
@@ -784,6 +798,9 @@ export function ImageWorkbenchPage({
const ratioVar = ratio.replace(":", " / ");
const candidateCount = Math.max(1, Number(count) || 4);
// PMC#5:平台套图(cover)对每个选中平台各起一批,实扣 = 平台数 × candidateCount × 单价。
// 预估必须乘平台数才与实扣口径一致(model/image 模式只生成 candidateCount 张,不乘)。
const estimateCount = mode === "cover" ? Math.max(1, pickedIds.length) * candidateCount : candidateCount;
// 行31:并发提交——只要 prompt 非空就能再次提交,不再因"有批次在跑"被禁用
// 模特上身图需结合「商品图 + 模特图」合成,故必须先选商品且选一个模特,否则只是凭文字脑补
const canGenerate =
@@ -1412,7 +1429,7 @@ export function ImageWorkbenchPage({
<div className="iw-cover-group-h">
{plat ? (
<>
<span className={`cg-logo p-logo-${plat.id}`}>{plat.logo}</span>
<PlatformLogo p={plat} className="cg-logo" />
<span className="cg-name">{plat.name}</span>
</>
) : (
@@ -1778,7 +1795,7 @@ export function ImageWorkbenchPage({
className={`iw-filter-opt${platformFilter.includes(p.id) ? " on" : ""}`}
onClick={() => setPlatformFilter((prev) => (prev.includes(p.id) ? prev.filter((x) => x !== p.id) : [...prev, p.id]))}
>
<span className={`p-logo p-logo-${p.id}`}>{p.logo}</span>
<PlatformLogo p={p} />
<span className="nm">{p.name}</span>
<Check className="ck" size={12} />
</button>
@@ -1894,7 +1911,7 @@ export function ImageWorkbenchPage({
onClick={() => togglePick(item.id)}
>
<span className="p-check" />
<span className={`p-logo p-logo-${item.id}`}>{item.logo}</span>
<PlatformLogo p={item} />
<span className="p-name">{item.name}</span>
</button>
))}
@@ -2012,7 +2029,7 @@ export function ImageWorkbenchPage({
{/* 行31:并发提交——有批次在跑时仍可再次提交,只在提交瞬间不重复触发 */}
<button className="btn btn-primary" type="button" onClick={runGenerate} disabled={!canGenerate}>
<WandSparkles size={13} />
( {estimateFee(candidateCount)})
( {estimateFee(estimateCount)})
</button>
{/* YYX#9:去掉生成按钮下方那行说明文字 */}
</div>
+24 -2
View File
@@ -4,10 +4,32 @@ import type { Page } from "./route-config";
import { api } from "../api";
import { SkeletonRows } from "../components/loading";
import { money, stageMeta, statusPill } from "./stage-config";
import { Progress } from "../components/pipeline-stage";
import { IconKitSvg } from "../components/IconKitSvg";
import { ProductCreateDrawer, type ProductCreatePayload } from "../components/product-create-drawer";
// 工作台「最近项目」进度条:4 段(与视频项目菜单一致)
// script→1 / base_assets→2 / storyboard→3 / video|export→4
// completed:全 done(绿);failed:当前格 fail(红),之前 done;其余:之前 done,当前 cur(橙闪)
const DASH_STAGE_TOTAL = 4;
const DASH_STAGE_NO: Record<string, number> = { script: 1, base_assets: 2, storyboard: 3, video: 4, export: 4 };
function dashProgClass(project: Project, i: number, no: number): string {
if (project.status === "completed") return i <= no ? "done" : "";
if (project.status === "failed") return i === no ? "fail" : i < no ? "done" : "";
return i < no ? "done" : i === no ? "cur" : "";
}
function DashProgress({ project }: { project: Project }) {
const no = project.status === "completed"
? DASH_STAGE_TOTAL
: (DASH_STAGE_NO[project.current_stage] || 1);
return (
<div className="prog">
{Array.from({ length: DASH_STAGE_TOTAL }, (_, k) => k + 1).map((i) => (
<span key={i} className={dashProgClass(project, i, no)} />
))}
</div>
);
}
export function Dashboard({ products, projects, productTotal, projectTotal, billing, userName, loading = false, navigate, onCreateProduct }: {
products: Product[];
projects: Project[];
@@ -74,7 +96,7 @@ export function Dashboard({ products, projects, productTotal, projectTotal, bill
</div>
</div>
<div className="stats with-corners"><span className="corner-tr">+</span><span className="corner-bl">+</span><KpiStat label="总项目" badge="ALL" value={projCount} delta={totalDelta} /><KpiStat label="进行中" badge="WIP" value={running} /><KpiStat label="成片" badge="DONE" value={completed} /><button className="stat" type="button" onClick={() => navigate("account")}><div className="lbl"> <span className="badge">¥</span></div><div className="v">{money(billing?.account.balance)}</div><div className="bar"><span style={{ width: `${reservedPct}%` }} /></div><div className="sub"> {money(reservedNum)} / {money(fundsTotal)}</div></button></div>
<div className="dash-grid"><div><div className="section-h"><h2></h2><button className="more" type="button" onClick={() => navigate("projects")}>[ ALL · {projCount} ] </button></div><div className="card-hard">{loading && projects.length === 0 ? <SkeletonRows count={4} /> : projects.slice(0, 6).map((project) => <button className="recent-row" key={project.id} type="button" onClick={() => navigate("pipeline")}><div className="placeholder thumb"><span className="ph-frame">9:16</span></div><div className="recent-meta"><div className="name">{project.name}</div><div className="sub">{[productTitle(project.product), "AI 全生", project.video_segment_count ? `${project.video_segment_count}` : null].filter(Boolean).join(" / ")}</div></div><Progress status={project.current_stage} /><span className={`pill ${statusPill(project.status)}`}><span className="dot" />{stageMeta[project.current_stage]?.label || project.current_stage}</span><span className="btn btn-sm"></span></button>)}</div></div><div style={{ display: "flex", flexDirection: "column", gap: 24 }}><div><div className="section-h"><h2></h2><span className="more">[ /shortcuts ]</span></div><div className="shortcuts"><Shortcut name="package" title="" desc={`${prodCount} SKU`} onClick={() => navigate("products")} /><Shortcut name="images" title="" desc={`${assetCount ?? ""} `} onClick={() => navigate("library")} /><Shortcut name="creditCard" title="" desc={money(billing?.account.balance)} onClick={() => navigate("account")} /><Shortcut name="clapperboard" title="" desc={`${projCount} `} onClick={() => navigate("projects")} /></div></div><div><div className="section-h"><h2></h2><span className="more">[ FAQ ]</span></div><div className="tip"><strong></strong> <span className="mono">[ ]</span> token </div></div></div></div>
<div className="dash-grid"><div><div className="section-h"><h2></h2><button className="more" type="button" onClick={() => navigate("projects")}>[ ALL · {projCount} ] </button></div><div className="card-hard">{loading && projects.length === 0 ? <SkeletonRows count={4} /> : projects.slice(0, 6).map((project) => <button className="recent-row" key={project.id} type="button" onClick={() => navigate("pipeline")}><div className="placeholder thumb"><span className="ph-frame">9:16</span></div><div className="recent-meta"><div className="name">{project.name}</div><div className="sub">{[productTitle(project.product), "AI 全生", project.video_segment_count ? `${project.video_segment_count}` : null].filter(Boolean).join(" / ")}</div></div><DashProgress project={project} /><span className={`pill ${statusPill(project.status)}`}><span className="dot" />{stageMeta[project.current_stage]?.label || project.current_stage}</span><span className="btn btn-sm"></span></button>)}</div></div><div style={{ display: "flex", flexDirection: "column", gap: 24 }}><div><div className="section-h"><h2></h2><span className="more">[ /shortcuts ]</span></div><div className="shortcuts"><Shortcut name="package" title="" desc={`${prodCount} SKU`} onClick={() => navigate("products")} /><Shortcut name="images" title="" desc={`${assetCount ?? ""} `} onClick={() => navigate("library")} /><Shortcut name="creditCard" title="" desc={money(billing?.account.balance)} onClick={() => navigate("account")} /><Shortcut name="clapperboard" title="" desc={`${projCount} `} onClick={() => navigate("projects")} /></div></div><div><div className="section-h"><h2></h2><span className="more">[ FAQ ]</span></div><div className="tip"><strong></strong> <span className="mono">[ ]</span> token </div></div></div></div>
{/* YYX#11:新建商品抽屉 · 复用商品库同一组件,创建成功后跳商品库看新商品 */}
{onCreateProduct && (
<ProductCreateDrawer
+53 -11
View File
@@ -740,8 +740,26 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
productImages.push({ key: product.cover_asset, url: product.cover_preview_url || "" });
}
for (const ref of imageRefs) productImages.push({ key: ref.id, url: ref.preview_url || "", imageId: ref.id });
// 乐观删除:本地立刻隐藏被删的图,等 loadData 回灌后自然消失(避免 toast 弹出后图仍在的视觉延迟)
const [deletedImageKeys, setDeletedImageKeys] = useState<Set<string>>(new Set());
// 当 product.images 从父组件刷新进来时,已删除的 key 在服务端确认后不再存在,清理本地标记
// eslint-disable-next-line react-hooks/exhaustive-deps
const productImageKeys = productImages.map((im) => im.key).join(",");
useEffect(() => {
setDeletedImageKeys((prev) => {
if (prev.size === 0) return prev;
// 保留仍在 productImages 里的 key(说明服务端还没确认),移除已经不在的 key(服务端已确认删除)
const stillExist = new Set(productImages.map((im) => im.key));
const next = new Set([...prev].filter((k) => stillExist.has(k)));
return next.size === prev.size ? prev : next;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [productImageKeys]);
const visibleProductImages = productImages.filter((im) => !deletedImageKeys.has(im.key));
// 可删的图(有 ProductImage 关联)数量:只剩一张时锁住最后一张,不允许删到没图
const deletableImageCount = productImages.filter((im) => im.imageId).length;
const deletableImageCount = visibleProductImages.filter((im) => im.imageId).length;
// AI 生成素材 · 服务端已按 ?product 把「该商品」的素材(metadata.product_id / origin_task→project→product /
// ProductImage 关联)懒加载到 productAssets;这里只按可显示的图片类型过滤。
@@ -978,21 +996,45 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
<span className="ct">({productImages.length})</span>
</div>
<div className="grid" id="ov-images-grid">
{productImages.map((image) => {
// 商品至少保留一张图:只剩一张可删图时,那张不显示叉子、不可删(要移除请删整个商品)
{visibleProductImages.map((image) => {
// 商品至少保留一张图:只剩一张可删图时,那张锁住不可删(要移除请删整个商品)
const canDelete = editing && Boolean(image.imageId) && deletableImageCount > 1;
const lastLocked = editing && Boolean(image.imageId) && deletableImageCount <= 1;
const onThumb = () => {
if (canDelete) void onDeleteImage?.(image.imageId as string);
else if (image.url) setPreview({ src: image.url, name: realName });
const handleDelete = async (event: { stopPropagation: () => void }) => {
event.stopPropagation();
if (!canDelete || !image.imageId || !onDeleteImage) return;
// 乐观隐藏:立刻从本地列表移除,toast/loadData 并行回填
setDeletedImageKeys((prev) => new Set([...prev, image.key]));
const result = await onDeleteImage(image.imageId);
if (result === null) {
// action 返回 null 表示调用失败(会弹错误 toast),回滚乐观隐藏
setDeletedImageKeys((prev) => { const next = new Set(prev); next.delete(image.key); return next; });
}
};
return (
<div className={`thumb placeholder${canDelete ? " is-del" : ""}${lastLocked ? " is-locked" : ""}`} key={image.key} role="button" tabIndex={0}
title={canDelete ? "点击移除该图" : lastLocked ? "商品至少保留一张图;要移除请删除整个商品" : image.url ? "点击放大" : undefined}
style={{ cursor: canDelete ? "pointer" : image.url ? "zoom-in" : "default" }}
onClick={onThumb}
onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); onThumb(); } }}>
<div
className={`thumb placeholder${editing ? " is-edit" : ""}${canDelete ? " is-del" : ""}${lastLocked ? " is-locked" : ""}`}
key={image.key}
role={image.url ? "button" : undefined}
tabIndex={image.url ? 0 : undefined}
title={lastLocked ? "商品至少保留一张图;要移除请删除整个商品" : image.url ? "点击放大" : undefined}
style={{ cursor: image.url ? "zoom-in" : "default" }}
onClick={() => { if (image.url) setPreview({ src: image.url, name: realName }); }}
onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); if (image.url) setPreview({ src: image.url, name: realName }); } }}
>
{image.url ? <img src={image.url} alt={realName} loading="lazy" /> : <span className="ph-frame">1:1</span>}
{canDelete && (
<button
className="img-del-btn"
type="button"
title="移除该图"
aria-label="移除该图"
onClick={handleDelete}
onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); void handleDelete(event); } }}
>
<Trash2 size={14} />
</button>
)}
</div>
);
})}
+1
View File
@@ -548,6 +548,7 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
icon={<UserPlus size={16} />}
close={() => setModal("")}
footer={<button className="btn btn-primary" type="button" onClick={submitCreate}></button>}
dismissable={false}
>
<div className="create-acct-modal">
<div className="field">