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:
Randark
2026-08-18 09:14:44 +00:00
parent 03c3f3b6fc
commit 5cbd0c96a2
5 changed files with 436 additions and 25 deletions
@@ -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 = ''
@@ -530,13 +530,15 @@ export default {
checkModeProbe: 'Probe',
checkModeProbeHint: 'Sends a lightweight LLM request to measure availability and latency',
checkModeQuota: 'Quota',
checkModeQuotaHint: 'Only queries the linked account usage windows / balance, no LLM requests',
checkModeQuotaHint: 'Only queries the linked account usage windows / balance without probe requests',
checkModeQuotaProbe: 'Probe + Quota',
checkModeQuotaProbeHint: 'Probes the channel and attaches the quota snapshot to the primary model result',
linkedAccount: 'Linked Account',
linkedAccountPlaceholder: 'Select an account',
linkedAccountHint: 'Quota data comes from the selected account (reuses the account-side usage/balance queries)',
linkedAccountEmpty: 'No accounts on this platform yet. Add one in Account Management first',
linkedAccountMissing: 'The linked account no longer exists or is not accessible. Please re-select an account',
openAIQuotaProbeHint: 'Note: on the OpenAI platform the usage query may trigger a Codex probe request that consumes the account\'s own quota (at most once every 10 minutes)',
apiMode: 'OpenAI protocol',
apiModeChatCompletions: 'OpenAI Compatible',
apiModeChatCompletionsHint: 'Use /v1/chat/completions with messages; works for most compatible providers.',
@@ -530,13 +530,15 @@ export default {
checkModeProbe: '探活',
checkModeProbeHint: '向上游发送轻量 LLM 请求,检测可用性与延迟',
checkModeQuota: '配额',
checkModeQuotaHint: '只查询关联账号的用量滚动窗口/余额,不产生 LLM 请求',
checkModeQuotaHint: '只查询关联账号的用量滚动窗口/余额,不发送探活请求',
checkModeQuotaProbe: '探活 + 配额',
checkModeQuotaProbeHint: '探活的同时查询配额,用量快照附加在主模型结果上',
linkedAccount: '关联账号',
linkedAccountPlaceholder: '选择账号',
linkedAccountHint: '配额数据来自所选账号(复用账号管理侧的用量/余额查询)',
linkedAccountEmpty: '当前平台暂无账号,请先在账号管理中添加',
linkedAccountMissing: '关联账号已不存在或不可访问,请重新选择账号',
openAIQuotaProbeHint: '注意:OpenAI 平台的用量查询可能触发 Codex 探测请求,会消耗账号自身的额度(每 10 分钟最多触发一次)',
apiMode: 'OpenAI 协议',
apiModeChatCompletions: 'OpenAI Compatible',
apiModeChatCompletionsHint: '使用 /v1/chat/completions,发送 messages;适合大多数兼容站。',
@@ -10,8 +10,10 @@ import {
PROVIDER_GROK,
} from '@/constants/channelMonitor'
const { listTemplates } = vi.hoisted(() => ({
const { listTemplates, accountsList, accountsGetById } = vi.hoisted(() => ({
listTemplates: vi.fn(),
accountsList: vi.fn(),
accountsGetById: vi.fn(),
}))
@@ -34,6 +36,10 @@ vi.mock('@/api/admin', () => ({
channelMonitorTemplate: {
list: listTemplates,
},
accounts: {
list: (...args: unknown[]) => accountsList(...args),
getById: (...args: unknown[]) => accountsGetById(...args),
},
},
}))
@@ -85,6 +91,8 @@ function mountDialog() {
describe('channel monitor Grok provider', () => {
beforeEach(() => {
listTemplates.mockReset().mockResolvedValue({ items: [] })
accountsList.mockReset().mockResolvedValue({ items: [] })
accountsGetById.mockReset()
})
it('offers Grok in the responsive provider grid and prefills its official defaults', async () => {
@@ -0,0 +1,311 @@
import { defineComponent, nextTick } from 'vue'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ChannelMonitor } from '@/api/admin/channelMonitor'
import MonitorFormDialog from '@/components/admin/monitor/MonitorFormDialog.vue'
const {
listTemplates,
accountsList,
accountsGetById,
monitorCreate,
monitorUpdate,
} = vi.hoisted(() => ({
listTemplates: vi.fn(),
accountsList: vi.fn(),
accountsGetById: vi.fn(),
monitorCreate: vi.fn(),
monitorUpdate: vi.fn(),
}))
vi.mock('@/utils/featureFlags', () => ({
isChannelMonitorV1Mode: () => true,
isChannelMonitorV2Mode: () => false,
getChannelMonitorMode: () => 'v1' as const,
}))
vi.mock('@/features/channel-monitor-v2/MonitorSettingsPanel.vue', () => ({
default: { name: 'MonitorSettingsPanel', template: '<div data-testid="v2-settings" />' },
}))
vi.mock('@/api/admin', () => ({
adminAPI: {
channelMonitor: {
create: monitorCreate,
update: monitorUpdate,
},
channelMonitorTemplate: {
list: listTemplates,
},
accounts: {
list: (...args: unknown[]) => accountsList(...args),
getById: (...args: unknown[]) => accountsGetById(...args),
},
},
}))
vi.mock('@/api/keys', () => ({
keysAPI: { list: vi.fn() },
}))
vi.mock('@/api/groups', () => ({
userGroupsAPI: { getUserGroupRates: vi.fn() },
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({
cachedPublicSettings: null,
showError: vi.fn(),
showSuccess: vi.fn(),
}),
}))
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({ t: (key: string) => key }),
}
})
const BaseDialogStub = defineComponent({
props: { show: { type: Boolean, default: false } },
template: '<div v-if="show"><slot /><slot name="footer" /></div>',
})
function makeMonitor(overrides: Partial<ChannelMonitor> = {}): ChannelMonitor {
return {
id: 42,
name: 'primary',
provider: 'openai',
api_mode: 'chat_completions',
endpoint: 'https://api.example.com',
api_key_masked: 'sk-t***',
primary_model: 'gpt-4o-mini',
extra_models: [],
group_name: '',
enabled: true,
interval_seconds: 60,
jitter_seconds: 0,
last_checked_at: null,
created_by: 1,
created_at: '2026-07-16T00:00:00Z',
updated_at: '2026-07-16T00:00:00Z',
primary_status: '',
primary_latency_ms: null,
availability_7d: 0,
extra_models_status: [],
template_id: null,
extra_headers: {},
body_override_mode: 'off',
body_override: null,
check_mode: 'probe',
account_id: null,
...overrides,
}
}
let unmountWrapper: (() => void) | undefined
function mountDialog(monitor: ChannelMonitor | null = null) {
const wrapper = mount(MonitorFormDialog, {
props: { show: true, monitor },
global: {
stubs: {
BaseDialog: BaseDialogStub,
Toggle: true,
ModelTagInput: true,
MonitorKeyPickerDialog: true,
MonitorAdvancedRequestConfig: true,
},
},
})
unmountWrapper = () => wrapper.unmount()
return wrapper
}
const accountTrigger = (wrapper: VueWrapper) =>
wrapper.get('[data-testid="monitor-linked-account"] button')
const openAccountDropdown = async (wrapper: VueWrapper) => {
await accountTrigger(wrapper).trigger('click')
await nextTick()
const dropdown = document.body.querySelector<HTMLElement>('.select-dropdown-portal')
expect(dropdown).not.toBeNull()
return dropdown as HTMLElement
}
const clickOption = (dropdown: HTMLElement, text: string) => {
const option = [...dropdown.querySelectorAll('.select-option')].find((el) =>
el.textContent?.includes(text),
)
expect(option, `option containing "${text}" not found`).toBeDefined()
;(option as HTMLElement).click()
}
const typeAccountSearch = async (dropdown: HTMLElement, query: string) => {
const input = dropdown.querySelector<HTMLInputElement>('.select-search-input')
expect(input).not.toBeNull()
input!.value = query
input!.dispatchEvent(new Event('input'))
await nextTick()
}
afterEach(() => {
unmountWrapper?.()
unmountWrapper = undefined
document.body.innerHTML = ''
vi.useRealTimers()
vi.restoreAllMocks()
})
describe('MonitorFormDialog linked account selector', () => {
beforeEach(() => {
listTemplates.mockReset().mockResolvedValue({ items: [] })
accountsList.mockReset().mockResolvedValue({ items: [] })
accountsGetById.mockReset()
monitorCreate.mockReset().mockResolvedValue({})
monitorUpdate.mockReset().mockResolvedValue({})
})
it('loads the first page of provider accounts when quota mode is enabled', async () => {
accountsList.mockResolvedValue({ items: [{ id: 1, name: 'a', platform: 'anthropic' }] })
const wrapper = mountDialog()
await flushPromises()
expect(accountsList).not.toHaveBeenCalled()
await wrapper.get('[data-testid="monitor-check-mode-quota"]').trigger('click')
await flushPromises()
expect(accountsList).toHaveBeenCalledTimes(1)
expect(accountsList).toHaveBeenCalledWith(
1,
50,
{ platform: 'anthropic' },
{ signal: expect.any(AbortSignal) },
)
})
it('debounced typing hits server-side search and aborts the previous request', async () => {
vi.useFakeTimers()
const wrapper = mountDialog()
await wrapper.get('[data-testid="monitor-check-mode-quota"]').trigger('click')
await vi.advanceTimersByTimeAsync(0)
const dropdown = await openAccountDropdown(wrapper)
await typeAccountSearch(dropdown, 'hidden')
expect(accountsList).toHaveBeenCalledTimes(1, 'debounce window must not fire a request')
await vi.advanceTimersByTimeAsync(300)
expect(accountsList).toHaveBeenCalledTimes(2)
const lastCall = accountsList.mock.calls[accountsList.mock.calls.length - 1]
expect(lastCall[2]).toMatchObject({ platform: 'anthropic', search: 'hidden' })
const firstSignal = (accountsList.mock.calls[0][3] as { signal: AbortSignal }).signal
expect(firstSignal.aborted).toBe(true)
})
it('hydrates a bound account missing from the first page and keeps the binding', async () => {
accountsList.mockResolvedValue({ items: [{ id: 1, name: 'first page account', platform: 'anthropic' }] })
accountsGetById.mockResolvedValue({ id: 999, name: 'hidden gem', platform: 'anthropic' })
const wrapper = mountDialog(makeMonitor({
provider: 'anthropic',
check_mode: 'quota',
account_id: 999,
endpoint: '',
primary_model: 'quota',
}))
await flushPromises()
expect(accountsGetById).toHaveBeenCalledWith(999)
expect(accountTrigger(wrapper).text()).toContain('hidden gem (#999)')
await wrapper.get('#channel-monitor-form').trigger('submit')
await flushPromises()
expect(monitorUpdate).toHaveBeenCalledWith(42, expect.objectContaining({ account_id: 999 }))
})
it('clears the binding with a visible hint when the bound account cannot be loaded', async () => {
accountsGetById.mockRejectedValue(new Error('gone'))
const wrapper = mountDialog(makeMonitor({
provider: 'anthropic',
check_mode: 'quota',
account_id: 999,
endpoint: '',
primary_model: 'quota',
}))
await flushPromises()
expect(wrapper.text()).toContain('admin.channelMonitor.form.linkedAccountMissing')
expect(accountTrigger(wrapper).text()).not.toContain('hidden gem')
await wrapper.get('#channel-monitor-form').trigger('submit')
await flushPromises()
expect(monitorUpdate).not.toHaveBeenCalled()
})
it('shows the OpenAI codex probe hint only in quota mode on openai', async () => {
const wrapper = mountDialog()
await flushPromises()
expect(wrapper.text()).not.toContain('openAIQuotaProbeHint')
await wrapper.get('[data-testid="monitor-check-mode-quota"]').trigger('click')
await flushPromises()
// anthropic 平台不显示。
expect(wrapper.text()).not.toContain('openAIQuotaProbeHint')
await wrapper.get('[data-testid="monitor-provider-openai"]').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('openAIQuotaProbeHint')
})
it('keeps a picked account visible when a later search hides it', async () => {
vi.useFakeTimers()
accountsList
.mockResolvedValueOnce({ items: [
{ id: 1, name: 'alpha', platform: 'anthropic' },
{ id: 2, name: 'beta', platform: 'anthropic' },
] })
.mockResolvedValueOnce({ items: [{ id: 2, name: 'beta', platform: 'anthropic' }] })
const wrapper = mountDialog()
await wrapper.get('[data-testid="monitor-check-mode-quota"]').trigger('click')
await vi.advanceTimersByTimeAsync(0)
const dropdown = await openAccountDropdown(wrapper)
clickOption(dropdown, 'alpha (#1)')
await nextTick()
expect(accountTrigger(wrapper).text()).toContain('alpha (#1)')
// 搜索 beta:结果页只剩 beta,但已选中的 alpha 仍固定展示、提交仍保留绑定。
const reopened = await openAccountDropdown(wrapper)
await typeAccountSearch(reopened, 'beta')
await vi.advanceTimersByTimeAsync(300)
expect(accountTrigger(wrapper).text()).toContain('alpha (#1)')
await wrapper.findAll('input[type="text"]')[0].setValue('my monitor')
await wrapper.get('#channel-monitor-form').trigger('submit')
await vi.advanceTimersByTimeAsync(0)
expect(monitorCreate).toHaveBeenCalledWith(expect.objectContaining({ account_id: 1 }))
})
it('clears the account binding when the provider switches', async () => {
accountsList.mockResolvedValue({ items: [{ id: 1, name: 'alpha', platform: 'anthropic' }] })
const wrapper = mountDialog()
await wrapper.get('[data-testid="monitor-check-mode-quota"]').trigger('click')
await flushPromises()
const dropdown = await openAccountDropdown(wrapper)
clickOption(dropdown, 'alpha (#1)')
await nextTick()
expect(accountTrigger(wrapper).text()).toContain('alpha (#1)')
await wrapper.get('[data-testid="monitor-provider-openai"]').trigger('click')
await flushPromises()
expect(accountTrigger(wrapper).text()).not.toContain('alpha')
expect(accountTrigger(wrapper).text()).toContain('linkedAccountPlaceholder')
})
})