1+ /**
2+ * 内测身份卡片 — 设置页嵌入
3+ *
4+ * @ai -context: 展示用户的内测层级、加入时间、徽章、权益摘要。
5+ * 核心层用户可在此查看/复制邀请码。
6+ * 所有用户可在此输入激活码升级 Pro。
7+ */
8+ import { useState } from 'react' ;
9+ import { Shield , Check , Gift , Key , Users , ChevronDown , ChevronUp } from 'lucide-react' ;
10+ import { Card , Button , Input , useToast } from '@/components/ui' ;
11+ import { cn } from '@/lib/utils' ;
12+ import { useAuth } from '@/lib/auth/AuthContext' ;
13+ import { useBetaStore } from '@/features/beta/betaStore' ;
14+ import { TIER_LABELS , TIER_COLORS , type UserTier } from '@/types/beta' ;
15+ import { InviteCodeSection } from './InviteCodeSection' ;
16+ import { LicenseActivation } from './LicenseActivation' ;
17+
18+ export function BetaProfile ( ) {
19+ const { user } = useAuth ( ) ;
20+ const { toast } = useToast ( ) ;
21+ const {
22+ betaProfile,
23+ effectiveTier,
24+ activeLicenses,
25+ } = useBetaStore ( ) ;
26+
27+ const [ expanded , setExpanded ] = useState ( false ) ;
28+
29+ // 是否显示内测面板:有内测身份 或 有付费许可
30+ const hasBeta = ! ! betaProfile ;
31+ const hasLicense = activeLicenses . length > 0 ;
32+
33+ if ( ! user ) return null ;
34+
35+ // 既不是内测用户也没有付费许可 → 显示邀请码输入(轻量版)
36+ if ( ! hasBeta && ! hasLicense ) {
37+ return < BetaInvitePrompt /> ;
38+ }
39+
40+ return (
41+ < Card padding = "md" className = "flex flex-col gap-kb-md" >
42+ { /* 标题行 */ }
43+ < div className = "flex items-center justify-between" >
44+ < div className = "flex items-center gap-2" >
45+ < Shield className = "w-icon-sm h-icon-sm text-brand-500" strokeWidth = { 1.5 } />
46+ < h2 className = "text-b1 font-semibold text-text-primary" > 我的身份</ h2 >
47+ </ div >
48+ < button
49+ type = "button"
50+ onClick = { ( ) => setExpanded ( ! expanded ) }
51+ className = "flex items-center gap-1 text-b3 text-text-tertiary hover:text-text-secondary transition-colors"
52+ >
53+ { expanded ? '收起' : '展开' }
54+ { expanded ? (
55+ < ChevronUp className = "w-3.5 h-3.5" strokeWidth = { 1.5 } />
56+ ) : (
57+ < ChevronDown className = "w-3.5 h-3.5" strokeWidth = { 1.5 } />
58+ ) }
59+ </ button >
60+ </ div >
61+
62+ { /* 身份摘要 */ }
63+ < div className = "flex items-center gap-3 p-3 rounded-kb-md bg-bg-elevated border border-border-default" >
64+ { /* Tier 层级标识 */ }
65+ < div className = { cn (
66+ 'w-10 h-10 rounded-kb-full flex items-center justify-center flex-shrink-0' ,
67+ effectiveTier === 'lifetime' || effectiveTier === 'core'
68+ ? 'bg-semantic-warning/10'
69+ : effectiveTier === 'pro'
70+ ? 'bg-brand-50 dark:bg-brand-900/20'
71+ : 'bg-bg-tertiary' ,
72+ ) } >
73+ < Shield className = { cn (
74+ 'w-5 h-5' ,
75+ effectiveTier === 'lifetime' || effectiveTier === 'core'
76+ ? 'text-semantic-warning'
77+ : effectiveTier === 'pro'
78+ ? 'text-brand-500'
79+ : 'text-text-tertiary' ,
80+ ) } strokeWidth = { 1.5 } />
81+ </ div >
82+ < div className = "flex-1 min-w-0" >
83+ < p className = { cn ( 'text-b1 font-semibold' , TIER_COLORS [ effectiveTier ] ) } >
84+ { TIER_LABELS [ effectiveTier ] }
85+ </ p >
86+ < p className = "text-c1 text-text-tertiary" >
87+ { betaProfile ?. joinedAt
88+ ? `加入于 ${ new Date ( betaProfile . joinedAt ) . toLocaleDateString ( 'zh-CN' ) } `
89+ : activeLicenses . length > 0
90+ ? '付费用户'
91+ : '' }
92+ { betaProfile ?. cohort && ` · 第 ${ betaProfile . cohort } 批` }
93+ </ p >
94+ </ div >
95+ { /* 徽章 */ }
96+ { betaProfile && betaProfile . badges . length > 0 && (
97+ < div className = "flex items-center gap-1" >
98+ { betaProfile . badges . slice ( 0 , 3 ) . map ( ( badge ) => (
99+ < span
100+ key = { badge }
101+ className = "w-6 h-6 rounded-kb-full bg-brand-50 dark:bg-brand-900/20 flex items-center justify-center text-c1"
102+ title = { badge }
103+ >
104+ { badge === 'founder' ? '🪼' : badge === 'bug-hunter' ? '🐛' : badge === 'idea-lighthouse' ? '💡' : '🌟' }
105+ </ span >
106+ ) ) }
107+ { betaProfile . badges . length > 3 && (
108+ < span className = "text-c1 text-text-tertiary" > +{ betaProfile . badges . length - 3 } </ span >
109+ ) }
110+ </ div >
111+ ) }
112+ </ div >
113+
114+ { /* 展开面板 */ }
115+ { expanded && (
116+ < div className = "space-y-4" >
117+ { /* 权益摘要 */ }
118+ < div className = "grid grid-cols-2 gap-2" >
119+ < PerkBadge
120+ label = "每日 AI 配额"
121+ value = { effectiveTier === 'lifetime' || effectiveTier === 'core' ? '120次' : effectiveTier === 'pro' || effectiveTier === 'active' ? '80次' : '50次' }
122+ />
123+ < PerkBadge
124+ label = "可用模型"
125+ value = { effectiveTier === 'lifetime' || effectiveTier === 'core' ? '全部模型' : effectiveTier === 'pro' || effectiveTier === 'active' ? '高级模型' : '基础模型' }
126+ />
127+ < PerkBadge
128+ label = "多模态"
129+ value = { effectiveTier === 'lifetime' || effectiveTier === 'core' || effectiveTier === 'active' ? '✅ 已开启' : '❌ 未开启' }
130+ />
131+ < PerkBadge
132+ label = "抢先体验"
133+ value = { effectiveTier === 'lifetime' || effectiveTier === 'core' ? '提前5天' : effectiveTier === 'active' ? '提前3天' : '同步发布' }
134+ />
135+ </ div >
136+
137+ { /* 内测用户专属:邀请码管理 */ }
138+ { betaProfile && ( betaProfile . tier === 'core' || betaProfile . tier === 'active' ) && (
139+ < InviteCodeSection />
140+ ) }
141+
142+ { /* 激活码输入(所有用户可见) */ }
143+ < LicenseActivation />
144+
145+ { /* 已有激活码列表 */ }
146+ { activeLicenses . length > 0 && (
147+ < div className = "space-y-1" >
148+ < p className = "text-b3 text-text-tertiary" > 已激活的许可</ p >
149+ { activeLicenses . map ( ( license ) => (
150+ < div
151+ key = { license . id }
152+ className = "flex items-center justify-between p-2 rounded-kb-sm bg-bg-tertiary/50"
153+ >
154+ < div className = "flex items-center gap-2" >
155+ < Key className = "w-3.5 h-3.5 text-text-tertiary" strokeWidth = { 1.5 } />
156+ < span className = "text-c1 text-text-secondary font-mono text-xs" >
157+ { license . code . slice ( 0 , 12 ) } ...
158+ </ span >
159+ < span className = { cn (
160+ 'text-c2 px-1.5 py-0.5 rounded-kb-sm' ,
161+ license . status === 'active' ? 'bg-semantic-success/10 text-semantic-success' : 'bg-semantic-error/10 text-semantic-error' ,
162+ ) } >
163+ { license . status === 'active' ? '有效' : '已失效' }
164+ </ span >
165+ </ div >
166+ < span className = "text-c1 text-text-tertiary" > { TIER_LABELS [ license . tier ] } </ span >
167+ </ div >
168+ ) ) }
169+ </ div >
170+ ) }
171+ </ div >
172+ ) }
173+ </ Card >
174+ ) ;
175+ }
176+
177+ /** 权益小标签 */
178+ function PerkBadge ( { label, value } : { label : string ; value : string } ) {
179+ return (
180+ < div className = "flex flex-col gap-0.5 p-2 rounded-kb-sm bg-bg-tertiary/50" >
181+ < span className = "text-c1 text-text-tertiary" > { label } </ span >
182+ < span className = "text-b3 font-medium text-text-primary" > { value } </ span >
183+ </ div >
184+ ) ;
185+ }
186+
187+ /** 非内测用户的邀请码输入提示 */
188+ function BetaInvitePrompt ( ) {
189+ const { toast } = useToast ( ) ;
190+ const { getAccessToken } = useAuth ( ) ;
191+ const [ inviteCode , setInviteCode ] = useState ( '' ) ;
192+ const [ activating , setActivating ] = useState ( false ) ;
193+
194+ async function handleActivate ( ) {
195+ if ( ! inviteCode . trim ( ) ) {
196+ toast ( { type : 'error' , message : '请输入邀请码' } ) ;
197+ return ;
198+ }
199+ setActivating ( true ) ;
200+ try {
201+ const code = inviteCode . trim ( ) . toUpperCase ( ) ;
202+
203+ // 调服务端验证邀请码
204+ const token = await getAccessToken ( ) ;
205+ const resp = await fetch ( '/api/v1/beta/use-invite' , {
206+ method : 'POST' ,
207+ headers : {
208+ 'Content-Type' : 'application/json' ,
209+ ...( token ? { 'Authorization' : `Bearer ${ token } ` } : { } ) ,
210+ } ,
211+ body : JSON . stringify ( { code } ) ,
212+ } ) ;
213+
214+ if ( ! resp . ok ) {
215+ const errBody = await resp . json ( ) . catch ( ( ) => ( { detail : '验证失败' } ) ) ;
216+ throw new Error ( errBody . detail || '邀请码无效' ) ;
217+ }
218+
219+ const data = await resp . json ( ) ;
220+
221+ // 服务端验证成功 → 更新本地身份
222+ const { addInviteCode, setBetaProfile } = useBetaStore . getState ( ) ;
223+ addInviteCode ( {
224+ id : crypto . randomUUID ( ) ,
225+ code,
226+ issuerUserId : 'pending' ,
227+ status : 'pending' ,
228+ createdAt : new Date ( ) . toISOString ( ) ,
229+ } ) ;
230+ setBetaProfile ( {
231+ id : crypto . randomUUID ( ) ,
232+ userId : 'local' ,
233+ tier : ( data . tier || 'observer' ) as UserTier ,
234+ cohort : data . cohort || 1 ,
235+ joinedAt : new Date ( ) . toISOString ( ) ,
236+ lifetimePro : false ,
237+ badges : [ ] ,
238+ perksConfig : '{}' ,
239+ } ) ;
240+ toast ( { type : 'success' , message : data . message || '欢迎加入内测!' } ) ;
241+ setInviteCode ( '' ) ;
242+ } catch ( err ) {
243+ const msg = err instanceof Error ? err . message : '邀请码验证失败' ;
244+ toast ( { type : 'error' , message : msg } ) ;
245+ } finally {
246+ setActivating ( false ) ;
247+ }
248+ }
249+
250+ return (
251+ < Card padding = "md" className = "flex flex-col gap-kb-md" >
252+ < div className = "flex items-center gap-2" >
253+ < Gift className = "w-icon-sm h-icon-sm text-brand-500" strokeWidth = { 1.5 } />
254+ < h2 className = "text-b1 font-semibold text-text-primary" > 内测邀请</ h2 >
255+ </ div >
256+ < p className = "text-b3 text-text-tertiary" >
257+ 你已获得内测邀请码?输入后即可加入内测,享受专属权益
258+ </ p >
259+ < div className = "flex items-center gap-2" >
260+ < Input
261+ placeholder = "输入邀请码,如 INVITE-XXXX-XXXX"
262+ value = { inviteCode }
263+ onChange = { ( e ) => setInviteCode ( e . target . value ) }
264+ className = "flex-1"
265+ />
266+ < Button
267+ variant = "primary"
268+ size = "sm"
269+ loading = { activating }
270+ onClick = { handleActivate }
271+ icon = { < Gift className = "w-3.5 h-3.5" strokeWidth = { 1.5 } /> }
272+ >
273+ 激活
274+ </ Button >
275+ </ div >
276+ </ Card >
277+ ) ;
278+ }
0 commit comments