diff --git a/frontend/src/components/admin/monitor/MonitorFormDialog.vue b/frontend/src/components/admin/monitor/MonitorFormDialog.vue
index b67bcb775..ea73b929e 100644
--- a/frontend/src/components/admin/monitor/MonitorFormDialog.vue
+++ b/frontend/src/components/admin/monitor/MonitorFormDialog.vue
@@ -56,13 +56,24 @@
-
+
+
+
{{ t('admin.channelMonitor.form.linkedAccountHint') }}
-
+
+ {{ t('admin.channelMonitor.form.openAIQuotaProbeHint') }}
+
+
+ {{ t('admin.channelMonitor.form.linkedAccountMissing') }}
+
+
{{ t('admin.channelMonitor.form.linkedAccountEmpty') }}
@@ -524,13 +535,27 @@ interface LinkedAccount {
const linkedAccounts = ref([])
const accountsLoading = ref(false)
+// 当前搜索词(用于空态文案区分「平台无账号」与「搜索无命中」)。
+const accountSearchQuery = ref('')
+// 已绑定账号回填失败(getById 失败或平台失配)时提示用户重新选择。
+const accountHydrationFailed = ref(false)
+// 固定选项:已绑定/已选中但不在当前结果页里的账号,保证搜索后 label 仍可见。
+const pinnedAccount = ref(null)
+let accountSearchSeq = 0
+let accountSearchAbort: AbortController | null = null
+const hydrationAttempted = new Set()
-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({
@@ -538,37 +563,95 @@ const accountSelectValue = computed({
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 = ''
diff --git a/frontend/src/i18n/locales/en/admin/channels.ts b/frontend/src/i18n/locales/en/admin/channels.ts
index 0dc9e80b0..41793496f 100644
--- a/frontend/src/i18n/locales/en/admin/channels.ts
+++ b/frontend/src/i18n/locales/en/admin/channels.ts
@@ -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.',
diff --git a/frontend/src/i18n/locales/zh/admin/channels.ts b/frontend/src/i18n/locales/zh/admin/channels.ts
index f18787ac3..60e154858 100644
--- a/frontend/src/i18n/locales/zh/admin/channels.ts
+++ b/frontend/src/i18n/locales/zh/admin/channels.ts
@@ -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;适合大多数兼容站。',
diff --git a/frontend/src/views/admin/__tests__/ChannelMonitorView.grok.spec.ts b/frontend/src/views/admin/__tests__/ChannelMonitorView.grok.spec.ts
index 94f619aea..007876a72 100644
--- a/frontend/src/views/admin/__tests__/ChannelMonitorView.grok.spec.ts
+++ b/frontend/src/views/admin/__tests__/ChannelMonitorView.grok.spec.ts
@@ -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 () => {
diff --git a/frontend/src/views/admin/__tests__/MonitorFormDialog.accountSelector.spec.ts b/frontend/src/views/admin/__tests__/MonitorFormDialog.accountSelector.spec.ts
new file mode 100644
index 000000000..9ff3fcd2b
--- /dev/null
+++ b/frontend/src/views/admin/__tests__/MonitorFormDialog.accountSelector.spec.ts
@@ -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: '' },
+}))
+
+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('vue-i18n')
+ return {
+ ...actual,
+ useI18n: () => ({ t: (key: string) => key }),
+ }
+})
+
+const BaseDialogStub = defineComponent({
+ props: { show: { type: Boolean, default: false } },
+ template: '
',
+})
+
+function makeMonitor(overrides: Partial = {}): 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('.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('.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')
+ })
+})