mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 22:48:17 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -99,6 +99,7 @@ export function useLogin() {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error('error', error)
|
||||
setErrorMessage('An unexpected error occurred. Please try again')
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}, [])
|
||||
|
||||
@@ -45,3 +45,5 @@ SELECT * FROM drizzle.__drizzle_migrations WHERE id = 7;
|
||||
*/
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "创建时间",
|
||||
|
||||
Reference in New Issue
Block a user