fix(frontend): monitor form check-mode restore, account unbinding and mode badge

审计 P2-7 三处前端收尾:

- 切离 antigravity 时对称还原 check_mode:antigravity 强制 quota,但反向
  切走没有还原——编辑存量 antigravity 监控换平台后停留在 quota,目标平台
  未必支持。现切走时还原为 probe 并清掉占位模型 'quota'(回填值),否则
  probe 模式拿 'quota' 当探活模型(与离开 grok 清 DEFAULT_GROK_MODEL
  同一先例)。
- update 切回 probe 显式发 account_id=0:后端语义 0=解绑、null=不动,
  此前 probe 模式 payload 带 null,存量关联永远清不掉。仅 update——
  create 发 0 会落 &0 触发外键违约(API 类型注释同步写明)。
- 管理列表 provider 徽章旁并列 check_mode 徽章(checkModeLabel 此前
  定义了却无处使用):quota 系=蓝、probe=中性灰,三种模式一眼可分;
  i18n 复用既有 monitorCommon.checkMode.*,零新增键。
This commit is contained in:
Randark
2026-08-18 10:28:28 +00:00
parent e2dfb3b8cb
commit c9effc4560
6 changed files with 266 additions and 2 deletions
+5 -2
View File
@@ -134,7 +134,9 @@ export interface CreateParams {
api_key: string
/** 缺省 probe;antigravity 仅支持 quota */
check_mode?: CheckMode
/** 配额模式必填:数据源账号(provider 需与账号平台一致) */
/** 配额模式必填:数据源账号(provider 需与账号平台一致)。
* update 语义:>0=换绑,0=解绑(切回 probe 模式时前端发 0 清空存量关联);
* create 绝不发 0——后端会把 0 存成 &0 触发外键违约。 */
account_id?: number | null
primary_model: string
extra_models?: string[]
@@ -148,7 +150,8 @@ export interface CreateParams {
body_override?: Record<string, unknown> | null
}
// Update request: api_key 空串 = 不修改;clear_template=true 时把 template_id 置空
// Update request: api_key 空串 = 不修改;clear_template=true 时把 template_id 置空;
// account_id=0 显式解绑关联账号(null = 不动,见 CreateParams 注释)
export type UpdateParams = Partial<CreateParams> & {
clear_template?: boolean
}
@@ -675,6 +675,14 @@ function selectProvider(provider: Provider) {
if (provider === PROVIDER_ANTIGRAVITY && form.check_mode !== CHECK_MODE_QUOTA) {
form.check_mode = CHECK_MODE_QUOTA
}
// 对称还原:从 antigravity 切走时撤掉强制 quota,否则编辑存量 antigravity
// 监控换平台后仍停留在 quota(目标平台未必支持),update 会携带残留配置。
// 同步清掉 quota 占位模型(loadFromMonitor 回填的 'quota'),否则切回
// probe 后拿 'quota' 当探活模型(与离开 grok 清 DEFAULT_GROK_MODEL 同理)。
if (previousProvider === PROVIDER_ANTIGRAVITY && form.check_mode === CHECK_MODE_QUOTA) {
form.check_mode = CHECK_MODE_PROBE
if (form.primary_model.trim() === 'quota') form.primary_model = ''
}
if (provider === PROVIDER_GROK) {
if (!form.endpoint.trim()) form.endpoint = DEFAULT_GROK_ENDPOINT
if (!form.primary_model.trim()) form.primary_model = DEFAULT_GROK_MODEL
@@ -849,6 +857,9 @@ async function handleSubmit() {
req.clear_template = true
delete req.template_id
}
// account_id 同理:probe 模式不带账号,update 发 0 显式解绑存量关联
// (后端 0=清空、null=不动)。仅 update——create 发 0 会落 &0 触发 FK 违约。
if (!usesQuotaMode.value) req.account_id = 0
await adminAPI.channelMonitor.update(target.id, req)
appStore.showSuccess(t('admin.channelMonitor.updateSuccess'))
} else {
@@ -26,6 +26,9 @@ import {
STATUS_DEGRADED,
STATUS_FAILED,
STATUS_ERROR,
CHECK_MODE_PROBE,
CHECK_MODE_QUOTA,
CHECK_MODE_QUOTA_PROBE,
} from '@/constants/channelMonitor'
const NEUTRAL_BADGE = 'bg-gray-100 text-gray-800 dark:bg-dark-700 dark:text-gray-300'
@@ -101,6 +104,22 @@ export function useChannelMonitorFormat() {
}
}
/**
* Tailwind class for the check-mode badge shown next to the provider badge
* in the admin monitor list. Quota-bearing modes = blue (数据源是账号配额),
* plain probe = neutral grey.
*/
function checkModeBadgeClass(m: CheckMode | string): string {
switch (m) {
case CHECK_MODE_QUOTA:
case CHECK_MODE_QUOTA_PROBE:
return 'bg-blue-100 text-blue-700 dark:bg-blue-500/15 dark:text-blue-300'
case CHECK_MODE_PROBE:
default:
return NEUTRAL_BADGE
}
}
/**
* Tailwind class for a provider radio-button-style picker (active/inactive state).
* Reuses the same emerald/orange/sky palette as providerBadgeClass to keep
@@ -182,6 +201,7 @@ export function useChannelMonitorFormat() {
providerLabel,
checkModeLabel,
providerBadgeClass,
checkModeBadgeClass,
providerPickerClass,
formatLatency,
formatPercent,
@@ -78,6 +78,10 @@
<span class="inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium" :class="providerBadgeClass(row.provider)">
{{ providerLabel(row.provider) }}
</span>
<!-- 三种检测模式并列展示,quota 系配额数据源与纯探活一眼可分 -->
<span class="ml-1 inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium" :class="checkModeBadgeClass(row.check_mode)">
{{ checkModeLabel(row.check_mode) }}
</span>
</template>
<template #cell-primary_model="{ row }">
@@ -204,6 +208,8 @@ const adminMonitorTab = ref<'v2' | 'legacy'>(isChannelMonitorV1Mode() ? 'legacy'
const {
providerLabel,
providerBadgeClass,
checkModeLabel,
checkModeBadgeClass,
formatLatency,
formatAvailability,
} = useChannelMonitorFormat()
@@ -0,0 +1,159 @@
import { defineComponent } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ChannelMonitor } from '@/api/admin/channelMonitor'
import ChannelMonitorView from '@/views/admin/ChannelMonitorView.vue'
// P2-7(d):provider 徽章旁并列 check_mode 徽章,三种检测模式一眼可分
// (quota 系 = 账号配额数据源,probe = 纯探活)。
const { listMonitors } = vi.hoisted(() => ({ listMonitors: 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: {
list: listMonitors,
duplicate: vi.fn(),
update: vi.fn(),
runNow: vi.fn(),
del: vi.fn(),
},
},
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({ showSuccess: vi.fn(), showError: 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 AppLayoutStub = defineComponent({
template: '<main><slot /></main>',
})
const TablePageLayoutStub = defineComponent({
template: '<section><slot name="filters" /><slot name="table" /><slot name="pagination" /></section>',
})
// 渲染 cell-provider slot,徽章断言基于它。
const DataTableStub = defineComponent({
props: {
data: { type: Array, default: () => [] },
columns: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
},
template: '<div><div v-for="row in data" :key="row.id" class="provider-cell"><slot name="cell-provider" :row="row" /></div></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,
}
}
function mountView() {
return mount(ChannelMonitorView, {
global: {
stubs: {
AppLayout: AppLayoutStub,
TablePageLayout: TablePageLayoutStub,
DataTable: DataTableStub,
MonitorFiltersBar: true,
Pagination: true,
ConfirmDialog: true,
EmptyState: true,
HelpTooltip: true,
Toggle: true,
MonitorFormDialog: true,
MonitorTemplateManagerDialog: true,
MonitorRunResultDialog: true,
MonitorPrimaryModelCell: true,
},
},
})
}
describe('ChannelMonitorView check-mode badge', () => {
beforeEach(() => {
localStorage.clear()
listMonitors.mockReset()
})
it.each([
['quota', 'monitorCommon.checkMode.quota'],
['quota_probe', 'monitorCommon.checkMode.quota_probe'],
['probe', 'monitorCommon.checkMode.probe'],
] as const)('renders the %s badge next to the provider badge', async (mode, label) => {
listMonitors.mockResolvedValue({
items: [makeMonitor({ check_mode: mode })],
total: 1,
page: 1,
page_size: 20,
pages: 1,
})
const wrapper = mountView()
await flushPromises()
const cell = wrapper.get('.provider-cell')
expect(cell.text()).toContain(label)
// provider 徽章仍在(不因并列展示被顶掉)。
expect(cell.text()).toContain('monitorCommon.providers.openai')
const badges = cell.findAll('span')
const modeBadge = badges.find(el => el.text() === label)
expect(modeBadge).toBeDefined()
// quota 系=蓝、probe=中性灰的配色区分。
const cls = modeBadge!.attributes('class')
if (mode === 'probe') {
expect(cls).toContain('bg-gray-100')
expect(cls).not.toContain('bg-blue-100')
} else {
expect(cls).toContain('bg-blue-100')
expect(cls).not.toContain('bg-gray-100')
}
wrapper.unmount()
})
})
@@ -308,4 +308,69 @@ describe('MonitorFormDialog linked account selector', () => {
expect(accountTrigger(wrapper).text()).not.toContain('alpha')
expect(accountTrigger(wrapper).text()).toContain('linkedAccountPlaceholder')
})
// P2-7(b):antigravity 强制的 quota 在切走时要对称还原,占位模型一并清掉。
it('restores probe mode and clears the quota placeholder when switching away from antigravity', async () => {
accountsGetById.mockRejectedValue(new Error('gone'))
const wrapper = mountDialog(makeMonitor({
provider: 'antigravity',
check_mode: 'quota',
account_id: 7,
endpoint: '',
primary_model: 'quota',
}))
await flushPromises()
await wrapper.get('[data-testid="monitor-provider-anthropic"]').trigger('click')
await flushPromises()
// 占位模型已被清空:probe 模式必填校验拦下提交,而不是拿 'quota' 探活。
await wrapper.get('#channel-monitor-form').trigger('submit')
await flushPromises()
expect(monitorUpdate).not.toHaveBeenCalled()
await wrapper.get('[data-testid="monitor-primary-model"]').setValue('claude-sonnet-4-5')
await wrapper.get('#channel-monitor-form').trigger('submit')
await flushPromises()
expect(monitorUpdate).toHaveBeenCalledWith(42, expect.objectContaining({
check_mode: 'probe',
primary_model: 'claude-sonnet-4-5',
}))
})
// P2-7(c):update 切回 probe 显式发 account_id=0 解绑存量关联(null=不动)。
it('unbinds the linked account with account_id=0 when a quota monitor switches back to probe', async () => {
accountsGetById.mockRejectedValue(new Error('gone'))
const wrapper = mountDialog(makeMonitor({
provider: 'anthropic',
check_mode: 'quota',
account_id: 999,
endpoint: '',
primary_model: 'quota',
}))
await flushPromises()
await wrapper.get('[data-testid="monitor-check-mode-probe"]').trigger('click')
await wrapper.get('[data-testid="monitor-primary-model"]').setValue('claude-sonnet-4-5')
await wrapper.get('#channel-monitor-form').trigger('submit')
await flushPromises()
expect(monitorUpdate).toHaveBeenCalledWith(42, expect.objectContaining({ account_id: 0 }))
})
// P2-7(c):create 绝不发 0(后端会把 0 存成 &0 触发外键违约),保持 null。
it('keeps account_id null when creating a probe monitor', async () => {
const wrapper = mountDialog()
await flushPromises()
await wrapper.findAll('input[type="text"]')[0].setValue('my monitor')
await wrapper.get('[data-testid="monitor-primary-model"]').setValue('claude-sonnet-4-5')
await wrapper.get('#channel-monitor-form').trigger('submit')
await flushPromises()
expect(monitorCreate).toHaveBeenCalledWith(expect.objectContaining({
check_mode: 'probe',
account_id: null,
}))
})
})