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:
+30
-11
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user