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:
Innei
2025-12-06 00:30:34 +08:00
parent 8c9e8a9575
commit b636c8cc10
14 changed files with 215 additions and 60 deletions

View File

@@ -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)',

View File

@@ -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',

View File

@@ -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)

View File

@@ -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(),
})

View File

@@ -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))

View File

@@ -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) {

View File

@@ -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',

View File

@@ -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
}

View File

@@ -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()

View File

@@ -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

View File

@@ -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">

View File

@@ -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">

View File

@@ -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",

View File

@@ -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",