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:
zyc
2026-06-17 21:37:07 +08:00
co-authored by Claude Opus 4.8
parent 6e73fb53dd
commit 7c6d4477da
9 changed files with 228 additions and 78 deletions
+77 -36
View File
@@ -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>