mirror of
https://github.com/Afilmory/afilmory
synced 2026-04-25 07:15:36 +00:00
feat: implement managed storage plans and provider settings
- Added new UI schema for managing storage plans, including catalog, pricing, and product configurations. - Introduced StoragePlanService to handle storage plan operations and integrate with existing billing services. - Updated SuperAdmin settings to include managed storage provider configurations. - Enhanced localization files with new keys for storage plan management. - Implemented API endpoints for fetching and updating storage plans. Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
@@ -298,6 +298,37 @@ const enUiSchema = {
|
||||
},
|
||||
},
|
||||
},
|
||||
'storage-plans': {
|
||||
title: 'Storage plans',
|
||||
description: 'Managed storage catalog, pricing, and Creem products for storage subscriptions.',
|
||||
fields: {
|
||||
catalog: {
|
||||
title: 'Plan catalog',
|
||||
description:
|
||||
'Manage storage plans for managed B2 space. Use the dashboard editor; JSON is no longer required.',
|
||||
placeholder: 'Configured via dashboard',
|
||||
helper: 'Plans include name/description/capacity and active flag.',
|
||||
},
|
||||
pricing: {
|
||||
title: 'Storage pricing',
|
||||
description: 'Monthly price and currency per storage plan.',
|
||||
placeholder: 'Configured via dashboard',
|
||||
helper: 'Blank values fall back to defaults or hide pricing.',
|
||||
},
|
||||
products: {
|
||||
title: 'Creem products',
|
||||
description: 'Creem product per storage plan for checkout and portal.',
|
||||
placeholder: 'Configured via dashboard',
|
||||
helper: 'Blank values hide the upgrade entry for that plan.',
|
||||
},
|
||||
'managed-provider': {
|
||||
title: 'Managed provider key',
|
||||
description: 'Provider ID from storage providers list that backs managed storage plans (e.g., b2-managed).',
|
||||
placeholder: 'b2-managed',
|
||||
helper: 'Used by backend to issue upload/read credentials for managed tenants.',
|
||||
},
|
||||
},
|
||||
},
|
||||
oauth: {
|
||||
title: 'OAuth providers',
|
||||
description: 'Configure shared third-party login providers for all tenants.',
|
||||
|
||||
@@ -296,6 +296,36 @@ const zhCnUiSchema = {
|
||||
},
|
||||
},
|
||||
},
|
||||
'storage-plans': {
|
||||
title: '存储计划',
|
||||
description: '管理托管存储方案的目录、定价以及 Creem 商品映射。',
|
||||
fields: {
|
||||
catalog: {
|
||||
title: '计划目录',
|
||||
description: '存储计划的名称/描述/容量与启用状态,建议在控制台中编辑,无需手填 JSON。',
|
||||
placeholder: '通过控制台编辑',
|
||||
helper: '包含 plan id、name、description、capacityBytes、isActive 等字段。',
|
||||
},
|
||||
pricing: {
|
||||
title: '存储定价',
|
||||
description: '每个存储计划的月费与币种。',
|
||||
placeholder: '通过控制台编辑',
|
||||
helper: '留空回退到默认值或隐藏价格。',
|
||||
},
|
||||
products: {
|
||||
title: 'Creem 商品',
|
||||
description: '为存储计划绑定 Creem 商品 ID,用于结算与用户门户。',
|
||||
placeholder: '通过控制台编辑',
|
||||
helper: '留空则该计划不会展示升级入口。',
|
||||
},
|
||||
'managed-provider': {
|
||||
title: '托管存储 Provider Key',
|
||||
description: '与存储提供商列表中的配置项对应(例如 b2-managed)。',
|
||||
placeholder: 'b2-managed',
|
||||
helper: '后端将用该 Provider 为托管存储租户发放上传/读取权限。',
|
||||
},
|
||||
},
|
||||
},
|
||||
oauth: {
|
||||
title: 'OAuth 登录渠道',
|
||||
description: '统一配置所有租户可用的第三方登录渠道。',
|
||||
|
||||
@@ -7,6 +7,13 @@ import {
|
||||
BILLING_PLAN_PRODUCTS_SETTING_KEY,
|
||||
} from 'core/modules/platform/billing/billing-plan.constants'
|
||||
import type { BillingPlanId, BillingPlanQuota } from 'core/modules/platform/billing/billing-plan.types'
|
||||
import {
|
||||
DEFAULT_STORAGE_PLAN_CATALOG,
|
||||
STORAGE_PLAN_CATALOG_SETTING_KEY,
|
||||
STORAGE_PLAN_PRICING_SETTING_KEY,
|
||||
STORAGE_PLAN_PRODUCTS_SETTING_KEY,
|
||||
} from 'core/modules/platform/billing/storage-plan.constants'
|
||||
import type { StoragePlanCatalog } from 'core/modules/platform/billing/storage-plan.types'
|
||||
import { z } from 'zod'
|
||||
|
||||
const nonEmptyString = z.string().trim().min(1)
|
||||
@@ -114,6 +121,36 @@ export const SYSTEM_SETTING_DEFINITIONS = {
|
||||
defaultValue: {},
|
||||
isSensitive: false,
|
||||
},
|
||||
storagePlanCatalog: {
|
||||
key: STORAGE_PLAN_CATALOG_SETTING_KEY,
|
||||
schema: z.record(z.string(), z.any()),
|
||||
defaultValue: DEFAULT_STORAGE_PLAN_CATALOG satisfies StoragePlanCatalog,
|
||||
isSensitive: false,
|
||||
},
|
||||
storagePlanProducts: {
|
||||
key: STORAGE_PLAN_PRODUCTS_SETTING_KEY,
|
||||
schema: z.record(z.string(), z.any()),
|
||||
defaultValue: {},
|
||||
isSensitive: false,
|
||||
},
|
||||
storagePlanPricing: {
|
||||
key: STORAGE_PLAN_PRICING_SETTING_KEY,
|
||||
schema: z.record(z.string(), z.any()),
|
||||
defaultValue: {},
|
||||
isSensitive: false,
|
||||
},
|
||||
managedStorageProvider: {
|
||||
key: 'system.storage.managed.provider',
|
||||
schema: z.string().trim().min(1).nullable(),
|
||||
defaultValue: null as string | null,
|
||||
isSensitive: false,
|
||||
},
|
||||
managedStorageProviders: {
|
||||
key: 'system.storage.managed.providers',
|
||||
schema: z.string(),
|
||||
defaultValue: '[]',
|
||||
isSensitive: true,
|
||||
},
|
||||
} as const
|
||||
|
||||
const BILLING_PLAN_QUOTA_KEYS = [
|
||||
|
||||
@@ -16,12 +16,24 @@ import type {
|
||||
BillingPlanProductConfigs,
|
||||
BillingPlanQuota,
|
||||
} from 'core/modules/platform/billing/billing-plan.types'
|
||||
import type {
|
||||
StoragePlanCatalog,
|
||||
StoragePlanPricingConfigs,
|
||||
StoragePlanProductConfigs,
|
||||
} from 'core/modules/platform/billing/storage-plan.types'
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { injectable } from 'tsyringe'
|
||||
import type { ZodType } from 'zod'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { getUiSchemaTranslator } from '../../ui/ui-schema/ui-schema.i18n'
|
||||
import type { BuilderStorageProvider } from '../setting/storage-provider.utils'
|
||||
import {
|
||||
maskStorageProviderSecrets,
|
||||
mergeStorageProviderSecrets,
|
||||
parseStorageProviders,
|
||||
serializeStorageProviders,
|
||||
} from '../setting/storage-provider.utils'
|
||||
import type { SystemSettingDbField } from './system-setting.constants'
|
||||
import {
|
||||
BILLING_PLAN_FIELD_DESCRIPTORS,
|
||||
@@ -135,6 +147,29 @@ export class SystemSettingService {
|
||||
BILLING_PLAN_PRICING_SCHEMA,
|
||||
{},
|
||||
) as BillingPlanPricingConfigs
|
||||
const storagePlanCatalog = this.parseSetting(
|
||||
rawValues[SYSTEM_SETTING_DEFINITIONS.storagePlanCatalog.key],
|
||||
STORAGE_PLAN_CATALOG_SCHEMA,
|
||||
SYSTEM_SETTING_DEFINITIONS.storagePlanCatalog.defaultValue as StoragePlanCatalog,
|
||||
) as StoragePlanCatalog
|
||||
const storagePlanProducts = this.parseSetting(
|
||||
rawValues[SYSTEM_SETTING_DEFINITIONS.storagePlanProducts.key],
|
||||
STORAGE_PLAN_PRODUCTS_SCHEMA,
|
||||
{},
|
||||
) as StoragePlanProductConfigs
|
||||
const storagePlanPricing = this.parseSetting(
|
||||
rawValues[SYSTEM_SETTING_DEFINITIONS.storagePlanPricing.key],
|
||||
STORAGE_PLAN_PRICING_SCHEMA,
|
||||
{},
|
||||
) as StoragePlanPricingConfigs
|
||||
const managedStorageProvider = this.parseSetting(
|
||||
rawValues[SYSTEM_SETTING_DEFINITIONS.managedStorageProvider.key],
|
||||
SYSTEM_SETTING_DEFINITIONS.managedStorageProvider.schema,
|
||||
SYSTEM_SETTING_DEFINITIONS.managedStorageProvider.defaultValue,
|
||||
)
|
||||
const managedStorageProviders = this.parseManagedStorageProviders(
|
||||
rawValues[SYSTEM_SETTING_DEFINITIONS.managedStorageProviders.key],
|
||||
)
|
||||
return {
|
||||
allowRegistration,
|
||||
maxRegistrableUsers,
|
||||
@@ -151,6 +186,11 @@ export class SystemSettingService {
|
||||
billingPlanOverrides,
|
||||
billingPlanProducts,
|
||||
billingPlanPricing,
|
||||
storagePlanCatalog,
|
||||
storagePlanProducts,
|
||||
storagePlanPricing,
|
||||
managedStorageProvider,
|
||||
managedStorageProviders,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,6 +209,26 @@ export class SystemSettingService {
|
||||
return settings.billingPlanPricing ?? {}
|
||||
}
|
||||
|
||||
async getStoragePlanCatalog(): Promise<StoragePlanCatalog> {
|
||||
const settings = await this.getSettings()
|
||||
return settings.storagePlanCatalog ?? {}
|
||||
}
|
||||
|
||||
async getStoragePlanProducts(): Promise<StoragePlanProductConfigs> {
|
||||
const settings = await this.getSettings()
|
||||
return settings.storagePlanProducts ?? {}
|
||||
}
|
||||
|
||||
async getStoragePlanPricing(): Promise<StoragePlanPricingConfigs> {
|
||||
const settings = await this.getSettings()
|
||||
return settings.storagePlanPricing ?? {}
|
||||
}
|
||||
|
||||
async getManagedStorageProviderKey(): Promise<string | null> {
|
||||
const settings = await this.getSettings()
|
||||
return settings.managedStorageProvider ?? null
|
||||
}
|
||||
|
||||
async getOverview(): Promise<SystemSettingOverview> {
|
||||
const settings = await this.getSettings()
|
||||
const totalUsers = await this.getTotalUserCount()
|
||||
@@ -194,9 +254,17 @@ export class SystemSettingService {
|
||||
|
||||
const updates: Array<{ field: SystemSettingDbField; value: unknown }> = []
|
||||
|
||||
const enqueueUpdate = <K extends SystemSettingDbField>(field: K, value: unknown) => {
|
||||
const enqueueUpdate = <K extends SystemSettingDbField>(
|
||||
field: K,
|
||||
value: unknown,
|
||||
currentValue?: SystemSettings[K],
|
||||
) => {
|
||||
updates.push({ field, value })
|
||||
;(current as unknown as Record<string, unknown>)[field] = value
|
||||
if (currentValue !== undefined) {
|
||||
;(current as unknown as Record<string, unknown>)[field] = currentValue
|
||||
} else {
|
||||
;(current as unknown as Record<string, unknown>)[field] = value
|
||||
}
|
||||
}
|
||||
|
||||
if (patch.allowRegistration !== undefined && patch.allowRegistration !== current.allowRegistration) {
|
||||
@@ -287,6 +355,37 @@ export class SystemSettingService {
|
||||
}
|
||||
}
|
||||
|
||||
if (patch.storagePlanCatalog !== undefined) {
|
||||
const parsed = STORAGE_PLAN_CATALOG_SCHEMA.parse(patch.storagePlanCatalog)
|
||||
enqueueUpdate('storagePlanCatalog', parsed)
|
||||
}
|
||||
|
||||
if (patch.storagePlanProducts !== undefined) {
|
||||
const parsed = STORAGE_PLAN_PRODUCTS_SCHEMA.parse(patch.storagePlanProducts)
|
||||
enqueueUpdate('storagePlanProducts', parsed)
|
||||
}
|
||||
|
||||
if (patch.storagePlanPricing !== undefined) {
|
||||
const parsed = STORAGE_PLAN_PRICING_SCHEMA.parse(patch.storagePlanPricing)
|
||||
enqueueUpdate('storagePlanPricing', parsed)
|
||||
}
|
||||
|
||||
if (patch.managedStorageProvider !== undefined && patch.managedStorageProvider !== current.managedStorageProvider) {
|
||||
enqueueUpdate('managedStorageProvider', this.normalizeNullableString(patch.managedStorageProvider))
|
||||
}
|
||||
|
||||
if (patch.managedStorageProviders !== undefined) {
|
||||
const normalizedProviders = this.normalizeManagedStorageProvidersPatch(
|
||||
patch.managedStorageProviders,
|
||||
current.managedStorageProviders ?? [],
|
||||
)
|
||||
const nextSerialized = serializeStorageProviders(normalizedProviders)
|
||||
const currentSerialized = serializeStorageProviders(current.managedStorageProviders ?? [])
|
||||
if (nextSerialized !== currentSerialized) {
|
||||
enqueueUpdate('managedStorageProviders', nextSerialized, normalizedProviders)
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return current
|
||||
}
|
||||
@@ -309,6 +408,10 @@ export class SystemSettingService {
|
||||
const map = {} as SystemSettingValueMap
|
||||
|
||||
;(Object.keys(SYSTEM_SETTING_DEFINITIONS) as SystemSettingDbField[]).forEach((field) => {
|
||||
if (field === 'managedStorageProviders') {
|
||||
;(map as Record<string, unknown>)[field] = maskStorageProviderSecrets(settings.managedStorageProviders ?? [])
|
||||
return
|
||||
}
|
||||
;(map as Record<string, unknown>)[field] = settings[field]
|
||||
})
|
||||
|
||||
@@ -342,6 +445,51 @@ export class SystemSettingService {
|
||||
return map
|
||||
}
|
||||
|
||||
private parseManagedStorageProviders(raw: unknown): BuilderStorageProvider[] {
|
||||
if (!raw) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (Array.isArray(raw) || typeof raw === 'object') {
|
||||
try {
|
||||
return parseStorageProviders(JSON.stringify(raw))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof raw === 'string') {
|
||||
const normalized = raw.trim()
|
||||
if (!normalized) {
|
||||
return []
|
||||
}
|
||||
return parseStorageProviders(normalized)
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
private normalizeManagedStorageProvidersPatch(
|
||||
patch: unknown,
|
||||
current: BuilderStorageProvider[],
|
||||
): BuilderStorageProvider[] {
|
||||
let normalized: string
|
||||
if (typeof patch === 'string') {
|
||||
normalized = patch
|
||||
} else if (patch == null) {
|
||||
normalized = '[]'
|
||||
} else {
|
||||
try {
|
||||
normalized = JSON.stringify(patch)
|
||||
} catch {
|
||||
normalized = '[]'
|
||||
}
|
||||
}
|
||||
|
||||
const incoming = parseStorageProviders(normalized)
|
||||
return mergeStorageProviderSecrets(incoming, current ?? [])
|
||||
}
|
||||
|
||||
private extractPlanFieldUpdates(patch: UpdateSystemSettingsInput): PlanFieldUpdateSummary {
|
||||
const summary: PlanFieldUpdateSummary = {
|
||||
hasUpdates: false,
|
||||
@@ -608,6 +756,17 @@ const PLAN_PRICING_ENTRY_SCHEMA = z.object({
|
||||
|
||||
const BILLING_PLAN_PRICING_SCHEMA = z.record(z.string(), PLAN_PRICING_ENTRY_SCHEMA).default({})
|
||||
|
||||
const STORAGE_PLAN_CATALOG_ENTRY_SCHEMA = z.object({
|
||||
name: z.string().trim().min(1),
|
||||
description: z.string().trim().nullable().optional(),
|
||||
capacityBytes: z.number().int().min(0).nullable().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const STORAGE_PLAN_CATALOG_SCHEMA = z.record(z.string(), STORAGE_PLAN_CATALOG_ENTRY_SCHEMA).default({})
|
||||
const STORAGE_PLAN_PRODUCTS_SCHEMA = z.record(z.string(), PLAN_PRODUCT_ENTRY_SCHEMA).default({})
|
||||
const STORAGE_PLAN_PRICING_SCHEMA = z.record(z.string(), PLAN_PRICING_ENTRY_SCHEMA).default({})
|
||||
|
||||
type PlanQuotaUpdateMap = Partial<Record<BillingPlanId, Partial<BillingPlanQuota>>>
|
||||
type PlanPricingUpdateMap = Partial<Record<BillingPlanId, Partial<BillingPlanPricing>>>
|
||||
type PlanProductUpdateMap = Partial<Record<BillingPlanId, BillingPlanPaymentInfo>>
|
||||
|
||||
@@ -3,8 +3,14 @@ import type {
|
||||
BillingPlanPricingConfigs,
|
||||
BillingPlanProductConfigs,
|
||||
} from 'core/modules/platform/billing/billing-plan.types'
|
||||
import type {
|
||||
StoragePlanCatalog,
|
||||
StoragePlanPricingConfigs,
|
||||
StoragePlanProductConfigs,
|
||||
} from 'core/modules/platform/billing/storage-plan.types'
|
||||
import type { UiSchema } from 'core/modules/ui/ui-schema/ui-schema.type'
|
||||
|
||||
import type { BuilderStorageProvider } from '../setting/storage-provider.utils'
|
||||
import type { BillingPlanSettingField, SystemSettingDbField, SystemSettingField } from './system-setting.constants'
|
||||
|
||||
export interface SystemSettings {
|
||||
@@ -23,6 +29,11 @@ export interface SystemSettings {
|
||||
billingPlanOverrides: BillingPlanOverrides
|
||||
billingPlanProducts: BillingPlanProductConfigs
|
||||
billingPlanPricing: BillingPlanPricingConfigs
|
||||
storagePlanCatalog: StoragePlanCatalog
|
||||
storagePlanProducts: StoragePlanProductConfigs
|
||||
storagePlanPricing: StoragePlanPricingConfigs
|
||||
managedStorageProvider: string | null
|
||||
managedStorageProviders: BuilderStorageProvider[]
|
||||
}
|
||||
|
||||
export type SystemSettingValueMap = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BILLING_PLAN_IDS } from 'core/modules/platform/billing/billing-plan.constants'
|
||||
import type {UiSchemaTFunction} from 'core/modules/ui/ui-schema/ui-schema.i18n';
|
||||
import { identityUiSchemaT } from 'core/modules/ui/ui-schema/ui-schema.i18n'
|
||||
import type { UiSchemaTFunction } from 'core/modules/ui/ui-schema/ui-schema.i18n'
|
||||
import { identityUiSchemaT } from 'core/modules/ui/ui-schema/ui-schema.i18n'
|
||||
import type { UiNode, UiSchema } from 'core/modules/ui/ui-schema/ui-schema.type'
|
||||
|
||||
import type { SystemSettingField } from './system-setting.constants'
|
||||
@@ -62,7 +62,7 @@ const PLAN_PAYMENT_FIELDS = [
|
||||
},
|
||||
] as const
|
||||
|
||||
function buildBillingPlanGroups (t: UiSchemaTFunction): ReadonlyArray<UiNode<SystemSettingField>> {
|
||||
function buildBillingPlanGroups(t: UiSchemaTFunction): ReadonlyArray<UiNode<SystemSettingField>> {
|
||||
return BILLING_PLAN_IDS.map((planId) => {
|
||||
const quotaFields = PLAN_QUOTA_FIELDS.map((field) => ({
|
||||
type: 'field' as const,
|
||||
@@ -115,163 +115,211 @@ function buildBillingPlanGroups (t: UiSchemaTFunction): ReadonlyArray<UiNode<Sys
|
||||
})
|
||||
}
|
||||
|
||||
export function createSystemSettingUiSchema (t: UiSchemaTFunction): UiSchema<SystemSettingField> {
|
||||
export function createSystemSettingUiSchema(t: UiSchemaTFunction): UiSchema<SystemSettingField> {
|
||||
return {
|
||||
version: SYSTEM_SETTING_UI_SCHEMA_VERSION,
|
||||
title: t('system.title'),
|
||||
description: t('system.description'),
|
||||
sections: [
|
||||
{
|
||||
type: 'section',
|
||||
id: 'registration-control',
|
||||
title: t('system.sections.registration.title'),
|
||||
description: t('system.sections.registration.description'),
|
||||
icon: 'user-cog',
|
||||
children: [
|
||||
{
|
||||
type: 'field',
|
||||
id: 'registration-allow',
|
||||
title: t('system.sections.registration.fields.allow-registration.title'),
|
||||
description: t('system.sections.registration.fields.allow-registration.description'),
|
||||
key: 'allowRegistration',
|
||||
component: {
|
||||
type: 'switch',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'field',
|
||||
id: 'local-provider-enabled',
|
||||
title: t('system.sections.registration.fields.local-provider.title'),
|
||||
description: t('system.sections.registration.fields.local-provider.description'),
|
||||
key: 'localProviderEnabled',
|
||||
component: {
|
||||
type: 'switch',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'field',
|
||||
id: 'platform-base-domain',
|
||||
title: t('system.sections.registration.fields.base-domain.title'),
|
||||
description: t('system.sections.registration.fields.base-domain.description'),
|
||||
helperText: t('system.sections.registration.fields.base-domain.helper'),
|
||||
key: 'baseDomain',
|
||||
component: {
|
||||
type: 'text',
|
||||
placeholder: t('system.sections.registration.fields.base-domain.placeholder'),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'field',
|
||||
id: 'registration-max-users',
|
||||
title: t('system.sections.registration.fields.max-users.title'),
|
||||
description: t('system.sections.registration.fields.max-users.description'),
|
||||
helperText: t('system.sections.registration.fields.max-users.helper'),
|
||||
key: 'maxRegistrableUsers',
|
||||
component: {
|
||||
type: 'text' as const,
|
||||
inputType: 'number',
|
||||
placeholder: t('system.sections.registration.fields.max-users.placeholder'),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'section',
|
||||
id: 'billing-plan-settings',
|
||||
title: t('system.sections.billing.title'),
|
||||
description: t('system.sections.billing.description'),
|
||||
icon: 'badge-dollar-sign',
|
||||
children: buildBillingPlanGroups(t),
|
||||
},
|
||||
{
|
||||
type: 'section',
|
||||
id: 'oauth-providers',
|
||||
title: t('system.sections.oauth.title'),
|
||||
description: t('system.sections.oauth.description'),
|
||||
icon: 'shield-check',
|
||||
children: [
|
||||
{
|
||||
type: 'field',
|
||||
id: 'oauth-gateway-url',
|
||||
title: t('system.sections.oauth.fields.gateway.title'),
|
||||
description: t('system.sections.oauth.fields.gateway.description'),
|
||||
helperText: t('system.sections.oauth.fields.gateway.helper'),
|
||||
key: 'oauthGatewayUrl',
|
||||
component: {
|
||||
type: 'text',
|
||||
placeholder: t('system.sections.oauth.fields.gateway.placeholder'),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'group',
|
||||
id: 'oauth-google',
|
||||
title: t('system.sections.oauth.groups.google.title'),
|
||||
description: t('system.sections.oauth.groups.google.description'),
|
||||
icon: 'badge-check',
|
||||
children: [
|
||||
{
|
||||
type: 'field',
|
||||
id: 'oauth-google-client-id',
|
||||
title: t('system.sections.oauth.groups.google.fields.client-id.title'),
|
||||
description: t('system.sections.oauth.groups.google.fields.client-id.description'),
|
||||
key: 'oauthGoogleClientId',
|
||||
component: {
|
||||
type: 'text',
|
||||
placeholder: t('system.sections.oauth.groups.google.fields.client-id.placeholder'),
|
||||
},
|
||||
version: SYSTEM_SETTING_UI_SCHEMA_VERSION,
|
||||
title: t('system.title'),
|
||||
description: t('system.description'),
|
||||
sections: [
|
||||
{
|
||||
type: 'section',
|
||||
id: 'registration-control',
|
||||
title: t('system.sections.registration.title'),
|
||||
description: t('system.sections.registration.description'),
|
||||
icon: 'user-cog',
|
||||
children: [
|
||||
{
|
||||
type: 'field',
|
||||
id: 'registration-allow',
|
||||
title: t('system.sections.registration.fields.allow-registration.title'),
|
||||
description: t('system.sections.registration.fields.allow-registration.description'),
|
||||
key: 'allowRegistration',
|
||||
component: {
|
||||
type: 'switch',
|
||||
},
|
||||
{
|
||||
type: 'field',
|
||||
id: 'oauth-google-client-secret',
|
||||
title: t('system.sections.oauth.groups.google.fields.client-secret.title'),
|
||||
description: t('system.sections.oauth.groups.google.fields.client-secret.description'),
|
||||
key: 'oauthGoogleClientSecret',
|
||||
component: {
|
||||
type: 'secret',
|
||||
placeholder: t('system.sections.oauth.groups.google.fields.client-secret.placeholder'),
|
||||
revealable: true,
|
||||
autoComplete: 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'field',
|
||||
id: 'local-provider-enabled',
|
||||
title: t('system.sections.registration.fields.local-provider.title'),
|
||||
description: t('system.sections.registration.fields.local-provider.description'),
|
||||
key: 'localProviderEnabled',
|
||||
component: {
|
||||
type: 'switch',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'group',
|
||||
id: 'oauth-github',
|
||||
title: t('system.sections.oauth.groups.github.title'),
|
||||
description: t('system.sections.oauth.groups.github.description'),
|
||||
icon: 'github',
|
||||
children: [
|
||||
{
|
||||
type: 'field',
|
||||
id: 'oauth-github-client-id',
|
||||
title: t('system.sections.oauth.groups.github.fields.client-id.title'),
|
||||
description: t('system.sections.oauth.groups.github.fields.client-id.description'),
|
||||
key: 'oauthGithubClientId',
|
||||
component: {
|
||||
type: 'text',
|
||||
placeholder: t('system.sections.oauth.groups.github.fields.client-id.placeholder'),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'field',
|
||||
id: 'platform-base-domain',
|
||||
title: t('system.sections.registration.fields.base-domain.title'),
|
||||
description: t('system.sections.registration.fields.base-domain.description'),
|
||||
helperText: t('system.sections.registration.fields.base-domain.helper'),
|
||||
key: 'baseDomain',
|
||||
component: {
|
||||
type: 'text',
|
||||
placeholder: t('system.sections.registration.fields.base-domain.placeholder'),
|
||||
},
|
||||
{
|
||||
type: 'field',
|
||||
id: 'oauth-github-client-secret',
|
||||
title: t('system.sections.oauth.groups.github.fields.client-secret.title'),
|
||||
description: t('system.sections.oauth.groups.github.fields.client-secret.description'),
|
||||
key: 'oauthGithubClientSecret',
|
||||
component: {
|
||||
type: 'secret',
|
||||
placeholder: t('system.sections.oauth.groups.github.fields.client-secret.placeholder'),
|
||||
revealable: true,
|
||||
autoComplete: 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'field',
|
||||
id: 'registration-max-users',
|
||||
title: t('system.sections.registration.fields.max-users.title'),
|
||||
description: t('system.sections.registration.fields.max-users.description'),
|
||||
helperText: t('system.sections.registration.fields.max-users.helper'),
|
||||
key: 'maxRegistrableUsers',
|
||||
component: {
|
||||
type: 'text' as const,
|
||||
inputType: 'number',
|
||||
placeholder: t('system.sections.registration.fields.max-users.placeholder'),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'section',
|
||||
id: 'billing-plan-settings',
|
||||
title: t('system.sections.billing.title'),
|
||||
description: t('system.sections.billing.description'),
|
||||
icon: 'badge-dollar-sign',
|
||||
children: buildBillingPlanGroups(t),
|
||||
},
|
||||
{
|
||||
type: 'section',
|
||||
id: 'storage-plan-settings',
|
||||
title: t('system.sections.storage-plans.title'),
|
||||
description: t('system.sections.storage-plans.description'),
|
||||
icon: 'database',
|
||||
children: [
|
||||
{
|
||||
type: 'field',
|
||||
id: 'storage-plan-catalog',
|
||||
title: t('system.sections.storage-plans.fields.catalog.title'),
|
||||
description: t('system.sections.storage-plans.fields.catalog.description'),
|
||||
helperText: t('system.sections.storage-plans.fields.catalog.helper'),
|
||||
key: 'storagePlanCatalog',
|
||||
component: { type: 'slot', name: 'storage-plan-catalog' } as const,
|
||||
},
|
||||
{
|
||||
type: 'field',
|
||||
id: 'storage-plan-pricing',
|
||||
title: t('system.sections.storage-plans.fields.pricing.title'),
|
||||
description: t('system.sections.storage-plans.fields.pricing.description'),
|
||||
helperText: t('system.sections.storage-plans.fields.pricing.helper'),
|
||||
key: 'storagePlanPricing',
|
||||
component: { type: 'slot', name: 'storage-plan-pricing' } as const,
|
||||
},
|
||||
{
|
||||
type: 'field',
|
||||
id: 'storage-plan-products',
|
||||
title: t('system.sections.storage-plans.fields.products.title'),
|
||||
description: t('system.sections.storage-plans.fields.products.description'),
|
||||
helperText: t('system.sections.storage-plans.fields.products.helper'),
|
||||
key: 'storagePlanProducts',
|
||||
component: { type: 'slot', name: 'storage-plan-products' } as const,
|
||||
},
|
||||
{
|
||||
type: 'field',
|
||||
id: 'storage-provider-managed',
|
||||
title: t('system.sections.storage-plans.fields.managed-provider.title'),
|
||||
description: t('system.sections.storage-plans.fields.managed-provider.description'),
|
||||
helperText: t('system.sections.storage-plans.fields.managed-provider.helper'),
|
||||
key: 'managedStorageProvider',
|
||||
component: {
|
||||
type: 'text',
|
||||
placeholder: t('system.sections.storage-plans.fields.managed-provider.placeholder'),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'section',
|
||||
id: 'oauth-providers',
|
||||
title: t('system.sections.oauth.title'),
|
||||
description: t('system.sections.oauth.description'),
|
||||
icon: 'shield-check',
|
||||
children: [
|
||||
{
|
||||
type: 'field',
|
||||
id: 'oauth-gateway-url',
|
||||
title: t('system.sections.oauth.fields.gateway.title'),
|
||||
description: t('system.sections.oauth.fields.gateway.description'),
|
||||
helperText: t('system.sections.oauth.fields.gateway.helper'),
|
||||
key: 'oauthGatewayUrl',
|
||||
component: {
|
||||
type: 'text',
|
||||
placeholder: t('system.sections.oauth.fields.gateway.placeholder'),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'group',
|
||||
id: 'oauth-google',
|
||||
title: t('system.sections.oauth.groups.google.title'),
|
||||
description: t('system.sections.oauth.groups.google.description'),
|
||||
icon: 'badge-check',
|
||||
children: [
|
||||
{
|
||||
type: 'field',
|
||||
id: 'oauth-google-client-id',
|
||||
title: t('system.sections.oauth.groups.google.fields.client-id.title'),
|
||||
description: t('system.sections.oauth.groups.google.fields.client-id.description'),
|
||||
key: 'oauthGoogleClientId',
|
||||
component: {
|
||||
type: 'text',
|
||||
placeholder: t('system.sections.oauth.groups.google.fields.client-id.placeholder'),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'field',
|
||||
id: 'oauth-google-client-secret',
|
||||
title: t('system.sections.oauth.groups.google.fields.client-secret.title'),
|
||||
description: t('system.sections.oauth.groups.google.fields.client-secret.description'),
|
||||
key: 'oauthGoogleClientSecret',
|
||||
component: {
|
||||
type: 'secret',
|
||||
placeholder: t('system.sections.oauth.groups.google.fields.client-secret.placeholder'),
|
||||
revealable: true,
|
||||
autoComplete: 'off',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'group',
|
||||
id: 'oauth-github',
|
||||
title: t('system.sections.oauth.groups.github.title'),
|
||||
description: t('system.sections.oauth.groups.github.description'),
|
||||
icon: 'github',
|
||||
children: [
|
||||
{
|
||||
type: 'field',
|
||||
id: 'oauth-github-client-id',
|
||||
title: t('system.sections.oauth.groups.github.fields.client-id.title'),
|
||||
description: t('system.sections.oauth.groups.github.fields.client-id.description'),
|
||||
key: 'oauthGithubClientId',
|
||||
component: {
|
||||
type: 'text',
|
||||
placeholder: t('system.sections.oauth.groups.github.fields.client-id.placeholder'),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'field',
|
||||
id: 'oauth-github-client-secret',
|
||||
title: t('system.sections.oauth.groups.github.fields.client-secret.title'),
|
||||
description: t('system.sections.oauth.groups.github.fields.client-secret.description'),
|
||||
key: 'oauthGithubClientSecret',
|
||||
component: {
|
||||
type: 'secret',
|
||||
placeholder: t('system.sections.oauth.groups.github.fields.client-secret.placeholder'),
|
||||
revealable: true,
|
||||
autoComplete: 'off',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const SYSTEM_SETTING_SCHEMA_FOR_KEYS = createSystemSettingUiSchema(identityUiSchemaT)
|
||||
|
||||
@@ -7,6 +7,7 @@ export const BILLING_PLAN_DEFINITIONS: Record<BillingPlanId, BillingPlanDefiniti
|
||||
id: 'free',
|
||||
name: 'Free',
|
||||
description: '默认入门方案,适用于个人与试用场景。',
|
||||
includedStorageBytes: 0,
|
||||
quotas: {
|
||||
monthlyAssetProcessLimit: 300,
|
||||
libraryItemLimit: 500,
|
||||
@@ -18,6 +19,7 @@ export const BILLING_PLAN_DEFINITIONS: Record<BillingPlanId, BillingPlanDefiniti
|
||||
id: 'pro',
|
||||
name: 'Pro',
|
||||
description: '专业方案,预留给即将上线的订阅。',
|
||||
includedStorageBytes: 0,
|
||||
quotas: {
|
||||
monthlyAssetProcessLimit: 1000,
|
||||
libraryItemLimit: 5000,
|
||||
@@ -29,6 +31,7 @@ export const BILLING_PLAN_DEFINITIONS: Record<BillingPlanId, BillingPlanDefiniti
|
||||
id: 'friend',
|
||||
name: 'Friend',
|
||||
description: '内部使用的好友方案,没有任何限制,仅超级管理员可设置。',
|
||||
includedStorageBytes: null,
|
||||
quotas: {
|
||||
monthlyAssetProcessLimit: null,
|
||||
libraryItemLimit: null,
|
||||
|
||||
@@ -50,6 +50,21 @@ export class BillingPlanService {
|
||||
return this.applyOverrides(definition.quotas, overrides[planId])
|
||||
}
|
||||
|
||||
async getPlanIdForTenant(tenantId: string): Promise<BillingPlanId> {
|
||||
return await this.resolvePlanIdForTenant(tenantId)
|
||||
}
|
||||
|
||||
getIncludedStorageBytes(planId: BillingPlanId): number {
|
||||
const definition = BILLING_PLAN_DEFINITIONS[planId]
|
||||
if (!definition) {
|
||||
return 0
|
||||
}
|
||||
if (definition.includedStorageBytes === null) {
|
||||
return Number.POSITIVE_INFINITY
|
||||
}
|
||||
return definition.includedStorageBytes ?? 0
|
||||
}
|
||||
|
||||
getPlanDefinitions(): BillingPlanDefinition[] {
|
||||
return BILLING_PLAN_IDS.map((id) => {
|
||||
const definition = BILLING_PLAN_DEFINITIONS[id]
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface BillingPlanDefinition {
|
||||
id: BillingPlanId
|
||||
name: string
|
||||
description: string
|
||||
includedStorageBytes?: number | null
|
||||
quotas: BillingPlanQuota
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Controller, createZodSchemaDto, Get, Query } from '@afilmory/framework'
|
||||
import { Body, Controller, createZodSchemaDto, Get, Patch, Query } from '@afilmory/framework'
|
||||
import { Roles } from 'core/guards/roles.decorator'
|
||||
import { inject } from 'tsyringe'
|
||||
import z from 'zod'
|
||||
@@ -7,18 +7,25 @@ import type { BillingPlanSummary } from './billing-plan.service'
|
||||
import { BillingPlanService } from './billing-plan.service'
|
||||
import type { BillingUsageOverview } from './billing-usage.service'
|
||||
import { BillingUsageService } from './billing-usage.service'
|
||||
import { StoragePlanService } from './storage-plan.service'
|
||||
|
||||
const usageQuerySchema = z.object({
|
||||
limit: z.coerce.number().positive().int().optional().default(10),
|
||||
})
|
||||
class UsageQueryDto extends createZodSchemaDto(usageQuerySchema) {}
|
||||
|
||||
const updateStoragePlanSchema = z.object({
|
||||
planId: z.string().trim().min(1).nullable().optional(),
|
||||
})
|
||||
class UpdateStoragePlanDto extends createZodSchemaDto(updateStoragePlanSchema) {}
|
||||
|
||||
@Controller('billing')
|
||||
@Roles('admin')
|
||||
export class BillingController {
|
||||
constructor(
|
||||
@inject(BillingUsageService) private readonly billingUsageService: BillingUsageService,
|
||||
@inject(BillingPlanService) private readonly billingPlanService: BillingPlanService,
|
||||
@inject(StoragePlanService) private readonly storagePlanService: StoragePlanService,
|
||||
) {}
|
||||
|
||||
@Get('usage')
|
||||
@@ -34,4 +41,15 @@ export class BillingController {
|
||||
])
|
||||
return { plan, availablePlans }
|
||||
}
|
||||
|
||||
@Get('storage')
|
||||
async getStoragePlans() {
|
||||
return await this.storagePlanService.getOverviewForCurrentTenant()
|
||||
}
|
||||
|
||||
@Patch('storage')
|
||||
async updateStoragePlan(@Body() payload: UpdateStoragePlanDto) {
|
||||
const planId = payload.planId ?? null
|
||||
return await this.storagePlanService.updateCurrentTenantPlan(planId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,11 @@ import { SystemSettingModule } from 'core/modules/configuration/system-setting/s
|
||||
import { BillingController } from './billing.controller'
|
||||
import { BillingPlanService } from './billing-plan.service'
|
||||
import { BillingUsageService } from './billing-usage.service'
|
||||
import { StoragePlanService } from './storage-plan.service'
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule, SystemSettingModule],
|
||||
controllers: [BillingController],
|
||||
providers: [BillingUsageService, BillingPlanService],
|
||||
providers: [BillingUsageService, BillingPlanService, StoragePlanService],
|
||||
})
|
||||
export class BillingModule {}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { StoragePlanCatalog } from './storage-plan.types'
|
||||
|
||||
export const STORAGE_PLAN_CATALOG_SETTING_KEY = 'system.storage.planCatalog'
|
||||
export const STORAGE_PLAN_PRODUCTS_SETTING_KEY = 'system.storage.planProducts'
|
||||
export const STORAGE_PLAN_PRICING_SETTING_KEY = 'system.storage.planPricing'
|
||||
|
||||
export const DEFAULT_STORAGE_PLAN_CATALOG: StoragePlanCatalog = {
|
||||
'managed-5gb': {
|
||||
name: 'Managed B2 • 5GB',
|
||||
description: '适用于入门用户的托管存储方案。',
|
||||
capacityBytes: 5 * 1024 * 1024 * 1024,
|
||||
isActive: true,
|
||||
},
|
||||
'managed-50gb': {
|
||||
name: 'Managed B2 • 50GB',
|
||||
description: '适用于成长阶段的托管存储方案。',
|
||||
capacityBytes: 50 * 1024 * 1024 * 1024,
|
||||
isActive: true,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { tenants } from '@afilmory/db'
|
||||
import { DbAccessor } from 'core/database/database.provider'
|
||||
import { BizException, ErrorCode } from 'core/errors'
|
||||
import { SystemSettingService } from 'core/modules/configuration/system-setting/system-setting.service'
|
||||
import { requireTenantContext } from 'core/modules/platform/tenant/tenant.context'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { injectable } from 'tsyringe'
|
||||
|
||||
import { BillingPlanService } from './billing-plan.service'
|
||||
import { DEFAULT_STORAGE_PLAN_CATALOG } from './storage-plan.constants'
|
||||
import type {
|
||||
StoragePlanCatalog,
|
||||
StoragePlanDefinition,
|
||||
StoragePlanOverview,
|
||||
StoragePlanPaymentInfo,
|
||||
StoragePlanPricing,
|
||||
StoragePlanSummary,
|
||||
} from './storage-plan.types'
|
||||
|
||||
export interface StorageQuotaSummary {
|
||||
appIncludedBytes: number
|
||||
storagePlanBytes: number | null
|
||||
totalBytes: number | null
|
||||
}
|
||||
|
||||
@injectable()
|
||||
export class StoragePlanService {
|
||||
constructor(
|
||||
private readonly dbAccessor: DbAccessor,
|
||||
private readonly systemSettingService: SystemSettingService,
|
||||
private readonly billingPlanService: BillingPlanService,
|
||||
) {}
|
||||
|
||||
async getPlanSummaries(): Promise<StoragePlanSummary[]> {
|
||||
const [catalog, pricing, products] = await Promise.all([
|
||||
this.getPlanCatalog(),
|
||||
this.systemSettingService.getStoragePlanPricing(),
|
||||
this.systemSettingService.getStoragePlanProducts(),
|
||||
])
|
||||
|
||||
return Object.entries(catalog)
|
||||
.map(([id, entry]) =>
|
||||
this.buildPlanSummary(
|
||||
{
|
||||
id,
|
||||
...entry,
|
||||
},
|
||||
pricing[id],
|
||||
products[id],
|
||||
),
|
||||
)
|
||||
.filter((plan) => plan.isActive !== false)
|
||||
}
|
||||
|
||||
async getPlanById(planId: string): Promise<StoragePlanSummary | null> {
|
||||
const [catalog, pricing, products] = await Promise.all([
|
||||
this.getPlanCatalog(),
|
||||
this.systemSettingService.getStoragePlanPricing(),
|
||||
this.systemSettingService.getStoragePlanProducts(),
|
||||
])
|
||||
|
||||
const definition = catalog[planId]
|
||||
if (!definition) {
|
||||
return null
|
||||
}
|
||||
|
||||
return this.buildPlanSummary({ id: planId, ...definition }, pricing[planId], products[planId])
|
||||
}
|
||||
|
||||
async getQuotaForTenant(tenantId: string): Promise<StorageQuotaSummary> {
|
||||
const [tenantPlanId, resolvedPlanId, catalog] = await Promise.all([
|
||||
this.resolveStoragePlanIdForTenant(tenantId),
|
||||
this.billingPlanService.getPlanIdForTenant(tenantId),
|
||||
this.getPlanCatalog(),
|
||||
])
|
||||
|
||||
const appIncluded = this.billingPlanService.getIncludedStorageBytes(resolvedPlanId)
|
||||
const storagePlan = tenantPlanId ? catalog[tenantPlanId] : undefined
|
||||
const storagePlanCapacity = storagePlan?.capacityBytes
|
||||
const storagePlanBytes = storagePlanCapacity === undefined ? 0 : storagePlanCapacity
|
||||
|
||||
const totalBytes =
|
||||
appIncluded === Number.POSITIVE_INFINITY || storagePlanCapacity === null
|
||||
? null
|
||||
: (appIncluded || 0) + (storagePlanBytes || 0)
|
||||
|
||||
return {
|
||||
appIncludedBytes: appIncluded,
|
||||
storagePlanBytes,
|
||||
totalBytes,
|
||||
}
|
||||
}
|
||||
|
||||
async getPlanSummaryForTenant(tenantId: string): Promise<StoragePlanSummary | null> {
|
||||
const planId = await this.resolveStoragePlanIdForTenant(tenantId)
|
||||
if (!planId) {
|
||||
return null
|
||||
}
|
||||
const plan = await this.getPlanById(planId)
|
||||
if (!plan) {
|
||||
return null
|
||||
}
|
||||
return plan
|
||||
}
|
||||
|
||||
async getOverviewForCurrentTenant(): Promise<StoragePlanOverview> {
|
||||
const tenant = requireTenantContext()
|
||||
const [plans, currentPlan, providerKey] = await Promise.all([
|
||||
this.getPlanSummaries(),
|
||||
this.getPlanSummaryForTenant(tenant.tenant.id),
|
||||
this.systemSettingService.getManagedStorageProviderKey(),
|
||||
])
|
||||
|
||||
return {
|
||||
managedStorageEnabled: Boolean(providerKey),
|
||||
managedProviderKey: providerKey ?? null,
|
||||
currentPlanId: currentPlan?.id ?? null,
|
||||
currentPlan,
|
||||
availablePlans: plans,
|
||||
}
|
||||
}
|
||||
|
||||
async updateCurrentTenantPlan(planId: string | null): Promise<StoragePlanOverview> {
|
||||
const tenant = requireTenantContext()
|
||||
await this.assignPlanToTenant(tenant.tenant.id, planId)
|
||||
return await this.getOverviewForCurrentTenant()
|
||||
}
|
||||
|
||||
private async resolveStoragePlanIdForTenant(tenantId: string): Promise<string | null> {
|
||||
const db = this.dbAccessor.get()
|
||||
const [record] = await db
|
||||
.select({ storagePlanId: tenants.storagePlanId })
|
||||
.from(tenants)
|
||||
.where(eq(tenants.id, tenantId))
|
||||
.limit(1)
|
||||
|
||||
if (!record) {
|
||||
throw new BizException(ErrorCode.TENANT_NOT_FOUND)
|
||||
}
|
||||
const planId = record.storagePlanId?.trim()
|
||||
return planId && planId.length > 0 ? planId : null
|
||||
}
|
||||
|
||||
private async getPlanCatalog(): Promise<Record<string, StoragePlanDefinition>> {
|
||||
const config = await this.systemSettingService.getStoragePlanCatalog()
|
||||
const merged: StoragePlanCatalog = { ...DEFAULT_STORAGE_PLAN_CATALOG, ...config }
|
||||
return Object.entries(merged).reduce<Record<string, StoragePlanDefinition>>((acc, [id, entry]) => {
|
||||
if (!id) {
|
||||
return acc
|
||||
}
|
||||
acc[id] = {
|
||||
id,
|
||||
name: entry.name,
|
||||
description: entry.description ?? null,
|
||||
capacityBytes: entry.capacityBytes ?? 0,
|
||||
isActive: entry.isActive ?? true,
|
||||
}
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
private buildPlanSummary(
|
||||
definition: StoragePlanDefinition,
|
||||
pricing?: StoragePlanPricing,
|
||||
payment?: StoragePlanPaymentInfo,
|
||||
): StoragePlanSummary {
|
||||
return {
|
||||
...definition,
|
||||
pricing,
|
||||
payment,
|
||||
}
|
||||
}
|
||||
|
||||
private async assignPlanToTenant(tenantId: string, planId: string | null): Promise<void> {
|
||||
const normalizedPlanId = this.normalizePlanId(planId)
|
||||
const managedProviderKey = await this.systemSettingService.getManagedStorageProviderKey()
|
||||
if (!managedProviderKey) {
|
||||
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, {
|
||||
message: '托管存储尚未启用,暂时无法订阅托管存储方案。',
|
||||
})
|
||||
}
|
||||
|
||||
if (normalizedPlanId) {
|
||||
const plan = await this.getPlanById(normalizedPlanId)
|
||||
if (!plan || plan.isActive === false) {
|
||||
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, {
|
||||
message: `未知或未启用的托管存储方案:${normalizedPlanId}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await this.persistTenantStoragePlan(tenantId, normalizedPlanId)
|
||||
}
|
||||
|
||||
private async persistTenantStoragePlan(tenantId: string, planId: string | null): Promise<void> {
|
||||
const db = this.dbAccessor.get()
|
||||
await db
|
||||
.update(tenants)
|
||||
.set({ storagePlanId: planId, updatedAt: new Date().toISOString() })
|
||||
.where(eq(tenants.id, tenantId))
|
||||
}
|
||||
|
||||
private normalizePlanId(planId?: string | null): string | null {
|
||||
if (typeof planId !== 'string') {
|
||||
return null
|
||||
}
|
||||
const trimmed = planId.trim()
|
||||
return trimmed.length > 0 ? trimmed : null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
export interface StoragePlanDefinition {
|
||||
id: string
|
||||
name: string
|
||||
description?: string | null
|
||||
capacityBytes: number | null
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export type StoragePlanCatalog = Record<string, Omit<StoragePlanDefinition, 'id'>>
|
||||
|
||||
export interface StoragePlanPricing {
|
||||
monthlyPrice: number | null
|
||||
currency: string | null
|
||||
}
|
||||
|
||||
export interface StoragePlanPaymentInfo {
|
||||
creemProductId?: string | null
|
||||
}
|
||||
|
||||
export type StoragePlanPricingConfigs = Record<string, StoragePlanPricing | undefined>
|
||||
export type StoragePlanProductConfigs = Record<string, StoragePlanPaymentInfo | undefined>
|
||||
|
||||
export interface StoragePlanSummary extends StoragePlanDefinition {
|
||||
pricing?: StoragePlanPricing
|
||||
payment?: StoragePlanPaymentInfo
|
||||
}
|
||||
|
||||
export interface StoragePlanOverview {
|
||||
managedStorageEnabled: boolean
|
||||
managedProviderKey: string | null
|
||||
currentPlanId: string | null
|
||||
currentPlan: StoragePlanSummary | null
|
||||
availablePlans: StoragePlanSummary[]
|
||||
}
|
||||
@@ -31,6 +31,14 @@ const planProductFields = (() => {
|
||||
return fields
|
||||
})()
|
||||
|
||||
const storageProviderConfigSchema = z.record(z.string(), z.string())
|
||||
const storageProviderSchema = z.object({
|
||||
id: z.string().trim().min(1).optional(),
|
||||
name: z.string().trim().optional(),
|
||||
type: z.string().trim().min(1),
|
||||
config: storageProviderConfigSchema.optional(),
|
||||
})
|
||||
|
||||
const updateSuperAdminSettingsSchema = z
|
||||
.object({
|
||||
allowRegistration: z.boolean().optional(),
|
||||
@@ -55,6 +63,11 @@ const updateSuperAdminSettingsSchema = z
|
||||
oauthGoogleClientSecret: z.string().trim().min(1).nullable().optional(),
|
||||
oauthGithubClientId: z.string().trim().min(1).nullable().optional(),
|
||||
oauthGithubClientSecret: z.string().trim().min(1).nullable().optional(),
|
||||
storagePlanCatalog: z.record(z.string(), z.any()).optional(),
|
||||
storagePlanPricing: z.record(z.string(), z.any()).optional(),
|
||||
storagePlanProducts: z.record(z.string(), z.any()).optional(),
|
||||
managedStorageProvider: z.string().trim().min(1).nullable().optional(),
|
||||
managedStorageProviders: z.array(storageProviderSchema).optional(),
|
||||
...planQuotaFields,
|
||||
...planPricingFields,
|
||||
...planProductFields,
|
||||
|
||||
@@ -31,7 +31,12 @@ export class TenantRepository {
|
||||
return { tenant }
|
||||
}
|
||||
|
||||
async createTenant(payload: { name: string; slug: string; planId?: BillingPlanId }): Promise<TenantAggregate> {
|
||||
async createTenant(payload: {
|
||||
name: string
|
||||
slug: string
|
||||
planId?: BillingPlanId
|
||||
storagePlanId?: string | null
|
||||
}): Promise<TenantAggregate> {
|
||||
const db = this.dbAccessor.get()
|
||||
const tenantId = generateId()
|
||||
const tenantRecord: typeof tenants.$inferInsert = {
|
||||
@@ -39,6 +44,7 @@ export class TenantRepository {
|
||||
name: payload.name,
|
||||
slug: payload.slug,
|
||||
planId: payload.planId ?? 'free',
|
||||
storagePlanId: payload.storagePlanId ?? null,
|
||||
status: 'active',
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,12 @@ import type { TenantAggregate, TenantContext, TenantResolutionInput } from './te
|
||||
export class TenantService {
|
||||
constructor(private readonly repository: TenantRepository) {}
|
||||
|
||||
async createTenant(payload: { name: string; slug: string; planId?: BillingPlanId }): Promise<TenantAggregate> {
|
||||
async createTenant(payload: {
|
||||
name: string
|
||||
slug: string
|
||||
planId?: BillingPlanId
|
||||
storagePlanId?: string | null
|
||||
}): Promise<TenantAggregate> {
|
||||
const normalizedSlug = this.normalizeSlug(payload.slug)
|
||||
|
||||
if (!normalizedSlug) {
|
||||
|
||||
21
be/apps/dashboard/src/modules/storage-plans/api.ts
Normal file
21
be/apps/dashboard/src/modules/storage-plans/api.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { coreApi } from '~/lib/api-client'
|
||||
import { camelCaseKeys } from '~/lib/case'
|
||||
|
||||
import type { ManagedStorageOverview } from './types'
|
||||
|
||||
const STORAGE_BILLING_ENDPOINT = '/billing/storage'
|
||||
|
||||
export async function getManagedStorageOverview(): Promise<ManagedStorageOverview> {
|
||||
return camelCaseKeys<ManagedStorageOverview>(
|
||||
await coreApi(STORAGE_BILLING_ENDPOINT, {
|
||||
method: 'GET',
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export async function updateManagedStoragePlan(planId: string | null): Promise<ManagedStorageOverview> {
|
||||
return await coreApi<ManagedStorageOverview>(STORAGE_BILLING_ENDPOINT, {
|
||||
method: 'PATCH',
|
||||
body: { planId },
|
||||
})
|
||||
}
|
||||
24
be/apps/dashboard/src/modules/storage-plans/hooks.ts
Normal file
24
be/apps/dashboard/src/modules/storage-plans/hooks.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import { getManagedStorageOverview, updateManagedStoragePlan } from './api'
|
||||
import type { ManagedStorageOverview } from './types'
|
||||
|
||||
export const MANAGED_STORAGE_PLAN_QUERY_KEY = ['billing', 'storage-plan'] as const
|
||||
|
||||
export function useManagedStoragePlansQuery() {
|
||||
return useQuery({
|
||||
queryKey: MANAGED_STORAGE_PLAN_QUERY_KEY,
|
||||
queryFn: getManagedStorageOverview,
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateManagedStoragePlanMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (planId: string | null) => updateManagedStoragePlan(planId),
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData<ManagedStorageOverview>(MANAGED_STORAGE_PLAN_QUERY_KEY, data)
|
||||
},
|
||||
})
|
||||
}
|
||||
2
be/apps/dashboard/src/modules/storage-plans/index.ts
Normal file
2
be/apps/dashboard/src/modules/storage-plans/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './hooks'
|
||||
export * from './types'
|
||||
25
be/apps/dashboard/src/modules/storage-plans/types.ts
Normal file
25
be/apps/dashboard/src/modules/storage-plans/types.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export interface ManagedStoragePricing {
|
||||
monthlyPrice: number | null
|
||||
currency: string | null
|
||||
}
|
||||
|
||||
export interface ManagedStoragePaymentInfo {
|
||||
creemProductId?: string | null
|
||||
}
|
||||
|
||||
export interface ManagedStoragePlanSummary {
|
||||
id: string
|
||||
name: string
|
||||
description?: string | null
|
||||
capacityBytes: number | null
|
||||
pricing?: ManagedStoragePricing
|
||||
payment?: ManagedStoragePaymentInfo
|
||||
}
|
||||
|
||||
export interface ManagedStorageOverview {
|
||||
managedStorageEnabled: boolean
|
||||
managedProviderKey: string | null
|
||||
currentPlanId: string | null
|
||||
currentPlan: ManagedStoragePlanSummary | null
|
||||
availablePlans: ManagedStoragePlanSummary[]
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Button, Modal } from '@afilmory/ui'
|
||||
import { DynamicIcon } from 'lucide-react/dynamic'
|
||||
import { m } from 'motion/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useManagedStoragePlansQuery } from '~/modules/storage-plans'
|
||||
|
||||
import { ManagedStoragePlansModal } from './ManagedStoragePlansModal'
|
||||
|
||||
const managedStorageI18nKeys = {
|
||||
title: 'photos.storage.managed.title',
|
||||
description: 'photos.storage.managed.description',
|
||||
unavailable: 'photos.storage.managed.unavailable',
|
||||
empty: 'photos.storage.managed.empty',
|
||||
action: 'photos.storage.managed.actions.subscribe',
|
||||
seePlans: 'photos.storage.managed.actions.switch',
|
||||
loading: 'photos.storage.managed.actions.loading',
|
||||
} as const
|
||||
|
||||
export function ManagedStorageEntryCard() {
|
||||
const { t } = useTranslation()
|
||||
const plansQuery = useManagedStoragePlansQuery()
|
||||
|
||||
const openModal = () => {
|
||||
Modal.present(ManagedStoragePlansModal, {}, { dismissOnOutsideClick: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<m.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }}>
|
||||
<div className="group relative flex h-full flex-col gap-3 overflow-hidden bg-background-tertiary p-5 text-left transition-all duration-200 hover:shadow-lg">
|
||||
<div className="via-text/20 group-hover:via-accent/40 absolute top-0 right-0 left-0 h-[0.5px] bg-linear-to-r from-transparent to-transparent transition-opacity" />
|
||||
<div className="via-text/20 group-hover:via-accent/40 absolute top-0 right-0 bottom-0 w-[0.5px] bg-linear-to-b from-transparent to-transparent transition-opacity" />
|
||||
<div className="via-text/20 group-hover:via-accent/40 absolute right-0 bottom-0 left-0 h-[0.5px] bg-linear-to-r from-transparent to-transparent transition-opacity" />
|
||||
<div className="via-text/20 group-hover:via-accent/40 absolute top-0 bottom-0 left-0 w-[0.5px] bg-linear-to-b from-transparent to-transparent transition-opacity" />
|
||||
|
||||
<div className="relative">
|
||||
<div className="bg-accent/15 inline-flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
<DynamicIcon name="hard-drive" className="h-6 w-6 text-accent" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-1">
|
||||
<h3 className="text-text text-sm font-semibold">{t(managedStorageI18nKeys.title)}</h3>
|
||||
<p className="text-text-tertiary text-xs">{t(managedStorageI18nKeys.description)}</p>
|
||||
</div>
|
||||
{/*
|
||||
<div className="text-text-tertiary/80 text-xs">
|
||||
{plansQuery.isLoading
|
||||
? t(managedStorageI18nKeys.loading)
|
||||
: plansQuery.isError
|
||||
? t(managedStorageI18nKeys.unavailable)
|
||||
: plansQuery.data?.managedStorageEnabled
|
||||
? t(managedStorageI18nKeys.seePlans)
|
||||
: t(managedStorageI18nKeys.unavailable)}
|
||||
</div> */}
|
||||
|
||||
<div className="flex justify-end -mb-3 -mt-2 -mr-3.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={
|
||||
plansQuery.isLoading ||
|
||||
plansQuery.isError ||
|
||||
!plansQuery.data ||
|
||||
(!plansQuery.data.managedStorageEnabled && plansQuery.data.availablePlans.length === 0)
|
||||
}
|
||||
onClick={openModal}
|
||||
>
|
||||
{t(managedStorageI18nKeys.action)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</m.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { Button, DialogDescription, DialogHeader, DialogTitle } from '@afilmory/ui'
|
||||
import { clsxm } from '@afilmory/utils'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { getI18n } from '~/i18n'
|
||||
import type { ManagedStoragePlanSummary } from '~/modules/storage-plans'
|
||||
import { useManagedStoragePlansQuery, useUpdateManagedStoragePlanMutation } from '~/modules/storage-plans'
|
||||
|
||||
const managedStorageI18nKeys = {
|
||||
title: 'photos.storage.managed.title',
|
||||
description: 'photos.storage.managed.description',
|
||||
unavailable: 'photos.storage.managed.unavailable',
|
||||
empty: 'photos.storage.managed.empty',
|
||||
capacityLabel: 'photos.storage.managed.capacity.label',
|
||||
capacityUnlimited: 'photos.storage.managed.capacity.unlimited',
|
||||
capacityUnknown: 'photos.storage.managed.capacity.unknown',
|
||||
priceLabel: 'photos.storage.managed.price.label',
|
||||
priceFree: 'photos.storage.managed.price.free',
|
||||
actionsSubscribe: 'photos.storage.managed.actions.subscribe',
|
||||
actionsSwitch: 'photos.storage.managed.actions.switch',
|
||||
actionsCurrent: 'photos.storage.managed.actions.current',
|
||||
actionsCancel: 'photos.storage.managed.actions.cancel',
|
||||
actionsLoading: 'photos.storage.managed.actions.loading',
|
||||
errorLoad: 'photos.storage.managed.error.load',
|
||||
toastSuccess: 'photos.storage.managed.toast.success',
|
||||
toastError: 'photos.storage.managed.toast.error',
|
||||
} as const
|
||||
|
||||
export function ManagedStoragePlansModal() {
|
||||
const { t, i18n } = useTranslation()
|
||||
const locale = i18n.language ?? i18n.resolvedLanguage ?? 'en'
|
||||
const plansQuery = useManagedStoragePlansQuery()
|
||||
const updateMutation = useUpdateManagedStoragePlanMutation()
|
||||
|
||||
const numberFormatter = new Intl.NumberFormat(locale, { maximumFractionDigits: 1 })
|
||||
const priceFormatter = new Intl.NumberFormat(locale, { minimumFractionDigits: 0, maximumFractionDigits: 2 })
|
||||
|
||||
const handleSelect = async (planId: string | null) => {
|
||||
try {
|
||||
await updateMutation.mutateAsync(planId)
|
||||
toast.success(t(managedStorageI18nKeys.toastSuccess))
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
t(managedStorageI18nKeys.toastError, {
|
||||
reason: extractErrorMessage(error, t('common.unknown-error')),
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full max-h-[85vh] flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-semibold leading-none tracking-tight">
|
||||
{t(managedStorageI18nKeys.title)}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-text-secondary text-sm">
|
||||
{t(managedStorageI18nKeys.description)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 mt-4">
|
||||
{plansQuery.isLoading ? (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{Array.from({ length: 2 }).map((_, index) => (
|
||||
<div key={index} className="bg-background-tertiary h-40 animate-pulse rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : plansQuery.isError ? (
|
||||
<div className="rounded-xl border border-red/30 bg-red/10 p-4 text-sm text-red">
|
||||
{t(managedStorageI18nKeys.errorLoad)}
|
||||
</div>
|
||||
) : !plansQuery.data?.managedStorageEnabled ? (
|
||||
<div className="rounded-xl border border-border/30 bg-background-secondary/30 p-4 text-sm text-text-secondary">
|
||||
{t(managedStorageI18nKeys.unavailable)}
|
||||
</div>
|
||||
) : plansQuery.data.availablePlans.length === 0 ? (
|
||||
<div className="rounded-xl border border-border/30 bg-background-secondary/30 p-4 text-sm text-text-secondary">
|
||||
{t(managedStorageI18nKeys.empty)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{plansQuery.data.availablePlans.map((plan) => (
|
||||
<PlanCard
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
isCurrent={plansQuery.data?.currentPlanId === plan.id}
|
||||
hasCurrentPlan={Boolean(plansQuery.data?.currentPlanId)}
|
||||
isProcessing={updateMutation.isPending}
|
||||
onSelect={() => handleSelect(plan.id)}
|
||||
formatCapacity={(bytes) => formatCapacity(bytes, numberFormatter)}
|
||||
formatPrice={(value, currency) => formatPrice(value, currency, priceFormatter)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{plansQuery.data?.currentPlanId ? (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={updateMutation.isPending}
|
||||
onClick={() => handleSelect(null)}
|
||||
>
|
||||
{updateMutation.isPending
|
||||
? t(managedStorageI18nKeys.actionsLoading)
|
||||
: t(managedStorageI18nKeys.actionsCancel)}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PlanCard({
|
||||
plan,
|
||||
isCurrent,
|
||||
hasCurrentPlan,
|
||||
isProcessing,
|
||||
onSelect,
|
||||
formatCapacity,
|
||||
formatPrice,
|
||||
}: {
|
||||
plan: ManagedStoragePlanSummary
|
||||
isCurrent: boolean
|
||||
hasCurrentPlan: boolean
|
||||
isProcessing: boolean
|
||||
onSelect: () => void
|
||||
formatCapacity: (bytes: number | null) => string
|
||||
formatPrice: (value: number, currency: string | null | undefined) => string
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const hasPrice =
|
||||
plan.pricing &&
|
||||
plan.pricing.monthlyPrice !== null &&
|
||||
plan.pricing.monthlyPrice !== undefined &&
|
||||
Number.isFinite(plan.pricing.monthlyPrice)
|
||||
|
||||
const priceLabel = hasPrice
|
||||
? t(managedStorageI18nKeys.priceLabel, {
|
||||
price: formatPrice(plan.pricing!.monthlyPrice as number, plan.pricing!.currency ?? null),
|
||||
})
|
||||
: t(managedStorageI18nKeys.priceFree)
|
||||
|
||||
const capacityLabel = formatCapacity(plan.capacityBytes)
|
||||
const actionLabel = isCurrent
|
||||
? t(managedStorageI18nKeys.actionsCurrent)
|
||||
: hasCurrentPlan
|
||||
? t(managedStorageI18nKeys.actionsSwitch)
|
||||
: t(managedStorageI18nKeys.actionsSubscribe)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsxm(
|
||||
'border-border/40 bg-background-secondary/40 flex h-full flex-col rounded-2xl border p-5',
|
||||
isCurrent && 'border-accent/50 shadow-[0_0_0_1px_rgba(var(--color-accent-rgb),0.3)]',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-text text-base font-semibold">{plan.name}</h3>
|
||||
{plan.description ? <p className="text-text-tertiary mt-1 text-sm leading-snug">{plan.description}</p> : null}
|
||||
</div>
|
||||
{isCurrent ? (
|
||||
<span className="text-accent border-accent/40 bg-accent/10 rounded-full px-2 py-0.5 text-xs font-semibold">
|
||||
{t(managedStorageI18nKeys.actionsCurrent)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-1 text-sm">
|
||||
<p className="text-text font-medium">{capacityLabel}</p>
|
||||
<p className="text-text-secondary">{priceLabel}</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className="mt-6 w-full"
|
||||
variant={isCurrent ? 'secondary' : 'primary'}
|
||||
disabled={isCurrent || isProcessing}
|
||||
onClick={onSelect}
|
||||
>
|
||||
{isProcessing ? t(managedStorageI18nKeys.actionsLoading) : actionLabel}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatCapacity(bytes: number | null, formatter: Intl.NumberFormat) {
|
||||
const { t } = getI18n()
|
||||
if (bytes === null) {
|
||||
return t(managedStorageI18nKeys.capacityUnlimited)
|
||||
}
|
||||
if (bytes === undefined || bytes <= 0 || Number.isNaN(bytes)) {
|
||||
return t(managedStorageI18nKeys.capacityUnknown)
|
||||
}
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
|
||||
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
|
||||
const value = bytes / 1024 ** exponent
|
||||
const formattedValue = formatter.format(value >= 10 ? Math.round(value) : value)
|
||||
return t(managedStorageI18nKeys.capacityLabel, { value: `${formattedValue} ${units[exponent]}` })
|
||||
}
|
||||
|
||||
function formatPrice(value: number, currency: string | null | undefined, formatter: Intl.NumberFormat) {
|
||||
const formatted = formatter.format(value)
|
||||
const normalizedCurrency = currency?.toUpperCase()?.trim()
|
||||
return normalizedCurrency ? `${normalizedCurrency} ${formatted}` : formatted
|
||||
}
|
||||
|
||||
function extractErrorMessage(error: unknown, fallback: string) {
|
||||
if (error instanceof Error && error.message) return error.message
|
||||
if (typeof error === 'object' && error && 'message' in error && typeof (error as any).message === 'string') {
|
||||
return (error as any).message
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -141,7 +141,7 @@ export const ProviderCard: FC<ProviderCardProps> = ({ provider, isActive, onEdit
|
||||
className="border-accent/30 bg-accent/10 text-accent hover:bg-accent/20 border"
|
||||
onClick={onToggleActive}
|
||||
>
|
||||
<DynamicIcon name="check" className="h-3.5 w-3.5" />
|
||||
<DynamicIcon name="check" className="h-3.5 w-3.5 mr-1" />
|
||||
<span>{t(storageProvidersI18nKeys.card.makeActive)}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -88,7 +88,7 @@ export function ProviderEditModal({
|
||||
|
||||
const handleSave = () => {
|
||||
if (!formData) return
|
||||
formData.id = formData.id ?? nanoid()
|
||||
formData.id = formData.id && formData.id.trim().length > 0 ? formData.id : nanoid()
|
||||
onSave(formData)
|
||||
dismiss()
|
||||
}
|
||||
|
||||
@@ -255,6 +255,8 @@ export function StorageProvidersManager() {
|
||||
transition={Spring.presets.smooth}
|
||||
className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
>
|
||||
{/* <ManagedStorageEntryCard /> */}
|
||||
|
||||
{orderedProviders.map((provider, index) => (
|
||||
<m.div
|
||||
key={provider.id}
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import { Button, Label, Modal } from '@afilmory/ui'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { LinearBorderPanel } from '~/components/common/LinearBorderPanel'
|
||||
import type { StorageProvider } from '~/modules/storage-providers'
|
||||
import { useStorageProviderSchemaQuery } from '~/modules/storage-providers'
|
||||
import { ProviderEditModal } from '~/modules/storage-providers/components/ProviderEditModal'
|
||||
import { storageProvidersI18nKeys } from '~/modules/storage-providers/constants'
|
||||
import {
|
||||
createEmptyProvider,
|
||||
normalizeStorageProviderConfig,
|
||||
reorderProvidersByActive,
|
||||
} from '~/modules/storage-providers/utils'
|
||||
|
||||
import { useSuperAdminSettingsQuery, useUpdateSuperAdminSettingsMutation } from '../hooks'
|
||||
import type { UpdateSuperAdminSettingsPayload } from '../types'
|
||||
|
||||
function coerceManagedProviders(input: unknown): StorageProvider[] {
|
||||
if (!Array.isArray(input)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return input
|
||||
.map((entry) => {
|
||||
const normalized = normalizeStorageProviderConfig(entry as StorageProvider)
|
||||
const id = normalizeProviderId(normalized.id)
|
||||
if (!id) {
|
||||
return null
|
||||
}
|
||||
return { ...normalized, id }
|
||||
})
|
||||
.filter((provider): provider is StorageProvider => provider !== null)
|
||||
}
|
||||
|
||||
const normalizeProviderId = (value: string | null | undefined): string | null => {
|
||||
if (typeof value !== 'string') {
|
||||
return null
|
||||
}
|
||||
const trimmed = value.trim()
|
||||
return trimmed.length > 0 ? trimmed : null
|
||||
}
|
||||
|
||||
const ensureLocalProviderId = (value: string | null | undefined): string => {
|
||||
return normalizeProviderId(value) ?? nanoid()
|
||||
}
|
||||
|
||||
export function ManagedStorageSettings() {
|
||||
const { t } = useTranslation()
|
||||
const schemaQuery = useStorageProviderSchemaQuery()
|
||||
const settingsQuery = useSuperAdminSettingsQuery()
|
||||
const updateSettings = useUpdateSuperAdminSettingsMutation()
|
||||
|
||||
const [providers, setProviders] = useState<StorageProvider[]>([])
|
||||
const [baselineProviders, setBaselineProviders] = useState<StorageProvider[]>([])
|
||||
const [managedId, setManagedId] = useState<string | null>(null)
|
||||
|
||||
const settingsSource = useMemo(() => {
|
||||
const payload = settingsQuery.data
|
||||
if (!payload) return null
|
||||
return 'values' in payload ? payload.values : payload.settings
|
||||
}, [settingsQuery.data])
|
||||
|
||||
const baselineManagedId = useMemo(() => {
|
||||
const managed = settingsSource?.managedStorageProvider
|
||||
return typeof managed === 'string' && managed.trim().length > 0 ? managed.trim() : null
|
||||
}, [settingsSource])
|
||||
|
||||
useEffect(() => {
|
||||
if (!settingsSource) {
|
||||
return
|
||||
}
|
||||
const rawProviders = settingsSource.managedStorageProviders
|
||||
const nextProviders = coerceManagedProviders(rawProviders)
|
||||
setProviders(nextProviders)
|
||||
setBaselineProviders(nextProviders)
|
||||
const fallbackId = baselineManagedId ?? normalizeProviderId(nextProviders[0]?.id) ?? null
|
||||
setManagedId(fallbackId)
|
||||
}, [settingsSource, baselineManagedId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!managedId) {
|
||||
return
|
||||
}
|
||||
const exists = providers.some((provider) => normalizeProviderId(provider.id) === managedId)
|
||||
if (!exists) {
|
||||
setManagedId(null)
|
||||
}
|
||||
}, [providers, managedId])
|
||||
|
||||
const orderedProviders = useMemo(() => reorderProvidersByActive(providers, managedId), [providers, managedId])
|
||||
const providersChanged = useMemo(
|
||||
() => JSON.stringify(baselineProviders) !== JSON.stringify(providers),
|
||||
[baselineProviders, providers],
|
||||
)
|
||||
const normalizedManagedId = normalizeProviderId(managedId)
|
||||
const managedChanged = normalizedManagedId !== baselineManagedId
|
||||
const canSave = (providersChanged || managedChanged) && !updateSettings.isPending
|
||||
|
||||
const handleEdit = (provider: StorageProvider | null) => {
|
||||
const providerForm = schemaQuery.data
|
||||
if (!providerForm) return
|
||||
|
||||
const seed = provider ?? createEmptyProvider(providerForm.types[0]?.value ?? 's3')
|
||||
Modal.present(
|
||||
ProviderEditModal,
|
||||
{
|
||||
provider: seed,
|
||||
activeProviderId: null,
|
||||
providerSchema: providerForm,
|
||||
onSave: (next) => {
|
||||
const normalized = normalizeStorageProviderConfig(next)
|
||||
const providerWithId: StorageProvider = { ...normalized, id: ensureLocalProviderId(normalized.id) }
|
||||
setProviders((prev) => {
|
||||
const exists = prev.some((item) => item.id === providerWithId.id)
|
||||
if (exists) {
|
||||
return prev.map((item) => (item.id === providerWithId.id ? providerWithId : item))
|
||||
}
|
||||
return [...prev, providerWithId]
|
||||
})
|
||||
setManagedId((prev) => prev ?? providerWithId.id)
|
||||
},
|
||||
onSetActive: () => {},
|
||||
},
|
||||
{ dismissOnOutsideClick: false },
|
||||
)
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
if (!providersChanged && !managedChanged) {
|
||||
return
|
||||
}
|
||||
|
||||
const payload: UpdateSuperAdminSettingsPayload = {}
|
||||
if (providersChanged) {
|
||||
payload.managedStorageProviders = providers
|
||||
}
|
||||
if (managedChanged) {
|
||||
payload.managedStorageProvider = normalizedManagedId
|
||||
}
|
||||
updateSettings.mutate(payload)
|
||||
}
|
||||
|
||||
if (schemaQuery.isLoading || settingsQuery.isLoading) {
|
||||
return (
|
||||
<LinearBorderPanel className="space-y-3 p-6">
|
||||
<div className="bg-fill/30 h-6 w-32 animate-pulse rounded" />
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="bg-fill/20 h-16 animate-pulse rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
</LinearBorderPanel>
|
||||
)
|
||||
}
|
||||
|
||||
if (schemaQuery.isError || settingsQuery.isError) {
|
||||
return (
|
||||
<LinearBorderPanel className="space-y-2 p-6">
|
||||
<h2 className="text-text text-lg font-semibold">{t('superadmin.settings.managed-storage.title')}</h2>
|
||||
<p className="text-red text-sm">
|
||||
{t('superadmin.settings.managed-storage.error', {
|
||||
reason:
|
||||
(settingsQuery.error as Error | undefined)?.message ||
|
||||
(schemaQuery.error as Error | undefined)?.message ||
|
||||
t('common.unknown-error'),
|
||||
})}
|
||||
</p>
|
||||
</LinearBorderPanel>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<LinearBorderPanel className="space-y-4 p-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-text text-lg font-semibold">{t('superadmin.settings.managed-storage.title')}</h2>
|
||||
<p className="text-text-secondary text-sm">{t('superadmin.settings.managed-storage.description')}</p>
|
||||
</div>
|
||||
<Button onClick={() => handleEdit(null)} disabled={!schemaQuery.data}>
|
||||
{t(storageProvidersI18nKeys.actions.add)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{orderedProviders.length === 0 ? (
|
||||
<p className="text-text-secondary text-sm">{t('superadmin.settings.managed-storage.empty')}</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{orderedProviders.map((provider) => (
|
||||
<div
|
||||
key={provider.id}
|
||||
className={[
|
||||
'border-fill/60 bg-fill/5 flex flex-wrap items-center gap-3 rounded-lg border p-4',
|
||||
managedId === provider.id ? 'border-accent' : '',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-text text-sm font-semibold">{provider.name || provider.id}</Label>
|
||||
{managedId === provider.id ? (
|
||||
<span className="text-accent border-accent/40 bg-accent/10 rounded-full px-2 py-0.5 text-[11px] font-semibold">
|
||||
{t('superadmin.settings.managed-storage.current')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-text-tertiary text-xs">
|
||||
{t('superadmin.settings.managed-storage.type', { type: provider.type })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" onClick={() => handleEdit(provider)}>
|
||||
{t('superadmin.settings.managed-storage.actions.edit')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={managedId === provider.id ? 'secondary' : 'primary'}
|
||||
onClick={() => setManagedId(normalizeProviderId(provider.id))}
|
||||
>
|
||||
{managedId === provider.id
|
||||
? t('superadmin.settings.managed-storage.actions.selected')
|
||||
: t('superadmin.settings.managed-storage.actions.select')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-3 my-4">
|
||||
<Button variant="secondary" disabled={!canSave} isLoading={updateSettings.isPending} onClick={handleSave}>
|
||||
{updateSettings.isPending
|
||||
? t(storageProvidersI18nKeys.actions.saving)
|
||||
: t('superadmin.settings.managed-storage.actions.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</LinearBorderPanel>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './api'
|
||||
export * from './components/ManagedStorageSettings'
|
||||
export * from './components/SuperAdminSettingsForm'
|
||||
export * from './components/SuperAdminTenantManager'
|
||||
export * from './hooks'
|
||||
|
||||
@@ -2,10 +2,18 @@ import type { PhotoManifestItem } from '@afilmory/builder'
|
||||
|
||||
import type { BillingUsageTotalsEntry, PhotoAssetListItem, PhotoSyncLogLevel } from '../photos/types'
|
||||
import type { SchemaFormValue, UiSchema } from '../schema-form/types'
|
||||
import type { StorageProvider } from '../storage-providers/types'
|
||||
|
||||
export type SuperAdminSettingField = string
|
||||
|
||||
export type SuperAdminSettings = Record<SuperAdminSettingField, SchemaFormValue | undefined>
|
||||
export type SuperAdminSettingsWithStorage = SuperAdminSettings & {
|
||||
storagePlanCatalog?: Record<string, unknown>
|
||||
storagePlanPricing?: Record<string, unknown>
|
||||
storagePlanProducts?: Record<string, unknown>
|
||||
managedStorageProvider?: string | null
|
||||
managedStorageProviders?: StorageProvider[]
|
||||
}
|
||||
|
||||
export interface SuperAdminStats {
|
||||
totalUsers: number
|
||||
@@ -27,9 +35,10 @@ export type SuperAdminSettingsResponse =
|
||||
values?: never
|
||||
})
|
||||
|
||||
export type UpdateSuperAdminSettingsPayload = Partial<
|
||||
Record<SuperAdminSettingField, SchemaFormValue | null | undefined>
|
||||
>
|
||||
export type UpdateSuperAdminSettingsPayload = Partial<{
|
||||
managedStorageProvider: string | null
|
||||
managedStorageProviders: StorageProvider[]
|
||||
}>
|
||||
|
||||
export type BuilderDebugProgressEvent =
|
||||
| {
|
||||
|
||||
@@ -1,13 +1,30 @@
|
||||
import { Button, Input, Switch, Textarea } from '@afilmory/ui'
|
||||
import { Spring } from '@afilmory/utils'
|
||||
import { m } from 'motion/react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { SuperAdminSettingsForm } from '~/modules/super-admin'
|
||||
import { LinearBorderPanel } from '~/components/common/LinearBorderPanel'
|
||||
import { PageTabs } from '~/components/navigation/PageTabs'
|
||||
import type { UpdateSuperAdminSettingsPayload } from '~/modules/super-admin'
|
||||
import {
|
||||
SuperAdminSettingsForm,
|
||||
useSuperAdminSettingsQuery,
|
||||
useUpdateSuperAdminSettingsMutation,
|
||||
} from '~/modules/super-admin'
|
||||
|
||||
const PLAN_SECTION_IDS = ['billing-plan-settings'] as const
|
||||
const APP_PLAN_SECTION_IDS = ['billing-plan-settings'] as const
|
||||
const STORAGE_PLAN_SECTION_IDS = ['storage-plan-settings'] as const
|
||||
const TABS = [
|
||||
{ id: 'app', labelKey: 'superadmin.plans.tabs.app', sections: APP_PLAN_SECTION_IDS },
|
||||
{ id: 'storage', labelKey: 'superadmin.plans.tabs.storage', sections: STORAGE_PLAN_SECTION_IDS },
|
||||
] as const
|
||||
|
||||
export function Component() {
|
||||
const { t } = useTranslation()
|
||||
const [activeTab, setActiveTab] = useState<(typeof TABS)[number]['id']>('app')
|
||||
const active = TABS.find((tab) => tab.id === activeTab) ?? TABS[0]
|
||||
|
||||
return (
|
||||
<m.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
@@ -20,7 +37,363 @@ export function Component() {
|
||||
<p className="text-text-secondary text-sm">{t('superadmin.plans.description')}</p>
|
||||
</header>
|
||||
|
||||
<SuperAdminSettingsForm visibleSectionIds={PLAN_SECTION_IDS} />
|
||||
<PageTabs
|
||||
activeId={activeTab}
|
||||
onSelect={(id) => setActiveTab(id as typeof activeTab)}
|
||||
items={TABS.map((tab) => ({
|
||||
id: tab.id,
|
||||
labelKey: tab.labelKey,
|
||||
}))}
|
||||
/>
|
||||
|
||||
{active.id === 'app' ? <SuperAdminSettingsForm visibleSectionIds={active.sections} /> : <StoragePlanEditor />}
|
||||
</m.div>
|
||||
)
|
||||
}
|
||||
|
||||
type StoragePlanRow = {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
capacityGb: string
|
||||
isActive: boolean
|
||||
monthlyPrice: string
|
||||
currency: string
|
||||
creemProductId: string
|
||||
}
|
||||
|
||||
function bytesToGb(bytes: unknown): string {
|
||||
if (bytes === null || bytes === undefined) return ''
|
||||
const num = Number(bytes)
|
||||
if (!Number.isFinite(num)) return ''
|
||||
return (num / 1024 / 1024 / 1024).toFixed(2)
|
||||
}
|
||||
|
||||
function gbToBytes(value: string): number | null {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed)) return null
|
||||
return Math.max(0, Math.round(parsed * 1024 * 1024 * 1024))
|
||||
}
|
||||
|
||||
function StoragePlanEditor() {
|
||||
const { t } = useTranslation()
|
||||
const query = useSuperAdminSettingsQuery()
|
||||
const mutation = useUpdateSuperAdminSettingsMutation()
|
||||
|
||||
const rawValues = useMemo(() => {
|
||||
const payload = query.data
|
||||
if (!payload) return null
|
||||
if ('values' in payload && payload.values) return payload.values
|
||||
if ('settings' in payload && payload.settings) return payload.settings
|
||||
return null
|
||||
}, [query.data])
|
||||
|
||||
type StoragePlanCatalogEntry = {
|
||||
name?: string
|
||||
description?: string | null
|
||||
capacityBytes?: number | null
|
||||
isActive?: boolean
|
||||
}
|
||||
type StoragePlanPricingEntry = { monthlyPrice?: number | null; currency?: string | null }
|
||||
type StoragePlanProductEntry = { creemProductId?: string | null }
|
||||
|
||||
const parsed = useMemo(() => {
|
||||
const catalog = (rawValues?.storagePlanCatalog as Record<string, StoragePlanCatalogEntry> | undefined) ?? {}
|
||||
const pricing = (rawValues?.storagePlanPricing as Record<string, StoragePlanPricingEntry> | undefined) ?? {}
|
||||
const products = (rawValues?.storagePlanProducts as Record<string, StoragePlanProductEntry> | undefined) ?? {}
|
||||
const provider = (rawValues?.managedStorageProvider as string | null | undefined) ?? ''
|
||||
|
||||
return {
|
||||
rows: Object.entries(catalog).map(([id, entry]) => ({
|
||||
id,
|
||||
name: entry?.name ?? '',
|
||||
description: entry?.description ?? '',
|
||||
capacityGb: bytesToGb(entry?.capacityBytes),
|
||||
isActive: entry?.isActive !== false,
|
||||
monthlyPrice: pricing[id]?.monthlyPrice?.toString() ?? '',
|
||||
currency: pricing[id]?.currency ?? '',
|
||||
creemProductId: products[id]?.creemProductId ?? '',
|
||||
})),
|
||||
provider,
|
||||
}
|
||||
}, [rawValues])
|
||||
|
||||
const [rows, setRows] = useState<StoragePlanRow[]>(parsed.rows)
|
||||
const [providerKey, setProviderKey] = useState<string>(parsed.provider ?? '')
|
||||
|
||||
useEffect(() => {
|
||||
setRows(parsed.rows)
|
||||
setProviderKey(parsed.provider ?? '')
|
||||
}, [parsed])
|
||||
|
||||
const errors = useMemo(() => {
|
||||
return rows.map((row) => {
|
||||
const rowErrors: string[] = []
|
||||
if (!row.id.trim()) rowErrors.push(t('superadmin.plans.storage.validation.id'))
|
||||
if (!row.name.trim()) rowErrors.push(t('superadmin.plans.storage.validation.name'))
|
||||
if (row.capacityGb && !Number.isFinite(Number(row.capacityGb))) {
|
||||
rowErrors.push(t('superadmin.plans.storage.validation.capacity'))
|
||||
}
|
||||
if (row.monthlyPrice && !Number.isFinite(Number(row.monthlyPrice))) {
|
||||
rowErrors.push(t('superadmin.plans.storage.validation.price'))
|
||||
}
|
||||
return rowErrors
|
||||
})
|
||||
}, [rows, t])
|
||||
|
||||
const hasErrors = errors.some((list) => list.length > 0)
|
||||
|
||||
const payload = useMemo((): UpdateSuperAdminSettingsPayload => {
|
||||
const catalog: Record<string, StoragePlanCatalogEntry> = {}
|
||||
const pricing: Record<string, StoragePlanPricingEntry> = {}
|
||||
const products: Record<string, StoragePlanProductEntry> = {}
|
||||
|
||||
rows.forEach((row) => {
|
||||
const id = row.id.trim()
|
||||
if (!id) return
|
||||
catalog[id] = {
|
||||
name: row.name.trim(),
|
||||
description: row.description.trim() || null,
|
||||
capacityBytes: gbToBytes(row.capacityGb),
|
||||
isActive: row.isActive,
|
||||
}
|
||||
|
||||
if (row.monthlyPrice || row.currency) {
|
||||
const parsedPrice = Number(row.monthlyPrice)
|
||||
pricing[id] = {
|
||||
monthlyPrice: Number.isFinite(parsedPrice) ? parsedPrice : null,
|
||||
currency: row.currency.trim() || null,
|
||||
}
|
||||
}
|
||||
|
||||
if (row.creemProductId.trim()) {
|
||||
products[id] = { creemProductId: row.creemProductId.trim() }
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
managedStorageProvider: providerKey.trim() || null,
|
||||
}
|
||||
}, [providerKey, rows])
|
||||
|
||||
const baselinePayload = useMemo(() => {
|
||||
const catalog = (rawValues?.storagePlanCatalog as Record<string, StoragePlanCatalogEntry> | undefined) ?? {}
|
||||
const pricing = (rawValues?.storagePlanPricing as Record<string, StoragePlanPricingEntry> | undefined) ?? {}
|
||||
const products = (rawValues?.storagePlanProducts as Record<string, StoragePlanProductEntry> | undefined) ?? {}
|
||||
const provider = (rawValues?.managedStorageProvider as string | null | undefined) ?? null
|
||||
return {
|
||||
storagePlanCatalog: catalog,
|
||||
storagePlanPricing: pricing,
|
||||
storagePlanProducts: products,
|
||||
managedStorageProvider: provider,
|
||||
}
|
||||
}, [rawValues])
|
||||
|
||||
const hasChanges = useMemo(
|
||||
() => JSON.stringify(payload) !== JSON.stringify(baselinePayload),
|
||||
[payload, baselinePayload],
|
||||
)
|
||||
|
||||
const handleRowChange = (id: string, patch: Partial<StoragePlanRow>) => {
|
||||
setRows((prev) => prev.map((row) => (row.id === id ? { ...row, ...patch } : row)))
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
setRows((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `managed-${prev.length + 1}`,
|
||||
name: '',
|
||||
description: '',
|
||||
capacityGb: '',
|
||||
isActive: true,
|
||||
monthlyPrice: '',
|
||||
currency: 'USD',
|
||||
creemProductId: '',
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
const handleRemove = (id: string) => {
|
||||
setRows((prev) => prev.filter((row) => row.id !== id))
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
if (hasErrors || rows.length === 0) {
|
||||
return
|
||||
}
|
||||
mutation.mutate(payload)
|
||||
}
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<LinearBorderPanel className="space-y-3 p-6">
|
||||
<div className="bg-fill/40 h-6 w-32 animate-pulse rounded-full" />
|
||||
<div className="space-y-3">
|
||||
{[1, 2].map((key) => (
|
||||
<div key={key} className="bg-fill/30 h-24 animate-pulse rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
</LinearBorderPanel>
|
||||
)
|
||||
}
|
||||
|
||||
if (query.isError || !rawValues) {
|
||||
return (
|
||||
<LinearBorderPanel className="space-y-2 p-6">
|
||||
<h2 className="text-text text-lg font-semibold">{t('superadmin.plans.storage.title')}</h2>
|
||||
<p className="text-red text-sm">
|
||||
{t('superadmin.plans.storage.error', {
|
||||
reason: query.error instanceof Error ? query.error.message : t('common.unknown-error'),
|
||||
})}
|
||||
</p>
|
||||
</LinearBorderPanel>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<LinearBorderPanel className="space-y-4 p-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-text text-lg font-semibold">{t('superadmin.plans.storage.title')}</h2>
|
||||
<p className="text-text-secondary text-sm">{t('superadmin.plans.storage.description')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-4 ml-auto">
|
||||
<Button variant="ghost" onClick={handleAdd}>
|
||||
{t('superadmin.plans.storage.actions.add')}
|
||||
</Button>
|
||||
<Button disabled={mutation.isPending || hasErrors || !hasChanges} onClick={handleSave}>
|
||||
{t('superadmin.plans.storage.actions.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasErrors ? <div className="text-red text-sm">{t('superadmin.plans.storage.validation.block')}</div> : null}
|
||||
|
||||
<div className="space-y-4">
|
||||
{rows.length === 0 ? (
|
||||
<p className="text-text-secondary text-sm">{t('superadmin.plans.storage.empty')}</p>
|
||||
) : null}
|
||||
{rows.map((row, idx) => (
|
||||
<div key={row.id} className="border-fill/60 bg-fill/5 rounded-xl border p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-1 space-y-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<label className="text-text text-xs font-medium">{t('superadmin.plans.storage.fields.id')}</label>
|
||||
<Input
|
||||
value={row.id}
|
||||
onInput={(e) => handleRowChange(row.id, { id: (e.target as HTMLInputElement).value })}
|
||||
placeholder="managed-5gb"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-text text-xs font-medium">{t('superadmin.plans.storage.fields.name')}</label>
|
||||
<Input
|
||||
value={row.name}
|
||||
onInput={(e) => handleRowChange(row.id, { name: (e.target as HTMLInputElement).value })}
|
||||
placeholder={t('superadmin.plans.storage.fields.placeholder.name')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-text text-xs font-medium">
|
||||
{t('superadmin.plans.storage.fields.description')}
|
||||
</label>
|
||||
<Textarea
|
||||
rows={2}
|
||||
value={row.description}
|
||||
onInput={(e) => handleRowChange(row.id, { description: (e.target as HTMLTextAreaElement).value })}
|
||||
placeholder={t('superadmin.plans.storage.fields.placeholder.description')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-text text-xs font-medium">
|
||||
{t('superadmin.plans.storage.fields.capacity')}
|
||||
</label>
|
||||
<Input
|
||||
inputMode="decimal"
|
||||
type="text"
|
||||
value={row.capacityGb}
|
||||
onInput={(e) => handleRowChange(row.id, { capacityGb: (e.target as HTMLInputElement).value })}
|
||||
placeholder="5.00"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-text text-xs font-medium">
|
||||
{t('superadmin.plans.storage.fields.price')}
|
||||
</label>
|
||||
<Input
|
||||
inputMode="decimal"
|
||||
type="text"
|
||||
value={row.monthlyPrice}
|
||||
onInput={(e) => handleRowChange(row.id, { monthlyPrice: (e.target as HTMLInputElement).value })}
|
||||
placeholder="0.99"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-text text-xs font-medium">
|
||||
{t('superadmin.plans.storage.fields.currency')}
|
||||
</label>
|
||||
<Input
|
||||
value={row.currency}
|
||||
onInput={(e) => handleRowChange(row.id, { currency: (e.target as HTMLInputElement).value })}
|
||||
placeholder="USD"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-text text-xs font-medium">
|
||||
{t('superadmin.plans.storage.fields.creem')}
|
||||
</label>
|
||||
<Input
|
||||
value={row.creemProductId}
|
||||
onInput={(e) => handleRowChange(row.id, { creemProductId: (e.target as HTMLInputElement).value })}
|
||||
placeholder="prod_xxx"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={row.isActive}
|
||||
onCheckedChange={(next) => handleRowChange(row.id, { isActive: next })}
|
||||
/>
|
||||
<span className="text-text-secondary text-sm">{t('superadmin.plans.storage.fields.active')}</span>
|
||||
</div>
|
||||
|
||||
<Button variant="ghost" onClick={() => handleRemove(row.id)} disabled={rows.length === 1}>
|
||||
{t('superadmin.plans.storage.actions.remove')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{errors[idx] && errors[idx]!.length > 0 ? (
|
||||
<ul className="text-red text-xs">
|
||||
{errors[idx]!.map((err) => (
|
||||
<li key={err}>{err}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{mutation.isError ? (
|
||||
<p className="text-red text-sm">
|
||||
{t('superadmin.plans.storage.error', {
|
||||
reason: mutation.error instanceof Error ? mutation.error.message : t('common.unknown-error'),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{mutation.isSuccess && !mutation.isPending ? (
|
||||
<p className="text-text-secondary text-sm">{t('superadmin.plans.storage.saved')}</p>
|
||||
) : null}
|
||||
</LinearBorderPanel>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { Spring } from '@afilmory/utils'
|
||||
import { m } from 'motion/react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { SuperAdminSettingsForm } from '~/modules/super-admin'
|
||||
import { PageTabs } from '~/components/navigation/PageTabs'
|
||||
import { ManagedStorageSettings, SuperAdminSettingsForm } from '~/modules/super-admin'
|
||||
|
||||
export function Component() {
|
||||
const { t } = useTranslation()
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'managed-storage'>('general')
|
||||
|
||||
return (
|
||||
<m.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
@@ -18,7 +22,20 @@ export function Component() {
|
||||
<p className="text-text-secondary text-sm">{t('superadmin.settings.description')}</p>
|
||||
</header>
|
||||
|
||||
<SuperAdminSettingsForm visibleSectionIds={['registration-control', 'oauth-providers']} />
|
||||
<PageTabs
|
||||
activeId={activeTab}
|
||||
onSelect={(id) => setActiveTab(id as typeof activeTab)}
|
||||
items={[
|
||||
{ id: 'general', labelKey: 'superadmin.settings.tabs.general' },
|
||||
{ id: 'managed-storage', labelKey: 'superadmin.settings.tabs.managed-storage' },
|
||||
]}
|
||||
/>
|
||||
|
||||
{activeTab === 'general' ? (
|
||||
<SuperAdminSettingsForm visibleSectionIds={['registration-control', 'oauth-providers']} />
|
||||
) : (
|
||||
<ManagedStorageSettings />
|
||||
)}
|
||||
</m.div>
|
||||
)
|
||||
}
|
||||
|
||||
1
be/packages/db/migrations/0006_quick_titania.sql
Normal file
1
be/packages/db/migrations/0006_quick_titania.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "tenant" ADD COLUMN "storage_plan_id" text;
|
||||
1593
be/packages/db/migrations/meta/0006_snapshot.json
Normal file
1593
be/packages/db/migrations/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,13 @@
|
||||
"when": 1763626275917,
|
||||
"tag": "0005_flawless_wild_pack",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1763656041992,
|
||||
"tag": "0006_quick_titania",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ export const tenants = pgTable(
|
||||
slug: text('slug').notNull(),
|
||||
name: text('name').notNull(),
|
||||
planId: text('plan_id').notNull().default('free'),
|
||||
storagePlanId: text('storage_plan_id'),
|
||||
banned: boolean('banned').notNull().default(false),
|
||||
status: tenantStatusEnum('status').notNull().default('inactive'),
|
||||
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(),
|
||||
|
||||
@@ -275,6 +275,25 @@
|
||||
"photos.library.tags.toast.success-description": "Storage paths have been updated for the new tag structure.",
|
||||
"photos.page.description": "Sync and manage photo assets on the server.",
|
||||
"photos.page.title": "Photo Library",
|
||||
"photos.storage.managed.actions.cancel": "Cancel managed storage",
|
||||
"photos.storage.managed.actions.current": "Current plan",
|
||||
"photos.storage.managed.actions.loading": "Updating…",
|
||||
"photos.storage.managed.actions.retry": "Try again",
|
||||
"photos.storage.managed.actions.subscribe": "Subscribe",
|
||||
"photos.storage.managed.actions.switch": "Switch to this plan",
|
||||
"photos.storage.managed.capacity.label": "Storage capacity: {{value}}",
|
||||
"photos.storage.managed.capacity.unknown": "Capacity information is not available.",
|
||||
"photos.storage.managed.capacity.unlimited": "Unlimited storage capacity",
|
||||
"photos.storage.managed.description": "Subscribe to managed storage plans powered by the platform’s shared provider—no self-hosted bucket required.",
|
||||
"photos.storage.managed.empty": "No managed storage plans are currently available.",
|
||||
"photos.storage.managed.error.load": "Failed to load managed storage plans.",
|
||||
"photos.storage.managed.price.free": "Included in your current plan",
|
||||
"photos.storage.managed.price.label": "{{price}} / month",
|
||||
"photos.storage.managed.provider": "Backed by provider {{provider}}",
|
||||
"photos.storage.managed.title": "Managed storage",
|
||||
"photos.storage.managed.toast.error": "Failed to update managed storage: {{reason}}",
|
||||
"photos.storage.managed.toast.success": "Managed storage plan updated.",
|
||||
"photos.storage.managed.unavailable": "Managed storage hasn’t been enabled for this workspace yet.",
|
||||
"photos.sync.actions.button.apply": "Sync photos",
|
||||
"photos.sync.actions.button.preview": "Preview sync",
|
||||
"photos.sync.actions.toast.apply-success": "Photo sync completed",
|
||||
@@ -716,11 +735,46 @@
|
||||
"superadmin.nav.settings": "System Settings",
|
||||
"superadmin.nav.tenants": "Tenants",
|
||||
"superadmin.plans.description": "Manage plan quotas, pricing, and Creem product mappings. Only super admins can edit these settings.",
|
||||
"superadmin.plans.storage.actions.add": "Add plan",
|
||||
"superadmin.plans.storage.actions.remove": "Remove",
|
||||
"superadmin.plans.storage.actions.save": "Save storage plans",
|
||||
"superadmin.plans.storage.description": "Create managed storage plans without editing JSON. One plan per tenant; capacity stacks with app plan bundled storage.",
|
||||
"superadmin.plans.storage.empty": "No storage plans yet. Add one to get started.",
|
||||
"superadmin.plans.storage.error": "Failed to load storage plans: {{reason}}",
|
||||
"superadmin.plans.storage.fields.active": "Expose this plan",
|
||||
"superadmin.plans.storage.fields.capacity": "Capacity (GB)",
|
||||
"superadmin.plans.storage.fields.creem": "Creem product ID",
|
||||
"superadmin.plans.storage.fields.currency": "Currency",
|
||||
"superadmin.plans.storage.fields.description": "Description",
|
||||
"superadmin.plans.storage.fields.id": "Plan ID",
|
||||
"superadmin.plans.storage.fields.name": "Display name",
|
||||
"superadmin.plans.storage.fields.placeholder.description": "Who is this plan for? What's included?",
|
||||
"superadmin.plans.storage.fields.placeholder.name": "Managed B2 • 5GB",
|
||||
"superadmin.plans.storage.fields.price": "Monthly price",
|
||||
"superadmin.plans.storage.saved": "Storage plans updated",
|
||||
"superadmin.plans.storage.title": "Storage plans",
|
||||
"superadmin.plans.storage.validation.block": "Fix validation errors before saving.",
|
||||
"superadmin.plans.storage.validation.capacity": "Capacity must be a number in GB.",
|
||||
"superadmin.plans.storage.validation.id": "Plan ID is required.",
|
||||
"superadmin.plans.storage.validation.name": "Display name is required.",
|
||||
"superadmin.plans.storage.validation.price": "Price must be a number.",
|
||||
"superadmin.plans.tabs.app": "App plans",
|
||||
"superadmin.plans.tabs.storage": "Storage plans",
|
||||
"superadmin.plans.title": "Plan Configuration",
|
||||
"superadmin.settings.button.loading": "Saving…",
|
||||
"superadmin.settings.button.save": "Save changes",
|
||||
"superadmin.settings.description": "Control platform-wide registration policies and local sign-in channels. Managed centrally by super admins.",
|
||||
"superadmin.settings.error.loading": "Unable to load super admin settings: {{reason}}",
|
||||
"superadmin.settings.managed-storage.actions.edit": "Edit",
|
||||
"superadmin.settings.managed-storage.actions.save": "Save managed provider",
|
||||
"superadmin.settings.managed-storage.actions.select": "Set as managed",
|
||||
"superadmin.settings.managed-storage.actions.selected": "Selected",
|
||||
"superadmin.settings.managed-storage.current": "Managed",
|
||||
"superadmin.settings.managed-storage.description": "Configure the storage provider used for built-in managed storage (e.g., B2).",
|
||||
"superadmin.settings.managed-storage.empty": "No storage providers yet. Add one to use as managed storage.",
|
||||
"superadmin.settings.managed-storage.error": "Failed to load storage providers: {{reason}}",
|
||||
"superadmin.settings.managed-storage.title": "Managed storage provider",
|
||||
"superadmin.settings.managed-storage.type": "Type: {{type}}",
|
||||
"superadmin.settings.message.dirty": "You have unsaved changes",
|
||||
"superadmin.settings.message.error": "Failed to save settings: {{reason}}",
|
||||
"superadmin.settings.message.idle": "All settings are up to date",
|
||||
@@ -730,6 +784,8 @@
|
||||
"superadmin.settings.stats.remaining": "Remaining registration slots",
|
||||
"superadmin.settings.stats.total-users": "Total users",
|
||||
"superadmin.settings.stats.unlimited": "Unlimited",
|
||||
"superadmin.settings.tabs.general": "General",
|
||||
"superadmin.settings.tabs.managed-storage": "Managed storage",
|
||||
"superadmin.settings.title": "System Settings",
|
||||
"superadmin.tenants.button.ban": "Ban",
|
||||
"superadmin.tenants.button.processing": "Working…",
|
||||
|
||||
@@ -275,6 +275,25 @@
|
||||
"photos.library.tags.toast.success-description": "已根据新标签更新存储路径。",
|
||||
"photos.page.description": "在此同步和管理服务器中的照片资产。",
|
||||
"photos.page.title": "照片库",
|
||||
"photos.storage.managed.actions.cancel": "取消托管存储",
|
||||
"photos.storage.managed.actions.current": "当前方案",
|
||||
"photos.storage.managed.actions.loading": "处理中…",
|
||||
"photos.storage.managed.actions.retry": "重试",
|
||||
"photos.storage.managed.actions.subscribe": "立即订阅",
|
||||
"photos.storage.managed.actions.switch": "切换到此方案",
|
||||
"photos.storage.managed.capacity.label": "可用容量:{{value}}",
|
||||
"photos.storage.managed.capacity.unknown": "暂无法获取该方案的容量信息。",
|
||||
"photos.storage.managed.capacity.unlimited": "容量无限制",
|
||||
"photos.storage.managed.description": "订阅由平台托管的存储方案,无需自建对象存储即可扩容。",
|
||||
"photos.storage.managed.empty": "当前没有可订阅的托管存储方案。",
|
||||
"photos.storage.managed.error.load": "无法加载托管存储方案。",
|
||||
"photos.storage.managed.price.free": "包含在当前订阅内",
|
||||
"photos.storage.managed.price.label": "{{price}} / 月",
|
||||
"photos.storage.managed.provider": "托管服务提供方:{{provider}}",
|
||||
"photos.storage.managed.title": "托管存储",
|
||||
"photos.storage.managed.toast.error": "更新托管存储方案失败:{{reason}}",
|
||||
"photos.storage.managed.toast.success": "托管存储方案已更新。",
|
||||
"photos.storage.managed.unavailable": "托管存储尚未启用,请联系管理员。",
|
||||
"photos.sync.actions.button.apply": "同步照片",
|
||||
"photos.sync.actions.button.preview": "预览同步",
|
||||
"photos.sync.actions.toast.apply-success": "照片同步完成",
|
||||
@@ -715,11 +734,46 @@
|
||||
"superadmin.nav.settings": "系统设置",
|
||||
"superadmin.nav.tenants": "租户管理",
|
||||
"superadmin.plans.description": "管理各个订阅计划的资源配额、定价信息与 Creem Product 连接,仅超级管理员可编辑。",
|
||||
"superadmin.plans.storage.actions.add": "新增计划",
|
||||
"superadmin.plans.storage.actions.remove": "删除",
|
||||
"superadmin.plans.storage.actions.save": "保存存储计划",
|
||||
"superadmin.plans.storage.description": "直接编辑托管存储方案,无需填 JSON。每个租户仅一份存储计划,会与应用计划的赠送存储叠加。",
|
||||
"superadmin.plans.storage.empty": "还没有存储计划,请先新增一条。",
|
||||
"superadmin.plans.storage.error": "加载存储计划失败:{{reason}}",
|
||||
"superadmin.plans.storage.fields.active": "在前台展示该计划",
|
||||
"superadmin.plans.storage.fields.capacity": "容量 (GB)",
|
||||
"superadmin.plans.storage.fields.creem": "Creem 商品 ID",
|
||||
"superadmin.plans.storage.fields.currency": "币种",
|
||||
"superadmin.plans.storage.fields.description": "描述",
|
||||
"superadmin.plans.storage.fields.id": "计划 ID",
|
||||
"superadmin.plans.storage.fields.name": "展示名称",
|
||||
"superadmin.plans.storage.fields.placeholder.description": "适用人群与包含的内容",
|
||||
"superadmin.plans.storage.fields.placeholder.name": "托管 B2 · 5GB",
|
||||
"superadmin.plans.storage.fields.price": "月费",
|
||||
"superadmin.plans.storage.saved": "存储计划已更新",
|
||||
"superadmin.plans.storage.title": "存储计划",
|
||||
"superadmin.plans.storage.validation.block": "请先修复校验错误后再保存。",
|
||||
"superadmin.plans.storage.validation.capacity": "容量必须是数字(GB)。",
|
||||
"superadmin.plans.storage.validation.id": "计划 ID 必填。",
|
||||
"superadmin.plans.storage.validation.name": "展示名称必填。",
|
||||
"superadmin.plans.storage.validation.price": "价格必须是数字。",
|
||||
"superadmin.plans.tabs.app": "应用计划",
|
||||
"superadmin.plans.tabs.storage": "存储计划",
|
||||
"superadmin.plans.title": "订阅计划配置",
|
||||
"superadmin.settings.button.loading": "保存中...",
|
||||
"superadmin.settings.button.save": "保存修改",
|
||||
"superadmin.settings.description": "管理整个平台的注册策略与本地登录渠道,由超级管理员统一维护。",
|
||||
"superadmin.settings.error.loading": "无法加载超级管理员设置:{{reason}}",
|
||||
"superadmin.settings.managed-storage.actions.edit": "编辑",
|
||||
"superadmin.settings.managed-storage.actions.save": "保存托管 Provider",
|
||||
"superadmin.settings.managed-storage.actions.select": "设为托管",
|
||||
"superadmin.settings.managed-storage.actions.selected": "已选择",
|
||||
"superadmin.settings.managed-storage.current": "托管使用",
|
||||
"superadmin.settings.managed-storage.description": "配置用于内置托管存储的 Provider(例如 B2)。",
|
||||
"superadmin.settings.managed-storage.empty": "还没有存储提供商,请先新增。",
|
||||
"superadmin.settings.managed-storage.error": "加载存储提供商失败:{{reason}}",
|
||||
"superadmin.settings.managed-storage.title": "托管存储 Provider",
|
||||
"superadmin.settings.managed-storage.type": "类型:{{type}}",
|
||||
"superadmin.settings.message.dirty": "您有尚未保存的变更",
|
||||
"superadmin.settings.message.error": "保存失败:{{reason}}",
|
||||
"superadmin.settings.message.idle": "所有设置已同步",
|
||||
@@ -729,6 +783,8 @@
|
||||
"superadmin.settings.stats.remaining": "剩余可注册名额",
|
||||
"superadmin.settings.stats.total-users": "当前用户总数",
|
||||
"superadmin.settings.stats.unlimited": "不限",
|
||||
"superadmin.settings.tabs.general": "通用",
|
||||
"superadmin.settings.tabs.managed-storage": "托管存储",
|
||||
"superadmin.settings.title": "系统设置",
|
||||
"superadmin.tenants.button.ban": "封禁",
|
||||
"superadmin.tenants.button.processing": "处理中…",
|
||||
|
||||
Reference in New Issue
Block a user