feat: paginate admin tags

This commit is contained in:
nianzhibai
2026-05-31 16:07:49 +08:00
parent 6345cf74e0
commit 38e62c6a2f
2 changed files with 173 additions and 64 deletions
+151 -64
View File
@@ -3,6 +3,10 @@ import { CheckSquare, Film, Plus, RefreshCw, Search, Tags, Trash2 } from "lucide
import * as api from "./api";
import { useToast } from "./ToastContext";
const DESKTOP_TAGS_PAGE_SIZE = 25;
const MOBILE_TAGS_PAGE_SIZE = 8;
const TAGS_MOBILE_QUERY = "(max-width: 640px)";
export function TagsPage() {
const [tags, setTags] = useState<api.AdminTag[]>([]);
const [label, setLabel] = useState("");
@@ -15,6 +19,8 @@ export function TagsPage() {
const [selectMode, setSelectMode] = useState(false);
const [selected, setSelected] = useState<Set<number>>(new Set());
const [bulkDeleting, setBulkDeleting] = useState(false);
const pageSize = useTagsPageSize();
const [page, setPage] = useState(1);
const { show } = useToast();
async function refresh() {
@@ -143,18 +149,37 @@ export function TagsPage() {
});
}, [tags, searchQuery, filterSource]);
const deletableFiltered = useMemo(
() => filteredTags.filter((t) => t.source !== "system"),
[filteredTags]
const totalPages = Math.max(1, Math.ceil(filteredTags.length / pageSize));
const currentPage = Math.min(page, totalPages);
const pageStartIndex = (currentPage - 1) * pageSize;
const pageEndIndex = pageStartIndex + pageSize;
const pagedTags = useMemo(
() => filteredTags.slice(pageStartIndex, pageEndIndex),
[filteredTags, pageStartIndex, pageEndIndex]
);
const pageStart = filteredTags.length === 0 ? 0 : pageStartIndex + 1;
const pageEnd = Math.min(filteredTags.length, pageEndIndex);
useEffect(() => {
setPage(1);
}, [searchQuery, filterSource, pageSize]);
useEffect(() => {
setPage((p) => Math.min(Math.max(1, p), totalPages));
}, [totalPages]);
const deletablePageTags = useMemo(
() => pagedTags.filter((t) => t.source !== "system"),
[pagedTags]
);
const allSelected =
deletableFiltered.length > 0 && deletableFiltered.every((t) => selected.has(t.id));
deletablePageTags.length > 0 && deletablePageTags.every((t) => selected.has(t.id));
function toggleSelectAll() {
setSelected((prev) => {
const next = new Set(prev);
if (allSelected) deletableFiltered.forEach((t) => next.delete(t.id));
else deletableFiltered.forEach((t) => next.add(t.id));
if (allSelected) deletablePageTags.forEach((t) => next.delete(t.id));
else deletablePageTags.forEach((t) => next.add(t.id));
return next;
});
}
@@ -288,7 +313,7 @@ export function TagsPage() {
<div className="admin-tags-bulkbar">
<label className="admin-check">
<input type="checkbox" checked={allSelected} onChange={toggleSelectAll} />
<span> ({deletableFiltered.length})</span>
<span> ({deletablePageTags.length})</span>
</label>
<span className="admin-tags-bulkbar__count"> {selected.size} </span>
<button
@@ -317,68 +342,110 @@ export function TagsPage() {
</div>
) : (
<div className="admin-tags-grid">
{filteredTags.map((tag) => {
const selectable = selectMode && tag.source !== "system";
const isSelected = selected.has(tag.id);
return (
<div
key={tag.id}
className={`admin-tag-card${selectable ? " is-selectable" : ""}${
selectable && isSelected ? " is-selected" : ""
}`}
onClick={selectable ? () => toggleSelect(tag.id) : undefined}
>
<div className="admin-tag-card__head">
{selectable && (
<input
type="checkbox"
className="admin-tag-card__check"
checked={isSelected}
readOnly
/>
)}
<span className="admin-tag-card__title">{tag.label}</span>
<span className="admin-tag-card__source-badge" data-source={tag.source}>
{sourceLabel(tag.source)}
</span>
</div>
{tag.aliases && tag.aliases.length > 0 && (
<div className="admin-tag-card__aliases">
{tag.aliases.map((alias) => (
<span key={alias} className="admin-tag-card__alias-pill">
{alias}
<>
<div className="admin-tags-grid">
{pagedTags.map((tag) => {
const selectable = selectMode && tag.source !== "system";
const isSelected = selected.has(tag.id);
return (
<div
key={tag.id}
className={`admin-tag-card${selectable ? " is-selectable" : ""}${
selectable && isSelected ? " is-selected" : ""
}`}
onClick={selectable ? () => toggleSelect(tag.id) : undefined}
>
<div className="admin-tag-card__head">
{selectable && (
<input
type="checkbox"
className="admin-tag-card__check"
checked={isSelected}
readOnly
/>
)}
<span className="admin-tag-card__title">{tag.label}</span>
<span className="admin-tag-card__source-badge" data-source={tag.source}>
{sourceLabel(tag.source)}
</span>
))}
</div>
)}
</div>
<div className="admin-tag-card__footer">
<span className="admin-tag-card__count">
<Film size={13} />
<strong>{tag.count}</strong>
</span>
<div className="admin-tag-card__footer-actions">
<span className="admin-tag-card__id">#{tag.id}</span>
{!selectMode && tag.source !== "system" && (
<button
type="button"
className="admin-tag-card__delete"
onClick={() => handleDelete(tag)}
disabled={deletingId === tag.id}
aria-label={`删除标签 ${tag.label}`}
>
<Trash2 size={11} />
<span>{deletingId === tag.id ? "删除中" : "删除"}</span>
</button>
{tag.aliases && tag.aliases.length > 0 && (
<div className="admin-tag-card__aliases">
{tag.aliases.map((alias) => (
<span key={alias} className="admin-tag-card__alias-pill">
{alias}
</span>
))}
</div>
)}
<div className="admin-tag-card__footer">
<span className="admin-tag-card__count">
<Film size={13} />
<strong>{tag.count}</strong>
</span>
<div className="admin-tag-card__footer-actions">
<span className="admin-tag-card__id">#{tag.id}</span>
{!selectMode && tag.source !== "system" && (
<button
type="button"
className="admin-tag-card__delete"
onClick={() => handleDelete(tag)}
disabled={deletingId === tag.id}
aria-label={`删除标签 ${tag.label}`}
>
<Trash2 size={11} />
<span>{deletingId === tag.id ? "删除中" : "删除"}</span>
</button>
)}
</div>
</div>
</div>
</div>
);
})}
</div>
{totalPages > 1 && (
<div className="admin-table-pagination admin-tags-pagination">
<button
type="button"
className="admin-btn"
onClick={() => setPage(1)}
disabled={currentPage <= 1}
>
</button>
<button
type="button"
className="admin-btn"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={currentPage <= 1}
>
</button>
<span className="admin-table-pagination__info">
{currentPage} / {totalPages} {pageStart}-{pageEnd} / {filteredTags.length} {pageSize}
</span>
<button
type="button"
className="admin-btn"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage >= totalPages}
>
</button>
<button
type="button"
className="admin-btn"
onClick={() => setPage(totalPages)}
disabled={currentPage >= totalPages}
>
</button>
</div>
);
})}
</div>
)}
</>
)}
</div>
</div>
@@ -386,6 +453,26 @@ export function TagsPage() {
);
}
function useTagsPageSize() {
const [pageSize, setPageSize] = useState(() =>
window.matchMedia(TAGS_MOBILE_QUERY).matches
? MOBILE_TAGS_PAGE_SIZE
: DESKTOP_TAGS_PAGE_SIZE
);
useEffect(() => {
const media = window.matchMedia(TAGS_MOBILE_QUERY);
const update = () => {
setPageSize(media.matches ? MOBILE_TAGS_PAGE_SIZE : DESKTOP_TAGS_PAGE_SIZE);
};
update();
media.addEventListener("change", update);
return () => media.removeEventListener("change", update);
}, []);
return pageSize;
}
function splitList(s: string): string[] {
return s
.split(/[,,、\s]+/)
+22
View File
@@ -0,0 +1,22 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const tagsPageSource = readFileSync(
new URL("../src/admin/TagsPage.tsx", import.meta.url),
"utf8"
);
test("admin tags page limits visible tags by viewport", () => {
assert.match(tagsPageSource, /const DESKTOP_TAGS_PAGE_SIZE = 25;/);
assert.match(tagsPageSource, /const MOBILE_TAGS_PAGE_SIZE = 8;/);
assert.match(tagsPageSource, /const TAGS_MOBILE_QUERY = "\(max-width: 640px\)";/);
assert.match(tagsPageSource, /window\.matchMedia\(TAGS_MOBILE_QUERY\)/);
});
test("admin tags page renders only the current page", () => {
assert.match(tagsPageSource, /filteredTags\.slice\(pageStartIndex, pageEndIndex\)/);
assert.match(tagsPageSource, /pagedTags\.map\(\(tag\) =>/);
assert.doesNotMatch(tagsPageSource, /filteredTags\.map\(\(tag\) =>/);
assert.match(tagsPageSource, /全选本页/);
});