Browse Source

feat(pagination): implement pagination for users and posts management

- Added pagination functionality to the users and posts management pages, allowing for better navigation through large datasets.
- Updated API endpoints to support pagination parameters and return total counts for users and posts.
- Introduced a reusable pagination component to enhance user experience and streamline page transitions.

These changes improve the overall usability of the admin interface by enabling efficient data handling and display.
main
npmrun 2 weeks ago
parent
commit
c8b96b2e8c
  1. 61
      app/pages/me/admin/users/index.vue
  2. 59
      app/pages/me/posts/index.vue
  3. 57
      server/api/admin/users.get.ts
  4. 7
      server/api/me/posts.get.ts
  5. 24
      server/service/posts/index.ts

61
app/pages/me/admin/users/index.vue

@ -7,6 +7,8 @@ usePageTitle('用户管理')
const { user, refresh } = useAuthSession() const { user, refresh } = useAuthSession()
const { fetchData } = useClientApi() const { fetchData } = useClientApi()
const toast = useToast() const toast = useToast()
const route = useRoute()
const router = useRouter()
const rows = ref< const rows = ref<
{ {
@ -21,9 +23,25 @@ const rows = ref<
}[] }[]
>([]) >([])
const loading = ref(true) const loading = ref(true)
const page = ref(1)
const total = ref(0)
const pageSize = ref(50)
const form = reactive({ username: '', password: '', email: '' }) const form = reactive({ username: '', password: '', email: '' })
const creating = ref(false) const creating = ref(false)
function parsePage(raw: unknown): number {
const n =
typeof raw === 'string'
? Number.parseInt(raw, 10)
: typeof raw === 'number'
? raw
: Number.NaN
if (!Number.isFinite(n) || n < 1) {
return 1
}
return Math.floor(n)
}
async function copyPublicUrl(publicSlug: string) { async function copyPublicUrl(publicSlug: string) {
const href = buildPublicProfileAbsoluteUrl(window.location.origin, publicSlug) const href = buildPublicProfileAbsoluteUrl(window.location.origin, publicSlug)
try { try {
@ -44,14 +62,46 @@ async function ensureAdmin() {
async function load() { async function load() {
loading.value = true loading.value = true
try { try {
const { users } = await fetchData<{ users: typeof rows.value }>('/api/admin/users') const url = page.value > 1 ? `/api/admin/users?page=${page.value}` : '/api/admin/users'
const { users, total: count, pageSize: size } = await fetchData<{
users: typeof rows.value
total: number
pageSize: number
}>(url)
rows.value = users rows.value = users
total.value = count
pageSize.value = size
} finally { } finally {
loading.value = false loading.value = false
} }
} }
function onPageChange(nextPage: number) {
page.value = nextPage
const query = { ...route.query }
if (nextPage > 1) {
query.page = String(nextPage)
} else {
delete query.page
}
void router.replace({ query })
void load()
}
watch(
() => route.query.page,
() => {
const next = parsePage(route.query.page)
if (next === page.value) {
return
}
page.value = next
void load()
},
)
onMounted(async () => { onMounted(async () => {
page.value = parsePage(route.query.page)
await ensureAdmin() await ensureAdmin()
await load() await load()
}) })
@ -205,6 +255,15 @@ async function setStatus(id: number, status: 'active' | 'disabled') {
</tr> </tr>
</tbody> </tbody>
</table> </table>
<div v-if="!loading && total > pageSize" class="mt-4 flex justify-end">
<UPagination
:page="page"
:total="total"
:items-per-page="pageSize"
size="sm"
@update:page="onPageChange"
/>
</div>
</UCard> </UCard>
</UContainer> </UContainer>
</template> </template>

59
app/pages/me/posts/index.vue

@ -6,24 +6,70 @@ usePageTitle('我的文章')
type Visibility = 'private' | 'unlisted' | 'public' type Visibility = 'private' | 'unlisted' | 'public'
type Row = { id: number; title: string; slug: string; visibility: Visibility } type Row = { id: number; title: string; slug: string; visibility: Visibility }
type ViewMode = 'list' | 'card' type ViewMode = 'list' | 'card'
type Payload = { items: Row[]; total: number; page: number; pageSize: number }
const posts = ref<Row[]>([]) const posts = ref<Row[]>([])
const loading = ref(true) const loading = ref(true)
const viewMode = ref<ViewMode>('card') const viewMode = ref<ViewMode>('card')
const { user } = useAuthSession() const { user } = useAuthSession()
const { fetchData } = useClientApi() const { fetchData } = useClientApi()
const route = useRoute()
const router = useRouter()
const page = ref(1)
const total = ref(0)
const pageSize = ref(20)
function parsePage(raw: unknown): number {
const n =
typeof raw === 'string'
? Number.parseInt(raw, 10)
: typeof raw === 'number'
? raw
: Number.NaN
if (!Number.isFinite(n) || n < 1) {
return 1
}
return Math.floor(n)
}
async function load() { async function load() {
loading.value = true loading.value = true
try { try {
const { posts: list } = await fetchData<{ posts: Row[] }>('/api/me/posts') const url = page.value > 1 ? `/api/me/posts?page=${page.value}` : '/api/me/posts'
posts.value = list const data = await fetchData<Payload>(url)
posts.value = data.items
total.value = data.total
pageSize.value = data.pageSize
} finally { } finally {
loading.value = false loading.value = false
} }
} }
function onPageChange(nextPage: number) {
page.value = nextPage
const query = { ...route.query }
if (nextPage > 1) {
query.page = String(nextPage)
} else {
delete query.page
}
void router.replace({ query })
}
watch(
() => route.query.page,
() => {
const next = parsePage(route.query.page)
if (next === page.value) {
return
}
page.value = next
void load()
},
)
onMounted(() => { onMounted(() => {
page.value = parsePage(route.query.page)
void load() void load()
}) })
@ -153,5 +199,14 @@ function visibilityLabel(visibility: string) {
</template> </template>
</UCard> </UCard>
</div> </div>
<div v-if="!loading && total > pageSize" class="flex justify-end">
<UPagination
:page="page"
:total="total"
:items-per-page="pageSize"
size="sm"
@update:page="onPageChange"
/>
</div>
</UContainer> </UContainer>
</template> </template>

57
server/api/admin/users.get.ts

@ -4,37 +4,42 @@ import { posts, timelineEvents } from "drizzle-pkg/lib/schema/content";
import { rssFeeds } from "drizzle-pkg/lib/schema/rss"; import { rssFeeds } from "drizzle-pkg/lib/schema/rss";
import { desc, sql } from "drizzle-orm"; import { desc, sql } from "drizzle-orm";
import { requireAdmin } from "#server/utils/admin-guard"; import { requireAdmin } from "#server/utils/admin-guard";
import { normalizePublicListPage } from "#server/utils/public-pagination";
export default defineWrappedResponseHandler(async (event) => { export default defineWrappedResponseHandler(async (event) => {
await requireAdmin(event); await requireAdmin(event);
const q = getQuery(event); const q = getQuery(event);
const limit = Math.min(Number(q.limit ?? 50) || 50, 100); const pageSize = Math.min(Number(q.pageSize ?? q.limit ?? 50) || 50, 100);
const offset = Math.max(Number(q.offset ?? 0) || 0, 0); const page = normalizePublicListPage(q.page);
const offset = Math.max(Number(q.offset ?? (page - 1) * pageSize) || 0, 0);
const rows = await dbGlobal const [countRows, rows] = await Promise.all([
.select({ dbGlobal.select({ total: sql<number>`count(*)`.mapWith(Number) }).from(users),
id: users.id, dbGlobal
username: users.username, .select({
email: users.email, id: users.id,
role: users.role, username: users.username,
status: users.status, email: users.email,
publicSlug: users.publicSlug, role: users.role,
createdAt: users.createdAt, status: users.status,
postCount: sql<number>`(select count(*) from ${posts} where ${posts.userId} = ${users.id})`.mapWith( publicSlug: users.publicSlug,
Number, createdAt: users.createdAt,
), postCount: sql<number>`(select count(*) from ${posts} where ${posts.userId} = ${users.id})`.mapWith(
timelineEventCount: sql<number>`(select count(*) from ${timelineEvents} where ${timelineEvents.userId} = ${users.id})`.mapWith( Number,
Number, ),
), timelineEventCount: sql<number>`(select count(*) from ${timelineEvents} where ${timelineEvents.userId} = ${users.id})`.mapWith(
rssFeedCount: sql<number>`(select count(*) from ${rssFeeds} where ${rssFeeds.userId} = ${users.id})`.mapWith( Number,
Number, ),
), rssFeedCount: sql<number>`(select count(*) from ${rssFeeds} where ${rssFeeds.userId} = ${users.id})`.mapWith(
}) Number,
.from(users) ),
.orderBy(desc(users.id)) })
.limit(limit) .from(users)
.offset(offset); .orderBy(desc(users.id))
.limit(pageSize)
.offset(offset),
]);
return R.success({ users: rows }); return R.success({ users: rows, total: countRows[0]?.total ?? 0, page, pageSize });
}); });

7
server/api/me/posts.get.ts

@ -1,7 +1,8 @@
import { listPostsForUser } from "#server/service/posts"; import { listPostsPageForUser } from "#server/service/posts";
export default defineWrappedResponseHandler(async (event) => { export default defineWrappedResponseHandler(async (event) => {
const user = await event.context.auth.requireUser(); const user = await event.context.auth.requireUser();
const rows = await listPostsForUser(user.id); const q = getQuery(event);
return R.success({ posts: rows }); const payload = await listPostsPageForUser(user.id, q.page);
return R.success(payload);
}); });

24
server/service/posts/index.ts

@ -27,6 +27,30 @@ export async function listPostsForUser(userId: number) {
.orderBy(desc(posts.publishedAt), desc(posts.id)); .orderBy(desc(posts.publishedAt), desc(posts.id));
} }
export async function listPostsPageForUser(userId: number, pageRaw: unknown, pageSize = 20) {
const page = normalizePublicListPage(pageRaw);
const offset = (page - 1) * pageSize;
const [countRows, items] = await Promise.all([
dbGlobal
.select({ total: count() })
.from(posts)
.where(eq(posts.userId, userId)),
dbGlobal
.select()
.from(posts)
.where(eq(posts.userId, userId))
.orderBy(desc(posts.publishedAt), desc(posts.id))
.limit(pageSize)
.offset(offset),
]);
return {
items,
total: countRows[0]?.total ?? 0,
page,
pageSize,
};
}
export async function getPostForUser(userId: number, id: number) { export async function getPostForUser(userId: number, id: number) {
const [row] = await dbGlobal const [row] = await dbGlobal
.select() .select()

Loading…
Cancel
Save