fix(license): 计划摘要用可读文案,禁止裸打 planType
trial 真码可能回 monthly;设置页映射为体验/包年/永久等 i18n, 短窗 monthly 软纠为体验;customer 空不渲染;到期仍用 expiresAt。
This commit is contained in:
1 parent
a6b0be9698
commit
8606b1ee31
5 files changed
+120
-2
No files matched your search
@@ -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…',
|
||||
|
||||
@@ -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: '永久',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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<LicenseInfo, 'planType' | 'expiresAt' | 'edition'> | 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
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
<div className="text-sm">
|
||||
{pro ? (
|
||||
<div className="grid gap-1 text-muted-foreground sm:grid-cols-2">
|
||||
<p>{t('edition')}: {info?.edition === 'team' ? t('teamTitle') : t('personalTitle')}</p>
|
||||
<p>{t('edition')}: {planLabel}</p>
|
||||
<p>{t('expiry')}: {expiry}</p>
|
||||
{info?.customer && <p>{t('customer')}: {info.customer}</p>}
|
||||
{Boolean(info?.customer?.trim()) && <p>{t('customer')}: {info?.customer}</p>}
|
||||
{Boolean(instanceLimit) && (
|
||||
<p>{t('instances')}: {instanceLimit}</p>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user