From 7c6d4477da14210b5a37ac63c2120dfc0070e8aa Mon Sep 17 00:00:00 2001 From: zyc <1439655764@qq.com> Date: Wed, 17 Jun 2026 21:33:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(core):=20=E5=88=97=E8=A1=A8=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=9C=A8=E9=80=94=E6=98=BE=E7=A4=BA=E9=AA=A8=E6=9E=B6?= =?UTF-8?q?=E5=B1=8F(=E5=8A=A0=E8=BD=BD=E6=80=81),=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E5=85=88=E6=98=BE=E7=A4=BA=E7=A9=BA=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 渐进渲染后,列表在接口返回前会短暂显示「0 SKU / 显示 0」的空状态,易被误读为「没数据」。 新增 SkeletonGrid/SkeletonRows(components/loading),区分「加载中(请求在途且无数据)」与 「加载完确实为空」: - 全局态页(商品库/视频项目/工作台最近项目):App 传 loading={!dataLoaded},未加载完显示骨架。 - 自取页(资产库/商品详情AI素材/图片生成任务/账单流水):各自 loading 标志,首次拉取时显示骨架/加载行。 - 团队成员有 owner 兜底行(非空)不改。 闸:9 页全绿、功能正常;实测 /products 加载期显示骨架→数据到后渲染真实卡片。 Co-Authored-By: Claude Opus 4.8 --- core/frontend/src/App.tsx | 41 +++++--- core/frontend/src/components/loading.css | 27 ++++++ core/frontend/src/components/loading.tsx | 28 ++++++ core/frontend/src/routes/account.tsx | 10 +- core/frontend/src/routes/ai-tools.tsx | 113 +++++++++++++++-------- core/frontend/src/routes/dashboard.tsx | 6 +- core/frontend/src/routes/library.tsx | 5 +- core/frontend/src/routes/products.tsx | 66 ++++++++----- core/frontend/src/routes/projects.tsx | 10 +- 9 files changed, 228 insertions(+), 78 deletions(-) create mode 100644 core/frontend/src/components/loading.css create mode 100644 core/frontend/src/components/loading.tsx diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index 937d59a..0b6f3c2 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -107,6 +107,7 @@ export function App() { const [modelConfigs, setModelConfigs] = useState([]); const [billing, setBilling] = useState(null); const [unreadCount, setUnreadCount] = useState(0); + const [dataLoaded, setDataLoaded] = useState(false); // 全局数据(商品/项目)首次加载完成前,列表显示加载态而非空态 const [projectDetail, setProjectDetail] = useState(null); const [exportResult, setExportResult] = useState(null); const [preferences, setPreferences] = useState(null); @@ -124,10 +125,6 @@ export function App() { return () => clearTimeout(timer); }, [notice]); - const activeProject = useMemo( - () => projects.find((project) => project.id === activeProjectId) || projects[0], - [projects, activeProjectId] - ); const activeProduct = useMemo( () => products.find((product) => product.id === activeProductId) || products[0], [products, activeProductId] @@ -154,6 +151,7 @@ export function App() { if (badgeData) setUnreadCount(badgeData.unread_count); setActiveProjectId((current) => current || projectData.results[0]?.id || ""); setActiveProductId((current) => current || productData.results[0]?.id || ""); + setDataLoaded(true); }, []); // 首登水合带重试:loadData 里 products/projects/allAssets 没有 .catch,任一瞬时失败会让整个 @@ -403,7 +401,7 @@ export function App() { if (res) setUser(res); } - function generateImages(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; reference_product?: boolean }) { + function generateImages(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; reference_product?: boolean; model_id?: string; ratio?: string }) { // 异步生图:提交后立刻拿到任务列表,前端轮询直到出图。慢的 ARK 出图在 Celery worker 里跑—— // Web 层不被 ~30s 请求占住 → 健康探针不饿死 → 根治"几张图整站 502";且提交成功后浏览器关掉/断网, // worker 仍会把图生成并落库(扣费/退费在 worker 内闭环),重开素材库即可见。 @@ -556,11 +554,12 @@ export function App() { function renderPage() { switch (page) { case "dashboard": - return ; + return ; case "products": return ( navigate("productDetail", { productId, hash: tab === "videos" ? "videos" : undefined })} @@ -575,6 +574,7 @@ export function App() { return ( navigate("productDetail", { productId, hash: tab === "videos" ? "videos" : undefined })} @@ -585,7 +585,7 @@ export function App() { /> ); case "productDetail": - if (!activeProduct) return navigate("productDetail", { productId, hash: tab === "videos" ? "videos" : undefined })} onCreate={(payload) => action(() => api.createProduct(payload), "")} onDelete={(productId) => action(() => api.deleteProduct(productId), "商品已删除")} />; + if (!activeProduct) return navigate("productDetail", { productId, hash: tab === "videos" ? "videos" : undefined })} onCreate={(payload) => action(() => api.createProduct(payload), "")} onDelete={(productId) => action(() => api.deleteProduct(productId), "商品已删除")} />; return ( action(() => api.createProject(payload), "项目已创建")} openPipeline={(projectId) => navigate("pipeline", { projectId })} @@ -699,7 +700,7 @@ export function App() { case "settingsNotify": return setNotice({ type: "success", text })} onLogout={logout} />; default: - return ; + return ; } } @@ -707,9 +708,27 @@ export function App() { const here = crumbLabels[page] || routeLabels[page] || "工作台"; // 生产管线 · 全屏 bespoke 布局(自带顶栏 + 步进器),脱离常规 shell - // projectDetail 只有在确实是当前激活项目时才用;否则回退 activeProject。 - // 否则后台 refreshProjectDetail 拿旧 id 的结果会把刚进入的新项目串成上一个项目(实测发现)。 - const pipelineProject = projectDetail && projectDetail.id === activeProjectId ? projectDetail : activeProject; + // 这里只认「当前激活项目的完整详情」(含 stages/timeline/script_versions/metadata 等嵌套字段)。 + // 不能回退到列表轻量项目(activeProject):ProjectListSerializer 不带这些嵌套字段,PipelinePage 直接读会白屏 —— + // 这正是「站内点进去白屏、刷新就正常」的根因(刷新走 booting 等详情拉好才渲染,站内导航却拿轻量数据先渲染)。 + const pipelineProject = projectDetail && projectDetail.id === activeProjectId ? projectDetail : null; + // 详情还在拉取(刚切项目 / 首进管线):显示全屏加载占位,等完整详情到位再渲染,而不是拿轻量数据去崩。 + if (page === "pipeline" && activeProjectId && !pipelineProject) { + return ( +
+
+
+
+
+

加载中…

+
// 正在拉取项目数据
+
+
+
+
+
+ ); + } if (page === "pipeline" && pipelineProject) { const textModel = modelConfigs.find((m) => m.capability === "text" && m.status === "active") || modelConfigs.find((m) => m.capability === "text"); return ( diff --git a/core/frontend/src/components/loading.css b/core/frontend/src/components/loading.css new file mode 100644 index 0000000..c4420bf --- /dev/null +++ b/core/frontend/src/components/loading.css @@ -0,0 +1,27 @@ +/* 列表加载骨架屏:数据在途时显示「加载中」的形,而非空状态。冷灰底上做轻微 shimmer。 */ +@keyframes sk-shimmer { + 0% { background-position: -480px 0; } + 100% { background-position: 480px 0; } +} +.skeleton-block { + background: var(--background-base, #f1f1f1); + background-image: linear-gradient(90deg, rgba(0,0,0,0.03) 0%, rgba(0,0,0,0.07) 40%, rgba(0,0,0,0.03) 80%); + background-size: 480px 100%; + background-repeat: no-repeat; + animation: sk-shimmer 1.2s ease-in-out infinite; + border-radius: 8px; +} +.skeleton-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 16px; +} +.skeleton-grid .skeleton-card { aspect-ratio: 1 / 1; } +.skeleton-rows { display: flex; flex-direction: column; gap: 10px; } +.skeleton-rows .skeleton-line { height: 56px; } +.skeleton-loading-tag { + font-family: var(--font-mono, monospace); + font-size: 12px; + color: var(--black-alpha-56, #8a8a8a); + padding: 4px 0 12px; +} diff --git a/core/frontend/src/components/loading.tsx b/core/frontend/src/components/loading.tsx new file mode 100644 index 0000000..830e900 --- /dev/null +++ b/core/frontend/src/components/loading.tsx @@ -0,0 +1,28 @@ +import "./loading.css"; + +// 列表数据在途时的骨架屏(grid 卡片 / 行),替代「为空」状态,让用户看到「加载中」而非「没数据」。 +export function SkeletonGrid({ count = 8 }: { count?: number }) { + return ( +
+
// 加载中…
+
+ {Array.from({ length: count }).map((_, i) => ( +
+ ))} +
+
+ ); +} + +export function SkeletonRows({ count = 6 }: { count?: number }) { + return ( +
+
// 加载中…
+
+ {Array.from({ length: count }).map((_, i) => ( +
+ ))} +
+
+ ); +} diff --git a/core/frontend/src/routes/account.tsx b/core/frontend/src/routes/account.tsx index 5fd8d0a..53bb30a 100644 --- a/core/frontend/src/routes/account.tsx +++ b/core/frontend/src/routes/account.tsx @@ -71,13 +71,15 @@ export function AccountPage({ billing, projects, onRecharge }: { const [billPage, setBillPage] = useState(1); const [ledgerRows, setLedgerRows] = useState([]); const [ledgerCount, setLedgerCount] = useState(0); + const [ledgersLoading, setLedgersLoading] = useState(true); useEffect(() => { let alive = true; + setLedgersLoading(true); api.ledgers(billPage, BILLS_PER_PAGE).then((data) => { if (!alive) return; setLedgerRows(data.results); setLedgerCount(data.count); - }).catch(() => {}); + }).catch(() => {}).finally(() => { if (alive) setLedgersLoading(false); }); return () => { alive = false; }; }, [billPage, reloadFlag]); const billTotalPages = Math.max(1, Math.ceil(ledgerCount / BILLS_PER_PAGE)); @@ -286,7 +288,11 @@ export function AccountPage({ billing, projects, onRecharge }: { - {ledgerRows.map((l) => ( + {ledgersLoading && ledgerRows.length === 0 ? ( + + ) : ledgerRows.length === 0 ? ( + + ) : ledgerRows.map((l) => ( diff --git a/core/frontend/src/routes/ai-tools.tsx b/core/frontend/src/routes/ai-tools.tsx index d10b4ea..1879520 100644 --- a/core/frontend/src/routes/ai-tools.tsx +++ b/core/frontend/src/routes/ai-tools.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { Fragment, useEffect, useMemo, useRef, useState } from "react"; import type { ChangeEvent } from "react"; import { ArrowLeft, @@ -23,6 +23,7 @@ import { } from "lucide-react"; import type { AITask, Asset, ModelConfig, Product } from "../types"; import { api } from "../api"; +import { SkeletonRows } from "../components/loading"; import { MediaLightbox, SuccessModal } from "../components/overlays"; import { Pager } from "../components/pager"; import type { Page } from "./route-config"; @@ -84,10 +85,12 @@ async function downloadImage(url: string, filename: string) { export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void }) { // 任务历史 + 任务→结果图:都在本页懒加载(不再吃全局 bootstrap 的 aiTasks/assets 全量)。 const [aiTasks, setAiTasks] = useState([]); + const [tasksLoading, setTasksLoading] = useState(true); const [assets, setAssets] = useState([]); useEffect(() => { let alive = true; - api.aiTasks().then((res) => { if (alive) setAiTasks(res.results || []); }).catch(() => {}); + setTasksLoading(true); + api.aiTasks().then((res) => { if (alive) setAiTasks(res.results || []); }).catch(() => {}).finally(() => { if (alive) setTasksLoading(false); }); api.assetsPage({ asset_type: "image", source: "ai_generated", pageSize: 200 }) .then((res) => { if (alive) setAssets(res.results); }) .catch(() => { if (alive) setAssets([]); }); @@ -288,7 +291,9 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void // 显示 {paged.length} / {visible.length} 个任务 - {aiTasks.length === 0 ? ( + {tasksLoading && aiTasks.length === 0 ? ( + + ) : aiTasks.length === 0 ? (
// NO TASKS YET
还没有任务,去上方选一个工序开始生成吧
@@ -477,6 +482,11 @@ type GenBatch = { /** 批次级:是否已"加入资产库"(可来回切) */ adopted: boolean; ts: number; + /** 该批次归属的商品(用于按商品分组,各商品一个导航头) */ + productId?: string; + productTitle?: string; + /** 该批次选中的模特资产 id(模特上身图:重跑时沿用同一模特) */ + modelId?: string; }; export function ImageWorkbenchPage({ @@ -494,7 +504,7 @@ export function ImageWorkbenchPage({ modelConfigs: ModelConfig[]; onBack: () => void; navigate?: (page: Page) => void; - onGenerate: (payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string }) => Promise<{ assets: Asset[] } | null>; + onGenerate: (payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; model_id?: string; ratio?: string }) => Promise<{ assets: Asset[] } | null>; onResume?: (mode: "image" | "model" | "cover", ids: string[]) => Promise<{ assets: Asset[] } | null>; /** 行19:从商品页带入的初始商品 id(可选);未传则回退到第一个商品 */ initialProductId?: string; @@ -571,7 +581,10 @@ export function ImageWorkbenchPage({ const ratioVar = ratio.replace(":", " / "); const candidateCount = Math.max(1, Number(count) || 4); // 行31:并发提交——只要 prompt 非空就能再次提交,不再因"有批次在跑"被禁用 - const canGenerate = prompt.trim().length > 0; + // 模特上身图需结合「商品图 + 模特图」合成,故必须先选商品且选一个模特,否则只是凭文字脑补 + const canGenerate = + prompt.trim().length > 0 && + (mode !== "model" || (!!product?.id && pickedIds.length > 0)); const anyGenerating = batches.some((b) => b.status === "generating"); /* 多批次本地持久化(行33):用 ai-tools 私有 key,不与 App 的单批 `airshelf:imgwb:{mode}` 抢同一槽。 @@ -598,12 +611,15 @@ export function ImageWorkbenchPage({ status: "generating", results: [], adopted: false, - ts: Date.now() + ts: Date.now(), + productId: product?.id, + productTitle: product?.title, + modelId: mode === "model" ? pickedIds[0] : undefined }; // 追加新批次(行32②:不覆盖,在下面新增一行) setBatches((prev) => [...prev, newBatch]); try { - const result = await onGenerate({ prompt: prompt.trim(), mode, count: candidateCount, product_id: product?.id }); + const result = await onGenerate({ prompt: prompt.trim(), mode, count: candidateCount, product_id: product?.id, model_id: mode === "model" ? pickedIds[0] : undefined, ratio }); setBatches((prev) => { const next = prev.map((b) => b.id === batchId @@ -633,11 +649,15 @@ export function ImageWorkbenchPage({ status: "generating", results: [], adopted: false, - ts: Date.now() + ts: Date.now(), + // 重跑归属原批次的商品,而非当前选中商品 + productId: src.productId ?? product?.id, + productTitle: src.productTitle ?? product?.title, + modelId: src.modelId }; setBatches((prev) => [...prev, newBatch]); try { - const result = await onGenerate({ prompt: src.prompt, mode, count: src.count, product_id: product?.id }); + const result = await onGenerate({ prompt: src.prompt, mode, count: src.count, product_id: src.productId ?? product?.id, model_id: src.modelId, ratio: src.ratio }); setBatches((prev) => { const next = prev.map((b) => b.id === batchId @@ -732,7 +752,7 @@ export function ImageWorkbenchPage({ if (prev.some((b) => b.results.length) && !(saved.results && saved.results.length)) return prev; return [ ...prev, - { id: batchId, prompt: meta.promptTemplate(product?.title || "商品"), ratio: meta.ratio, count: saved.count || candidateCount, status: "generating" as const, results: saved.results || [], adopted: false, ts: Date.now() } + { id: batchId, prompt: meta.promptTemplate(product?.title || "商品"), ratio: meta.ratio, count: saved.count || candidateCount, status: "generating" as const, results: saved.results || [], adopted: false, ts: Date.now(), productId: product?.id, productTitle: product?.title } ]; }); onResume(mode, pending) @@ -759,6 +779,22 @@ export function ImageWorkbenchPage({ const hasResults = batches.length > 0; + /* 按商品分组(行34):各商品各自一个导航头,避免多商品批次被并到同一个头里。 + 保持首次出现顺序;productId 缺失的旧批次归到当前商品组。 */ + const batchGroups = (() => { + const order: string[] = []; + const map = new Map(); + for (const b of batches) { + const key = b.productId || product?.id || "—"; + if (!map.has(key)) { + order.push(key); + map.set(key, { key, title: b.productTitle || product?.title || "未选择", items: [] }); + } + map.get(key)!.items.push(b); + } + return order.map((k) => map.get(k)!); + })(); + /* ── 单个批次的结果网格(行30:生成中转圈 icon;行32:单图 hover「更多」气泡菜单)── */ function renderBatchGrid(batch: GenBatch) { const generating = batch.status === "generating"; @@ -819,11 +855,10 @@ export function ImageWorkbenchPage({ } /* ── 批次列表(行31/32/33):每个批次一行卡片,底部一组操作 + 底部「更多」气泡 ── */ - function renderBatchList() { + function renderBatchCards(list: GenBatch[]) { return ( <> - setPreview(null)} /> - {batches.map((batch) => { + {list.map((batch) => { const generating = batch.status === "generating"; const failed = batch.status === "failed"; return ( @@ -1307,30 +1342,36 @@ export function ImageWorkbenchPage({
) : ( <> -
- -
- {batches.length} 批 - {mode === "model" ? ` · ${ratio}` : ""} -
-
- 商品 - {product?.title || "未选择"} -
- {mode === "cover" && ( -
- 平台 - - {pickedIds.length - ? PLATFORM_OPTIONS.filter((p) => pickedIds.includes(p.id)) - .map((p) => p.name) - .join("、") - : "未选择"} - + setPreview(null)} /> + {/* 行34:按商品分组——每个商品一个独立导航头 + 自己的批次列表 */} + {batchGroups.map((group) => ( + +
+ +
+ {group.items.length} 批 + {mode === "model" ? ` · ${ratio}` : ""} +
+
+ 商品 + {group.title} +
+ {mode === "cover" && ( +
+ 平台 + + {pickedIds.length + ? PLATFORM_OPTIONS.filter((p) => pickedIds.includes(p.id)) + .map((p) => p.name) + .join("、") + : "未选择"} + +
+ )}
- )} -
- {renderBatchList()} + {renderBatchCards(group.items)} + + ))} )}
diff --git a/core/frontend/src/routes/dashboard.tsx b/core/frontend/src/routes/dashboard.tsx index e0534ab..ad6c42f 100644 --- a/core/frontend/src/routes/dashboard.tsx +++ b/core/frontend/src/routes/dashboard.tsx @@ -2,17 +2,19 @@ import { useEffect, useState } from "react"; import type { BillingSummary, Product, Project } from "../types"; 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"; -export function Dashboard({ products, projects, productTotal, projectTotal, billing, userName, navigate }: { +export function Dashboard({ products, projects, productTotal, projectTotal, billing, userName, loading = false, navigate }: { products: Product[]; projects: Project[]; productTotal?: number; // 后端真实总数(分页 count),用于「N 个/SKU」展示 projectTotal?: number; billing: BillingSummary | null; userName?: string; + loading?: boolean; navigate: (page: Page) => void; }) { const projCount = projectTotal ?? projects.length; @@ -48,7 +50,7 @@ export function Dashboard({ products, projects, productTotal, projectTotal, bill
++
-

最近项目

{projects.slice(0, 6).map((project) => )}

快捷入口

[ /shortcuts ]
navigate("products")} /> navigate("library")} /> navigate("account")} /> navigate("projects")} />

提示

[ FAQ ]
扣费规则生成失败、超时、用户重跑 — 均不扣费。仅在你点 [ 确认通过 ] 时按 token 实际结算。
+

最近项目

{loading && projects.length === 0 ? : projects.slice(0, 6).map((project) => )}

快捷入口

[ /shortcuts ]
navigate("products")} /> navigate("library")} /> navigate("account")} /> navigate("projects")} />

提示

[ FAQ ]
扣费规则生成失败、超时、用户重跑 — 均不扣费。仅在你点 [ 确认通过 ] 时按 token 实际结算。
); } diff --git a/core/frontend/src/routes/library.tsx b/core/frontend/src/routes/library.tsx index a7da2f0..708f803 100644 --- a/core/frontend/src/routes/library.tsx +++ b/core/frontend/src/routes/library.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import type { FormEvent } from "react"; import { api } from "../api"; +import { SkeletonGrid } from "../components/loading"; import type { Asset } from "../types"; import { ConfirmModal, Drawer, MediaLightbox } from "../components/overlays"; import { Pager } from "../components/pager"; @@ -316,8 +317,10 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD ); })} + ) : loading ? ( + ) : ( -
// {loading ? "加载中…" : "当前分类暂无真实资产"}
+
// 当前分类暂无真实资产
)} diff --git a/core/frontend/src/routes/products.tsx b/core/frontend/src/routes/products.tsx index 347bc7b..a58c7b7 100644 --- a/core/frontend/src/routes/products.tsx +++ b/core/frontend/src/routes/products.tsx @@ -4,6 +4,7 @@ import { ArrowLeft, Trash2, X } from "lucide-react"; import { ConfirmModal, MediaLightbox, SuccessModal } from "../components/overlays"; import { ProductCreateDrawer, PC_CAT_OPTIONS } from "../components/product-create-drawer"; import { Pager } from "../components/pager"; +import { SkeletonGrid } from "../components/loading"; import { api } from "../api"; const PROD_PAGE_SIZE = 10; @@ -44,9 +45,10 @@ function resolveCoverUrl(product: Product): string { return product.cover_preview_url || firstImage?.preview_url || ""; } -export function ProductsPage({ products, projects = [], navigate, openProduct, onCreate, onUploadImage, onDelete, autoOpenCreate = false }: { +export function ProductsPage({ products, projects = [], loading = false, navigate, openProduct, onCreate, onUploadImage, onDelete, autoOpenCreate = false }: { products: Product[]; projects?: Project[]; + loading?: boolean; navigate: (page: Page) => void; openProduct: (productId: string, tab?: "assets" | "videos") => void; onCreate: (payload: ProductPayload) => Promise | void; @@ -198,27 +200,31 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o
- // 显示 {pageItems.length} / {filtered.length} 个商品 + // {loading && products.length === 0 ? "加载中…" : <>显示 {pageItems.length} / {filtered.length} 个商品}
-
-
- {pageItems.map((product) => ( - p.product === product.id).length} - editMode={editMode} - selected={selected.has(product.id)} - onOpen={() => (editMode ? toggleSelect(product.id) : openProduct(product.id))} - onOpenVideos={() => openProduct(product.id, "videos")} - onDelete={onDelete ? () => setConfirmIds([product.id]) : undefined} - /> - ))} + {loading && products.length === 0 ? ( + + ) : ( +
+
+ {pageItems.map((product) => ( + p.product === product.id).length} + editMode={editMode} + selected={selected.has(product.id)} + onOpen={() => (editMode ? toggleSelect(product.id) : openProduct(product.id))} + onOpenVideos={() => openProduct(product.id, "videos")} + onDelete={onDelete ? () => setConfirmIds([product.id]) : undefined} + /> + ))} +
+
- -
+ )}
{/* 编辑模式浮动操作条 */} @@ -506,6 +512,13 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na }) { const [tab, setTab] = useState<"assets" | "videos">(initialTab); const [editing, setEditing] = useState(false); + // 进编辑前实测「快速操作」面板的查看态高度并锁定,使其在编辑态保持原大小(左栏变高也不跟随拉伸) + const actionsRef = useRef(null); + const [actionsLockH, setActionsLockH] = useState(null); + function startEditing() { + if (actionsRef.current) setActionsLockH(actionsRef.current.offsetHeight); + setEditing(true); + } const [triOpen, setTriOpen] = useState(false); const imgInputRef = useRef(null); const [uploading, setUploading] = useState(false); @@ -529,13 +542,16 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na // ── 该商品的 AI 素材:服务端按 ?product 懒加载(不再吃全局 assets 全量数组)── const [productAssets, setProductAssets] = useState([]); + const [assetsLoading, setAssetsLoading] = useState(true); const [assetReload, setAssetReload] = useState(0); useEffect(() => { if (!product?.id) return; let alive = true; + setAssetsLoading(true); api.assetsPage({ product: product.id, pageSize: 200 }) .then((res) => { if (alive) setProductAssets(res.results); }) - .catch(() => { if (alive) setProductAssets([]); }); + .catch(() => { if (alive) setProductAssets([]); }) + .finally(() => { if (alive) setAssetsLoading(false); }); return () => { alive = false; }; }, [product?.id, assetReload]); @@ -665,6 +681,7 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na selling_points: points.map((item, index) => ({ title: item, detail: item, sort_order: index })) as Product["selling_points"] }); setEditing(false); + setActionsLockH(null); } function cancel() { setName(realName); @@ -673,6 +690,7 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na setPoints(realBullets); setPointDraft(""); setEditing(false); + setActionsLockH(null); } return ( @@ -749,7 +767,7 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na {/* view 模式: 单个 [编辑信息] */} - @@ -851,7 +869,7 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na -
+
快速操作
// 图片生成
@@ -930,7 +948,9 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
- {assetCount === 0 ? ( + {assetsLoading && assetCount === 0 ? ( + + ) : assetCount === 0 ? (
diff --git a/core/frontend/src/routes/projects.tsx b/core/frontend/src/routes/projects.tsx index 513f651..16b60c1 100644 --- a/core/frontend/src/routes/projects.tsx +++ b/core/frontend/src/routes/projects.tsx @@ -5,6 +5,7 @@ import type { Page } from "./route-config"; import { ConfirmModal, EmptyPanel } from "../components/overlays"; import { ProductCreateDrawer } from "../components/product-create-drawer"; import { Pager } from "../components/pager"; +import { SkeletonRows } from "../components/loading"; import "../project-wizard-page.css"; const PROJ_PAGE_SIZE = 10; // 项目列表/网格每页条数 @@ -388,9 +389,10 @@ const PROJ_TABS: Array<{ filter: "all" | "wip" | "done" | "fail"; label: string { filter: "all", label: "全部" }, { filter: "wip", label: "进行中" }, { filter: "done", label: "已完成" }, { filter: "fail", label: "失败" } ]; -export function ProjectsPage({ products, projects, navigate, openPipeline, onDelete }: { +export function ProjectsPage({ products, projects, loading = false, navigate, openPipeline, onDelete }: { products: Product[]; projects: Project[]; + loading?: boolean; navigate: (page: Page) => void; onCreate: (payload: { name: string; product: string }) => Promise | void; openPipeline: (projectId: string) => void; @@ -564,9 +566,11 @@ export function ProjectsPage({ products, projects, navigate, openPipeline, onDel
-
// 显示 {pageItems.length} / {filtered.length} 个项目
+
// {loading && projects.length === 0 ? "加载中…" : <>显示 {pageItems.length} / {filtered.length} 个项目}
- {view === "list" ? ( + {loading && projects.length === 0 ? ( + + ) : view === "list" ? (
时间项目 / 类型详情成员状态金额
// 加载中…
// 暂无账单流水
{new Date(l.created_at).toLocaleString("zh-CN")} {ledgerTypeLabel(l.ledger_type)}