perf(core): 资产库改服务端懒加载分页(tab/筛选/计数走后端,不再前端取全量切片)
- api.ts 加 assetsPage(分页+tab/category/source/type/product/q/metadata 过滤)、 assetSummary(tab 计数)、assetFacets(某 tab 真实存在的来源/类型/metadata 取值)。 - LibraryPage 不再吃全局 assets 全量数组:列表按页拉、tab 徽标走 summary、 筛选下拉走 facets(「只列真有的」体验保留)、搜索去抖、上传/删除后刷新。 - App.tsx 不再向 LibraryPage 透传 assets。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -660,7 +660,7 @@ export function App() {
|
||||
</div>
|
||||
);
|
||||
case "library":
|
||||
return <LibraryPage assets={assets} onUpload={(formData) => action(() => api.uploadAsset(formData), "资产已上传")} onDelete={(id) => action(() => api.deleteAsset(id), "资产已删除")} />;
|
||||
return <LibraryPage onUpload={(formData) => action(() => api.uploadAsset(formData), "资产已上传")} onDelete={(id) => action(() => api.deleteAsset(id), "资产已删除")} />;
|
||||
case "account":
|
||||
return (
|
||||
<AccountPage
|
||||
|
||||
@@ -370,6 +370,30 @@ export const api = {
|
||||
assets() {
|
||||
return request<Paginated<Asset>>("/api/assets/");
|
||||
},
|
||||
// 服务端分页+过滤的资产列表(各页按需懒加载,不再前端取全量再切片)。
|
||||
assetsPage(params: {
|
||||
tab?: string; category?: string; source?: string; asset_type?: string;
|
||||
product?: string; q?: string; ordering?: string; page?: number; pageSize?: number;
|
||||
meta?: Record<string, string>;
|
||||
} = {}) {
|
||||
const qs = new URLSearchParams();
|
||||
const { meta, pageSize, ...rest } = params;
|
||||
for (const [k, v] of Object.entries(rest)) if (v !== undefined && v !== "" && v !== null) qs.set(k, String(v));
|
||||
if (pageSize) qs.set("page_size", String(pageSize));
|
||||
if (meta) for (const [k, v] of Object.entries(meta)) if (v) qs.set(`m_${k}`, v);
|
||||
return request<Paginated<Asset>>(`/api/assets/?${qs.toString()}`);
|
||||
},
|
||||
// 资产库各 tab 计数(tab 徽标用,不必取全量)
|
||||
assetSummary() {
|
||||
return request<Record<string, number>>("/api/assets/summary/");
|
||||
},
|
||||
// 某 tab 下真实存在的筛选项(来源/类型/指定 metadata 键取值),供下拉「只列真有的」
|
||||
assetFacets(tab?: string, metaKeys: string[] = []) {
|
||||
const qs = new URLSearchParams();
|
||||
if (tab) qs.set("tab", tab);
|
||||
if (metaKeys.length) qs.set("meta_keys", metaKeys.join(","));
|
||||
return request<{ sources: string[]; kinds: string[]; metadata: Record<string, string[]> }>(`/api/assets/facets/?${qs.toString()}`);
|
||||
},
|
||||
// 经后端同源代理取资产原始文件(TOS 未配 CORS,浏览器抽帧/解码必须同源)
|
||||
async fetchAssetBlob(id: string): Promise<Blob> {
|
||||
const token = getToken();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { api } from "../api";
|
||||
import type { Asset } from "../types";
|
||||
import { ConfirmModal, Drawer, MediaLightbox } from "../components/overlays";
|
||||
import { Pager } from "../components/pager";
|
||||
@@ -53,7 +54,7 @@ function assetTab(asset: Asset): LibTab {
|
||||
}
|
||||
}
|
||||
|
||||
export function LibraryPage({ assets, onUpload, onDelete }: { assets: Asset[]; onUpload: (formData: FormData) => Promise<unknown> | void; onDelete?: (id: string) => Promise<unknown> | void }) {
|
||||
export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormData) => Promise<unknown> | void; onDelete?: (id: string) => Promise<unknown> | void }) {
|
||||
const [tab, setTab] = useState<LibTab>("people");
|
||||
const [query, setQuery] = useState("");
|
||||
const [drawer, setDrawer] = useState(false);
|
||||
@@ -75,12 +76,62 @@ export function LibraryPage({ assets, onUpload, onDelete }: { assets: Asset[]; o
|
||||
return () => document.body.classList.remove("edit-mode");
|
||||
}, [editMode]);
|
||||
|
||||
// 分页:每页 10 个,切 tab / 改筛选 / 改排序回到第 1 页(数据多时全量渲染卡顿,视频卡每张挂一个 <video>)
|
||||
// ── 服务端懒加载:列表按页拉、tab 计数走 summary、筛选项走 facets(不再前端取全量再切片)──
|
||||
const [items, setItems] = useState<Asset[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [counts, setCounts] = useState<Record<LibTab, number>>({ people: 0, scenes: 0, products: 0, finals: 0, uploads: 0, unclassified: 0 });
|
||||
const [facets, setFacets] = useState<{ sources: string[]; kinds: string[]; metadata: Record<string, string[]> }>({ sources: [], kinds: [], metadata: {} });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
useEffect(() => { setPage(1); }, [tab, query, srcFilter, kindFilter, metaFilter, sortDesc]);
|
||||
|
||||
// 搜索去抖:输入停 400ms 才打接口,避免逐字符请求
|
||||
const [debouncedQuery, setDebouncedQuery] = useState("");
|
||||
useEffect(() => { const id = setTimeout(() => setDebouncedQuery(query), 400); return () => clearTimeout(id); }, [query]);
|
||||
|
||||
// 切 tab / 改筛选 / 改排序回到第 1 页
|
||||
useEffect(() => { setPage(1); }, [tab, debouncedQuery, srcFilter, kindFilter, metaFilter, sortDesc]);
|
||||
// 切 tab 时清空与该 tab 无关的筛选
|
||||
useEffect(() => { setOpenChip(""); setSrcFilter(""); setKindFilter(""); setMetaFilter({}); }, [tab]);
|
||||
|
||||
// 当前 tab 的 metadata 筛选键(来源/类型走独立字段,不算 metadata)
|
||||
const metaKeys = LIB_CHIPS.filter((c) => c.tabs.includes(tab) && c.key !== "source" && c.key !== "kind").map((c) => c.key);
|
||||
|
||||
// 列表数据(分页 + 过滤)
|
||||
const [reloadFlag, setReloadFlag] = useState(0);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
api.assetsPage({
|
||||
tab,
|
||||
q: debouncedQuery || undefined,
|
||||
source: srcFilter || undefined,
|
||||
asset_type: kindFilter || undefined,
|
||||
meta: metaFilter,
|
||||
ordering: sortDesc ? "-created_at" : "created_at",
|
||||
page,
|
||||
pageSize: LIB_PAGE_SIZE
|
||||
}).then((res) => { if (alive) { setItems(res.results); setTotal(res.count); } })
|
||||
.catch(() => { if (alive) { setItems([]); setTotal(0); } })
|
||||
.finally(() => { if (alive) setLoading(false); });
|
||||
return () => { alive = false; };
|
||||
}, [tab, debouncedQuery, srcFilter, kindFilter, metaFilter, sortDesc, page, reloadFlag]);
|
||||
|
||||
// tab 计数(徽标)+ 当前 tab 的筛选项(下拉「只列真有的」):切 tab / 上传 / 删除后刷新
|
||||
useEffect(() => { api.assetSummary().then((c) => setCounts((prev) => ({ ...prev, ...c }))).catch(() => {}); }, [reloadFlag]);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
api.assetFacets(tab, metaKeys).then((f) => { if (alive) setFacets(f); }).catch(() => {});
|
||||
return () => { alive = false; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tab, reloadFlag]);
|
||||
|
||||
const srcOptions = facets.sources;
|
||||
const kindOptions = facets.kinds;
|
||||
const metaOptions = (key: string) => facets.metadata[key] || [];
|
||||
const hasFilter = Boolean(debouncedQuery || srcFilter || kindFilter || Object.values(metaFilter).some(Boolean));
|
||||
const totalPages = Math.max(1, Math.ceil(total / LIB_PAGE_SIZE));
|
||||
const curPage = Math.min(page, totalPages);
|
||||
|
||||
useEffect(() => {
|
||||
if (!openChip) return;
|
||||
const close = (event: MouseEvent) => {
|
||||
@@ -90,27 +141,6 @@ export function LibraryPage({ assets, onUpload, onDelete }: { assets: Asset[]; o
|
||||
return () => document.removeEventListener("click", close);
|
||||
}, [openChip]);
|
||||
|
||||
const counts = LIB_TABS.reduce((acc, t) => { acc[t.key] = assets.filter((a) => assetTab(a) === t.key).length; return acc; }, {} as Record<LibTab, number>);
|
||||
const inTab = assets.filter((a) => assetTab(a) === tab);
|
||||
// 当前 tab 下真实存在的来源 / 类型(下拉只列真有的)
|
||||
const srcOptions = Array.from(new Set(inTab.map((a) => a.source).filter(Boolean)));
|
||||
const kindOptions = Array.from(new Set(inTab.map((a) => a.asset_type).filter(Boolean)));
|
||||
// 元数据筛选项:从当前 tab 真实资产的 metadata 派生(无则下拉只有「全部」)
|
||||
const metaOptions = (key: string) => Array.from(new Set(inTab.map((a) => String((a.metadata as Record<string, unknown> | undefined)?.[key] ?? "")).filter(Boolean)));
|
||||
const filtered = inTab
|
||||
.filter((a) => `${a.name} ${a.category}`.toLowerCase().includes(query.toLowerCase()))
|
||||
.filter((a) => !srcFilter || a.source === srcFilter)
|
||||
.filter((a) => !kindFilter || a.asset_type === kindFilter)
|
||||
.filter((a) => Object.entries(metaFilter).every(([k, v]) => !v || String((a.metadata as Record<string, unknown> | undefined)?.[k] ?? "") === v))
|
||||
.sort((a, b) => {
|
||||
const cmp = (b.created_at || "").localeCompare(a.created_at || "");
|
||||
return sortDesc ? cmp : -cmp;
|
||||
});
|
||||
// 当前页切片(删除资产后页数缩水时钳回最后一页)
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / LIB_PAGE_SIZE));
|
||||
const curPage = Math.min(page, totalPages);
|
||||
const pageItems = filtered.slice((curPage - 1) * LIB_PAGE_SIZE, curPage * LIB_PAGE_SIZE);
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!file || uploading) return;
|
||||
@@ -126,6 +156,7 @@ export function LibraryPage({ assets, onUpload, onDelete }: { assets: Asset[]; o
|
||||
setDrawer(false);
|
||||
setFile(null);
|
||||
setName("");
|
||||
setReloadFlag((f) => f + 1); // 上传后刷新列表/计数/筛选项
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
@@ -241,11 +272,11 @@ export function LibraryPage({ assets, onUpload, onDelete }: { assets: Asset[]; o
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="result-meta" id="result-meta">// 显示 <span className="count">{pageItems.length}</span> / {filtered.length} 个资产{filtered.length !== inTab.length ? "(已筛选)" : ""}</div>
|
||||
<div className="result-meta" id="result-meta">// 显示 <span className="count">{items.length}</span> / {total} 个资产{hasFilter ? "(已筛选)" : ""}{loading ? " · 加载中…" : ""}</div>
|
||||
|
||||
{filtered.length ? (
|
||||
{items.length ? (
|
||||
<div className="asset-grid" id="asset-grid">
|
||||
{pageItems.map((asset) => {
|
||||
{items.map((asset) => {
|
||||
const cover = asset.files?.find((f) => f.is_primary)?.preview_url || asset.files?.[0]?.preview_url || "";
|
||||
const isVideo = asset.asset_type === "video";
|
||||
const isAudio = asset.asset_type === "audio";
|
||||
@@ -286,10 +317,10 @@ export function LibraryPage({ assets, onUpload, onDelete }: { assets: Asset[]; o
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-filter">// 当前分类暂无真实资产</div>
|
||||
<div className="empty-filter">// {loading ? "加载中…" : "当前分类暂无真实资产"}</div>
|
||||
)}
|
||||
|
||||
<Pager page={curPage} total={filtered.length} pageSize={LIB_PAGE_SIZE} onChange={setPage} />
|
||||
<Pager page={curPage} total={total} pageSize={LIB_PAGE_SIZE} onChange={setPage} />
|
||||
|
||||
<MediaLightbox open={!!preview} src={preview?.src || ""} kind={preview?.kind} name={preview?.name} close={() => setPreview(null)} />
|
||||
|
||||
@@ -299,7 +330,7 @@ export function LibraryPage({ assets, onUpload, onDelete }: { assets: Asset[]; o
|
||||
detail="确定删除该资产?该操作不可撤销。"
|
||||
confirmText="删除"
|
||||
onCancel={() => setConfirmId(null)}
|
||||
onConfirm={async () => { const id = confirmId; setConfirmId(null); if (id) await onDelete?.(id); }}
|
||||
onConfirm={async () => { const id = confirmId; setConfirmId(null); if (id) { await onDelete?.(id); setReloadFlag((f) => f + 1); } }}
|
||||
/>
|
||||
|
||||
<Drawer title="上传资产" open={drawer} close={() => setDrawer(false)}><form onSubmit={submit}><div className="field"><label className="field-label">文件</label><input className="input file-input" type="file" accept="image/*,video/*,audio/*,.srt,.vtt,.ass" onChange={(event) => setFile(event.target.files?.[0] || null)} /></div><div className="field"><label className="field-label">资产名称</label><input className="input" value={name} onChange={(event) => setName(event.target.value)} /></div><div className="drawer-actions"><button className="btn btn-ghost" type="button" onClick={() => setDrawer(false)}>取消</button><button className="btn btn-primary" type="submit" disabled={!file || uploading}>{uploading ? "上传中…" : "上传资产"}</button></div></form></Drawer>
|
||||
|
||||
Reference in New Issue
Block a user