feat: enhance tenant management with pagination and sorting options

- Updated the SuperAdminTenantManager component to support pagination and sorting of tenant data.
- Introduced new parameters for fetching tenants, including page, limit, status, sortBy, and sortDir.
- Enhanced the UI to allow users to filter tenants by status and sort by name or creation date.
- Updated localization files to include new strings for tenant filtering and pagination.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-12-04 15:54:47 +08:00
parent 9fb7f5525f
commit e8550cee42
19 changed files with 251 additions and 30 deletions

View File

@@ -2,7 +2,7 @@
title: Quick Start
description: Get your gallery running in about 5 minutes.
createdAt: 2025-11-14T22:20:00+08:00
lastModified: 2025-11-30T14:03:05+08:00
lastModified: 2025-12-04T15:54:49+08:00
order: 2
---
@@ -112,3 +112,5 @@ Deploy to Vercel or any Node.js host. See [Vercel Deployment](/deployment/vercel

View File

@@ -2,7 +2,7 @@
title: B2 (Backblaze B2)
description: Configure Backblaze B2 storage for cost-effective cloud storage.
createdAt: 2025-11-14T22:10:00+08:00
lastModified: 2025-11-30T14:03:05+08:00
lastModified: 2025-12-04T15:54:49+08:00
order: 33
---
@@ -95,3 +95,5 @@ Compare with AWS S3 to see which fits your usage pattern better.
- B2 has generous rate limits, but very high concurrency may still hit limits
- Reduce concurrency if needed

View File

@@ -2,7 +2,7 @@
title: Eagle Storage
description: Publish directly from an Eagle 4 library with filtering support.
createdAt: 2025-11-14T22:10:00+08:00
lastModified: 2025-11-30T14:03:05+08:00
lastModified: 2025-12-04T15:54:49+08:00
order: 36
---
@@ -166,3 +166,5 @@ This creates tags in the manifest based on folder structure, useful for organizi

View File

@@ -2,7 +2,7 @@
title: GitHub Storage
description: Use a GitHub repository as photo storage for simple deployments.
createdAt: 2025-11-14T22:10:00+08:00
lastModified: 2025-11-30T14:03:05+08:00
lastModified: 2025-12-04T15:54:49+08:00
order: 34
---
@@ -132,3 +132,5 @@ For private repositories:

View File

@@ -2,7 +2,7 @@
title: Storage Providers
description: Choose a storage provider for your photo collection.
createdAt: 2025-11-14T22:40:00+08:00
lastModified: 2025-11-30T14:03:05+08:00
lastModified: 2025-12-04T15:54:49+08:00
order: 30
---
@@ -112,3 +112,5 @@ See each provider's documentation for specific configuration options.

View File

@@ -2,7 +2,7 @@
title: Local Storage
description: Use local file system paths for development and self-hosting.
createdAt: 2025-11-14T22:10:00+08:00
lastModified: 2025-11-30T14:03:05+08:00
lastModified: 2025-12-04T15:54:49+08:00
order: 35
---
@@ -135,3 +135,5 @@ If you want to serve original photos:

View File

@@ -2,7 +2,7 @@
title: S3 / S3-Compatible
description: Configure S3 or S3-compatible storage for your photo collection.
createdAt: 2025-11-14T22:10:00+08:00
lastModified: 2025-11-30T14:03:05+08:00
lastModified: 2025-12-04T15:54:49+08:00
order: 32
---
@@ -122,3 +122,5 @@ This prevents processing temporary or system files.

View File

@@ -41,12 +41,26 @@ export class SuperAdminTenantController {
}
@Get('/')
async listTenants() {
const [tenantAggregates, plans] = await Promise.all([
this.tenantService.listTenants(),
async listTenants(
@Query('page') page = '1',
@Query('limit') limit = '20',
@Query('status') status?: string,
@Query('sortBy') sortBy?: 'createdAt' | 'name',
@Query('sortDir') sortDir?: 'asc' | 'desc',
) {
const [tenantResult, plans] = await Promise.all([
this.tenantService.listTenants({
page: Number(page),
limit: Number(limit),
status: status as any,
sortBy,
sortDir,
}),
Promise.resolve(this.billingPlanService.getPlanDefinitions()),
])
const { items: tenantAggregates, total } = tenantResult
const tenantIds = tenantAggregates.map((aggregate) => aggregate.tenant.id)
const usageTotalsMap = await this.billingUsageService.getUsageTotalsForTenants(tenantIds)
@@ -56,6 +70,7 @@ export class SuperAdminTenantController {
usageTotals: usageTotalsMap[aggregate.tenant.id] ?? [],
})),
plans,
total,
}
}

View File

@@ -1,12 +1,12 @@
import { generateId, tenants } from '@afilmory/db'
import { isTenantSlugReserved } from '@afilmory/utils'
import { RESERVED_TENANT_SLUGS } from '@afilmory/utils'
import { DbAccessor } from 'core/database/database.provider'
import { BizException, ErrorCode } from 'core/errors'
import type { BillingPlanId } from 'core/modules/platform/billing/billing-plan.types'
import { desc, eq } from 'drizzle-orm'
import { and, asc, count, desc, eq, notInArray } from 'drizzle-orm'
import { injectable } from 'tsyringe'
import type { TenantAggregate } from './tenant.types'
import type { TenantAggregate, TenantRecord } from './tenant.types'
@injectable()
export class TenantRepository {
@@ -76,11 +76,45 @@ export class TenantRepository {
await db.update(tenants).set({ banned, updatedAt: new Date().toISOString() }).where(eq(tenants.id, id))
}
async listTenants(): Promise<TenantAggregate[]> {
async listTenants(options: {
page: number
limit: number
status?: TenantRecord['status']
sortBy?: 'createdAt' | 'name'
sortDir?: 'asc' | 'desc'
}): Promise<{ items: TenantAggregate[]; total: number }> {
const db = this.dbAccessor.get()
const rows = await db.select().from(tenants).orderBy(desc(tenants.createdAt))
const { page, limit, status, sortBy = 'createdAt', sortDir = 'desc' } = options
// Ignore preseve slug
return rows.filter((tenant) => !isTenantSlugReserved(tenant.slug)).map((tenant) => ({ tenant }))
const conditions = [notInArray(tenants.slug, RESERVED_TENANT_SLUGS)]
if (status) {
conditions.push(eq(tenants.status, status))
}
const where = and(...conditions)
const [total] = await db.select({ count: count() }).from(tenants).where(where)
let orderBy
const sortColumn = sortBy === 'name' ? tenants.name : tenants.createdAt
if (sortDir === 'asc') {
orderBy = asc(sortColumn)
} else {
orderBy = desc(sortColumn)
}
const rows = await db
.select()
.from(tenants)
.where(where)
.limit(limit)
.offset((page - 1) * limit)
.orderBy(orderBy)
return {
items: rows.map((tenant) => ({ tenant })),
total: total?.count ?? 0,
}
}
}

View File

@@ -115,8 +115,20 @@ export class TenantService {
await this.repository.deleteById(id)
}
async listTenants(): Promise<TenantAggregate[]> {
return await this.repository.listTenants()
async listTenants(options?: {
page?: number
limit?: number
status?: TenantRecord['status']
sortBy?: 'createdAt' | 'name'
sortDir?: 'asc' | 'desc'
}): Promise<{ items: TenantAggregate[]; total: number }> {
return await this.repository.listTenants({
page: options?.page ?? 1,
limit: options?.limit ?? 20,
status: options?.status,
sortBy: options?.sortBy,
sortDir: options?.sortDir,
})
}
async setBanned(id: string, banned: boolean): Promise<void> {

View File

@@ -99,6 +99,7 @@ export function useLogin() {
}
}
} else {
console.error('error', error)
setErrorMessage('An unexpected error occurred. Please try again')
}
},

View File

@@ -7,6 +7,7 @@ import type {
BuilderDebugProgressEvent,
BuilderDebugResult,
SuperAdminSettingsResponse,
SuperAdminTenantListParams,
SuperAdminTenantListResponse,
SuperAdminTenantPhotosResponse,
UpdateSuperAdminSettingsPayload,
@@ -39,8 +40,22 @@ export async function updateSuperAdminSettings(payload: UpdateSuperAdminSettings
})
}
export async function fetchSuperAdminTenants(): Promise<SuperAdminTenantListResponse> {
const response = await coreApi<SuperAdminTenantListResponse>(`${SUPER_ADMIN_TENANTS_ENDPOINT}`, {
export async function fetchSuperAdminTenants(
params?: SuperAdminTenantListParams,
): Promise<SuperAdminTenantListResponse> {
const query = new URLSearchParams()
if (params) {
if (params.page) query.set('page', String(params.page))
if (params.limit) query.set('limit', String(params.limit))
if (params.status) query.set('status', params.status)
if (params.sortBy) query.set('sortBy', params.sortBy)
if (params.sortDir) query.set('sortDir', params.sortDir)
}
const queryString = query.toString()
const url = queryString ? `${SUPER_ADMIN_TENANTS_ENDPOINT}?${queryString}` : SUPER_ADMIN_TENANTS_ENDPOINT
const response = await coreApi<SuperAdminTenantListResponse>(url, {
method: 'GET',
})
return camelCaseKeys<SuperAdminTenantListResponse>(response)

View File

@@ -1,7 +1,8 @@
import { Button, Modal, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@afilmory/ui'
import { Spring } from '@afilmory/utils'
import { RefreshCcwIcon } from 'lucide-react'
import { ArrowDownIcon, ArrowUpIcon, ChevronLeftIcon, ChevronRightIcon, RefreshCcwIcon } from 'lucide-react'
import { m } from 'motion/react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@@ -18,7 +19,19 @@ const DATE_FORMATTER = new Intl.DateTimeFormat('zh-CN', {
})
export function SuperAdminTenantManager() {
const tenantsQuery = useSuperAdminTenantsQuery()
const [page, setPage] = useState(1)
const [limit] = useState(10)
const [status, setStatus] = useState<string>('all')
const [sortBy, setSortBy] = useState<string>('createdAt')
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
const tenantsQuery = useSuperAdminTenantsQuery({
page,
limit,
status: status === 'all' ? undefined : status,
sortBy,
sortDir,
})
const updatePlanMutation = useUpdateTenantPlanMutation()
const updateBanMutation = useUpdateTenantBanMutation()
const { t } = useTranslation()
@@ -29,6 +42,17 @@ export function SuperAdminTenantManager() {
const plans = data?.plans ?? []
const tenants = data?.tenants ?? []
const total = data?.total ?? 0
const totalPages = Math.ceil(total / limit)
const handleSort = (field: string) => {
if (sortBy === field) {
setSortDir(sortDir === 'asc' ? 'desc' : 'asc')
} else {
setSortBy(field)
setSortDir('asc')
}
}
const handlePlanChange = (tenant: SuperAdminTenantSummary, planId: string) => {
if (planId === tenant.planId) {
@@ -90,9 +114,34 @@ export function SuperAdminTenantManager() {
return <TenantSkeleton />
}
const SortIcon = ({ field }: { field: string }) => {
if (sortBy !== field) return null
return sortDir === 'asc' ? <ArrowUpIcon className="size-3 ml-1" /> : <ArrowDownIcon className="size-3 ml-1" />
}
return (
<m.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={Spring.presets.smooth}>
<header className="flex items-center justify-end gap-3">
<header className="flex items-center justify-between gap-3 mb-4">
<div className="w-[200px]">
<Select
value={status}
onValueChange={(val) => {
setStatus(val)
setPage(1)
}}
>
<SelectTrigger>
<SelectValue placeholder={t('superadmin.tenants.filter.status')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('superadmin.tenants.filter.all')}</SelectItem>
<SelectItem value="active">{t('superadmin.tenants.status.active')}</SelectItem>
<SelectItem value="inactive">{t('superadmin.tenants.status.inactive')}</SelectItem>
<SelectItem value="suspended">{t('superadmin.tenants.status.suspended')}</SelectItem>
<SelectItem value="pending">{t('superadmin.tenants.status.pending')}</SelectItem>
</SelectContent>
</Select>
</div>
<Button
type="button"
variant="ghost"
@@ -108,7 +157,7 @@ export function SuperAdminTenantManager() {
</Button>
</header>
<LinearBorderPanel className="p-6 bg-background-secondary mt-4">
<LinearBorderPanel className="p-6 bg-background-secondary">
{tenants.length === 0 ? (
<p className="text-text-secondary text-sm">{t('superadmin.tenants.empty')}</p>
) : (
@@ -116,12 +165,28 @@ export function SuperAdminTenantManager() {
<table className="min-w-full divide-y divide-border/40 text-sm">
<thead>
<tr className="text-text-tertiary text-xs uppercase tracking-wide">
<th className="px-3 py-2 text-left">{t('superadmin.tenants.table.tenant')}</th>
<th
className="px-3 py-2 text-left cursor-pointer hover:text-text select-none"
onClick={() => handleSort('name')}
>
<div className="flex items-center">
{t('superadmin.tenants.table.tenant')}
<SortIcon field="name" />
</div>
</th>
<th className="px-3 py-2 text-left">{t('superadmin.tenants.table.plan')}</th>
<th className="px-3 py-2 text-left">{t('superadmin.tenants.table.usage')}</th>
<th className="px-3 py-2 text-center">{t('superadmin.tenants.table.status')}</th>
<th className="px-3 py-2 text-center">{t('superadmin.tenants.table.ban')}</th>
<th className="px-3 py-2 text-left">{t('superadmin.tenants.table.created')}</th>
<th
className="px-3 py-2 text-left cursor-pointer hover:text-text select-none"
onClick={() => handleSort('createdAt')}
>
<div className="flex items-center">
{t('superadmin.tenants.table.created')}
<SortIcon field="createdAt" />
</div>
</th>
</tr>
</thead>
<tbody className="divide-y divide-border/20">
@@ -180,6 +245,39 @@ export function SuperAdminTenantManager() {
))}
</tbody>
</table>
<div className="flex items-center justify-between mt-6 border-t border-border/40 pt-4">
<div className="text-xs text-text-tertiary">
{t('superadmin.tenants.pagination.showing', {
start: (page - 1) * limit + 1,
end: Math.min(page * limit, total),
total,
})}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
className="size-8"
disabled={page <= 1 || tenantsQuery.isFetching}
onClick={() => setPage((p) => p - 1)}
>
<ChevronLeftIcon className="size-4" />
</Button>
<div className="text-sm text-text-secondary font-medium">
{page} / {totalPages || 1}
</div>
<Button
variant="outline"
size="icon"
className="size-8"
disabled={page >= totalPages || tenantsQuery.isFetching}
onClick={() => setPage((p) => p + 1)}
>
<ChevronRightIcon className="size-4" />
</Button>
</div>
</div>
</div>
)}
</LinearBorderPanel>
@@ -248,6 +346,13 @@ function StatusBadge({ status, banned }: { status: SuperAdminTenantSummary['stat
</span>
)
}
if (status === 'pending') {
return (
<span className="bg-blue-500/10 text-blue-400 rounded-full px-2 py-0.5 text-xs">
{t('superadmin.tenants.status.pending')}
</span>
)
}
return (
<span className="bg-slate-500/10 text-slate-400 rounded-full px-2 py-0.5 text-xs">
{t('superadmin.tenants.status.inactive')}

View File

@@ -10,6 +10,7 @@ import {
} from './api'
import type {
SuperAdminSettingsResponse,
SuperAdminTenantListParams,
SuperAdminTenantListResponse,
SuperAdminTenantPhotosResponse,
UpdateSuperAdminSettingsPayload,
@@ -28,10 +29,11 @@ export function useSuperAdminSettingsQuery() {
})
}
export function useSuperAdminTenantsQuery() {
export function useSuperAdminTenantsQuery(params?: SuperAdminTenantListParams) {
return useQuery<SuperAdminTenantListResponse>({
queryKey: SUPER_ADMIN_TENANTS_QUERY_KEY,
queryFn: fetchSuperAdminTenants,
queryKey: [...SUPER_ADMIN_TENANTS_QUERY_KEY, params],
queryFn: () => fetchSuperAdminTenants(params),
placeholderData: (previousData) => previousData,
})
}

View File

@@ -112,6 +112,15 @@ export interface SuperAdminTenantSummary {
export interface SuperAdminTenantListResponse {
tenants: SuperAdminTenantSummary[]
plans: BillingPlanDefinition[]
total: number
}
export interface SuperAdminTenantListParams {
page: number
limit: number
status?: string
sortBy?: string
sortDir?: 'asc' | 'desc'
}
export interface UpdateTenantPlanPayload {

View File

@@ -13,6 +13,10 @@ export function Component() {
const [isRedirecting, setIsRedirecting] = useState(false)
const { login, isLoading, error, clearError } = useLogin()
useEffect(() => {
console.error('error', error)
}, [error])
const tenantSlug = useMemo(() => {
return getTenantSlugFromHost(window.location.hostname)
}, [])

View File

@@ -45,3 +45,5 @@ SELECT * FROM drizzle.__drizzle_migrations WHERE id = 7;
*/

View File

@@ -826,6 +826,8 @@
"superadmin.tenants.description": "Switch plans for specific tenants or suspend those that violate policies.",
"superadmin.tenants.empty": "There are no tenants to manage right now.",
"superadmin.tenants.error.loading": "Unable to load tenant data: {{reason}}",
"superadmin.tenants.filter.all": "All",
"superadmin.tenants.filter.status": "Status",
"superadmin.tenants.modal.overview.details": "Details",
"superadmin.tenants.modal.overview.photos": "Total Photos",
"superadmin.tenants.modal.photos.empty": "No photos found for this tenant.",
@@ -837,12 +839,14 @@
"superadmin.tenants.modal.photos.table.size": "Size",
"superadmin.tenants.modal.tab.overview": "Overview",
"superadmin.tenants.modal.tab.photos": "Photos",
"superadmin.tenants.pagination.showing": "Showing {{start}}-{{end}} of {{total}}",
"superadmin.tenants.plan.placeholder": "Select a plan",
"superadmin.tenants.refresh.button": "Refresh list",
"superadmin.tenants.refresh.loading": "Refreshing…",
"superadmin.tenants.status.active": "Active",
"superadmin.tenants.status.banned": "Banned",
"superadmin.tenants.status.inactive": "Inactive",
"superadmin.tenants.status.pending": "Pending",
"superadmin.tenants.status.suspended": "Suspended",
"superadmin.tenants.table.ban": "Ban",
"superadmin.tenants.table.created": "Created",

View File

@@ -825,16 +825,20 @@
"superadmin.tenants.description": "为特定租户切换订阅计划或封禁违规租户。",
"superadmin.tenants.empty": "当前没有可管理的租户。",
"superadmin.tenants.error.loading": "无法加载租户数据:{{reason}}",
"superadmin.tenants.filter.all": "全部",
"superadmin.tenants.filter.status": "状态筛选",
"superadmin.tenants.modal.overview.details": "详细信息",
"superadmin.tenants.modal.overview.photos": "照片总数",
"superadmin.tenants.modal.tab.overview": "概览",
"superadmin.tenants.modal.tab.photos": "照片",
"superadmin.tenants.pagination.showing": "显示 {{start}}-{{end}} 项,共 {{total}} 项",
"superadmin.tenants.plan.placeholder": "选择订阅计划",
"superadmin.tenants.refresh.button": "刷新列表",
"superadmin.tenants.refresh.loading": "正在刷新…",
"superadmin.tenants.status.active": "活跃",
"superadmin.tenants.status.banned": "已封禁",
"superadmin.tenants.status.inactive": "未激活",
"superadmin.tenants.status.pending": "待处理",
"superadmin.tenants.status.suspended": "已暂停",
"superadmin.tenants.table.ban": "封禁",
"superadmin.tenants.table.created": "创建时间",