mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-31 16:26:30 +00:00
fix(monitor-ui): 关联账号选择器改服务端搜索+回填,OpenAI 配额模式加消耗提示
- 选择器改为 /admin/accounts?platform=&search= 服务端搜索(50 条/页, 300ms 防抖由 Select 承担),AbortController + 序列守卫防乱序覆盖 - 已绑定账号不在结果页时用 getById 回填为固定选项,删除「不在列表即 静默清空绑定」;绑定仅在显式切换 provider 或账号确实失效时清空(带提示) - OpenAI 平台配额模式显示 Codex 探测额度消耗提示;checkModeQuotaHint 修正为「不发送探活请求」不再宣称「不产生 LLM 请求」 - grok spec 补齐缺失的 accounts mock;新增账号选择器 7 用例 (加载/防抖搜索/回填保留/失效清空/提示门控/固定选项/切平台清空)
This commit is contained in:
@@ -56,13 +56,24 @@
|
||||
<label class="input-label">
|
||||
{{ t('admin.channelMonitor.form.linkedAccount') }} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<Select
|
||||
v-model="accountSelectValue"
|
||||
:options="accountOptions"
|
||||
:placeholder="t('admin.channelMonitor.form.linkedAccountPlaceholder')"
|
||||
/>
|
||||
<div data-testid="monitor-linked-account">
|
||||
<Select
|
||||
v-model="accountSelectValue"
|
||||
:options="accountOptions"
|
||||
:placeholder="t('admin.channelMonitor.form.linkedAccountPlaceholder')"
|
||||
remote
|
||||
:loading="accountsLoading"
|
||||
@search="onAccountSearch"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-400">{{ t('admin.channelMonitor.form.linkedAccountHint') }}</p>
|
||||
<p v-if="accountOptions.length === 0 && !accountsLoading" class="mt-1 text-xs text-amber-600 dark:text-amber-400">
|
||||
<p v-if="form.provider === PROVIDER_OPENAI" class="mt-1 text-xs text-amber-600 dark:text-amber-400">
|
||||
{{ t('admin.channelMonitor.form.openAIQuotaProbeHint') }}
|
||||
</p>
|
||||
<p v-if="accountHydrationFailed" class="mt-1 text-xs text-amber-600 dark:text-amber-400">
|
||||
{{ t('admin.channelMonitor.form.linkedAccountMissing') }}
|
||||
</p>
|
||||
<p v-if="accountOptions.length === 0 && !accountsLoading && !accountSearchQuery" class="mt-1 text-xs text-amber-600 dark:text-amber-400">
|
||||
{{ t('admin.channelMonitor.form.linkedAccountEmpty') }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -524,13 +535,27 @@ interface LinkedAccount {
|
||||
|
||||
const linkedAccounts = ref<LinkedAccount[]>([])
|
||||
const accountsLoading = ref(false)
|
||||
// 当前搜索词(用于空态文案区分「平台无账号」与「搜索无命中」)。
|
||||
const accountSearchQuery = ref('')
|
||||
// 已绑定账号回填失败(getById 失败或平台失配)时提示用户重新选择。
|
||||
const accountHydrationFailed = ref(false)
|
||||
// 固定选项:已绑定/已选中但不在当前结果页里的账号,保证搜索后 label 仍可见。
|
||||
const pinnedAccount = ref<LinkedAccount | null>(null)
|
||||
let accountSearchSeq = 0
|
||||
let accountSearchAbort: AbortController | null = null
|
||||
const hydrationAttempted = new Set<number>()
|
||||
|
||||
const accountOptions = computed(() =>
|
||||
linkedAccounts.value.map((a) => ({
|
||||
const accountOptions = computed(() => {
|
||||
const opts = linkedAccounts.value.map((a) => ({
|
||||
value: String(a.id),
|
||||
label: `${a.name} (#${a.id})`,
|
||||
})),
|
||||
)
|
||||
}))
|
||||
const pinned = pinnedAccount.value
|
||||
if (pinned && !linkedAccounts.value.some((a) => a.id === pinned.id)) {
|
||||
opts.unshift({ value: String(pinned.id), label: `${pinned.name} (#${pinned.id})` })
|
||||
}
|
||||
return opts
|
||||
})
|
||||
|
||||
// Select 组件绑定 string,与 number | null 互转。
|
||||
const accountSelectValue = computed<string>({
|
||||
@@ -538,37 +563,95 @@ const accountSelectValue = computed<string>({
|
||||
set: (raw: string) => {
|
||||
if (raw === '') {
|
||||
form.account_id = null
|
||||
pinnedAccount.value = null
|
||||
accountHydrationFailed.value = false
|
||||
return
|
||||
}
|
||||
const id = Number(raw)
|
||||
if (Number.isFinite(id)) form.account_id = id
|
||||
if (Number.isFinite(id)) {
|
||||
form.account_id = id
|
||||
pinnedAccount.value = linkedAccounts.value.find((a) => a.id === id) ?? pinnedAccount.value
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// 拉取当前 provider 平台的账号(大分页一次取齐,客户端不再过滤)。
|
||||
// 服务端搜索当前 provider 平台的账号(支持关键字,避免大分页截断取不齐)。
|
||||
// seq + abort 防止快速切换 provider / 连续输入时乱序响应覆盖新结果。
|
||||
// 失败不阻塞表单:下拉为空 + 空态提示。
|
||||
async function loadLinkedAccounts() {
|
||||
async function loadLinkedAccounts(search = '') {
|
||||
if (!usesQuotaMode.value || !props.show) return
|
||||
accountSearchQuery.value = search
|
||||
const seq = ++accountSearchSeq
|
||||
accountSearchAbort?.abort()
|
||||
const controller = new AbortController()
|
||||
accountSearchAbort = controller
|
||||
accountsLoading.value = true
|
||||
try {
|
||||
const res = await adminAPI.accounts.list(1, 200, { platform: form.provider })
|
||||
const res = await adminAPI.accounts.list(
|
||||
1,
|
||||
50,
|
||||
{ platform: form.provider, ...(search ? { search } : {}) },
|
||||
{ signal: controller.signal },
|
||||
)
|
||||
if (seq !== accountSearchSeq) return
|
||||
linkedAccounts.value = (res.items || []).map((a) => ({ id: a.id, name: a.name }))
|
||||
// 关联账号必须与 provider 同平台:平台切换后原选择失效,自动清掉。
|
||||
if (form.account_id != null && !linkedAccounts.value.some((a) => a.id === form.account_id)) {
|
||||
form.account_id = null
|
||||
}
|
||||
await ensureSelectedAccountHydrated()
|
||||
} catch (err: unknown) {
|
||||
if (controller.signal.aborted) return
|
||||
console.warn('load linked accounts failed', err)
|
||||
linkedAccounts.value = []
|
||||
if (!search) linkedAccounts.value = []
|
||||
} finally {
|
||||
accountsLoading.value = false
|
||||
if (seq === accountSearchSeq) accountsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑已有 quota 监控时,已绑定账号可能不在搜索结果第一页:用 getById
|
||||
// 回填为固定选项,绑定不因分页截断而丢失。仅当账号确实无法加载或平台
|
||||
// 失配时才清空绑定(带可见提示),否则绑定只在用户显式切换 provider 时清空。
|
||||
async function ensureSelectedAccountHydrated() {
|
||||
const id = form.account_id
|
||||
if (id == null || !usesQuotaMode.value) return
|
||||
if (linkedAccounts.value.some((a) => a.id === id) || pinnedAccount.value?.id === id) return
|
||||
if (hydrationAttempted.has(id)) return
|
||||
hydrationAttempted.add(id)
|
||||
try {
|
||||
const account = await adminAPI.accounts.getById(id)
|
||||
if (form.account_id !== id) return
|
||||
if (String(account.platform) !== form.provider) {
|
||||
form.account_id = null
|
||||
pinnedAccount.value = null
|
||||
accountHydrationFailed.value = true
|
||||
return
|
||||
}
|
||||
pinnedAccount.value = { id: account.id, name: account.name }
|
||||
} catch {
|
||||
if (form.account_id === id) {
|
||||
form.account_id = null
|
||||
pinnedAccount.value = null
|
||||
accountHydrationFailed.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onAccountSearch(query: string) {
|
||||
void loadLinkedAccounts(query)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.show, form.provider, form.check_mode] as const,
|
||||
([show]) => {
|
||||
if (!show) return
|
||||
([show, provider], prev) => {
|
||||
const [prevShow, prevProvider] = prev ?? []
|
||||
if (!show) {
|
||||
accountSearchAbort?.abort()
|
||||
return
|
||||
}
|
||||
// 弹窗重开 / provider 真正变化时重置回填状态(check_mode 变化不重置,
|
||||
// 避免 probe↔quota 切换时无谓地重拉列表)。
|
||||
if (show !== prevShow || provider !== prevProvider) {
|
||||
hydrationAttempted.clear()
|
||||
accountHydrationFailed.value = false
|
||||
pinnedAccount.value = null
|
||||
}
|
||||
void loadLinkedAccounts()
|
||||
},
|
||||
{ immediate: true },
|
||||
@@ -584,7 +667,10 @@ function selectProvider(provider: Provider) {
|
||||
const clearPrevDefaultEndpoint =
|
||||
!!PROVIDER_DEFAULT_ENDPOINTS[previousProvider] && form.endpoint === PROVIDER_DEFAULT_ENDPOINTS[previousProvider]
|
||||
form.provider = provider
|
||||
// 关联账号与平台绑定:切换 provider 时显式清空(这是唯一主动清空的入口)。
|
||||
form.account_id = null
|
||||
pinnedAccount.value = null
|
||||
accountHydrationFailed.value = false
|
||||
// antigravity 仅配额模式:切到它时强制 quota(checkModeOptions 同步禁用其余项)。
|
||||
if (provider === PROVIDER_ANTIGRAVITY && form.check_mode !== CHECK_MODE_QUOTA) {
|
||||
form.check_mode = CHECK_MODE_QUOTA
|
||||
@@ -628,6 +714,8 @@ function resetForm() {
|
||||
form.api_mode = API_MODE_CHAT_COMPLETIONS
|
||||
form.check_mode = CHECK_MODE_PROBE
|
||||
form.account_id = null
|
||||
pinnedAccount.value = null
|
||||
accountHydrationFailed.value = false
|
||||
form.endpoint = ''
|
||||
form.api_key = ''
|
||||
form.primary_model = ''
|
||||
|
||||
Reference in New Issue
Block a user