mirror of
https://github.com/Afilmory/afilmory
synced 2026-05-02 18:57:20 +00:00
feat: add storage plan management to tenant administration
- Introduced functionality to update storage plans for tenants in the SuperAdmin interface. - Enhanced the SuperAdminTenantController and related services to handle storage plan updates. - Updated frontend components to include a storage plan selector in the tenant management UI. - Added necessary DTOs and types for storage plan handling. - Improved localization for storage plan features in both English and Chinese. Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
@@ -280,12 +280,12 @@ const enUiSchema = {
|
||||
},
|
||||
plans: {
|
||||
free: {
|
||||
title: 'Free Plan (free)',
|
||||
title: 'Free Plan (hobby)',
|
||||
description: 'Default starter tier for individuals and trials.',
|
||||
},
|
||||
pro: {
|
||||
title: 'Pro Plan (pro)',
|
||||
description: 'Professional tier reserved for the upcoming subscription release.',
|
||||
description: 'Professional plan.',
|
||||
},
|
||||
friend: {
|
||||
title: 'Friend Plan (friend)',
|
||||
|
||||
@@ -278,12 +278,12 @@ const zhCnUiSchema = {
|
||||
},
|
||||
plans: {
|
||||
free: {
|
||||
title: 'Free 计划(free)',
|
||||
title: 'Free 计划(hobby)',
|
||||
description: '默认入门方案,适用于个人与试用场景。',
|
||||
},
|
||||
pro: {
|
||||
title: 'Pro 计划(pro)',
|
||||
description: '专业方案,预留给即将上线的订阅。',
|
||||
description: '专业方案',
|
||||
},
|
||||
friend: {
|
||||
title: 'Friend 计划(friend)',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Body, Controller, Delete, Get, Param, Patch, Query } from '@afilmory/fr
|
||||
import { DbAccessor } from 'core/database/database.provider'
|
||||
import { Roles } from 'core/guards/roles.decorator'
|
||||
import { BypassResponseTransform } from 'core/interceptors/response-transform.decorator'
|
||||
import { SystemSettingService } from 'core/modules/configuration/system-setting/system-setting.service'
|
||||
import { BillingPlanService } from 'core/modules/platform/billing/billing-plan.service'
|
||||
import { BillingUsageService } from 'core/modules/platform/billing/billing-usage.service'
|
||||
import { TenantService } from 'core/modules/platform/tenant/tenant.service'
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
TenantPhotosQueryDto,
|
||||
UpdateTenantBanDto,
|
||||
UpdateTenantPlanDto,
|
||||
UpdateTenantStoragePlanDto,
|
||||
} from './super-admin.dto'
|
||||
|
||||
@Controller('super-admin/tenants')
|
||||
@@ -27,6 +29,7 @@ export class SuperAdminTenantController {
|
||||
private readonly dataManagementService: DataManagementService,
|
||||
private readonly billingPlanService: BillingPlanService,
|
||||
private readonly billingUsageService: BillingUsageService,
|
||||
private readonly systemSettings: SystemSettingService,
|
||||
private readonly db: DbAccessor,
|
||||
) {}
|
||||
|
||||
@@ -50,7 +53,7 @@ export class SuperAdminTenantController {
|
||||
|
||||
@Get('/')
|
||||
async listTenants(@Query() query: ListTenantsQueryDto) {
|
||||
const [tenantResult, plans] = await Promise.all([
|
||||
const [tenantResult, plans, storagePlanCatalog] = await Promise.all([
|
||||
this.tenantService.listTenants({
|
||||
page: query.page,
|
||||
limit: query.limit,
|
||||
@@ -60,6 +63,7 @@ export class SuperAdminTenantController {
|
||||
sortDir: query.sortDir,
|
||||
}),
|
||||
Promise.resolve(this.billingPlanService.getPlanDefinitions()),
|
||||
this.systemSettings.getStoragePlanCatalog(),
|
||||
])
|
||||
|
||||
const { items: tenantAggregates, total } = tenantResult
|
||||
@@ -73,6 +77,10 @@ export class SuperAdminTenantController {
|
||||
usageTotals: usageTotalsMap[aggregate.tenant.id] ?? [],
|
||||
})),
|
||||
plans,
|
||||
storagePlans: Object.entries(storagePlanCatalog).map(([id, def]) => ({
|
||||
id,
|
||||
...def,
|
||||
})),
|
||||
total,
|
||||
}
|
||||
}
|
||||
@@ -83,6 +91,12 @@ export class SuperAdminTenantController {
|
||||
return { updated: true }
|
||||
}
|
||||
|
||||
@Patch('/:tenantId/storage-plan')
|
||||
async updateTenantStoragePlan(@Param() params: TenantIdParamDto, @Body() dto: UpdateTenantStoragePlanDto) {
|
||||
await this.tenantService.updateStoragePlan(params.tenantId, dto.storagePlanId)
|
||||
return { updated: true }
|
||||
}
|
||||
|
||||
@Patch('/:tenantId/ban')
|
||||
async updateTenantBan(@Param() params: TenantIdParamDto, @Body() dto: UpdateTenantBanDto) {
|
||||
await this.tenantService.setBanned(params.tenantId, dto.banned)
|
||||
|
||||
@@ -91,6 +91,12 @@ const updateTenantPlanSchema = z.object({
|
||||
|
||||
export class UpdateTenantPlanDto extends createZodDto(updateTenantPlanSchema) {}
|
||||
|
||||
const updateTenantStoragePlanSchema = z.object({
|
||||
storagePlanId: z.string().trim().min(1).nullable(),
|
||||
})
|
||||
|
||||
export class UpdateTenantStoragePlanDto extends createZodDto(updateTenantStoragePlanSchema) {}
|
||||
|
||||
const updateTenantBanSchema = z.object({
|
||||
banned: z.boolean(),
|
||||
})
|
||||
|
||||
@@ -71,6 +71,11 @@ export class TenantRepository {
|
||||
await db.update(tenants).set({ planId, updatedAt: new Date().toISOString() }).where(eq(tenants.id, id))
|
||||
}
|
||||
|
||||
async updateStoragePlan(id: string, storagePlanId: string | null): Promise<void> {
|
||||
const db = this.dbAccessor.get()
|
||||
await db.update(tenants).set({ storagePlanId, updatedAt: new Date().toISOString() }).where(eq(tenants.id, id))
|
||||
}
|
||||
|
||||
async updateBanned(id: string, banned: boolean): Promise<void> {
|
||||
const db = this.dbAccessor.get()
|
||||
await db.update(tenants).set({ banned, updatedAt: new Date().toISOString() }).where(eq(tenants.id, id))
|
||||
|
||||
@@ -137,6 +137,10 @@ export class TenantService {
|
||||
await this.repository.updateBanned(id, banned)
|
||||
}
|
||||
|
||||
async updateStoragePlan(id: string, storagePlanId: string | null): Promise<void> {
|
||||
await this.repository.updateStoragePlan(id, storagePlanId)
|
||||
}
|
||||
|
||||
async isSlugAvailable(slug: string): Promise<boolean> {
|
||||
const normalized = this.normalizeSlug(slug)
|
||||
if (!normalized) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
UpdateSuperAdminSettingsPayload,
|
||||
UpdateTenantBanPayload,
|
||||
UpdateTenantPlanPayload,
|
||||
UpdateTenantStoragePlanPayload,
|
||||
} from './types'
|
||||
|
||||
const SUPER_ADMIN_SETTINGS_ENDPOINT = '/super-admin/settings'
|
||||
@@ -69,6 +70,13 @@ export async function updateSuperAdminTenantPlan(payload: UpdateTenantPlanPayloa
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateSuperAdminTenantStoragePlan(payload: UpdateTenantStoragePlanPayload): Promise<void> {
|
||||
await coreApi(`${SUPER_ADMIN_TENANTS_ENDPOINT}/${payload.tenantId}/storage-plan`, {
|
||||
method: 'PATCH',
|
||||
body: { storagePlanId: payload.storagePlanId },
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateSuperAdminTenantBan(payload: UpdateTenantBanPayload): Promise<void> {
|
||||
await coreApi(`${SUPER_ADMIN_TENANTS_ENDPOINT}/${payload.tenantId}/ban`, {
|
||||
method: 'PATCH',
|
||||
|
||||
@@ -18,14 +18,16 @@ import { toast } from 'sonner'
|
||||
|
||||
import { LinearBorderPanel } from '~/components/common/LinearBorderPanel'
|
||||
import { getRequestErrorMessage } from '~/lib/errors'
|
||||
import { buildTenantUrl } from '~/modules/auth/utils/domain'
|
||||
|
||||
import {
|
||||
useDeleteTenantMutation,
|
||||
useSuperAdminTenantsQuery,
|
||||
useUpdateTenantBanMutation,
|
||||
useUpdateTenantPlanMutation,
|
||||
useUpdateTenantStoragePlanMutation,
|
||||
} from '../hooks'
|
||||
import type { BillingPlanDefinition, SuperAdminTenantSummary } from '../types'
|
||||
import type { BillingPlanDefinition, StoragePlanDefinition, SuperAdminTenantSummary } from '../types'
|
||||
import { TenantDetailModal } from './TenantDetailModal'
|
||||
import { TenantUsageCell } from './TenantUsageCell'
|
||||
|
||||
@@ -60,6 +62,7 @@ export function SuperAdminTenantManager() {
|
||||
sortDir,
|
||||
})
|
||||
const updatePlanMutation = useUpdateTenantPlanMutation()
|
||||
const updateStoragePlanMutation = useUpdateTenantStoragePlanMutation()
|
||||
const updateBanMutation = useUpdateTenantBanMutation()
|
||||
const deleteTenantMutation = useDeleteTenantMutation()
|
||||
const { t } = useTranslation()
|
||||
@@ -69,6 +72,7 @@ export function SuperAdminTenantManager() {
|
||||
const { data } = tenantsQuery
|
||||
|
||||
const plans = data?.plans ?? []
|
||||
const storagePlans = data?.storagePlans ?? []
|
||||
const tenants = data?.tenants ?? []
|
||||
const total = data?.total ?? 0
|
||||
const totalPages = Math.ceil(total / limit)
|
||||
@@ -101,6 +105,26 @@ export function SuperAdminTenantManager() {
|
||||
)
|
||||
}
|
||||
|
||||
const handleStoragePlanChange = (tenant: SuperAdminTenantSummary, storagePlanId: string | null) => {
|
||||
const nextId = storagePlanId === 'default' ? null : storagePlanId
|
||||
if (nextId === tenant.storagePlanId) {
|
||||
return
|
||||
}
|
||||
updateStoragePlanMutation.mutate(
|
||||
{ tenantId: tenant.id, storagePlanId: nextId },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(t('superadmin.tenants.toast.storage-plan-success', { name: tenant.name }))
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(t('superadmin.tenants.toast.storage-plan-error'), {
|
||||
description: error instanceof Error ? error.message : t('common.retry-later'),
|
||||
})
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const handleToggleBanned = (tenant: SuperAdminTenantSummary) => {
|
||||
const next = !tenant.banned
|
||||
updateBanMutation.mutate(
|
||||
@@ -125,6 +149,9 @@ export function SuperAdminTenantManager() {
|
||||
const isPlanUpdating = (tenantId: string) =>
|
||||
updatePlanMutation.isPending && updatePlanMutation.variables?.tenantId === tenantId
|
||||
|
||||
const isStoragePlanUpdating = (tenantId: string) =>
|
||||
updateStoragePlanMutation.isPending && updateStoragePlanMutation.variables?.tenantId === tenantId
|
||||
|
||||
const isBanUpdating = (tenantId: string) =>
|
||||
updateBanMutation.isPending && updateBanMutation.variables?.tenantId === tenantId
|
||||
|
||||
@@ -248,9 +275,9 @@ export function SuperAdminTenantManager() {
|
||||
</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.storage-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 cursor-pointer hover:text-text select-none"
|
||||
onClick={() => handleSort('createdAt')}
|
||||
@@ -267,7 +294,16 @@ export function SuperAdminTenantManager() {
|
||||
{tenants.map((tenant) => (
|
||||
<tr key={tenant.id}>
|
||||
<td className="px-3 py-3 align-top">
|
||||
<div className="font-medium text-text">{tenant.name}</div>
|
||||
<div className="font-medium text-text">
|
||||
<a
|
||||
href={buildTenantUrl(tenant.slug)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{tenant.name}
|
||||
</a>
|
||||
</div>
|
||||
<div className="text-text-secondary text-xs">{tenant.slug}</div>
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top">
|
||||
@@ -278,6 +314,14 @@ export function SuperAdminTenantManager() {
|
||||
onChange={(nextPlan) => handlePlanChange(tenant, nextPlan)}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top">
|
||||
<StoragePlanSelector
|
||||
value={tenant.storagePlanId}
|
||||
plans={storagePlans}
|
||||
disabled={isStoragePlanUpdating(tenant.id)}
|
||||
onChange={(nextPlan) => handleStoragePlanChange(tenant, nextPlan)}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top">
|
||||
<div
|
||||
className="cursor-pointer hover:opacity-80 transition-opacity"
|
||||
@@ -296,37 +340,37 @@ export function SuperAdminTenantManager() {
|
||||
<td className="px-3 py-3 text-center align-top">
|
||||
<StatusBadge status={tenant.status} banned={tenant.banned} />
|
||||
</td>
|
||||
<td className="px-3 py-1 text-center align-top">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className={tenant.banned ? 'text-rose-400' : undefined}
|
||||
onClick={() => handleToggleBanned(tenant)}
|
||||
disabled={isBanUpdating(tenant.id)}
|
||||
>
|
||||
{isBanUpdating(tenant.id)
|
||||
? t('superadmin.tenants.button.processing')
|
||||
: tenant.banned
|
||||
? t('superadmin.tenants.button.unban')
|
||||
: t('superadmin.tenants.button.ban')}
|
||||
</Button>
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top text-text-secondary text-xs">
|
||||
{formatDateLabel(tenant.createdAt)}
|
||||
</td>
|
||||
<td className="px-3 py-2 align-top text-right">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={isDeleting(tenant.id)}
|
||||
onClick={() => handleDeleteTenant(tenant)}
|
||||
>
|
||||
{isDeleting(tenant.id)
|
||||
? t('superadmin.tenants.button.processing')
|
||||
: t('superadmin.tenants.button.delete')}
|
||||
</Button>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className={tenant.banned ? 'text-rose-400' : undefined}
|
||||
onClick={() => handleToggleBanned(tenant)}
|
||||
disabled={isBanUpdating(tenant.id)}
|
||||
>
|
||||
{isBanUpdating(tenant.id)
|
||||
? t('superadmin.tenants.button.processing')
|
||||
: tenant.banned
|
||||
? t('superadmin.tenants.button.unban')
|
||||
: t('superadmin.tenants.button.ban')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={isDeleting(tenant.id)}
|
||||
onClick={() => handleDeleteTenant(tenant)}
|
||||
>
|
||||
{isDeleting(tenant.id)
|
||||
? t('superadmin.tenants.button.processing')
|
||||
: t('superadmin.tenants.button.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -352,7 +396,7 @@ export function SuperAdminTenantManager() {
|
||||
<ChevronLeftIcon className="size-4" />
|
||||
</Button>
|
||||
<div className="text-sm text-text-secondary font-medium">
|
||||
{page} / {totalPages || 1}
|
||||
<span>{page}</span> / <span>{totalPages || 1}</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -403,7 +447,40 @@ function PlanSelector({
|
||||
)
|
||||
}
|
||||
|
||||
function PlanDescription({ plan }: { plan: BillingPlanDefinition | undefined }) {
|
||||
function StoragePlanSelector({
|
||||
value,
|
||||
plans,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
value?: string | null
|
||||
plans: StoragePlanDefinition[]
|
||||
disabled?: boolean
|
||||
onChange: (value: string) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const current = value ?? 'default'
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Select value={current} onValueChange={(val) => onChange(val)} disabled={disabled}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('superadmin.tenants.storage-plan.placeholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">{t('superadmin.tenants.storage-plan.default')}</SelectItem>
|
||||
{plans.map((plan) => (
|
||||
<SelectItem value={plan.id} key={plan.id}>
|
||||
{plan.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{current !== 'default' && <PlanDescription plan={plans.find((p) => p.id === current)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PlanDescription({ plan }: { plan: BillingPlanDefinition | StoragePlanDefinition | undefined }) {
|
||||
if (!plan) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
updateSuperAdminSettings,
|
||||
updateSuperAdminTenantBan,
|
||||
updateSuperAdminTenantPlan,
|
||||
updateSuperAdminTenantStoragePlan,
|
||||
} from './api'
|
||||
import type {
|
||||
SuperAdminSettingsResponse,
|
||||
@@ -17,6 +18,7 @@ import type {
|
||||
UpdateSuperAdminSettingsPayload,
|
||||
UpdateTenantBanPayload,
|
||||
UpdateTenantPlanPayload,
|
||||
UpdateTenantStoragePlanPayload,
|
||||
} from './types'
|
||||
|
||||
export const SUPER_ADMIN_SETTINGS_QUERY_KEY = ['super-admin', 'settings'] as const
|
||||
@@ -67,6 +69,19 @@ export function useUpdateTenantPlanMutation() {
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateTenantStoragePlanMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (payload: UpdateTenantStoragePlanPayload) => {
|
||||
await updateSuperAdminTenantStoragePlan(payload)
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: SUPER_ADMIN_TENANTS_QUERY_KEY })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateTenantBanMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
|
||||
@@ -97,11 +97,19 @@ export interface BillingPlanDefinition {
|
||||
quotas: BillingPlanQuota
|
||||
}
|
||||
|
||||
export interface StoragePlanDefinition {
|
||||
id: string
|
||||
name: string
|
||||
description?: string | null
|
||||
capacityBytes?: number | null
|
||||
}
|
||||
|
||||
export interface SuperAdminTenantSummary {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
planId: string
|
||||
storagePlanId?: string | null
|
||||
status: 'active' | 'inactive' | 'suspended'
|
||||
banned: boolean
|
||||
createdAt: string
|
||||
@@ -112,6 +120,7 @@ export interface SuperAdminTenantSummary {
|
||||
export interface SuperAdminTenantListResponse {
|
||||
tenants: SuperAdminTenantSummary[]
|
||||
plans: BillingPlanDefinition[]
|
||||
storagePlans: StoragePlanDefinition[]
|
||||
total: number
|
||||
}
|
||||
|
||||
@@ -129,6 +138,11 @@ export interface UpdateTenantPlanPayload {
|
||||
planId: string
|
||||
}
|
||||
|
||||
export interface UpdateTenantStoragePlanPayload {
|
||||
tenantId: string
|
||||
storagePlanId: string | null
|
||||
}
|
||||
|
||||
export interface UpdateTenantBanPayload {
|
||||
tenantId: string
|
||||
banned: boolean
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { Button, Input, Label, LinearBorderContainer } from '@afilmory/ui'
|
||||
import { Spring } from '@afilmory/utils'
|
||||
import { m } from 'motion/react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router'
|
||||
|
||||
@@ -114,18 +112,12 @@ export function Component() {
|
||||
</header>
|
||||
|
||||
{error && (
|
||||
<m.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={Spring.presets.snappy}
|
||||
className="border-red/60 bg-red/10 rounded-lg border px-4 py-3.5"
|
||||
>
|
||||
<div className="border-red/60 bg-red/10 rounded-lg border px-4 py-3.5">
|
||||
<div className="flex items-start gap-3">
|
||||
<i className="i-lucide-circle-alert text-red mt-0.5 text-base" />
|
||||
<p className="text-red flex-1 text-sm">{error}</p>
|
||||
</div>
|
||||
</m.div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ScrollArea } from '@afilmory/ui'
|
||||
import { clsxm } from '@afilmory/utils'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Navigate, NavLink, Outlet } from 'react-router'
|
||||
|
||||
@@ -27,33 +28,40 @@ export function Component() {
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
<nav className="bg-background-tertiary relative shrink-0 px-6 py-3">
|
||||
{/* Bottom border with gradient */}
|
||||
<div className="via-text/20 absolute right-0 bottom-0 left-0 h-[0.5px] bg-linear-to-r from-transparent to-transparent" />
|
||||
|
||||
<div className="flex items-center justify-between gap-6">
|
||||
<header className="bg-background relative shrink-0 border-b border-fill-tertiary/50">
|
||||
<div className="flex h-14 items-center px-3 sm:px-6">
|
||||
{/* Logo/Brand */}
|
||||
<div className="text-text text-base font-semibold">{t('superadmin.brand')}</div>
|
||||
<div className="text-text mr-2 sm:mr-8 text-sm sm:text-base font-semibold tracking-tight">
|
||||
{t('superadmin.brand')}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 items-center gap-1">
|
||||
{/* Navigation Tabs */}
|
||||
<nav className="flex flex-1 items-center gap-0.5 sm:gap-1 overflow-x-auto [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
|
||||
{navItems.map((tab) => (
|
||||
<NavLink key={tab.to} to={tab.to} end={tab.end}>
|
||||
{({ isActive }) => (
|
||||
<div
|
||||
className="relative overflow-hidden rounded-md shape-squircle px-3 py-1.5 group data-[state=active]:bg-accent/80 data-[state=active]:text-white"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
className={clsxm(
|
||||
'relative rounded-lg px-2 sm:px-4 py-1.5 sm:py-2 text-xs sm:text-sm font-medium transition-all duration-200 whitespace-nowrap',
|
||||
'hover:bg-fill/30',
|
||||
isActive ? 'bg-accent/10 text-accent' : 'text-text-secondary hover:text-text',
|
||||
)}
|
||||
>
|
||||
<span className="relative z-10 text-[13px] font-medium">{t(tab.labelKey)}</span>
|
||||
{t(tab.labelKey)}
|
||||
</div>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Right side - User Menu */}
|
||||
{user && <SuperAdminUserMenu user={user} />}
|
||||
{user && (
|
||||
<div className="border-fill-tertiary/50 ml-2 sm:ml-auto flex items-center gap-3 border-l pl-2 sm:pl-4">
|
||||
<SuperAdminUserMenu user={user} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main className="bg-background flex-1 overflow-hidden">
|
||||
<ScrollArea rootClassName="h-full" viewportClassName="h-full">
|
||||
|
||||
@@ -850,16 +850,20 @@
|
||||
"superadmin.tenants.prompt.delete.title": "Delete tenant",
|
||||
"superadmin.tenants.refresh.button": "Refresh list",
|
||||
"superadmin.tenants.refresh.loading": "Refreshing…",
|
||||
"superadmin.tenants.search.placeholder": "Search tenants...",
|
||||
"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.storage-plan.default": "Default (None)",
|
||||
"superadmin.tenants.storage-plan.placeholder": "Select storage plan",
|
||||
"superadmin.tenants.table.actions": "Actions",
|
||||
"superadmin.tenants.table.ban": "Ban",
|
||||
"superadmin.tenants.table.created": "Created",
|
||||
"superadmin.tenants.table.plan": "Plan",
|
||||
"superadmin.tenants.table.status": "Status",
|
||||
"superadmin.tenants.table.storage-plan": "Storage Plan",
|
||||
"superadmin.tenants.table.tenant": "Tenant",
|
||||
"superadmin.tenants.table.usage": "Usage",
|
||||
"superadmin.tenants.title": "Tenant Subscription Management",
|
||||
@@ -869,6 +873,8 @@
|
||||
"superadmin.tenants.toast.delete-success": "Tenant {{name}} has been deleted.",
|
||||
"superadmin.tenants.toast.plan-error": "Failed to update subscription plan.",
|
||||
"superadmin.tenants.toast.plan-success": "{{name}} switched to the {{planId}} plan.",
|
||||
"superadmin.tenants.toast.storage-plan-error": "Failed to update storage plan",
|
||||
"superadmin.tenants.toast.storage-plan-success": "Storage plan updated for {{name}}",
|
||||
"superadmin.tenants.toast.unban-success": "Tenant {{name}} is no longer banned.",
|
||||
"superadmin.tenants.usage.empty": "No usage events recorded yet.",
|
||||
"welcome.tenant-missing.code": "404",
|
||||
|
||||
@@ -842,16 +842,20 @@
|
||||
"superadmin.tenants.prompt.delete.title": "删除租户",
|
||||
"superadmin.tenants.refresh.button": "刷新列表",
|
||||
"superadmin.tenants.refresh.loading": "正在刷新…",
|
||||
"superadmin.tenants.search.placeholder": "搜索租户...",
|
||||
"superadmin.tenants.status.active": "活跃",
|
||||
"superadmin.tenants.status.banned": "已封禁",
|
||||
"superadmin.tenants.status.inactive": "未激活",
|
||||
"superadmin.tenants.status.pending": "待处理",
|
||||
"superadmin.tenants.status.suspended": "已暂停",
|
||||
"superadmin.tenants.storage-plan.default": "默认(无)",
|
||||
"superadmin.tenants.storage-plan.placeholder": "选择存储计划",
|
||||
"superadmin.tenants.table.actions": "操作",
|
||||
"superadmin.tenants.table.ban": "封禁",
|
||||
"superadmin.tenants.table.created": "创建时间",
|
||||
"superadmin.tenants.table.plan": "订阅计划",
|
||||
"superadmin.tenants.table.status": "状态",
|
||||
"superadmin.tenants.table.storage-plan": "存储计划",
|
||||
"superadmin.tenants.table.tenant": "租户",
|
||||
"superadmin.tenants.table.usage": "用量",
|
||||
"superadmin.tenants.title": "租户订阅管理",
|
||||
@@ -861,6 +865,8 @@
|
||||
"superadmin.tenants.toast.delete-success": "已删除租户 {{name}}",
|
||||
"superadmin.tenants.toast.plan-error": "更新订阅失败",
|
||||
"superadmin.tenants.toast.plan-success": "已将 {{name}} 切换到 {{planId}} 计划",
|
||||
"superadmin.tenants.toast.storage-plan-error": "更新存储计划失败",
|
||||
"superadmin.tenants.toast.storage-plan-success": "已更新 {{name}} 的存储计划",
|
||||
"superadmin.tenants.toast.unban-success": "已解除封禁 {{name}}",
|
||||
"superadmin.tenants.usage.empty": "尚无用量记录",
|
||||
"welcome.tenant-missing.code": "404",
|
||||
|
||||
Reference in New Issue
Block a user