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:
zyc
2026-06-17 18:10:05 +08:00
co-authored by Claude Opus 4.8
parent ce809b2d32
commit 59ab3c0404
3 changed files with 86 additions and 31 deletions
+1 -1
View File
@@ -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
+24
View File
@@ -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();
+61 -30
View File
@@ -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>