fix(license): 计划摘要用可读文案,禁止裸打 planType
CI / Backend (go vet + test) (push) Waiting to run
CI / Frontend (lint + build + test) (push) Waiting to run

trial 真码可能回 monthly;设置页映射为体验/包年/永久等 i18n,
短窗 monthly 软纠为体验;customer 空不渲染;到期仍用 expiresAt。
This commit is contained in:
opsx committed 2026-11-01 04:00:12 +08:00
1 parent a6b0be9698
commit 8606b1ee31
5 files changed
+120 -2

No files matched your search

+6
View File
@@ -14,6 +14,12 @@ export default {
copy: 'Copy', copy: 'Copy',
activateHint: 'Online keys start with LIC-; offline LICOFF- keys must bind the Instance ID above', activateHint: 'Online keys start with LIC-; offline LICOFF- keys must bind the Instance ID above',
permanent: 'perpetual', permanent: 'perpetual',
plan: {
trial: 'Trial',
monthly: 'Monthly',
yearly: 'Yearly',
perpetual: 'Perpetual',
},
personalTitle: 'Personal', personalTitle: 'Personal',
teamTitle: 'Team', teamTitle: 'Team',
keyPlaceholder: 'Enter LIC- / LICOFF- key to activate Pro…', keyPlaceholder: 'Enter LIC- / LICOFF- key to activate Pro…',
+12
View File
@@ -14,6 +14,12 @@ export default {
copy: '复制', copy: '复制',
activateHint: '在线码以 LIC- 开头;离线码 LICOFF-,须与上方实例 ID 绑定', activateHint: '在线码以 LIC- 开头;离线码 LICOFF-,须与上方实例 ID 绑定',
permanent: '永久', permanent: '永久',
plan: {
trial: '体验',
monthly: '包月',
yearly: '包年',
perpetual: '永久',
},
personalTitle: '个人版', personalTitle: '个人版',
teamTitle: '团队版', teamTitle: '团队版',
keyPlaceholder: '输入 LIC- / LICOFF- 许可码激活 Pro…', keyPlaceholder: '输入 LIC- / LICOFF- 许可码激活 Pro…',
@@ -60,5 +66,11 @@ export default {
teamTitle: '团队版', teamTitle: '团队版',
teamDesc: '多实例 · 商业使用授权 · 优先支持', teamDesc: '多实例 · 商业使用授权 · 优先支持',
permanent: '永久', permanent: '永久',
plan: {
trial: '体验',
monthly: '包月',
yearly: '包年',
perpetual: '永久',
},
}, },
} }
+31
View File
@@ -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()
})
})
+59
View File
@@ -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 { licenseApi } from '@/api/license'
import { useLicense } from '@/hooks/useLicense' import { useLicense } from '@/hooks/useLicense'
import { getUser } from '@/lib/auth-storage' import { getUser } from '@/lib/auth-storage'
import { licensePlanI18nKey, resolveLicensePlanKind } from '@/lib/license-plan'
/** 授权区块:免费态激活 / Pro 态退出(仅管理员;未授权不渲染退出) */ /** 授权区块:免费态激活 / Pro 态退出(仅管理员;未授权不渲染退出) */
export const LicenseSection = memo(function LicenseSection() { export const LicenseSection = memo(function LicenseSection() {
@@ -71,6 +72,15 @@ export const LicenseSection = memo(function LicenseSection() {
: t('permanent') : t('permanent')
: null : 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 instanceLimit = info?.maxDevices || info?.instances
const inGrace = Boolean(info?.inGrace) const inGrace = Boolean(info?.inGrace)
@@ -96,9 +106,9 @@ export const LicenseSection = memo(function LicenseSection() {
<div className="text-sm"> <div className="text-sm">
{pro ? ( {pro ? (
<div className="grid gap-1 text-muted-foreground sm:grid-cols-2"> <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> <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) && ( {Boolean(instanceLimit) && (
<p>{t('instances')}: {instanceLimit}</p> <p>{t('instances')}: {instanceLimit}</p>
)} )}