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') + }) +})