mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-21 15:51:05 +00:00
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:
@@ -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,
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user