11import { useEffect , useMemo , useRef , useState } from 'react'
2+ import type { KeyboardEvent as ReactKeyboardEvent } from 'react'
23import { useTranslation } from 'react-i18next'
34import {
45 AlertTriangle ,
@@ -76,10 +77,40 @@ function accountSearchText(account: AccountRow): string {
7677}
7778
7879function isCodexInviteCandidate ( account : AccountRow ) : boolean {
79- if ( account . openai_responses_api || account . at_only ) return false
80- if ( account . enabled === false || account . locked ) return false
80+ // 仅排除发不出邀请的硬条件:中转 / AT-only 账号没有可用于 referral 的 Codex OAuth 凭证。
81+ // enabled / locked / status 只是调度开关与健康状态,不影响 access token 是否可用——
82+ // 后端 SendCodexInvite 只要求账号有 access token,不校验这些字段,故前端不在此过滤,
83+ // 否则会把仅被禁用调度或临时异常、但凭证仍可用的账号从下拉中隐藏(见 issue #281)。
84+ return ! account . openai_responses_api && ! account . at_only
85+ }
86+
87+ // 状态圆点配色,与全局 StatusBadge 保持一致。
88+ const STATUS_DOT_COLOR : Record < string , string > = {
89+ active : 'bg-emerald-500' ,
90+ ready : 'bg-emerald-500' ,
91+ cooldown : 'bg-amber-500' ,
92+ rate_limited : 'bg-yellow-500' ,
93+ usage_exhausted : 'bg-yellow-500' ,
94+ quota_paused : 'bg-yellow-500' ,
95+ unauthorized : 'bg-red-500' ,
96+ error : 'bg-red-400' ,
97+ refreshing : 'bg-blue-500 animate-pulse' ,
98+ paused : 'bg-blue-500' ,
99+ }
100+
101+ function statusDotColor ( status ?: string | null ) : string {
102+ return STATUS_DOT_COLOR [ ( status || '' ) . toLowerCase ( ) ] ?? 'bg-gray-400'
103+ }
104+
105+ // 账号是否处于「非正常」状态。仅用于 UI 提示与视觉区分,不影响能否发送邀请——
106+ // 凭证(access token)可用即可发邀请,这里只是提醒用户当前选的是什么状态的号。
107+ function accountAbnormalKey ( account : AccountRow ) : 'disabled' | 'locked' | 'unauthorized' | 'error' | null {
108+ if ( account . enabled === false ) return 'disabled'
109+ if ( account . locked ) return 'locked'
81110 const status = ( account . status || '' ) . toLowerCase ( )
82- return status !== 'unauthorized' && status !== 'error'
111+ if ( status === 'unauthorized' ) return 'unauthorized'
112+ if ( status === 'error' ) return 'error'
113+ return null
83114}
84115
85116function resolveAccountInput ( accounts : AccountRow [ ] , input : string ) : AccountRow | null {
@@ -117,6 +148,8 @@ export default function CodexInviteView({ accounts, onClose }: Props) {
117148 // accountTyping 区分「用户正在输入搜索」与「输入框只是回显已选账号」。仅在输入时
118149 // 才按文本过滤,否则展开下拉应显示全部账号(否则会被已选账号的邮箱过滤成只剩一条)。
119150 const [ accountTyping , setAccountTyping ] = useState ( false )
151+ // 下拉键盘导航的高亮项索引(指向 filteredAccounts)。-1 表示未高亮任何项。
152+ const [ activeIndex , setActiveIndex ] = useState ( - 1 )
120153 const [ emailsText , setEmailsText ] = useState ( '' )
121154 const [ showAdvanced , setShowAdvanced ] = useState ( false )
122155 const [ proxyUrl , setProxyUrl ] = useState ( '' )
@@ -140,6 +173,66 @@ export default function CodexInviteView({ accounts, onClose }: Props) {
140173 const overLimit = parsed . valid . length > MAX_EMAILS
141174 const canSend =
142175 ! sending && accountQuery . trim ( ) !== '' && parsed . valid . length > 0 && parsed . invalid . length === 0 && ! overLimit
176+ // 选中账号的非正常状态(禁用/锁定/封禁/错误);用于提示用户当前选的不是正常号。
177+ const selectedAbnormal = useMemo (
178+ ( ) => ( selectedAccount ? accountAbnormalKey ( selectedAccount ) : null ) ,
179+ [ selectedAccount ] ,
180+ )
181+
182+ // 统一的选中逻辑:下拉点击、键盘回车共用。
183+ const selectAccount = ( account : AccountRow ) => {
184+ setAccountId ( account . id )
185+ setAccountQuery ( accountDisplayName ( account ) )
186+ setAccountOpen ( false )
187+ setAccountTyping ( false )
188+ setActiveIndex ( - 1 )
189+ setError ( null )
190+ }
191+
192+ // 打开下拉或过滤结果变化时,重置高亮到当前选中项(没有则不高亮)。
193+ useEffect ( ( ) => {
194+ if ( ! accountOpen ) {
195+ setActiveIndex ( - 1 )
196+ return
197+ }
198+ setActiveIndex ( ( prev ) => {
199+ if ( prev >= 0 && prev < filteredAccounts . length ) return prev
200+ const selectedIdx = filteredAccounts . findIndex ( ( a ) => a . id === accountId )
201+ return selectedIdx >= 0 ? selectedIdx : filteredAccounts . length > 0 ? 0 : - 1
202+ } )
203+ } , [ accountOpen , filteredAccounts , accountId ] )
204+
205+ // 下拉键盘导航:↑↓ 移动高亮、回车确认、Esc 关闭。
206+ const handlePickerKeyDown = ( event : ReactKeyboardEvent < HTMLInputElement > ) => {
207+ if ( event . key === 'Escape' ) {
208+ if ( accountOpen ) {
209+ event . preventDefault ( )
210+ setAccountOpen ( false )
211+ }
212+ return
213+ }
214+ if ( event . key === 'ArrowDown' || event . key === 'ArrowUp' ) {
215+ event . preventDefault ( )
216+ if ( ! accountOpen ) {
217+ setAccountOpen ( true )
218+ setAccountTyping ( false )
219+ return
220+ }
221+ if ( filteredAccounts . length === 0 ) return
222+ setActiveIndex ( ( prev ) => {
223+ const delta = event . key === 'ArrowDown' ? 1 : - 1
224+ const base = prev < 0 ? ( delta === 1 ? - 1 : 0 ) : prev
225+ return ( base + delta + filteredAccounts . length ) % filteredAccounts . length
226+ } )
227+ return
228+ }
229+ if ( event . key === 'Enter' ) {
230+ if ( accountOpen && activeIndex >= 0 && activeIndex < filteredAccounts . length ) {
231+ event . preventDefault ( )
232+ selectAccount ( filteredAccounts [ activeIndex ] )
233+ }
234+ }
235+ }
143236
144237 useEffect ( ( ) => {
145238 if ( accountId == null ) return
@@ -230,6 +323,7 @@ export default function CodexInviteView({ accounts, onClose }: Props) {
230323 value = { accountQuery }
231324 onFocus = { ( ) => { setAccountOpen ( true ) ; setAccountTyping ( false ) } }
232325 onClick = { ( ) => { setAccountOpen ( true ) ; setAccountTyping ( false ) } }
326+ onKeyDown = { handlePickerKeyDown }
233327 onChange = { ( e ) => {
234328 const next = e . target . value
235329 setAccountQuery ( next )
@@ -240,8 +334,14 @@ export default function CodexInviteView({ accounts, onClose }: Props) {
240334 } }
241335 placeholder = { t ( 'invite.accountPlaceholder' ) }
242336 role = "combobox"
337+ autoComplete = "off"
243338 aria-expanded = { accountOpen }
244339 aria-controls = "codex-invite-account-list"
340+ aria-activedescendant = {
341+ accountOpen && activeIndex >= 0 && activeIndex < filteredAccounts . length
342+ ? `codex-invite-option-${ filteredAccounts [ activeIndex ] . id } `
343+ : undefined
344+ }
245345 className = "h-10 pr-9"
246346 />
247347 < button
@@ -260,31 +360,38 @@ export default function CodexInviteView({ accounts, onClose }: Props) {
260360 className = "absolute z-30 mt-1.5 max-h-72 w-full overflow-auto rounded-lg border bg-popover p-1 text-popover-foreground shadow-lg"
261361 >
262362 { filteredAccounts . length > 0 ? (
263- filteredAccounts . map ( ( account ) => {
363+ filteredAccounts . map ( ( account , index ) => {
264364 const active = account . id === accountId
365+ const highlighted = index === activeIndex
366+ const abnormal = accountAbnormalKey ( account )
265367 return (
266368 < button
267369 key = { account . id }
370+ id = { `codex-invite-option-${ account . id } ` }
268371 type = "button"
269372 role = "option"
270373 aria-selected = { active }
374+ ref = { highlighted ? ( el ) => el ?. scrollIntoView ( { block : 'nearest' } ) : undefined }
271375 onMouseDown = { ( event ) => event . preventDefault ( ) }
272- onClick = { ( ) => {
273- setAccountId ( account . id )
274- setAccountQuery ( accountDisplayName ( account ) )
275- setAccountOpen ( false )
276- setAccountTyping ( false )
277- setError ( null )
278- } }
376+ onMouseEnter = { ( ) => setActiveIndex ( index ) }
377+ onClick = { ( ) => selectAccount ( account ) }
279378 className = { `flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-sm transition-colors ${
280- active ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/70 hover:text-accent-foreground'
379+ highlighted ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/70 hover:text-accent-foreground'
281380 } `}
282381 >
283382 < span className = "flex size-7 shrink-0 items-center justify-center rounded-md bg-muted text-[11px] font-semibold text-muted-foreground" >
284383 #{ account . id }
285384 </ span >
286385 < span className = "min-w-0 flex-1" >
287- < span className = "block truncate font-medium" > { accountDisplayName ( account ) } </ span >
386+ < span className = "flex items-center gap-1.5" >
387+ < span className = { `size-1.5 shrink-0 rounded-full ${ statusDotColor ( account . status ) } ` } />
388+ < span className = "truncate font-medium" > { accountDisplayName ( account ) } </ span >
389+ { abnormal && (
390+ < span className = "shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground" >
391+ { t ( `invite.state.${ abnormal } ` ) }
392+ </ span >
393+ ) }
394+ </ span >
288395 < span className = "block truncate text-xs text-muted-foreground" >
289396 { [ account . name && account . name !== account . email ? account . name : '' , account . plan_type , account . status ]
290397 . filter ( Boolean )
@@ -311,6 +418,12 @@ export default function CodexInviteView({ accounts, onClose }: Props) {
311418 < InfoPill label = { t ( 'invite.statusLabel' ) } value = { selectedAccount . status || '-' } />
312419 </ div >
313420 ) }
421+ { selectedAbnormal && (
422+ < p className = "mt-2 flex items-start gap-1.5 text-xs text-amber-600" >
423+ < AlertTriangle className = "mt-0.5 size-3.5 shrink-0" />
424+ < span > { t ( 'invite.abnormalHint' , { state : t ( `invite.state.${ selectedAbnormal } ` ) } ) } </ span >
425+ </ p >
426+ ) }
314427 < p className = "mt-2 text-xs text-muted-foreground" > { t ( 'invite.accountHint' ) } </ p >
315428 </ div >
316429
0 commit comments