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>
|
</div>
|
||||||
);
|
);
|
||||||
case "library":
|
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":
|
case "account":
|
||||||
return (
|
return (
|
||||||
<AccountPage
|
<AccountPage
|
||||||
|
|||||||
@@ -370,6 +370,30 @@ export const api = {
|
|||||||
assets() {
|
assets() {
|
||||||
return request<Paginated<Asset>>("/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,浏览器抽帧/解码必须同源)
|
// 经后端同源代理取资产原始文件(TOS 未配 CORS,浏览器抽帧/解码必须同源)
|
||||||
async fetchAssetBlob(id: string): Promise<Blob> {
|
async fetchAssetBlob(id: string): Promise<Blob> {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import type { FormEvent } from "react";
|
import type { FormEvent } from "react";
|
||||||
|
import { api } from "../api";
|
||||||
import type { Asset } from "../types";
|
import type { Asset } from "../types";
|
||||||
import { ConfirmModal, Drawer, MediaLightbox } from "../components/overlays";
|
import { ConfirmModal, Drawer, MediaLightbox } from "../components/overlays";
|
||||||
import { Pager } from "../components/pager";
|
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 [tab, setTab] = useState<LibTab>("people");
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [drawer, setDrawer] = useState(false);
|
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");
|
return () => document.body.classList.remove("edit-mode");
|
||||||
}, [editMode]);
|
}, [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);
|
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 无关的筛选
|
// 切 tab 时清空与该 tab 无关的筛选
|
||||||
useEffect(() => { setOpenChip(""); setSrcFilter(""); setKindFilter(""); setMetaFilter({}); }, [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(() => {
|
useEffect(() => {
|
||||||
if (!openChip) return;
|
if (!openChip) return;
|
||||||
const close = (event: MouseEvent) => {
|
const close = (event: MouseEvent) => {
|
||||||
@@ -90,27 +141,6 @@ export function LibraryPage({ assets, onUpload, onDelete }: { assets: Asset[]; o
|
|||||||
return () => document.removeEventListener("click", close);
|
return () => document.removeEventListener("click", close);
|
||||||
}, [openChip]);
|
}, [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) {
|
async function submit(event: FormEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!file || uploading) return;
|
if (!file || uploading) return;
|
||||||
@@ -126,6 +156,7 @@ export function LibraryPage({ assets, onUpload, onDelete }: { assets: Asset[]; o
|
|||||||
setDrawer(false);
|
setDrawer(false);
|
||||||
setFile(null);
|
setFile(null);
|
||||||
setName("");
|
setName("");
|
||||||
|
setReloadFlag((f) => f + 1); // 上传后刷新列表/计数/筛选项
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
}
|
}
|
||||||
@@ -241,11 +272,11 @@ export function LibraryPage({ assets, onUpload, onDelete }: { assets: Asset[]; o
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<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 cover = asset.files?.find((f) => f.is_primary)?.preview_url || asset.files?.[0]?.preview_url || "";
|
||||||
const isVideo = asset.asset_type === "video";
|
const isVideo = asset.asset_type === "video";
|
||||||
const isAudio = asset.asset_type === "audio";
|
const isAudio = asset.asset_type === "audio";
|
||||||
@@ -286,10 +317,10 @@ export function LibraryPage({ assets, onUpload, onDelete }: { assets: Asset[]; o
|
|||||||
})}
|
})}
|
||||||
</div>
|
</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)} />
|
<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="确定删除该资产?该操作不可撤销。"
|
detail="确定删除该资产?该操作不可撤销。"
|
||||||
confirmText="删除"
|
confirmText="删除"
|
||||||
onCancel={() => setConfirmId(null)}
|
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>
|
<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