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
+30 -11
View File
@@ -107,6 +107,7 @@ export function App() {
const [modelConfigs, setModelConfigs] = useState<ModelConfig[]>([]);
const [billing, setBilling] = useState<BillingSummary | null>(null);
const [unreadCount, setUnreadCount] = useState(0);
const [dataLoaded, setDataLoaded] = useState(false); // 全局数据(商品/项目)首次加载完成前,列表显示加载态而非空态
const [projectDetail, setProjectDetail] = useState<Project | null>(null);
const [exportResult, setExportResult] = useState<ExportPoll | null>(null);
const [preferences, setPreferences] = useState<UserPreference | null>(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 <Dashboard products={products} projects={projects} productTotal={productTotal} projectTotal={projectTotal} billing={billing} userName={currentUser.username} navigate={navigate} />;
return <Dashboard products={products} projects={projects} productTotal={productTotal} projectTotal={projectTotal} billing={billing} userName={currentUser.username} loading={!dataLoaded} navigate={navigate} />;
case "products":
return (
<ProductsPage
products={products}
loading={!dataLoaded}
projects={projects}
navigate={navigate}
openProduct={(productId, tab) => navigate("productDetail", { productId, hash: tab === "videos" ? "videos" : undefined })}
@@ -575,6 +574,7 @@ export function App() {
return (
<ProductsPage
products={products}
loading={!dataLoaded}
projects={projects}
navigate={navigate}
openProduct={(productId, tab) => navigate("productDetail", { productId, hash: tab === "videos" ? "videos" : undefined })}
@@ -585,7 +585,7 @@ export function App() {
/>
);
case "productDetail":
if (!activeProduct) return <ProductsPage products={products} navigate={navigate} openProduct={(productId, tab) => navigate("productDetail", { productId, hash: tab === "videos" ? "videos" : undefined })} onCreate={(payload) => action(() => api.createProduct(payload), "")} onDelete={(productId) => action(() => api.deleteProduct(productId), "商品已删除")} />;
if (!activeProduct) return <ProductsPage products={products} loading={!dataLoaded} navigate={navigate} openProduct={(productId, tab) => navigate("productDetail", { productId, hash: tab === "videos" ? "videos" : undefined })} onCreate={(payload) => action(() => api.createProduct(payload), "")} onDelete={(productId) => action(() => api.deleteProduct(productId), "商品已删除")} />;
return (
<ProductDetailPage
product={activeProduct}
@@ -604,6 +604,7 @@ export function App() {
<ProjectsPage
products={products}
projects={projects}
loading={!dataLoaded}
navigate={navigate}
onCreate={(payload) => action(() => api.createProject(payload), "项目已创建")}
openPipeline={(projectId) => navigate("pipeline", { projectId })}
@@ -699,7 +700,7 @@ export function App() {
case "settingsNotify":
return <SettingsPage user={currentUser} team={currentTeam} initialSection="notify" preferences={preferences} sessions={sessions} onSavePreferences={savePreferences} onRevokeSession={revokeSession} onRevokeOthers={revokeOtherSessions} onSaveProfile={saveProfile} onChangePassword={changeOwnPassword} onUploadAvatar={uploadOwnAvatar} onResetAvatar={resetOwnAvatar} onNotify={(text) => setNotice({ type: "success", text })} onLogout={logout} />;
default:
return <Dashboard products={products} projects={projects} productTotal={productTotal} projectTotal={projectTotal} billing={billing} navigate={navigate} />;
return <Dashboard products={products} projects={projects} productTotal={productTotal} projectTotal={projectTotal} billing={billing} loading={!dataLoaded} navigate={navigate} />;
}
}
@@ -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 (
<div className="app">
<main>
<div className="content">
<div className="page-head">
<div>
<h1></h1>
<div className="sub"><span className="mono">// 正在拉取项目数据</span></div>
</div>
</div>
</div>
</main>
</div>
);
}
if (page === "pipeline" && pipelineProject) {
const textModel = modelConfigs.find((m) => m.capability === "text" && m.status === "active") || modelConfigs.find((m) => m.capability === "text");
return (