Skip to content

Commit a030478

Browse files
committed
fix(invite): 下拉不再隐藏禁用/异常但凭证可用的账号并优化选择器 (#281)
放宽 isCodexInviteCandidate 至与后端一致的口径:仅排除中转/AT-only 账号,去掉 enabled/locked/status 过滤——后端 SendCodexInvite 只要求 账号有 access token,不校验这些字段,否则会把仅被禁用调度或临时异常、 但凭证仍可用的账号从下拉中隐藏。 同时优化账号选择器: - 下拉项与已选账号加状态圆点 + 禁用/锁定/封禁/错误徽标 - 选中异常但可用账号时给轻量提示 - 下拉支持 ↑↓ 导航、回车确认、Esc 关闭、高亮滚入可视区
1 parent 7d33cc9 commit a030478

3 files changed

Lines changed: 140 additions & 13 deletions

File tree

frontend/src/components/CodexInviteView.tsx

Lines changed: 126 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useEffect, useMemo, useRef, useState } from 'react'
2+
import type { KeyboardEvent as ReactKeyboardEvent } from 'react'
23
import { useTranslation } from 'react-i18next'
34
import {
45
AlertTriangle,
@@ -76,10 +77,40 @@ function accountSearchText(account: AccountRow): string {
7677
}
7778

7879
function 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

85116
function 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

frontend/src/locales/en.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -960,6 +960,13 @@
960960
"accountToggle": "Toggle account list",
961961
"accountHint": "Invites are sent through this account's referral link.",
962962
"accountNotFound": "Account does not exist",
963+
"abnormalHint": "This account is currently {{state}}, but its credentials are still usable, so invites can still be sent.",
964+
"state": {
965+
"disabled": "disabled",
966+
"locked": "locked",
967+
"unauthorized": "banned",
968+
"error": "in error"
969+
},
963970
"noAccountMatches": "No matching accounts",
964971
"planLabel": "Plan",
965972
"statusLabel": "Status",

frontend/src/locales/zh.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -960,6 +960,13 @@
960960
"accountToggle": "展开账号列表",
961961
"accountHint": "邀请将通过该账号的推荐链接发送。",
962962
"accountNotFound": "账号不存在",
963+
"abnormalHint": "该账号当前为「{{state}}」状态,但凭证仍可用,可继续发送邀请。",
964+
"state": {
965+
"disabled": "已禁用",
966+
"locked": "已锁定",
967+
"unauthorized": "封禁",
968+
"error": "错误"
969+
},
963970
"noAccountMatches": "没有匹配的账号",
964971
"planLabel": "套餐",
965972
"statusLabel": "状态",

0 commit comments

Comments
 (0)