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 (
+27
View File
@@ -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;
}
+28
View File
@@ -0,0 +1,28 @@
import "./loading.css";
// 列表数据在途时的骨架屏(grid 卡片 / 行),替代「为空」状态,让用户看到「加载中」而非「没数据」。
export function SkeletonGrid({ count = 8 }: { count?: number }) {
return (
<div>
<div className="skeleton-loading-tag">// 加载中…</div>
<div className="skeleton-grid">
{Array.from({ length: count }).map((_, i) => (
<div className="skeleton-block skeleton-card" key={i} />
))}
</div>
</div>
);
}
export function SkeletonRows({ count = 6 }: { count?: number }) {
return (
<div>
<div className="skeleton-loading-tag">// 加载中…</div>
<div className="skeleton-rows">
{Array.from({ length: count }).map((_, i) => (
<div className="skeleton-block skeleton-line" key={i} />
))}
</div>
</div>
);
}
+8 -2
View File
@@ -71,13 +71,15 @@ export function AccountPage({ billing, projects, onRecharge }: {
const [billPage, setBillPage] = useState(1);
const [ledgerRows, setLedgerRows] = useState<Ledger[]>([]);
const [ledgerCount, setLedgerCount] = useState<number>(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 }: {
<table className="billing-table">
<thead><tr><th></th><th> / </th><th></th><th></th><th></th><th style={{ textAlign: "right" }}></th></tr></thead>
<tbody>
{ledgerRows.map((l) => (
{ledgersLoading && ledgerRows.length === 0 ? (
<tr><td colSpan={6} className="muted" style={{ textAlign: "center", padding: "24px 0" }}>// 加载中…</td></tr>
) : ledgerRows.length === 0 ? (
<tr><td colSpan={6} className="muted" style={{ textAlign: "center", padding: "24px 0" }}>// 暂无账单流水</td></tr>
) : ledgerRows.map((l) => (
<tr key={l.id}>
<td className="ts">{new Date(l.created_at).toLocaleString("zh-CN")}</td>
<td>{ledgerTypeLabel(l.ledger_type)}</td>
+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>
+4 -2
View File
@@ -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
</div>
</div>
<div className="stats with-corners"><span className="corner-tr">+</span><span className="corner-bl">+</span><KpiStat label="总项目" badge="ALL" value={projCount} delta={`↑ 本月 +${Math.max(projCount, 0)}`} /><KpiStat label="进行中" badge="WIP" value={running} delta="待处理" /><KpiStat label="成片" badge="DONE" value={completed} delta="导出完成" /><button className="stat" type="button" onClick={() => navigate("account")}><div className="lbl"> <span className="badge">¥</span></div><div className="v">{money(billing?.account.balance)}</div><div className="bar"><span style={{ width: "38%" }} /></div><div className="sub"> {money(billing?.account.reserved_balance)}</div></button></div>
<div className="dash-grid"><div><div className="section-h"><h2></h2><button className="more" type="button" onClick={() => navigate("projects")}>[ ALL · {projCount} ] </button></div><div className="card-hard">{projects.slice(0, 6).map((project) => <button className="recent-row" key={project.id} type="button" onClick={() => navigate("pipeline")}><div className="placeholder thumb"><span className="ph-frame">9:16</span></div><div className="recent-meta"><div className="name">{project.name}</div><div className="sub">{productTitle(project.product)} / AI / 4 </div></div><Progress status={project.current_stage} /><span className={`pill ${statusPill(project.status)}`}><span className="dot" />{stageMeta[project.current_stage]?.label || project.current_stage}</span><span className="btn btn-sm"></span></button>)}</div></div><div style={{ display: "flex", flexDirection: "column", gap: 24 }}><div><div className="section-h"><h2></h2><span className="more">[ /shortcuts ]</span></div><div className="shortcuts"><Shortcut name="package" title="" desc={`${prodCount} SKU`} onClick={() => navigate("products")} /><Shortcut name="images" title="" desc={`${assetCount ?? ""} `} onClick={() => navigate("library")} /><Shortcut name="creditCard" title="" desc={money(billing?.account.balance)} onClick={() => navigate("account")} /><Shortcut name="clapperboard" title="" desc={`${projCount} `} onClick={() => navigate("projects")} /></div></div><div><div className="section-h"><h2></h2><span className="more">[ FAQ ]</span></div><div className="tip"><strong></strong> <span className="mono">[ ]</span> token </div></div></div></div>
<div className="dash-grid"><div><div className="section-h"><h2></h2><button className="more" type="button" onClick={() => navigate("projects")}>[ ALL · {projCount} ] </button></div><div className="card-hard">{loading && projects.length === 0 ? <SkeletonRows count={4} /> : projects.slice(0, 6).map((project) => <button className="recent-row" key={project.id} type="button" onClick={() => navigate("pipeline")}><div className="placeholder thumb"><span className="ph-frame">9:16</span></div><div className="recent-meta"><div className="name">{project.name}</div><div className="sub">{productTitle(project.product)} / AI / 4 </div></div><Progress status={project.current_stage} /><span className={`pill ${statusPill(project.status)}`}><span className="dot" />{stageMeta[project.current_stage]?.label || project.current_stage}</span><span className="btn btn-sm"></span></button>)}</div></div><div style={{ display: "flex", flexDirection: "column", gap: 24 }}><div><div className="section-h"><h2></h2><span className="more">[ /shortcuts ]</span></div><div className="shortcuts"><Shortcut name="package" title="" desc={`${prodCount} SKU`} onClick={() => navigate("products")} /><Shortcut name="images" title="" desc={`${assetCount ?? ""} `} onClick={() => navigate("library")} /><Shortcut name="creditCard" title="" desc={money(billing?.account.balance)} onClick={() => navigate("account")} /><Shortcut name="clapperboard" title="" desc={`${projCount} `} onClick={() => navigate("projects")} /></div></div><div><div className="section-h"><h2></h2><span className="more">[ FAQ ]</span></div><div className="tip"><strong></strong> <span className="mono">[ ]</span> token </div></div></div></div>
</>
);
}
+4 -1
View File
@@ -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
);
})}
</div>
) : loading ? (
<SkeletonGrid count={8} />
) : (
<div className="empty-filter">// {loading ? "加载中…" : "当前分类暂无真实资产"}</div>
<div className="empty-filter">// 当前分类暂无真实资产</div>
)}
<Pager page={curPage} total={total} pageSize={LIB_PAGE_SIZE} onChange={setPage} />
+43 -23
View File
@@ -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<Product | null | undefined> | void;
@@ -198,27 +200,31 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o
</div>
<div className="result-meta" id="result-meta">
<span>// 显示 <span className="count">{pageItems.length}</span> / {filtered.length} 个商品</span>
<span>// {loading && products.length === 0 ? "加载中…" : <>显示 <span className="count">{pageItems.length}</span> / {filtered.length} 个商品</>}</span>
</div>
<div className="product-grid-wrap">
<div className="product-grid" id="product-grid">
{pageItems.map((product) => (
<ProductCard
key={product.id}
product={product}
coverUrl={resolveCoverUrl(product)}
videoCount={projects.filter((p) => 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 ? (
<SkeletonGrid count={8} />
) : (
<div className="product-grid-wrap">
<div className="product-grid" id="product-grid">
{pageItems.map((product) => (
<ProductCard
key={product.id}
product={product}
coverUrl={resolveCoverUrl(product)}
videoCount={projects.filter((p) => 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}
/>
))}
</div>
<Pager page={curPage} total={filtered.length} pageSize={PROD_PAGE_SIZE} onChange={setPage} />
</div>
<Pager page={curPage} total={filtered.length} pageSize={PROD_PAGE_SIZE} onChange={setPage} />
</div>
)}
</div>
{/* 编辑模式浮动操作条 */}
@@ -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<HTMLDivElement>(null);
const [actionsLockH, setActionsLockH] = useState<number | null>(null);
function startEditing() {
if (actionsRef.current) setActionsLockH(actionsRef.current.offsetHeight);
setEditing(true);
}
const [triOpen, setTriOpen] = useState(false);
const imgInputRef = useRef<HTMLInputElement>(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<Asset[]>([]);
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
</div>
</div>
{/* view 模式: 单个 [编辑信息] */}
<button className="ov-edit ov-edit-single" type="button" id="ov-edit-btn" title="编辑商品信息" onClick={() => setEditing(true)}>
<button className="ov-edit ov-edit-single" type="button" id="ov-edit-btn" title="编辑商品信息" onClick={startEditing}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 20h9" /><path d="M16.5 3.5a2.12 2.12 0 013 3L7 19l-4 1 1-4L16.5 3.5z" /></svg>
</button>
@@ -851,7 +869,7 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
</div>
</div>
<div className="ov-card ov-actions">
<div className="ov-card ov-actions" ref={actionsRef} style={editing && actionsLockH ? { height: `${actionsLockH}px` } : undefined}>
<div className="ov-h"><span className="ti"></span></div>
<div className="qa-section">
<div className="qa-section-h">// 图片生成</div>
@@ -930,7 +948,9 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
</div>
</div>
{assetCount === 0 ? (
{assetsLoading && assetCount === 0 ? (
<SkeletonGrid count={6} />
) : assetCount === 0 ? (
<div className="empty-state show pd-empty-assets">
<div className="ic-empty">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" /><circle cx="9" cy="9" r="2" /><path d="M21 15l-5-5L5 21" /></svg>
+7 -3
View File
@@ -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<unknown> | void;
openPipeline: (projectId: string) => void;
@@ -564,9 +566,11 @@ export function ProjectsPage({ products, projects, navigate, openPipeline, onDel
</div>
</div>
<div className="result-meta" id="result-meta">// 显示 <span className="count">{pageItems.length}</span> / {filtered.length} 个项目</div>
<div className="result-meta" id="result-meta">// {loading && projects.length === 0 ? "加载中…" : <>显示 <span className="count">{pageItems.length}</span> / {filtered.length} 个项目</>}</div>
{view === "list" ? (
{loading && projects.length === 0 ? (
<SkeletonRows count={6} />
) : view === "list" ? (
<div id="list-view">
<table className="t">
<thead>