diff --git a/core/frontend/src/ai-tools-page.css b/core/frontend/src/ai-tools-page.css index 4cc3247..b50c66d 100644 --- a/core/frontend/src/ai-tools-page.css +++ b/core/frontend/src/ai-tools-page.css @@ -632,6 +632,83 @@ /* 胶囊在框底 → 下拉菜单向上弹,避免被框/CTA 遮住 */ .image-workbench .iw-model-pill .chip-menu { top: auto; bottom: calc(100% + 4px); } +/* ── YYX#17:左侧商品栏折叠 ── */ +.image-workbench .iw-side-top .iw-side-collapse { + margin-left: auto; + width: 28px; height: 28px; + display: grid; place-items: center; + border: 1px solid var(--border-faint); border-radius: var(--r-md); + background: var(--surface); color: var(--black-alpha-48); cursor: pointer; + transition: background var(--t-base), color var(--t-base), border-color var(--t-base); +} +.image-workbench .iw-side-top .iw-side-collapse:hover { background: var(--heat-12); color: var(--heat); border-color: var(--heat-20); } +/* 收起后整列变窄,只剩商品图 */ +.image-workbench .iw-layout:has(.iw-prod-space.collapsed) { grid-template-columns: 76px minmax(0, 1fr); } +.image-workbench .iw-prod-space.collapsed .back-pill, +.image-workbench .iw-prod-space.collapsed .iw-ps-search, +.image-workbench .iw-prod-space.collapsed .iw-list-h, +.image-workbench .iw-prod-space.collapsed .iw-prod-item .body { display: none; } +.image-workbench .iw-prod-space.collapsed .iw-side-top { justify-content: center; padding: 12px 0 10px; } +.image-workbench .iw-prod-space.collapsed .iw-side-top .iw-side-collapse { margin-left: 0; } +.image-workbench .iw-prod-space.collapsed .iw-ps-list { padding-left: 0; padding-right: 0; align-items: center; } +.image-workbench .iw-prod-space.collapsed .iw-prod-item { justify-content: center; padding: 4px; } + +/* ── YYX#12:结果搜索框 × 清空 + 平台筛选弹窗 ── */ +.image-workbench .iw-search-box { position: relative; display: inline-flex; align-items: center; margin-right: 6px; } +.image-workbench .iw-search-box .input { height: 30px; width: 150px; padding-right: 26px; } +.image-workbench .iw-search-clear { + position: absolute; right: 6px; + width: 16px; height: 16px; display: grid; place-items: center; + border: 0; border-radius: 50%; background: var(--black-alpha-12); color: var(--accent-black); + cursor: pointer; +} +.image-workbench .iw-search-clear:hover { background: var(--black-alpha-24); } +.image-workbench .iw-filter-wrap { position: relative; } +.image-workbench .iw-filter-wrap .search-btn { position: relative; } +.image-workbench .iw-filter-count { + position: absolute; top: -4px; right: -4px; + min-width: 14px; height: 14px; padding: 0 3px; + display: grid; place-items: center; + background: var(--heat); color: var(--accent-white); + font-family: var(--font-mono); font-size: 10px; line-height: 1; border-radius: 999px; +} +.image-workbench .iw-filter-menu { width: 244px; padding: 10px; } +.image-workbench .iw-filter-h { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; } +.image-workbench .iw-filter-h .mono { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); } +.image-workbench .iw-filter-clear { + border: 0; background: none; cursor: pointer; + font-size: 12px; color: var(--heat); +} +.image-workbench .iw-filter-clear:disabled { color: var(--black-alpha-24); cursor: default; } +.image-workbench .iw-filter-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; } +.image-workbench .iw-filter-opt { + display: flex; align-items: center; gap: 6px; + padding: 6px 8px; border: 1px solid var(--border-faint); border-radius: var(--r-md); + background: var(--background-lighter); cursor: pointer; font-family: inherit; +} +.image-workbench .iw-filter-opt:hover { background: var(--surface); } +.image-workbench .iw-filter-opt.on { border-color: var(--heat); background: var(--heat-12); } +.image-workbench .iw-filter-opt .p-logo { width: 20px; height: 20px; border-radius: 5px; font-size: 11px; } +.image-workbench .iw-filter-opt .nm { font-size: 12px; color: var(--accent-black); flex: 1; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.image-workbench .iw-filter-opt .ck { color: var(--heat); opacity: 0; flex-shrink: 0; } +.image-workbench .iw-filter-opt.on .ck { opacity: 1; } + +/* ── YYX#18:结果卡精简头部(提示词折叠 + 灰色张数,无容器/引号/标题)── */ +.image-workbench .iw-batch-head { padding: 2px 2px 0; margin-bottom: 8px; } +.image-workbench .iw-batch-head .ibh-prompt { + font-size: 13px; color: var(--accent-black); line-height: 1.6; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.image-workbench .iw-batch-head .ibh-prompt.expanded { white-space: normal; } +.image-workbench .iw-batch-head .ibh-toggle { + border: 0; background: none; padding: 2px 0; cursor: pointer; + font-size: 12px; color: var(--heat); +} +.image-workbench .iw-batch-head .ibh-count { + margin-top: 2px; + font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); +} + /* 左栏底部 · 立即生成(主 CTA · 通栏) */ .image-workbench .iw-cta { margin-top: auto; padding-top: 14px; } .image-workbench .iw-cta .btn { diff --git a/core/frontend/src/components/product-create-drawer.tsx b/core/frontend/src/components/product-create-drawer.tsx index 8bfd4f5..7797005 100644 --- a/core/frontend/src/components/product-create-drawer.tsx +++ b/core/frontend/src/components/product-create-drawer.tsx @@ -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(null); const toastTimer = useRef | 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( <> -
+
)} + {/* YYX#24:填了内容中途退出 → 二次确认(复用通用 ConfirmModal) */} + setConfirmExit(false)} + onConfirm={() => { setConfirmExit(false); close(); }} + /> , document.body, ); diff --git a/core/frontend/src/product-create-page.css b/core/frontend/src/product-create-page.css index fbd0315..9674642 100644 --- a/core/frontend/src/product-create-page.css +++ b/core/frontend/src/product-create-page.css @@ -469,6 +469,23 @@ } .pc-drawer .form-card .pf-thumb .pf-state-err { background: var(--crimson-bg); color: var(--accent-crimson); font-weight: 600; } .pc-drawer .form-card .pf-thumb.pf-error { border-color: var(--crimson-bd); } +/* YYX#22:上传失败 —— 黑色遮罩 + 感叹号 + 提示 + 重新上传胶囊 */ +.pc-drawer .form-card .pf-thumb .pf-fail-mask { + position: absolute; inset: 0; z-index: 2; + display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 5px; + background: rgba(0, 0, 0, .62); color: var(--accent-white); text-align: center; padding: 4px; +} +.pc-drawer .form-card .pf-thumb .pf-fail-ic svg { width: 20px; height: 20px; } +.pc-drawer .form-card .pf-thumb .pf-fail-t { font-size: 11px; font-weight: 600; } +.pc-drawer .form-card .pf-thumb .pf-retry { + display: inline-flex; align-items: center; gap: 4px; + height: 22px; padding: 0 8px; + border: 0; border-radius: 999px; + background: var(--accent-white); color: var(--accent-black); + font-size: 11px; cursor: pointer; +} +.pc-drawer .form-card .pf-thumb .pf-retry:hover { background: var(--heat); color: var(--accent-white); } +.pc-drawer .form-card .pf-thumb .pf-retry svg { width: 11px; height: 11px; } .pc-drawer .form-card .pf-thumb .pf-cover { position: absolute; left: 4px; bottom: 4px; z-index: 1; background: var(--heat); color: var(--accent-white); diff --git a/core/frontend/src/routes/ai-tools.tsx b/core/frontend/src/routes/ai-tools.tsx index edb1b4c..d25320b 100644 --- a/core/frontend/src/routes/ai-tools.tsx +++ b/core/frontend/src/routes/ai-tools.tsx @@ -1,4 +1,4 @@ -import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import type { ChangeEvent } from "react"; import { @@ -13,11 +13,14 @@ import { LayoutGrid, List, MoreHorizontal, + PanelLeftClose, + PanelLeftOpen, Pencil, Plus, Quote, RefreshCw, Search, + SlidersHorizontal, Sparkles, Trash2, Users, @@ -32,6 +35,7 @@ import { MediaLightbox } from "../components/overlays"; import { Pager } from "../components/pager"; import type { Page } from "./route-config"; import { statusPill } from "./stage-config"; +import { productMockCoverUrl } from "./products"; import "../ai-tools-page.css"; // 工作台生成模式 → 中文标签(优先用它给任务卡命名:能区分模特上身图/平台套图/图片创作, @@ -640,8 +644,6 @@ export function ImageWorkbenchPage({ const [plDraft, setPlDraft] = useState([]); const [plQuery, setPlQuery] = useState(""); const [plCat, setPlCat] = useState(""); - // gen-card 就地反馈(§4.18:采用反馈不走全局弹窗,改卡内 show-feedback 1.5s) - const [feedbackKey, setFeedbackKey] = useState(""); // 单图「更多」气泡当前展开的 key(点击切换,点空收起) const [openMore, setOpenMore] = useState(""); // 批次列表:每次生成/重跑追加一条,各自展示;可多批并行 generating @@ -662,6 +664,8 @@ export function ImageWorkbenchPage({ // 参考图:支持多张(可多选 / 多次追加),逐张可移除。提交时上传成 Asset 作生成参考。 const [refImages, setRefImages] = useState<{ name: string; url: string; file: File }[]>([]); const refInputRef = useRef(null); + // 生成后把结果面板滚到最新批次用的哨兵(PMC#14/#22) + const resultsEndRef = useRef(null); // 生成结果图片放大预览 const [preview, setPreview] = useState<{ src: string; name: string } | null>(null); // 模特/平台 工作台头部:搜索 + 时间排序 + 模特筛选(对左侧网格真实生效) @@ -669,6 +673,11 @@ export function ImageWorkbenchPage({ const [gridSort, setGridSort] = useState<"recent" | "name">("recent"); const [tbOpen, setTbOpen] = useState<"" | "time" | "model">(""); const [searchOpen, setSearchOpen] = useState(false); + // YYX#17:左侧商品栏折叠(收起后只剩商品图) + const [sideCollapsed, setSideCollapsed] = useState(false); + // YYX#12:平台套图结果筛选弹窗 —— 按平台筛选已生成的图(空集 = 不筛) + const [filterOpen, setFilterOpen] = useState(false); + const [platformFilter, setPlatformFilter] = useState([]); // 模特网格默认只露 6 个;「全部模特 →」展开后露全部,再点收起(超过 6 个时第 7 个之后本来选不到) const [showAllModels, setShowAllModels] = useState(false); // 左侧商品空间搜索(原 input 无 state/无过滤=死框,输入无效);按名称/分类过滤 @@ -682,7 +691,8 @@ export function ImageWorkbenchPage({ 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 || ""; + // YYX#14:无真封面时回退到按商品名匹配的 mock 图,与商品库一致显图,不再露灰占位 + return p.cover_preview_url || primary?.preview_url || productMockCoverUrl(p.title); }; function pickReference(event: ChangeEvent) { const files = Array.from(event.target.files || []); @@ -740,11 +750,6 @@ export function ImageWorkbenchPage({ setPickedIds((prev) => (prev[0] === id ? [] : [id])); if (mode !== "cover") setPickedModelName((prev) => (pickedIds[0] === id ? "" : name || "")); } - // 单图就地反馈(§4.18):显示 1.5s 后自动撤掉 - function flashFeedback(key: string) { - setFeedbackKey(key); - window.setTimeout(() => setFeedbackKey((k) => (k === key ? "" : k)), 1500); - } // 点击空白收起单图「更多」气泡 useEffect(() => { if (!openMore) return; @@ -752,6 +757,13 @@ export function ImageWorkbenchPage({ document.addEventListener("click", close); return () => document.removeEventListener("click", close); }, [openMore]); + // YYX#12:点空白收起平台筛选弹窗 + useEffect(() => { + if (!filterOpen) return; + const close = (event: MouseEvent) => { if (!(event.target as HTMLElement).closest(".iw-filter-wrap")) setFilterOpen(false); }; + document.addEventListener("click", close); + return () => document.removeEventListener("click", close); + }, [filterOpen]); const ratioVar = ratio.replace(":", " / "); const candidateCount = Math.max(1, Number(count) || 4); @@ -939,6 +951,8 @@ export function ImageWorkbenchPage({ refs: opts.refs?.map((r) => ({ name: r.name, url: r.url })) }; setBatches((prev) => [...prev, newBatch]); + // YYX#16:新批次(含重跑/单图再生成)一律追加到末尾,提交即把面板滚到底部新任务处 + window.setTimeout(() => resultsEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }), 80); try { // 先把参考图上传成 Asset,拿到 id 列表带给后端 → 生成时真正作多图参考(image_edit)。 // 上传任一失败不阻断:跳过该张,有几张算几张。 @@ -1017,8 +1031,11 @@ export function ImageWorkbenchPage({ // 图片创作:把已选参考图带进这一批(startBatch 内上传并传给后端) refs: mode === "image" && refImages.length ? refImages : undefined }); - // 参考图已交给本批,清空输入栏待下次(批次头会保留这次用过的参考图) - if (mode === "image") setRefImages([]); + // 图片创作:提交后清空输入栏(参考图 + 提示词文字),像对话一样「发完即清」,等下次输入(PMC#15)。 + // 批次头会保留这次用过的提示词/参考图,信息不丢。模特/平台模式的提示词是按商品预填的模板,不清。 + if (mode === "image") { setRefImages([]); setPrompt(""); } + // 生成后自动把面板滚到最新批次,不用用户自己往下拖找(PMC#14/#22)。等新批次渲染出来再滚。 + window.setTimeout(() => resultsEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }), 80); } /* 重跑指定批次(行32①②):用该批次的参数新起一个批次追加到列表末尾,原批次保留。 */ @@ -1088,17 +1105,15 @@ export function ImageWorkbenchPage({ const withId = batch.results.filter((a) => a.id); if (!withId.length) return; const next = !withId.every((a) => a.in_library); - if (next) flashFeedback(`${batchId}:${withId[0].id}`); applyLibraryState(batchId, withId.map((a) => a.id), next); } - /* 加入资产库 / 取消(单图级:只改被点的那一张,§4.18 就地反馈不弹全局窗) */ + /* 加入资产库 / 取消(单图级:只改被点的那一张) */ function toggleAdoptImage(batchId: string, assetId: string) { if (!assetId) return; const asset = batches.find((b) => b.id === batchId)?.results.find((a) => a.id === assetId); if (!asset) return; const next = !asset.in_library; - if (next) flashFeedback(`${batchId}:${assetId}`); applyLibraryState(batchId, [assetId], next); } @@ -1169,8 +1184,12 @@ export function ImageWorkbenchPage({ const q = gridQuery.trim().toLowerCase(); list = list.filter((b) => (b.prompt || "").toLowerCase().includes(q)); } + // YYX#12:筛选弹窗按平台筛选已生成的图(选了哪些平台就只看哪些) + if (mode === "cover" && platformFilter.length) { + list = list.filter((b) => (b.platformIds || []).some((p) => platformFilter.includes(p))); + } return list; - }, [batches, productId, mode, gridQuery]); + }, [batches, productId, mode, gridQuery, platformFilter]); const hasResults = productBatches.length > 0; /* ── 单个批次的结果网格 · §4.18 gen-card 三件套 ── @@ -1196,9 +1215,8 @@ export function ImageWorkbenchPage({ const failed = !generating && !asset; // 非生成中且该格无图 → 这张失败了 const key = assetId || `ph-${index}`; const cellKey = `${batch.id}:${assetId || key}`; - const showFeedback = feedbackKey === `${batch.id}:${assetId}` && !!assetId; return ( -
+
{url ? ( {`${meta.title} setPreview({ src: url, name: `${meta.title} #${index + 1}` })} /> ) : ( @@ -1229,13 +1247,7 @@ export function ImageWorkbenchPage({ 已入库 )} - {/* ② 中央就地反馈(采用瞬间亮 1.5s,§4.18 禁全局 toast) */} - {url && ( -
- - 已加入资产库 -
- )} + {/* YYX#20:去掉「加入资产库」后的中央遮罩反馈,只保留左上角「已入库」标签 */} {/* ① 右上悬浮操作组:再生成 / 下载 / 更多 */} {url && (
@@ -1249,11 +1261,8 @@ export function ImageWorkbenchPage({ + {/* YYX#19:删掉「更多」气泡里的加入资产库(与批次操作行重复),只保留删除 */}
- + {/* YYX#17:折叠/展开左侧商品栏 */} +
@@ -1676,7 +1661,8 @@ export function ImageWorkbenchPage({ )}
{item.title}
-
// {item.category || "未分类"}
+ {/* YYX#15:商品分类去掉前缀「//」 */} +
{item.category || "未分类"}
); @@ -1703,12 +1689,50 @@ export function ImageWorkbenchPage({
{searchOpen && ( - setGridQuery(event.target.value)} style={{ height: 30, marginRight: 6, width: 140 }} /> +
+ setGridQuery(event.target.value)} /> + {/* YYX#12:输入框内 × 清空已输入内容 */} + {gridQuery && ( + + )} +
)}
+ {/* YYX#12:平台套图 —— 搜索 icon 右侧加筛选按钮,点开悬浮弹窗按平台筛选结果图 */} + {mode === "cover" && ( +
+ +
+
+ // 按平台筛选 + {/* 筛选弹窗清空按钮 */} + +
+
+ {PLATFORM_OPTIONS.map((p) => ( + + ))} +
+
+
+ )} {mode === "model" && ( <>
@@ -1955,6 +1979,8 @@ export function ImageWorkbenchPage({ setPreview(null)} /> {/* P0③:平台套图按平台分组出 section;模特图沿用每批一个导航头。按商品隔离 → productBatches */} {mode === "cover" ? renderCoverGrouped(productBatches) : renderBatchCards(productBatches)} + {/* YYX#16:新任务滚到底锚点 */} + @@ -2023,6 +2049,30 @@ export function ImageWorkbenchPage({ ); } +/* YYX#18:结果卡精简头部 —— 提示词单行折叠(超出给「展开/收起」)+ 灰色张数文案 */ +function BatchHeader({ prompt, count, statusText, adoptedCount }: { prompt: string; count: number; statusText: string; adoptedCount: number }) { + const [expanded, setExpanded] = useState(false); + const [overflow, setOverflow] = useState(false); + const ref = useRef(null); + useLayoutEffect(() => { + const el = ref.current; + if (el) setOverflow(el.scrollWidth > el.clientWidth + 1); // 折叠态(单行)是否被截断 + }, [prompt]); + return ( +
+
{prompt || "—"}
+ {(overflow || expanded) && ( + + )} +
+ {count} 张 · {statusText}{adoptedCount > 0 ? ` · ${adoptedCount} 张已入库` : ""} +
+
+ ); +} + /* 底部 chat 输入栏 · 参数胶囊(基线 image-optimize .param + 下拉气泡) */ function Pill({ label, diff --git a/core/frontend/src/routes/products.tsx b/core/frontend/src/routes/products.tsx index 899b026..1e0b52c 100644 --- a/core/frontend/src/routes/products.tsx +++ b/core/frontend/src/routes/products.tsx @@ -28,6 +28,12 @@ type ProductPayload = { selling_points?: Array<{ title: string; detail: string; sort_order: number }>; }; +// 复刻 mock-media productFor:按商品名关键词映射商品图 → 完整 mock 图 URL(无匹配返回 "") +// 导出供平台套图工作台复用(YYX#14:左侧商品列表无真封面时也回退到 mock,与商品库一致显图) +export function productMockCoverUrl(name: string): string { + const file = productCover(name); + return file ? `/exact/assets/mock/${file}` : ""; +} // 复刻 mock-media productFor:按商品名关键词映射商品图 function productCover(name: string): string { const t = name.replace(/\s+/g, "");