feat: paginate admin gallery management
This commit is contained in:
@@ -112,7 +112,7 @@ Important generation helpers:
|
||||
| GET | `/api/gallery` | Public | `src/app/api/gallery/route.ts` | Query `type=image|video`, `category=text2img|img2img|text2video|img2video`, `limit`, `offset`, `sort=newest|popular`, `q`/`search` | Public completed works with `thumbnailUrl`, `total`, `nextOffset`, and `hasMore`; missing public image thumbnails are lazily generated into local `thumbnails/gallery`. Responses allow short private browser caching while the gallery page also keeps a bounded localStorage cache for instant first paint. |
|
||||
| DELETE | `/api/gallery` | Admin | `src/app/api/gallery/route.ts` | Query `id` or body `{ ids: [...] }` | Unpublishes up to 100 works by setting `is_public=false`. |
|
||||
| POST | `/api/gallery/publish` | User | `src/app/api/gallery/publish/route.ts` | Work metadata, `resultUrl`, optional thumbnail/reference/model fields | Copies image originals to object-backed gallery storage, ensures local gallery thumbnails, and inserts public completed work. |
|
||||
| GET | `/api/admin/gallery/works` | Admin | `src/app/api/admin/gallery/works/route.ts` | Query `q`, `type=all|image|video|text2img|img2img|text2video|img2video`, `limit`, `offset`, `sort` | Admin gallery-management list of public completed works with author email/nickname, prompt, media URL, thumbnail, total, `nextOffset`, and `hasMore`. |
|
||||
| GET | `/api/admin/gallery/works` | Admin | `src/app/api/admin/gallery/works/route.ts` | Query `q`, `type=all|image|video|text2img|img2img|text2video|img2video`, `page`, `pageSize`, legacy `limit`, `offset`, `sort` | Admin gallery-management list of public completed works with author email/nickname, prompt, media URL, thumbnail, `total`, `page`, `pageSize`, `totalPages`, legacy `nextOffset`, and `hasMore`. |
|
||||
| PUT | `/api/admin/gallery/prompt` | Admin | `src/app/api/admin/gallery/prompt/route.ts`, `src/lib/admin-gallery-prompt-service.ts` | `{ workId, prompt, emailSubject, emailBody, reasonKey }` | Sends the author notification email first, then updates `works.prompt` only after email success, and writes a platform log without storing full prompt text. Missing/invalid author email, unchanged prompt, non-public work, or email failure blocks the update. |
|
||||
|
||||
## Admin Routes
|
||||
|
||||
@@ -186,7 +186,7 @@ Gallery detail metadata must not load original images just to compute size. `Ima
|
||||
|
||||
The public gallery page should use server gallery rows only. It must not merge `miaojing_published_gallery` or `miaojing_creation_history` from browser localStorage into the gallery feed, and it must not auto-sync historical local published records into Supabase on page load. `/api/gallery` is the authority for all gallery views, including all/category filters and search, and should only return stable platform media URLs under `/api/local-storage/...`; legacy external import URLs are not public gallery candidates. To keep reopen latency low, `src/app/gallery/page.tsx` caches bounded page data in browser localStorage for instant first paint, prunes entries after 7 days or when the cache cap is exceeded, and immediately revalidates the first page in the background so published/deleted works replace cached rows. It should request small pages and append via IntersectionObserver as the user scrolls, not load the entire public gallery into the DOM.
|
||||
|
||||
Admin gallery moderation is separate from the public gallery page. `src/components/admin/gallery-management-tab.tsx` lists public completed works through `/api/admin/gallery/works`; prompt edits go through `/api/admin/gallery/prompt` and `src/lib/admin-gallery-prompt-service.ts`. The service enforces the moderation rule that the author notification email must send successfully before `works.prompt` is updated. Platform logs record the admin, work, author, reason key, prompt length changes, and notification result, but must not store the full original or edited prompt text.
|
||||
Admin gallery moderation is separate from the public gallery page. `src/components/admin/gallery-management-tab.tsx` lists public completed works through `/api/admin/gallery/works` with page/pageSize pagination; `src/lib/admin-gallery-works-pagination.ts` keeps the route compatible with older limit/offset callers. Prompt edits go through `/api/admin/gallery/prompt` and `src/lib/admin-gallery-prompt-service.ts`. The service enforces the moderation rule that the author notification email must send successfully before `works.prompt` is updated. Platform logs record the admin, work, author, reason key, prompt length changes, and notification result, but must not store the full original or edited prompt text.
|
||||
|
||||
Fullscreen image overlays should accept a thumbnail fallback and display it immediately while the original object-storage image loads. If object storage is slow or the original fails, the user still sees the high-quality local preview and the fullscreen controls stay usable; copy/download/share actions still receive the original URL.
|
||||
|
||||
|
||||
@@ -109,6 +109,7 @@ Use this guide when the user reports behavior. Start from the symptom row, inspe
|
||||
| Rainyun ROS bucket created but object storage still fails | `scripts/rainyun-ros-prepare.mjs`, `.env.local`, `src/lib/local-storage.ts`, `scripts/storage-sync-to-object.mjs`, `/api/health` | The Rainyun API link is control-plane bucket creation, not the media upload path. Verify `.env.local` has reviewed `OBJECT_STORAGE_BUCKET`, `OBJECT_STORAGE_ENDPOINT`, `OBJECT_STORAGE_ACCESS_KEY_ID`, `OBJECT_STORAGE_SECRET_ACCESS_KEY`, `OBJECT_STORAGE_FORCE_PATH_STYLE=true`, and `STORAGE_MODE=dual`; then run `/api/health` and `pnpm run storage:sync-object -- --dry-run`. |
|
||||
| Gallery delete does not remove public item | `src/app/api/gallery/route.ts`, admin UI route using it | DELETE unpublishes by setting `is_public = false`, not hard delete. |
|
||||
| Admin gallery prompt edit fails, sends no email, or prompt changes without audit trail | `src/components/admin/gallery-management-tab.tsx`, `src/app/api/admin/gallery/works/route.ts`, `src/app/api/admin/gallery/prompt/route.ts`, `src/lib/admin-gallery-prompt-service.ts`, `src/lib/email-service.ts`, `src/lib/platform-logs.ts` | Prompt moderation is console-only and requires a valid author email. `/api/admin/gallery/prompt` must send the email before updating `works.prompt`; SMTP failure, unchanged prompt, non-public work, or invalid author email should block the update. Platform logs should include reason key and prompt length metadata, not full prompt text. |
|
||||
| Admin gallery management pagination wrong or page buttons skip records | `src/components/admin/gallery-management-tab.tsx`, `src/app/api/admin/gallery/works/route.ts`, `src/lib/admin-gallery-works-pagination.ts` | The admin table uses `page` and `pageSize`; the route converts those to SQL `LIMIT/OFFSET` and still accepts legacy `limit/offset`. Verify `total`, `page`, `pageSize`, `totalPages`, `nextOffset`, and `hasMore` in the response, and reset page to 1 when search/type/page size changes. |
|
||||
| Search/filter/sort wrong | `src/app/api/gallery/route.ts`, `src/app/gallery/page.tsx` | Query params `type`, `category`, `limit`, `offset`, `sort`, `q/search`; SQL where/order, browser cache signature, and pagination append state. |
|
||||
| Gallery search box looks inconsistent with the rest of the UI | `src/app/gallery/page.tsx` | The search field is a custom glass panel with an inner focused input surface; avoid reverting it to a plain transparent input row. |
|
||||
| Gallery hover makes images muddy, covers the image with prompt text, shows only a single-color/static glow, has transparent gaps, does not match image colors, misses the card corners, moves too fast, looks too hard-edged, or action buttons disappear on dark/light images | `src/app/gallery/page.tsx`, `src/app/globals.css` | Gallery cards should not use a full-image dark hover overlay, center prompt text, transparent border gaps, generated unrelated colors, broad square glow under the card, or a separate outer halo layer. Keep hover feedback on the card container with scale plus a real `gallery-card-border-frame` wrapper using 3-5 sampled image colors in a single blurred 3px continuous clockwise border around the full work-card container, including all four corners and the prompt/footer area, and keep like/download buttons legible through sampled image brightness inversion. |
|
||||
|
||||
@@ -110,7 +110,7 @@ Use this document to jump directly to code before broad searching.
|
||||
| Public gallery page | `src/app/gallery/page.tsx`, `src/app/globals.css` | Lists public works, search/sort/filter, preview/download, and one-click reuse. It requests `/api/gallery` in small pages instead of fetching the full gallery, uses a bounded `miaojing:gallery:v3` browser localStorage cache for instant reopen, revalidates page 0 in the background, debounces search, and uses an IntersectionObserver sentinel to append the next page only when the user scrolls near it. Cached entries expire quickly for freshness and are pruned after 7 days or when the entry cap is exceeded. Image cards and detail display use `thumbnailUrl || url`, while fullscreen, download, copy/share, and reuse actions use original `url`. The search box is custom styled in-page to match the glass UI; gallery cards sample 3-5 distinct colors from the image and use a real `gallery-card-border-frame` wrapper with a single 3px blurred, continuous clockwise multicolor border around the full work-card container, including all four corners and the prompt/footer area. Avoid image-covering dark overlays, broad square glow blocks, or a separate outer halo layer. Hover like/download/reuse buttons invert against sampled image brightness. Gallery detail image previews use `ImageMetadataBadge` for actual ratio/resolution, and the detail footer writes a reuse draft before navigating to the matching `/create?type=...` mode. Mobile gallery must keep at least two masonry columns; `masonryColumnCount` bottoms out at 2 and `.gallery-masonry-grid`/card CSS trims spacing and metadata density on phones. |
|
||||
| Public gallery API | `src/app/api/gallery/route.ts` | GET public works with `thumbnailUrl`, `total`, `nextOffset`, and `hasMore`, queues missing or old-profile image thumbnails for background backfill without delaying the response, admin DELETE unpublishes. Gallery author names use `profiles.display_nickname` first and never expose login username unless no display nickname exists. |
|
||||
| Publish API | `src/app/api/gallery/publish/route.ts` | Copies image originals into object-backed gallery folders, stores local thumbnails, and inserts public work. |
|
||||
| Admin gallery prompt moderation | `src/components/admin/gallery-management-tab.tsx`, `src/app/api/admin/gallery/works/route.ts`, `src/app/api/admin/gallery/prompt/route.ts`, `src/lib/admin-gallery-prompt-service.ts`, `scripts/test-admin-gallery-prompt-service.mjs` | Console-only workflow for editing public gallery `works.prompt`. Admins must send an email notification to the author; the service sends email before updating the prompt and logs metadata without storing full prompt text. |
|
||||
| Admin gallery prompt moderation | `src/components/admin/gallery-management-tab.tsx`, `src/app/api/admin/gallery/works/route.ts`, `src/app/api/admin/gallery/prompt/route.ts`, `src/lib/admin-gallery-prompt-service.ts`, `src/lib/admin-gallery-works-pagination.ts`, `scripts/test-admin-gallery-prompt-service.mjs` | Console-only workflow for editing public gallery `works.prompt`. The management table uses page/pageSize pagination while the list API keeps limit/offset compatibility. Admins must send an email notification to the author; the service sends email before updating the prompt and logs metadata without storing full prompt text. |
|
||||
| History persistence | `src/app/api/creation-history/route.ts`, `src/lib/creation-history-store.ts` | User-private completed works, `thumbnailUrl`, and published state. Missing image thumbnails are queued for background backfill instead of blocking the history response. Single-record deletion is server-first when logged in; detail dialogs call the same store path and then refresh local history. |
|
||||
|
||||
## Admin Console
|
||||
@@ -126,7 +126,7 @@ Use this document to jump directly to code before broad searching.
|
||||
| Payment | `src/components/admin/payment-tab.tsx` | `src/app/api/admin/payment-methods/route.ts`, `src/lib/server-payment-config.ts` |
|
||||
| Orders | `src/components/admin/order-management-tab.tsx` | `src/app/api/admin/orders/route.ts` |
|
||||
| Announcements | `src/components/admin/announcement-tab.tsx` | `src/app/api/announcements/route.ts` |
|
||||
| Gallery management | `src/components/admin/gallery-management-tab.tsx` | `src/app/api/admin/gallery/works/route.ts`, `src/app/api/admin/gallery/prompt/route.ts`, `src/lib/admin-gallery-prompt-service.ts`. Lists public works, edits prompt text, opens a required notification email dialog with built-in reason templates, and only completes the update after email send success. |
|
||||
| Gallery management | `src/components/admin/gallery-management-tab.tsx` | `src/app/api/admin/gallery/works/route.ts`, `src/app/api/admin/gallery/prompt/route.ts`, `src/lib/admin-gallery-prompt-service.ts`, `src/lib/admin-gallery-works-pagination.ts`. Lists public works with admin page/pageSize pagination, edits prompt text, opens a required notification email dialog with built-in reason templates, and only completes the update after email send success. |
|
||||
| Data import/export | `src/components/admin/data-management-tab.tsx` | `src/app/api/admin/data-export/route.ts`, `src/app/api/admin/data-import/route.ts`, `scripts/migration-integrity-check.mjs`. Export bundles storage URLs from works/site config into `_media`; import restores those files through `src/lib/local-storage.ts`, maps old IDs, merges duplicate works only within the same `user_id`, and runs DB writes in a transaction. Import preserves password hashes, encrypted API keys, `manifest_path`, system API pricing fields, and `redeem_codes` state so users, credentials, works, intelligent API configs, and unused/used redemption state survive migration. Run `pnpm run migration:check` before and after production migration. |
|
||||
| System upgrade | `src/components/admin/system-upgrade-tab.tsx` | `src/app/api/admin/upgrade/route.ts`, `scripts/admin-upgrade-runner.mjs` |
|
||||
| Logs/tasks | `src/components/admin/log-management-tab.tsx`, `src/components/admin/task-management-tab.tsx` | `src/lib/platform-logs.ts`, `src/app/api/admin/logs/route.ts`, `src/app/api/admin/generation-jobs/route.ts` |
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { updateAdminGalleryPrompt } from '../src/lib/admin-gallery-prompt-service.ts';
|
||||
import {
|
||||
buildAdminGalleryWorksPaginationMeta,
|
||||
parseAdminGalleryWorksPagination,
|
||||
} from '../src/lib/admin-gallery-works-pagination.ts';
|
||||
|
||||
function createWork(overrides = {}) {
|
||||
return {
|
||||
@@ -121,4 +125,36 @@ await runTest('writes moderation log metadata without full prompt text', async (
|
||||
assert.doesNotMatch(logText, /new compliant prompt/);
|
||||
});
|
||||
|
||||
await runTest('parses admin gallery page and pageSize into limit and offset', async () => {
|
||||
const pagination = parseAdminGalleryWorksPagination(new URLSearchParams('page=3&pageSize=50'));
|
||||
assert.deepEqual(pagination, {
|
||||
page: 3,
|
||||
pageSize: 50,
|
||||
limit: 50,
|
||||
offset: 100,
|
||||
});
|
||||
});
|
||||
|
||||
await runTest('keeps limit and offset compatibility for admin gallery works', async () => {
|
||||
const pagination = parseAdminGalleryWorksPagination(new URLSearchParams('limit=15&offset=30'));
|
||||
assert.deepEqual(pagination, {
|
||||
page: 3,
|
||||
pageSize: 15,
|
||||
limit: 15,
|
||||
offset: 30,
|
||||
});
|
||||
});
|
||||
|
||||
await runTest('builds admin gallery pagination metadata', async () => {
|
||||
const meta = buildAdminGalleryWorksPaginationMeta({ total: 46, page: 2, pageSize: 20, resultCount: 20 });
|
||||
assert.deepEqual(meta, {
|
||||
total: 46,
|
||||
page: 2,
|
||||
pageSize: 20,
|
||||
totalPages: 3,
|
||||
nextOffset: 40,
|
||||
hasMore: true,
|
||||
});
|
||||
});
|
||||
|
||||
if (process.exitCode) process.exit(process.exitCode);
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdmin } from '@/lib/admin-auth';
|
||||
import { toAdminGalleryPromptWork, type AdminGalleryPromptWorkRow } from '@/lib/admin-gallery-prompt-service';
|
||||
import {
|
||||
buildAdminGalleryWorksPaginationMeta,
|
||||
parseAdminGalleryWorksPagination,
|
||||
} from '@/lib/admin-gallery-works-pagination';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
const WORK_TYPES = new Set(['text2img', 'img2img', 'text2video', 'img2video']);
|
||||
|
||||
function intParam(value: string | null, fallback: number, min: number, max: number) {
|
||||
const parsed = Number.parseInt(value || '', 10);
|
||||
if (!Number.isFinite(parsed)) return fallback;
|
||||
return Math.min(max, Math.max(min, parsed));
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const authError = await requireAdmin(request);
|
||||
if (authError) return authError;
|
||||
@@ -21,8 +19,7 @@ export async function GET(request: NextRequest) {
|
||||
const q = (searchParams.get('q') || searchParams.get('search') || '').trim().toLowerCase();
|
||||
const type = searchParams.get('type') || 'all';
|
||||
const sort = searchParams.get('sort') || 'newest';
|
||||
const limit = intParam(searchParams.get('limit'), 20, 1, 100);
|
||||
const offset = intParam(searchParams.get('offset'), 0, 0, 1000000);
|
||||
const pagination = parseAdminGalleryWorksPagination(searchParams);
|
||||
|
||||
const where: string[] = [
|
||||
'w.is_public = true',
|
||||
@@ -84,17 +81,20 @@ export async function GET(request: NextRequest) {
|
||||
${orderSql}
|
||||
LIMIT $${params.length + 1}
|
||||
OFFSET $${params.length + 2}`,
|
||||
[...params, limit, offset],
|
||||
[...params, pagination.limit, pagination.offset],
|
||||
);
|
||||
|
||||
const works = (result.rows as AdminGalleryPromptWorkRow[]).map(row => toAdminGalleryPromptWork(row));
|
||||
const total = Number(countResult.rows[0]?.total || 0);
|
||||
const nextOffset = offset + works.length;
|
||||
return NextResponse.json({
|
||||
works,
|
||||
total,
|
||||
nextOffset,
|
||||
hasMore: nextOffset < total,
|
||||
...buildAdminGalleryWorksPaginationMeta({
|
||||
total,
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
resultCount: works.length,
|
||||
offset: pagination.offset,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[admin/gallery/works] GET error:', error);
|
||||
|
||||
@@ -42,6 +42,9 @@ interface AdminGalleryWork {
|
||||
interface GalleryWorksResponse {
|
||||
works?: AdminGalleryWork[];
|
||||
total?: number;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
totalPages?: number;
|
||||
nextOffset?: number;
|
||||
hasMore?: boolean;
|
||||
error?: string;
|
||||
@@ -53,7 +56,8 @@ type ReasonTemplateKey =
|
||||
| 'remove_private_info'
|
||||
| 'platform_policy_adjustment';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const DEFAULT_PAGE_SIZE = 20;
|
||||
const PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
|
||||
|
||||
const TYPE_OPTIONS: Array<{ value: GalleryFilterType; label: string }> = [
|
||||
{ value: 'all', label: '全部公开作品' },
|
||||
@@ -120,11 +124,11 @@ export default function GalleryManagementTab() {
|
||||
const [searchDraft, setSearchDraft] = useState('');
|
||||
const [activeSearch, setActiveSearch] = useState('');
|
||||
const [type, setType] = useState<GalleryFilterType>('all');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [nextOffset, setNextOffset] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [emailOpen, setEmailOpen] = useState(false);
|
||||
const [selectedWork, setSelectedWork] = useState<AdminGalleryWork | null>(null);
|
||||
@@ -139,13 +143,13 @@ export default function GalleryManagementTab() {
|
||||
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
||||
}), [accessToken]);
|
||||
|
||||
const loadWorks = useCallback(async ({ append = false, offset = 0 }: { append?: boolean; offset?: number } = {}) => {
|
||||
const loadWorks = useCallback(async () => {
|
||||
if (!accessToken) return;
|
||||
append ? setLoadingMore(true) : setLoading(true);
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
limit: String(PAGE_SIZE),
|
||||
offset: String(offset),
|
||||
page: String(page),
|
||||
pageSize: String(pageSize),
|
||||
type,
|
||||
});
|
||||
if (activeSearch.trim()) params.set('q', activeSearch.trim());
|
||||
@@ -157,25 +161,25 @@ export default function GalleryManagementTab() {
|
||||
if (!res.ok) throw new Error(data.error || '加载画廊作品失败');
|
||||
|
||||
const incoming = Array.isArray(data.works) ? data.works : [];
|
||||
setWorks(prev => append
|
||||
? [...prev, ...incoming.filter(work => !prev.some(item => item.id === work.id))]
|
||||
: incoming);
|
||||
setWorks(incoming);
|
||||
setTotal(Number(data.total || 0));
|
||||
setNextOffset(Number(data.nextOffset || offset + incoming.length));
|
||||
setHasMore(Boolean(data.hasMore));
|
||||
const normalizedTotalPages = Math.max(1, Number(data.totalPages || 1));
|
||||
setTotalPages(normalizedTotalPages);
|
||||
if (page > normalizedTotalPages) setPage(normalizedTotalPages);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '加载画廊作品失败');
|
||||
} finally {
|
||||
append ? setLoadingMore(false) : setLoading(false);
|
||||
setLoading(false);
|
||||
}
|
||||
}, [accessToken, activeSearch, headers, type]);
|
||||
}, [accessToken, activeSearch, headers, page, pageSize, type]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadWorks({ offset: 0 });
|
||||
void loadWorks();
|
||||
}, [loadWorks]);
|
||||
|
||||
function submitSearch(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setPage(1);
|
||||
setActiveSearch(searchDraft.trim());
|
||||
}
|
||||
|
||||
@@ -261,7 +265,7 @@ export default function GalleryManagementTab() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<form className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between" onSubmit={submitSearch}>
|
||||
<div className="grid flex-1 gap-3 md:grid-cols-[minmax(0,1fr)_220px]">
|
||||
<div className="grid flex-1 gap-3 md:grid-cols-[minmax(0,1fr)_220px_140px]">
|
||||
<div className="space-y-2">
|
||||
<Label>搜索</Label>
|
||||
<div className="flex gap-2">
|
||||
@@ -278,7 +282,7 @@ export default function GalleryManagementTab() {
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>作品类型</Label>
|
||||
<Select value={type} onValueChange={(value) => { setType(value as GalleryFilterType); setNextOffset(0); }}>
|
||||
<Select value={type} onValueChange={(value) => { setType(value as GalleryFilterType); setPage(1); }}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{TYPE_OPTIONS.map(option => (
|
||||
@@ -287,8 +291,19 @@ export default function GalleryManagementTab() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>每页数量</Label>
|
||||
<Select value={String(pageSize)} onValueChange={(value) => { setPageSize(Number(value)); setPage(1); }}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{PAGE_SIZE_OPTIONS.map(value => (
|
||||
<SelectItem key={value} value={String(value)}>{value} 条</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="button" variant="outline" className="gap-2" onClick={() => loadWorks({ offset: 0 })} disabled={loading}>
|
||||
<Button type="button" variant="outline" className="gap-2" onClick={loadWorks} disabled={loading}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCcw className="h-4 w-4" />}
|
||||
刷新
|
||||
</Button>
|
||||
@@ -364,16 +379,11 @@ export default function GalleryManagementTab() {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
|
||||
<span>共 {total} 个公开作品,当前已加载 {works.length} 个</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!hasMore || loadingMore}
|
||||
onClick={() => loadWorks({ append: true, offset: nextOffset })}
|
||||
>
|
||||
{loadingMore ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
{hasMore ? '加载更多' : '没有更多'}
|
||||
</Button>
|
||||
<span>共 {total} 个公开作品,第 {page} / {totalPages} 页,当前显示 {works.length} 个</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" disabled={page <= 1 || loading} onClick={() => setPage(current => Math.max(1, current - 1))}>上一页</Button>
|
||||
<Button variant="outline" size="sm" disabled={page >= totalPages || loading} onClick={() => setPage(current => Math.min(totalPages, current + 1))}>下一页</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
55
src/lib/admin-gallery-works-pagination.ts
Normal file
55
src/lib/admin-gallery-works-pagination.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
export interface AdminGalleryWorksPagination {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface AdminGalleryWorksPaginationMetaInput {
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
resultCount: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
function intParam(value: string | null, fallback: number, min: number, max: number) {
|
||||
const parsed = Number.parseInt(value || '', 10);
|
||||
if (!Number.isFinite(parsed)) return fallback;
|
||||
return Math.min(max, Math.max(min, parsed));
|
||||
}
|
||||
|
||||
export function parseAdminGalleryWorksPagination(params: URLSearchParams): AdminGalleryWorksPagination {
|
||||
const hasPage = params.has('page');
|
||||
const pageSize = intParam(params.get('pageSize') || params.get('limit'), 20, 1, 100);
|
||||
const offset = hasPage
|
||||
? (intParam(params.get('page'), 1, 1, 50000) - 1) * pageSize
|
||||
: intParam(params.get('offset'), 0, 0, 1000000);
|
||||
const page = Math.floor(offset / pageSize) + 1;
|
||||
|
||||
return {
|
||||
page,
|
||||
pageSize,
|
||||
limit: pageSize,
|
||||
offset,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAdminGalleryWorksPaginationMeta(input: AdminGalleryWorksPaginationMetaInput) {
|
||||
const total = Math.max(0, Number(input.total || 0));
|
||||
const pageSize = Math.max(1, Number(input.pageSize || 20));
|
||||
const page = Math.max(1, Number(input.page || 1));
|
||||
const offset = Math.max(0, Number(input.offset ?? ((page - 1) * pageSize)));
|
||||
const resultCount = Math.max(0, Number(input.resultCount || 0));
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
const nextOffset = offset + resultCount;
|
||||
|
||||
return {
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages,
|
||||
nextOffset,
|
||||
hasMore: nextOffset < total,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user