44 * @ai -context: 用户输入激活码(ENTROPY-{TYPE}-{XXXX}-{XXXX})升级付费 tier。
55 * 本地优先:首次验证需联网,成功后本地缓存 7 天宽限期。
66 * 激活码格式:ENTROPY-PRO-XXXX-XXXX(订阅)/ ENTROPY-LIFE-XXXX-XXXX(终身)
7+ * @ai -context: machine_id 取主进程设备指纹(electronAPI.getMachineId),
8+ * 服务端据此做一码多设备绑定(防分享);到期前 3 天展示续费提醒。
79 */
810import { useState } from 'react' ;
911import { Key , Check , AlertTriangle , Sparkles , ExternalLink } from 'lucide-react' ;
@@ -24,6 +26,22 @@ const LICENSE_TYPE_TO_TIER: Record<string, UserTier> = {
2426 THM1 : 'free' , // 主题包不升级 tier
2527} ;
2628
29+ /** 剩余天数(不足 1 天按 0 处理) */
30+ function daysRemaining ( expiresAt : string ) : number {
31+ return Math . ceil ( ( new Date ( expiresAt ) . getTime ( ) - Date . now ( ) ) / 86_400_000 ) ;
32+ }
33+
34+ /** 获取设备指纹(Electron 主进程;Web 环境降级为固定占位符) */
35+ async function getDeviceMachineId ( ) : Promise < string > {
36+ try {
37+ const mid = await window . electronAPI ?. getMachineId ?.( ) ;
38+ if ( mid ) return mid ;
39+ } catch {
40+ // 主进程不可用(Web 预览等)时降级,服务端按单设备兼容处理
41+ }
42+ return 'web' ;
43+ }
44+
2745export function LicenseActivation ( ) {
2846 const { toast } = useToast ( ) ;
2947 const { getAccessToken } = useAuth ( ) ;
@@ -62,6 +80,9 @@ export function LicenseActivation() {
6280 // 提取类型(trimmed 已 toUpperCase,type 为大写字符串如 'PRO'/'LIFE')
6381 const type = trimmed . split ( '-' ) [ 1 ] ;
6482
83+ // 设备指纹(一码多设备绑定;Web 环境降级 'web')
84+ const machineId = await getDeviceMachineId ( ) ;
85+
6586 // 调服务端验证激活码
6687 const token = await getAccessToken ( ) ;
6788 const resp = await fetch ( '/api/v1/license/activate' , {
@@ -72,7 +93,7 @@ export function LicenseActivation() {
7293 } ,
7394 body : JSON . stringify ( {
7495 code : trimmed ,
75- machine_id : 'local' ,
96+ machine_id : machineId ,
7697 } ) ,
7798 } ) ;
7899
@@ -84,18 +105,19 @@ export function LicenseActivation() {
84105 const data = await resp . json ( ) ;
85106 const tier = LICENSE_TYPE_TO_TIER [ type ] ?? 'free' ;
86107
108+ // 服务端返回的到期时间优先(池记录 duration_days 为准,客户端不再自行 30 天)
109+ const serverExpiresAt = typeof data . expires_at === 'string' ? data . expires_at : undefined ;
110+
87111 // 创建本地激活码记录
88112 const license : License = {
89113 id : crypto . randomUUID ( ) ,
90114 code : trimmed ,
91115 type : type . toLowerCase ( ) as LicenseType ,
92116 tier,
93117 status : 'active' ,
94- machineId : 'local' ,
118+ machineId,
95119 activatedAt : new Date ( ) . toISOString ( ) ,
96- expiresAt : type === 'PRO' // 使用大写常量比较
97- ? new Date ( Date . now ( ) + 30 * 24 * 60 * 60 * 1000 ) . toISOString ( )
98- : data . expires_at || undefined ,
120+ expiresAt : serverExpiresAt ,
99121 syncedAt : new Date ( ) . toISOString ( ) ,
100122 } ;
101123
@@ -121,20 +143,34 @@ export function LicenseActivation() {
121143 const activeLicense = activeLicenses . find (
122144 ( l ) => ( l . tier === 'pro' || l . tier === 'lifetime' ) && l . status === 'active' ,
123145 ) ;
146+ const expiresAt = activeLicense ?. expiresAt ;
147+ const remainingDays = expiresAt ? daysRemaining ( expiresAt ) : 0 ;
148+ const isAnnual = ! ! expiresAt && remainingDays >= 300 ; // 年付方案识别(服务端时长为准)
149+ const expiringSoon = ! ! expiresAt && remainingDays > 0 && remainingDays <= 3 ;
124150 return (
125- < div className = "flex items-center gap-2 p-3 rounded-kb-md bg-semantic-success/5 border border-semantic-success/20" >
126- < Sparkles className = "w-4 h-4 text-semantic-success flex-shrink-0" strokeWidth = { 1.5 } />
127- < div className = "flex-1 min-w-0" >
128- < p className = "text-b3 font-medium text-semantic-success" >
129- { activeLicense ?. tier === 'lifetime' ? '终身 Pro 已激活' : 'Pro 已激活' }
130- </ p >
131- { activeLicense ?. expiresAt && (
132- < p className = "text-c1 text-text-tertiary" >
133- 有效期至 { new Date ( activeLicense . expiresAt ) . toLocaleDateString ( 'zh-CN' ) }
151+ < div className = "flex flex-col gap-2 p-3 rounded-kb-md bg-semantic-success/5 border border-semantic-success/20" >
152+ < div className = "flex items-center gap-2" >
153+ < Sparkles className = "w-4 h-4 text-semantic-success flex-shrink-0" strokeWidth = { 1.5 } />
154+ < div className = "flex-1 min-w-0" >
155+ < p className = "text-b3 font-medium text-semantic-success" >
156+ { activeLicense ?. tier === 'lifetime' ? '终身 Pro 已激活' : `Pro 已激活${ isAnnual ? '(年付方案)' : '' } ` }
134157 </ p >
135- ) }
158+ { expiresAt && (
159+ < p className = "text-c1 text-text-tertiary" >
160+ 有效期至 { new Date ( expiresAt ) . toLocaleDateString ( 'zh-CN' ) }
161+ { activeLicense ?. tier !== 'lifetime' && `(剩余 ${ remainingDays } 天)` }
162+ </ p >
163+ ) }
164+ </ div >
165+ < span className = "text-c1 text-semantic-success" > 已激活</ span >
136166 </ div >
137- < span className = "text-c1 text-semantic-success" > 已激活</ span >
167+ { /* 到期前 3 天温和提醒(非阻断) */ }
168+ { expiringSoon && (
169+ < div className = "flex items-center gap-1.5 text-c1 text-semantic-warning" >
170+ < AlertTriangle className = "w-3.5 h-3.5 flex-shrink-0" strokeWidth = { 1.5 } />
171+ 订阅将于 { remainingDays } 天后到期,续费保持 Pro 权益
172+ </ div >
173+ ) }
138174 </ div >
139175 ) ;
140176 }
0 commit comments