diff --git a/frontend/src/i18n/locales/en/license.ts b/frontend/src/i18n/locales/en/license.ts index 9f90191..45fb6f5 100644 --- a/frontend/src/i18n/locales/en/license.ts +++ b/frontend/src/i18n/locales/en/license.ts @@ -14,6 +14,12 @@ export default { copy: 'Copy', activateHint: 'Online keys start with LIC-; offline LICOFF- keys must bind the Instance ID above', permanent: 'perpetual', + plan: { + trial: 'Trial', + monthly: 'Monthly', + yearly: 'Yearly', + perpetual: 'Perpetual', + }, personalTitle: 'Personal', teamTitle: 'Team', keyPlaceholder: 'Enter LIC- / LICOFF- key to activate Pro…', diff --git a/frontend/src/i18n/locales/zh/license.ts b/frontend/src/i18n/locales/zh/license.ts index 9b0d77c..c2e093c 100644 --- a/frontend/src/i18n/locales/zh/license.ts +++ b/frontend/src/i18n/locales/zh/license.ts @@ -14,6 +14,12 @@ export default { copy: '复制', activateHint: '在线码以 LIC- 开头;离线码 LICOFF-,须与上方实例 ID 绑定', permanent: '永久', + plan: { + trial: '体验', + monthly: '包月', + yearly: '包年', + perpetual: '永久', + }, personalTitle: '个人版', teamTitle: '团队版', keyPlaceholder: '输入 LIC- / LICOFF- 许可码激活 Pro…', @@ -60,5 +66,11 @@ export default { teamTitle: '团队版', teamDesc: '多实例 · 商业使用授权 · 优先支持', permanent: '永久', + plan: { + trial: '体验', + monthly: '包月', + yearly: '包年', + perpetual: '永久', + }, }, } diff --git a/frontend/src/lib/license-plan.test.ts b/frontend/src/lib/license-plan.test.ts new file mode 100644 index 0000000..1befef5 --- /dev/null +++ b/frontend/src/lib/license-plan.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { licensePlanI18nKey, resolveLicensePlanKind } from './license-plan' + +describe('resolveLicensePlanKind', () => { + it('maps canonical planType', () => { + expect(resolveLicensePlanKind({ planType: 'trial' })).toBe('trial') + expect(resolveLicensePlanKind({ planType: 'yearly' })).toBe('yearly') + expect(resolveLicensePlanKind({ planType: 'perpetual' })).toBe('perpetual') + }) + + it('soft-corrects monthly + short window to trial', () => { + const in14d = Math.floor(Date.now() / 1000) + 14 * 86400 + expect(resolveLicensePlanKind({ planType: 'monthly', expiresAt: in14d })).toBe('trial') + }) + + it('keeps monthly when window looks like a month', () => { + const in30d = Math.floor(Date.now() / 1000) + 30 * 86400 + expect(resolveLicensePlanKind({ planType: 'monthly', expiresAt: in30d })).toBe('monthly') + }) + + it('treats expiresAt<=0 as perpetual when planType empty', () => { + expect(resolveLicensePlanKind({ expiresAt: 0 })).toBe('perpetual') + }) +}) + +describe('licensePlanI18nKey', () => { + it('returns plan.* keys', () => { + expect(licensePlanI18nKey('trial')).toBe('plan.trial') + expect(licensePlanI18nKey('unknown')).toBeNull() + }) +}) diff --git a/frontend/src/lib/license-plan.ts b/frontend/src/lib/license-plan.ts new file mode 100644 index 0000000..d5039ab --- /dev/null +++ b/frontend/src/lib/license-plan.ts @@ -0,0 +1,59 @@ +import type { LicenseInfo } from '@/api/license' + +/** Canonical plan kinds we show in settings. */ +export type LicensePlanKind = 'trial' | 'monthly' | 'yearly' | 'perpetual' | 'unknown' + +const DAY_MS = 86_400_000 + +/** + * Resolve a readable plan kind from license info. + * Prefer explicit planType (trial/yearly/perpetual); never surface raw API literals in UI. + * Soft-correct: some opsx verify responses label short trial keys as "monthly" — + * when remaining window is ≤21d, treat as trial. expiresAt stays the source of truth for dates. + */ +export function resolveLicensePlanKind(info?: Pick | null): LicensePlanKind { + if (!info) return 'unknown' + const raw = (info.planType || '').trim().toLowerCase() + + if (raw === 'trial') return 'trial' + if (raw === 'yearly' || raw === 'annual' || raw === 'year') return 'yearly' + if (raw === 'perpetual' || raw === 'permanent' || raw === 'lifetime') return 'perpetual' + if (raw === 'monthly' || raw === 'month') { + const daysLeft = daysUntilExpiry(info.expiresAt) + if (daysLeft != null && daysLeft > 0 && daysLeft <= 21) return 'trial' + return 'monthly' + } + + // No / unknown planType: infer lightly from expiry window, else unknown (UI falls back to edition). + const daysLeft = daysUntilExpiry(info.expiresAt) + if (info.expiresAt != null && info.expiresAt <= 0) return 'perpetual' + if (daysLeft != null) { + if (daysLeft > 0 && daysLeft <= 21) return 'trial' + if (daysLeft > 21 && daysLeft <= 45) return 'monthly' + if (daysLeft > 45 && daysLeft <= 400) return 'yearly' + if (daysLeft > 400) return 'perpetual' + } + return 'unknown' +} + +function daysUntilExpiry(expiresAt?: number): number | null { + if (expiresAt == null) return null + if (expiresAt <= 0) return null + return (expiresAt * 1000 - Date.now()) / DAY_MS +} + +/** i18n key under namespace `license` for the plan value, or null → use edition fallback. */ +export function licensePlanI18nKey(kind: LicensePlanKind): string | null { + switch (kind) { + case 'trial': + return 'plan.trial' + case 'monthly': + return 'plan.monthly' + case 'yearly': + return 'plan.yearly' + case 'perpetual': + return 'plan.perpetual' + default: + return null + } +} diff --git a/frontend/src/pages/settings/components/LicenseSection.tsx b/frontend/src/pages/settings/components/LicenseSection.tsx index 6d4da2b..a2e6b4b 100644 --- a/frontend/src/pages/settings/components/LicenseSection.tsx +++ b/frontend/src/pages/settings/components/LicenseSection.tsx @@ -12,6 +12,7 @@ import { LicenseStatusBanners } from '@/components/LicenseStatusBanners' import { licenseApi } from '@/api/license' import { useLicense } from '@/hooks/useLicense' import { getUser } from '@/lib/auth-storage' +import { licensePlanI18nKey, resolveLicensePlanKind } from '@/lib/license-plan' /** 授权区块:免费态激活 / Pro 态退出(仅管理员;未授权不渲染退出) */ export const LicenseSection = memo(function LicenseSection() { @@ -71,6 +72,15 @@ export const LicenseSection = memo(function LicenseSection() { : t('permanent') : null + // 可读计划名;禁止裸打 API planType(trial 可能误回 monthly) + const planKind = resolveLicensePlanKind(info) + const planKey = licensePlanI18nKey(planKind) + const planLabel = planKey + ? t(planKey) + : info?.edition === 'team' + ? t('teamTitle') + : t('personalTitle') + const instanceLimit = info?.maxDevices || info?.instances const inGrace = Boolean(info?.inGrace) @@ -96,9 +106,9 @@ export const LicenseSection = memo(function LicenseSection() {
{pro ? (
-

{t('edition')}: {info?.edition === 'team' ? t('teamTitle') : t('personalTitle')}

+

{t('edition')}: {planLabel}

{t('expiry')}: {expiry}

- {info?.customer &&

{t('customer')}: {info.customer}

} + {Boolean(info?.customer?.trim()) &&

{t('customer')}: {info?.customer}

} {Boolean(instanceLimit) && (

{t('instances')}: {instanceLimit}

)}