feat(core): 列表数据在途显示骨架屏(加载态),不再先显示空状态
渐进渲染后,列表在接口返回前会短暂显示「0 SKU / 显示 0」的空状态,易被误读为「没数据」。
新增 SkeletonGrid/SkeletonRows(components/loading),区分「加载中(请求在途且无数据)」与
「加载完确实为空」:
- 全局态页(商品库/视频项目/工作台最近项目):App 传 loading={!dataLoaded},未加载完显示骨架。
- 自取页(资产库/商品详情AI素材/图片生成任务/账单流水):各自 loading 标志,首次拉取时显示骨架/加载行。
- 团队成员有 owner 兜底行(非空)不改。
闸:9 页全绿、功能正常;实测 /products 加载期显示骨架→数据到后渲染真实卡片。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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<AITask[]>([]);
|
||||
const [tasksLoading, setTasksLoading] = useState(true);
|
||||
const [assets, setAssets] = useState<Asset[]>([]);
|
||||
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} 个任务
|
||||
</div>
|
||||
|
||||
{aiTasks.length === 0 ? (
|
||||
{tasksLoading && aiTasks.length === 0 ? (
|
||||
<SkeletonRows count={5} />
|
||||
) : aiTasks.length === 0 ? (
|
||||
<div className="task-empty">
|
||||
<div className="mono">// NO TASKS YET</div>
|
||||
<div>还没有任务,去上方选一个工序开始生成吧</div>
|
||||
@@ -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<string, { key: string; title: string; items: GenBatch[] }>();
|
||||
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 (
|
||||
<>
|
||||
<MediaLightbox open={!!preview} src={preview?.src || ""} kind="image" name={preview?.name} close={() => 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({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="iw-pv-h">
|
||||
<Quote className="quote-icon" />
|
||||
<div className="pv-meta">
|
||||
<b>{batches.length} 批</b>
|
||||
{mode === "model" ? ` · ${ratio}` : ""}
|
||||
</div>
|
||||
<div className="pv-line">
|
||||
<span className="k">商品</span>
|
||||
<span className="v">{product?.title || "未选择"}</span>
|
||||
</div>
|
||||
{mode === "cover" && (
|
||||
<div className="pv-line">
|
||||
<span className="k">平台</span>
|
||||
<span className="v">
|
||||
{pickedIds.length
|
||||
? PLATFORM_OPTIONS.filter((p) => pickedIds.includes(p.id))
|
||||
.map((p) => p.name)
|
||||
.join("、")
|
||||
: "未选择"}
|
||||
</span>
|
||||
<MediaLightbox open={!!preview} src={preview?.src || ""} kind="image" name={preview?.name} close={() => setPreview(null)} />
|
||||
{/* 行34:按商品分组——每个商品一个独立导航头 + 自己的批次列表 */}
|
||||
{batchGroups.map((group) => (
|
||||
<Fragment key={group.key}>
|
||||
<div className="iw-pv-h">
|
||||
<Quote className="quote-icon" />
|
||||
<div className="pv-meta">
|
||||
<b>{group.items.length} 批</b>
|
||||
{mode === "model" ? ` · ${ratio}` : ""}
|
||||
</div>
|
||||
<div className="pv-line">
|
||||
<span className="k">商品</span>
|
||||
<span className="v">{group.title}</span>
|
||||
</div>
|
||||
{mode === "cover" && (
|
||||
<div className="pv-line">
|
||||
<span className="k">平台</span>
|
||||
<span className="v">
|
||||
{pickedIds.length
|
||||
? PLATFORM_OPTIONS.filter((p) => pickedIds.includes(p.id))
|
||||
.map((p) => p.name)
|
||||
.join("、")
|
||||
: "未选择"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{renderBatchList()}
|
||||
{renderBatchCards(group.items)}
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user