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 = ''
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user