Merge pull request #5761 from Randark-JMT/feat/channel-monitor-quota-mode

feat(monitor): 渠道监控配额模式——关联账号展示用量/余额(重启 #5387)
This commit is contained in:
Wesley Liddick authored and GitHub committed 2026-08-18 17:36:27 +08:00
commit e0c48a19ed
79 files changed
+4827 -166

No files matched your search

+64 -1
View File
@@ -5,10 +5,57 @@
import { apiClient } from '../client'
export type Provider = 'openai' | 'anthropic' | 'gemini' | 'grok'
export type Provider =
| 'openai'
| 'anthropic'
| 'gemini'
| 'grok'
| 'antigravity'
| 'kimi'
| 'zhipu'
| 'deepseek'
export type MonitorStatus = 'operational' | 'degraded' | 'failed' | 'error'
export type BodyOverrideMode = 'off' | 'merge' | 'replace'
export type APIMode = 'chat_completions' | 'responses'
/**
* probe = LLM 探活(默认);quota = 仅查关联账号用量(零 LLM 成本);
* quota_probe = 探活 + 配额快照挂主模型行。
*/
export type CheckMode = 'probe' | 'quota' | 'quota_probe'
/** 配额快照中的单个用量窗口(与后端 domain.MonitorQuotaTier 一致)。 */
export interface MonitorQuotaTier {
/** 5h | 7d | 7d-sonnet | 7d-fable | 30d | daily | weekly | total */
window: string
/** 同窗口多档时的机器标识(gemini shared/pro/flash、grok requests/tokens、antigravity 模型名) */
label?: string
used_percent: number
used?: number
limit?: number
/** RFC3339;空表示无重置时间 */
reset_at?: string
}
export interface MonitorBalance {
currency: string
balance: number
}
/** 归一化配额快照(与后端 domain.MonitorQuotaSnapshot 一致)。 */
export interface MonitorQuotaSnapshot {
/** usage | cn_quota | cn_balance */
source: string
success: boolean
tiers?: MonitorQuotaTier[]
balance?: number | null
balances?: MonitorBalance[]
currency?: string
plan_level?: string
/** 401/403 鉴权失败标记(推导为 failed 状态) */
credential_invalid?: boolean
error?: string
fetched_at: string
}
export interface ChannelMonitor {
id: number
@@ -47,6 +94,12 @@ export interface ChannelMonitor {
extra_headers: Record<string, string>
body_override_mode: BodyOverrideMode
body_override: Record<string, unknown> | null
/** 检测模式:probe(默认)/ quota / quota_probe */
check_mode: CheckMode
/** 配额模式关联的账号 ID;探活模式为 null */
account_id: number | null
/** 主模型最近一次配额快照(配额模式;无历史时为 null) */
latest_quota?: MonitorQuotaSnapshot | null
}
export interface ExtraModelStatus {
@@ -75,8 +128,14 @@ export interface CreateParams {
name: string
provider: Provider
api_mode?: APIMode
/** 探活模式必填(base origin);quota 模式可留空 */
endpoint: string
/** 探活模式必填;quota 模式可留空 */
api_key: string
/** 缺省 probe;antigravity 仅支持 quota */
check_mode?: CheckMode
/** 配额模式必填:数据源账号(provider 需与账号平台一致) */
account_id?: number | null
primary_model: string
extra_models?: string[]
group_name?: string
@@ -101,6 +160,8 @@ export interface CheckResult {
ping_latency_ms: number | null
message: string
checked_at: string
/** 配额模式(quota / quota_probe 主模型行)附带的配额快照 */
quota?: MonitorQuotaSnapshot | null
}
export interface RunNowResponse {
@@ -115,6 +176,8 @@ export interface HistoryItem {
ping_latency_ms: number | null
message: string
checked_at: string
/** 配额快照(配额模式行;探活行为空) */
quota?: MonitorQuotaSnapshot | null
}
export interface HistoryParams {
+2
View File
@@ -718,6 +718,7 @@ export interface SystemSettings {
channel_monitor_mode?: 'v1' | 'v2';
channel_monitor_default_interval_seconds: number;
channel_monitor_hide_throughput?: boolean;
channel_monitor_show_quota?: boolean;
// Available Channels feature switch
available_channels_enabled: boolean;
@@ -1016,6 +1017,7 @@ export interface UpdateSettingsRequest {
channel_monitor_mode?: 'v1' | 'v2';
channel_monitor_default_interval_seconds?: number;
channel_monitor_hide_throughput?: boolean;
channel_monitor_show_quota?: boolean;
// Available Channels feature switch
available_channels_enabled?: boolean;
+6 -1
View File
@@ -4,7 +4,7 @@
*/
import { apiClient } from './client'
import type { Provider, MonitorStatus } from './admin/channelMonitor'
import type { MonitorQuotaSnapshot, Provider, MonitorStatus } from './admin/channelMonitor'
export type { Provider, MonitorStatus } from './admin/channelMonitor'
@@ -33,6 +33,11 @@ export interface UserMonitorView {
availability_7d: number
extra_models: UserMonitorExtraModel[]
timeline: MonitorTimelinePoint[]
/**
* 主模型最近配额快照。仅当系统开启 channel_monitor_show_quota 时
* 服务端才会下发(关闭时服务端已剥离,前端 flag 仅作纵深防御)。
*/
latest_quota?: MonitorQuotaSnapshot | null
}
export interface UserMonitorListResponse {
@@ -34,6 +34,8 @@ function makeMonitor(overrides: Partial<ChannelMonitor> = {}): ChannelMonitor {
extra_headers: {},
body_override_mode: 'off',
body_override: null,
check_mode: 'probe',
account_id: null,
...overrides,
}
}
@@ -71,6 +71,10 @@ import {
PROVIDER_ANTHROPIC,
PROVIDER_GEMINI,
PROVIDER_GROK,
PROVIDER_ANTIGRAVITY,
PROVIDER_KIMI,
PROVIDER_ZHIPU,
PROVIDER_DEEPSEEK,
} from '@/constants/channelMonitor'
defineProps<{
@@ -96,6 +100,10 @@ const providerFilterOptions = computed(() => [
{ value: PROVIDER_ANTHROPIC, label: t('monitorCommon.providers.anthropic') },
{ value: PROVIDER_GEMINI, label: t('monitorCommon.providers.gemini') },
{ value: PROVIDER_GROK, label: t('monitorCommon.providers.grok') },
{ value: PROVIDER_ANTIGRAVITY, label: t('monitorCommon.providers.antigravity') },
{ value: PROVIDER_KIMI, label: t('monitorCommon.providers.kimi') },
{ value: PROVIDER_ZHIPU, label: t('monitorCommon.providers.zhipu') },
{ value: PROVIDER_DEEPSEEK, label: t('monitorCommon.providers.deepseek') },
])
const enabledFilterOptions = computed(() => [
@@ -11,6 +11,27 @@
<input v-model="form.name" type="text" required class="input" :placeholder="t('admin.channelMonitor.form.namePlaceholder')" />
</div>
<!-- 检测模式:probe(探活)/ quota(仅配额)/ quota_probe(探活+配额) -->
<div>
<label class="input-label">{{ t('admin.channelMonitor.form.checkMode') }}</label>
<div class="grid gap-3 sm:grid-cols-3" data-testid="monitor-check-mode">
<button
v-for="opt in checkModeOptions"
:key="opt.value"
type="button"
:data-testid="`monitor-check-mode-${opt.value}`"
:aria-pressed="form.check_mode === opt.value"
:disabled="opt.disabled"
class="rounded-lg border-2 px-3 py-2 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50"
:class="checkModeButtonClass(opt.value)"
@click="selectCheckMode(opt.value)"
>
<span class="block text-sm font-semibold">{{ opt.label }}</span>
<span class="mt-0.5 block text-xs opacity-80">{{ opt.hint }}</span>
</button>
</div>
</div>
<div>
<label class="input-label">{{ t('admin.channelMonitor.form.provider') }} <span class="text-red-500">*</span></label>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
@@ -30,7 +51,34 @@
</div>
</div>
<div v-if="form.provider === PROVIDER_OPENAI" class="rounded-lg border border-blue-100 bg-blue-50/50 p-3 dark:border-blue-500/20 dark:bg-blue-500/10">
<!-- 配额模式数据源:关联账号(复用账号侧用量/余额服务) -->
<div v-if="usesQuotaMode">
<label class="input-label">
{{ t('admin.channelMonitor.form.linkedAccount') }} <span class="text-red-500">*</span>
</label>
<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="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>
<div v-if="form.provider === PROVIDER_OPENAI && usesProbePart" class="rounded-lg border border-blue-100 bg-blue-50/50 p-3 dark:border-blue-500/20 dark:bg-blue-500/10">
<label class="input-label">{{ t('admin.channelMonitor.form.apiMode') }}</label>
<div class="grid gap-3 sm:grid-cols-2">
<button
@@ -48,7 +96,7 @@
</div>
</div>
<div>
<div v-if="usesProbePart">
<label class="input-label">{{ t('admin.channelMonitor.form.endpoint') }} <span class="text-red-500">*</span></label>
<div class="flex gap-2">
<input v-model="form.endpoint" data-testid="monitor-endpoint" type="text" required class="input flex-1" :placeholder="t('admin.channelMonitor.form.endpointPlaceholder')" />
@@ -58,7 +106,7 @@
</div>
</div>
<div>
<div v-if="usesProbePart">
<label class="input-label">
{{ t('admin.channelMonitor.form.apiKey') }}<span v-if="!editing" class="text-red-500"> *</span>
</label>
@@ -77,7 +125,7 @@
<p v-if="editing && editing.api_key_masked" class="mt-1 text-xs text-gray-400">{{ editing.api_key_masked }}</p>
</div>
<div>
<div v-if="usesProbePart">
<label class="input-label">{{ t('admin.channelMonitor.form.primaryModel') }} <span class="text-red-500">*</span></label>
<input
v-model="form.primary_model"
@@ -90,7 +138,7 @@
/>
</div>
<div>
<div v-if="usesProbePart">
<label class="input-label">{{ t('admin.channelMonitor.form.extraModels') }}</label>
<ModelTagInput
:models="form.extra_models"
@@ -122,8 +170,8 @@
<Toggle v-model="form.enabled" />
</div>
<!-- 高级设置区:请求模板 + 自定义 headers/body -->
<details class="rounded-lg border border-gray-200 bg-gray-50/50 p-3 dark:border-dark-700 dark:bg-dark-900/30">
<!-- 高级设置区:请求模板 + 自定义 headers/body(仅探活模式有意义) -->
<details v-if="usesProbePart" class="rounded-lg border border-gray-200 bg-gray-50/50 p-3 dark:border-dark-700 dark:bg-dark-900/30">
<summary class="cursor-pointer text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.channelMonitor.advanced.section') }}
</summary>
@@ -197,6 +245,7 @@ import type {
ChannelMonitor,
CreateParams,
APIMode,
CheckMode,
Provider,
UpdateParams,
} from '@/api/admin/channelMonitor'
@@ -216,10 +265,20 @@ import {
PROVIDER_ANTHROPIC,
PROVIDER_GEMINI,
PROVIDER_GROK,
PROVIDER_ANTIGRAVITY,
PROVIDER_KIMI,
PROVIDER_ZHIPU,
PROVIDER_DEEPSEEK,
API_MODE_CHAT_COMPLETIONS,
API_MODE_RESPONSES,
CHECK_MODE_PROBE,
CHECK_MODE_QUOTA,
CHECK_MODE_QUOTA_PROBE,
DEFAULT_GROK_ENDPOINT,
DEFAULT_GROK_MODEL,
DEFAULT_KIMI_ENDPOINT,
DEFAULT_ZHIPU_ENDPOINT,
DEFAULT_DEEPSEEK_ENDPOINT,
DEFAULT_INTERVAL_SECONDS,
} from '@/constants/channelMonitor'
@@ -259,6 +318,8 @@ interface MonitorForm {
name: string
provider: Provider
api_mode: APIMode
check_mode: CheckMode
account_id: number | null
endpoint: string
api_key: string
primary_model: string
@@ -278,6 +339,8 @@ const form = reactive<MonitorForm>({
name: '',
provider: PROVIDER_ANTHROPIC,
api_mode: API_MODE_CHAT_COMPLETIONS,
check_mode: CHECK_MODE_PROBE,
account_id: null,
endpoint: '',
api_key: '',
primary_model: '',
@@ -292,6 +355,10 @@ const form = reactive<MonitorForm>({
body_override: null,
})
// quota / quota_probe 需要关联账号;probe / quota_probe 需要探活字段。
const usesQuotaMode = computed(() => form.check_mode !== CHECK_MODE_PROBE)
const usesProbePart = computed(() => form.check_mode !== CHECK_MODE_QUOTA)
// jitter 上限与后端校验一致:interval - jitter 不得低于最小检测间隔 15 秒。
const maxJitterSeconds = computed<number>(() => Math.max(0, (form.interval_seconds || 0) - 15))
@@ -402,8 +469,194 @@ const providerOptions = computed<ProviderOption[]>(() => [
{ value: PROVIDER_OPENAI, label: t('monitorCommon.providers.openai') },
{ value: PROVIDER_GEMINI, label: t('monitorCommon.providers.gemini') },
{ value: PROVIDER_GROK, label: t('monitorCommon.providers.grok') },
{ value: PROVIDER_ANTIGRAVITY, label: t('monitorCommon.providers.antigravity') },
{ value: PROVIDER_KIMI, label: t('monitorCommon.providers.kimi') },
{ value: PROVIDER_ZHIPU, label: t('monitorCommon.providers.zhipu') },
{ value: PROVIDER_DEEPSEEK, label: t('monitorCommon.providers.deepseek') },
])
// 国产 provider 预填的官方 endpoint(仅探活侧;配额模式 endpoint 可留空)。
const PROVIDER_DEFAULT_ENDPOINTS: Partial<Record<Provider, string>> = {
[PROVIDER_KIMI]: DEFAULT_KIMI_ENDPOINT,
[PROVIDER_ZHIPU]: DEFAULT_ZHIPU_ENDPOINT,
[PROVIDER_DEEPSEEK]: DEFAULT_DEEPSEEK_ENDPOINT,
}
interface CheckModeOption {
value: CheckMode
label: string
hint: string
disabled: boolean
}
const checkModeOptions = computed<CheckModeOption[]>(() => [
{
value: CHECK_MODE_PROBE,
label: t('admin.channelMonitor.form.checkModeProbe'),
hint: t('admin.channelMonitor.form.checkModeProbeHint'),
// antigravity 无探活 adapter,仅配额模式。
disabled: form.provider === PROVIDER_ANTIGRAVITY,
},
{
value: CHECK_MODE_QUOTA,
label: t('admin.channelMonitor.form.checkModeQuota'),
hint: t('admin.channelMonitor.form.checkModeQuotaHint'),
disabled: false,
},
{
value: CHECK_MODE_QUOTA_PROBE,
label: t('admin.channelMonitor.form.checkModeQuotaProbe'),
hint: t('admin.channelMonitor.form.checkModeQuotaProbeHint'),
// antigravity 无探活 adapter,只支持配额模式。
disabled: form.provider === PROVIDER_ANTIGRAVITY,
},
])
function checkModeButtonClass(mode: CheckMode): string {
const active = form.check_mode === mode
if (active) {
return 'border-primary-500 bg-white text-primary-700 shadow-sm dark:border-primary-400 dark:bg-primary-500/15 dark:text-primary-300'
}
return 'border-blue-100 bg-white/70 text-gray-600 hover:border-primary-300 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-400'
}
function selectCheckMode(mode: CheckMode) {
if (checkModeOptions.value.find((opt) => opt.value === mode)?.disabled) return
form.check_mode = mode
if (!usesQuotaMode.value) form.account_id = null
}
// --- 关联账号选择器 ---
interface LinkedAccount {
id: number
name: string
}
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(() => {
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>({
get: () => (form.account_id == null ? '' : String(form.account_id)),
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
pinnedAccount.value = linkedAccounts.value.find((a) => a.id === id) ?? pinnedAccount.value
}
},
})
// 服务端搜索当前 provider 平台的账号(支持关键字,避免大分页截断取不齐)。
// seq + abort 防止快速切换 provider / 连续输入时乱序响应覆盖新结果。
// 失败不阻塞表单:下拉为空 + 空态提示。
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,
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 }))
await ensureSelectedAccountHydrated()
} catch (err: unknown) {
if (controller.signal.aborted) return
console.warn('load linked accounts failed', err)
if (!search) linkedAccounts.value = []
} finally {
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, 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 },
)
function selectProvider(provider: Provider) {
if (form.provider === provider) return
const previousProvider = form.provider
@@ -411,14 +664,26 @@ function selectProvider(provider: Provider) {
previousProvider === PROVIDER_GROK && form.endpoint === DEFAULT_GROK_ENDPOINT
const clearGrokModel =
previousProvider === PROVIDER_GROK && form.primary_model === DEFAULT_GROK_MODEL
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
}
if (provider === PROVIDER_GROK) {
if (!form.endpoint.trim()) form.endpoint = DEFAULT_GROK_ENDPOINT
if (!form.primary_model.trim()) form.primary_model = DEFAULT_GROK_MODEL
return
}
if (clearGrokEndpoint) form.endpoint = ''
if (clearGrokEndpoint || clearPrevDefaultEndpoint) form.endpoint = ''
if (clearGrokModel) form.primary_model = ''
const defaultEndpoint = PROVIDER_DEFAULT_ENDPOINTS[provider]
if (defaultEndpoint && !form.endpoint.trim()) form.endpoint = defaultEndpoint
}
// Clear api_key whenever provider changes to avoid cross-provider key mismatch.
@@ -447,6 +712,10 @@ function resetForm() {
form.name = ''
form.provider = PROVIDER_ANTHROPIC
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 = ''
@@ -467,6 +736,8 @@ function loadFromMonitor(m: ChannelMonitor) {
form.name = m.name
form.provider = m.provider
form.api_mode = normalizeAPIMode(m.api_mode)
form.check_mode = m.check_mode || CHECK_MODE_PROBE
form.account_id = m.account_id ?? null
form.endpoint = m.endpoint
form.api_key = ''
form.primary_model = m.primary_model
@@ -533,15 +804,17 @@ function buildPayload(): CreateParams {
name: form.name.trim(),
provider: form.provider,
api_mode: form.provider === PROVIDER_OPENAI ? form.api_mode : API_MODE_CHAT_COMPLETIONS,
endpoint: form.endpoint.trim(),
api_key: form.api_key.trim(),
primary_model: form.primary_model.trim(),
extra_models: form.extra_models,
check_mode: form.check_mode,
account_id: usesQuotaMode.value ? form.account_id : null,
endpoint: usesProbePart.value ? form.endpoint.trim() : '',
api_key: usesProbePart.value ? form.api_key.trim() : '',
primary_model: usesProbePart.value ? form.primary_model.trim() : 'quota',
extra_models: usesProbePart.value ? form.extra_models : [],
group_name: form.group_name.trim(),
enabled: form.enabled,
interval_seconds: form.interval_seconds,
jitter_seconds: form.jitter_seconds || 0,
template_id: form.template_id,
template_id: usesProbePart.value ? form.template_id : null,
extra_headers: form.extra_headers,
body_override_mode: form.body_override_mode,
body_override: form.body_override,
@@ -554,7 +827,11 @@ async function handleSubmit() {
appStore.showError(t('admin.channelMonitor.nameRequired'))
return
}
if (!form.primary_model.trim()) {
if (usesQuotaMode.value && form.account_id == null) {
appStore.showError(t('admin.channelMonitor.linkedAccountRequired'))
return
}
if (usesProbePart.value && !form.primary_model.trim()) {
appStore.showError(t('admin.channelMonitor.primaryModelRequired'))
return
}
@@ -568,7 +845,7 @@ async function handleSubmit() {
// Only send api_key if user typed a new value
if (api_key) req.api_key = api_key
// template_id=null 用 clear_template=true 明确告诉后端清空(pointer 语义)
if (form.template_id == null) {
if (usesProbePart.value && form.template_id == null) {
req.clear_template = true
delete req.template_id
}
@@ -1,7 +1,8 @@
<template>
<div class="flex items-center gap-2">
<span class="text-sm text-gray-900 dark:text-gray-100">{{ row.primary_model }}</span>
<HelpTooltip>
<div class="flex flex-col gap-0.5">
<div class="flex items-center gap-2">
<span class="text-sm text-gray-900 dark:text-gray-100">{{ row.primary_model }}</span>
<HelpTooltip>
<template #trigger>
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium"
@@ -52,7 +53,10 @@
</table>
</div>
</div>
</HelpTooltip>
</HelpTooltip>
</div>
<!-- 配额模式监控:主模型行内联展示最新用量/余额快照(管理端不受用户端开关限制) -->
<MonitorQuotaView :snapshot="row.latest_quota" />
</div>
</template>
@@ -60,6 +64,7 @@
import { useI18n } from 'vue-i18n'
import type { ChannelMonitor } from '@/api/admin/channelMonitor'
import HelpTooltip from '@/components/common/HelpTooltip.vue'
import MonitorQuotaView from '@/components/common/MonitorQuotaView.vue'
import { useChannelMonitorFormat } from '@/composables/useChannelMonitorFormat'
defineProps<{
@@ -14,6 +14,7 @@
<div class="flex flex-col">
<span class="font-medium text-gray-900 dark:text-white">{{ r.model }}</span>
<span v-if="r.message" class="text-xs text-gray-500 dark:text-gray-400">{{ r.message }}</span>
<MonitorQuotaView :snapshot="r.quota" class="mt-1" />
</div>
<div class="flex items-center gap-2">
<span
@@ -40,6 +41,7 @@
import { useI18n } from 'vue-i18n'
import type { CheckResult } from '@/api/admin/channelMonitor'
import BaseDialog from '@/components/common/BaseDialog.vue'
import MonitorQuotaView from '@/components/common/MonitorQuotaView.vue'
import { useChannelMonitorFormat } from '@/composables/useChannelMonitorFormat'
defineProps<{
@@ -249,6 +249,11 @@ import {
PROVIDER_OPENAI,
PROVIDER_GEMINI,
PROVIDER_GROK,
PROVIDER_ANTIGRAVITY,
PROVIDER_KIMI,
PROVIDER_ZHIPU,
PROVIDER_DEEPSEEK,
PROVIDERS,
API_MODE_CHAT_COMPLETIONS,
API_MODE_RESPONSES,
} from '@/constants/channelMonitor'
@@ -269,6 +274,10 @@ const providerTabs = computed<{ value: Provider; label: string }[]>(() => [
{ value: PROVIDER_OPENAI, label: t('monitorCommon.providers.openai') },
{ value: PROVIDER_GEMINI, label: t('monitorCommon.providers.gemini') },
{ value: PROVIDER_GROK, label: t('monitorCommon.providers.grok') },
{ value: PROVIDER_ANTIGRAVITY, label: t('monitorCommon.providers.antigravity') },
{ value: PROVIDER_KIMI, label: t('monitorCommon.providers.kimi') },
{ value: PROVIDER_ZHIPU, label: t('monitorCommon.providers.zhipu') },
{ value: PROVIDER_DEEPSEEK, label: t('monitorCommon.providers.deepseek') },
])
const activeProvider = ref<Provider>(PROVIDER_ANTHROPIC)
@@ -280,12 +289,7 @@ const templatesForActiveProvider = computed(() =>
)
const countByProvider = computed<Record<Provider, number>>(() => {
const out: Record<Provider, number> = {
anthropic: 0,
openai: 0,
gemini: 0,
grok: 0,
}
const out = Object.fromEntries(PROVIDERS.map((p) => [p, 0])) as Record<Provider, number>
for (const t of templates.value) out[t.provider]++
return out
})
@@ -0,0 +1,156 @@
<template>
<div v-if="snapshot" class="space-y-1" data-testid="monitor-quota-view">
<!-- 套餐等级徽章(如智谱 plan level / Claude 订阅档) -->
<div v-if="snapshot.plan_level" class="flex flex-wrap items-center gap-1.5">
<span class="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-600 dark:bg-dark-600 dark:text-gray-300">
{{ snapshot.plan_level }}
</span>
</div>
<!-- 用量窗口条形图(样式/阈值对齐账号页 CNProviderQuotaCell) -->
<div v-if="snapshot.success && tierRows.length" class="space-y-1">
<div v-for="row in tierRows" :key="row.key" class="flex items-center gap-1.5 text-[10px]">
<span class="w-14 shrink-0 truncate text-gray-500 dark:text-gray-400" :title="row.title">
{{ row.label }}
</span>
<div class="h-1.5 w-16 shrink-0 overflow-hidden rounded-full bg-gray-200 dark:bg-dark-600">
<div
class="h-full rounded-full transition-all"
:class="utilizationColor(row.tier.used_percent)"
:style="{ width: `${Math.min(100, Math.max(0, row.tier.used_percent))}%` }"
/>
</div>
<span :class="['shrink-0 font-medium', utilizationTextColor(row.tier.used_percent)]">
{{ Math.round(row.tier.used_percent) }}%
</span>
<span v-if="row.tier.reset_at" class="truncate text-gray-400 dark:text-gray-500" :title="row.tier.reset_at">
· {{ formatReset(row.tier.reset_at) }}
</span>
</div>
</div>
<!-- 余额(国产 payg;支持多币种) -->
<div v-if="snapshot.success && balanceRows.length" class="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[10px]">
<span
v-for="b in balanceRows"
:key="b.currency"
:class="['font-medium', b.balance <= 0 ? 'text-red-600 dark:text-red-400' : 'text-gray-600 dark:text-gray-300']"
>
{{ b.balance.toFixed(2) }} {{ b.currency }}
</span>
</div>
<div v-if="!snapshot.success" class="truncate text-[10px] text-red-600 dark:text-red-400" :title="snapshot.error" data-testid="monitor-quota-error">
{{ truncatedError }}
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { MonitorQuotaSnapshot, MonitorQuotaTier } from '@/api/admin/channelMonitor'
/**
* 配额快照渲染(管理端监控列表/运行结果 + 用户端监控卡片共用)。
* 展示形态对齐账号管理侧的用量视图(CNProviderQuotaCell:同阈值配色、
* 同倒计时格式);tier 的 Window/Label 是后端约定的机器 token,
* 已知 token 走 i18n,未知 token 原样展示(前向兼容)。
*/
const props = defineProps<{
snapshot?: MonitorQuotaSnapshot | null
}>()
const { t, te } = useI18n()
interface QuotaTierRow {
key: string
label: string
title: string
tier: MonitorQuotaTier
}
// 已知的 window/label 机器 token → i18n key(monitorCommon.quota.*)。
const windowI18nKeys: Record<string, string> = {
'5h': 'monitorCommon.quota.windows.5h',
'7d': 'monitorCommon.quota.windows.7d',
'7d-sonnet': 'monitorCommon.quota.windows.7dSonnet',
'7d-fable': 'monitorCommon.quota.windows.7dFable',
weekly: 'monitorCommon.quota.windows.weekly',
daily: 'monitorCommon.quota.windows.daily',
'30d': 'monitorCommon.quota.windows.30d',
total: 'monitorCommon.quota.windows.total',
}
const labelI18nKeys: Record<string, string> = {
requests: 'monitorCommon.quota.labels.requests',
tokens: 'monitorCommon.quota.labels.tokens',
shared: 'monitorCommon.quota.labels.shared',
pro: 'monitorCommon.quota.labels.pro',
flash: 'monitorCommon.quota.labels.flash',
}
function windowLabel(window: string): string {
const key = windowI18nKeys[window]
return key && te(key) ? t(key) : window
}
function tierLabel(tier: MonitorQuotaTier): string {
const window = windowLabel(tier.window)
if (!tier.label) return window
const labelKey = labelI18nKeys[tier.label]
const label = labelKey && te(labelKey) ? t(labelKey) : tier.label
return `${label}/${window}`
}
const tierRows = computed<QuotaTierRow[]>(() =>
(props.snapshot?.tiers || []).map((tier, idx) => ({
key: `${tier.window}-${tier.label || ''}-${idx}`,
label: tierLabel(tier),
title: tierLabel(tier),
tier,
})),
)
const balanceRows = computed(() => {
const snapshot = props.snapshot
if (!snapshot) return []
if (snapshot.balances?.length) return snapshot.balances
if (snapshot.balance != null) {
return [{ currency: snapshot.currency || '?', balance: snapshot.balance }]
}
return []
})
const truncatedError = computed(() => {
const error = props.snapshot?.error || t('monitorCommon.quota.unavailable')
return error.length > 48 ? `${error.slice(0, 48)}…` : error
})
const utilizationColor = (pct: number) => {
if (pct >= 90) return 'bg-red-500'
if (pct >= 75) return 'bg-amber-500'
return 'bg-emerald-500'
}
const utilizationTextColor = (pct: number) => {
if (pct >= 90) return 'text-red-600 dark:text-red-400'
if (pct >= 75) return 'text-amber-600 dark:text-amber-400'
return 'text-emerald-600 dark:text-emerald-400'
}
// 重置时间相对/绝对简短显示(与账号页一致)。
const formatReset = (iso: string) => {
const d = new Date(iso)
if (isNaN(d.getTime())) return iso
const now = Date.now()
const diffMs = d.getTime() - now
if (diffMs <= 0) return t('monitorCommon.quota.resetSoon')
if (diffMs < 3_600_000) return `${Math.max(1, Math.round(diffMs / 60_000))}m`
const hours = Math.round(diffMs / 3_600_000)
if (hours < 48) return `${hours}h`
const mm = String(d.getMonth() + 1).padStart(2, '0')
const dd = String(d.getDate()).padStart(2, '0')
return `${mm}-${dd}`
}
</script>
+36 -3
View File
@@ -111,7 +111,7 @@
<!-- Empty state -->
<div v-if="filteredOptions.length === 0" class="select-empty">
{{ emptyTextDisplay }}
{{ props.loading ? t('common.loading') : emptyTextDisplay }}
</div>
</div>
</div>
@@ -154,11 +154,16 @@ interface Props {
id?: string
ariaLabel?: string
ariaDescribedby?: string
/** 远程搜索模式:输入不在本地过滤 options,而是防抖后 emit('search', query),由父组件请求数据更新 options */
remote?: boolean
/** 远程搜索模式下的加载态:options 为空时下拉显示 loading 文案 */
loading?: boolean
}
interface Emits {
(e: 'update:modelValue', value: string | number | boolean | null): void
(e: 'change', value: string | number | boolean | null, option: SelectOption | null): void
(e: 'search', query: string): void
}
const props = withDefaults(defineProps<Props>(), {
@@ -169,7 +174,9 @@ const props = withDefaults(defineProps<Props>(), {
creatablePrefix: '',
clearable: false,
valueKey: 'value',
labelKey: 'label'
labelKey: 'label',
remote: false,
loading: false
})
const emit = defineEmits<Emits>()
@@ -192,7 +199,13 @@ const placeholderText = computed(() => props.placeholder ?? t('common.selectOpti
const searchPlaceholderText = computed(() => props.searchPlaceholder ?? t('common.searchPlaceholder'))
const emptyTextDisplay = computed(() => props.emptyText ?? t('common.noOptionsFound'))
// 远程搜索的防抖间隔(对齐 OpenAIFastPolicyUserSelector 的 300ms 惯例)。
const REMOTE_SEARCH_DEBOUNCE_MS = 300
let remoteSearchTimer: ReturnType<typeof setTimeout> | null = null
const isSearchable = computed(() => {
// 远程搜索模式始终显示搜索框(选项只是服务端结果的一页)。
if (props.remote) return true
if (props.searchable === 'auto') return props.options.length > 5
return props.searchable
})
@@ -276,7 +289,8 @@ const hasValue = computed(
const filteredOptions = computed(() => {
let opts = props.options as any[]
if (isSearchable.value && searchQuery.value) {
// 远程搜索模式不在本地过滤(选项即服务端搜索结果的一页)。
if (isSearchable.value && searchQuery.value && !props.remote) {
const query = searchQuery.value.toLowerCase()
opts = opts.filter((opt) => {
// Match label
@@ -377,11 +391,26 @@ watch(isOpen, (open) => {
} else {
searchQuery.value = ''
focusedIndex.value = -1
// 关闭时取消仍在排队的远程搜索(避免关闭后尾随 emit 一次 search(''))。
if (remoteSearchTimer) {
clearTimeout(remoteSearchTimer)
remoteSearchTimer = null
}
window.removeEventListener('scroll', updateTriggerRect, { capture: true })
window.removeEventListener('resize', calculateDropdownPosition)
}
})
// 远程搜索:输入防抖后交给父组件请求(!isOpen 抑制关闭重置 searchQuery 触发的空 query)。
watch(searchQuery, (query) => {
if (!props.remote || !isOpen.value) return
if (remoteSearchTimer) clearTimeout(remoteSearchTimer)
remoteSearchTimer = setTimeout(() => {
remoteSearchTimer = null
emit('search', query.trim())
}, REMOTE_SEARCH_DEBOUNCE_MS)
})
const selectOption = (option: any) => {
const value = getOptionValue(option) ?? null
emit('update:modelValue', value)
@@ -467,6 +496,10 @@ onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
window.removeEventListener('scroll', updateTriggerRect, { capture: true })
window.removeEventListener('resize', calculateDropdownPosition)
if (remoteSearchTimer) {
clearTimeout(remoteSearchTimer)
remoteSearchTimer = null
}
})
</script>
@@ -0,0 +1,128 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import type { MonitorQuotaSnapshot } from '@/api/admin/channelMonitor'
import MonitorQuotaView from '../MonitorQuotaView.vue'
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
// te() 恒真:已知 token 直接返回 i18n key,便于断言 window/label 映射。
useI18n: () => ({ t: (key: string) => key, te: () => true }),
}
})
function makeSnapshot(overrides: Partial<MonitorQuotaSnapshot> = {}): MonitorQuotaSnapshot {
return {
source: 'usage',
success: true,
fetched_at: '2026-08-18T00:00:00Z',
...overrides,
}
}
describe('MonitorQuotaView', () => {
it('renders nothing without a snapshot', () => {
const wrapper = mount(MonitorQuotaView, { props: { snapshot: null } })
expect(wrapper.find('[data-testid="monitor-quota-view"]').exists()).toBe(false)
expect(wrapper.text()).toBe('')
})
it('maps tier window/label tokens through i18n and colors utilization', () => {
const wrapper = mount(MonitorQuotaView, {
props: {
snapshot: makeSnapshot({
tiers: [
{ window: '5h', used_percent: 42.4 },
{ window: '7d', label: 'pro', used_percent: 80 },
{ window: 'weekly', label: 'unknown-token', used_percent: 95 },
],
}),
},
})
const rows = wrapper.findAll('[data-testid="monitor-quota-view"] .flex.items-center')
// tier 行只在 success 且有数据时渲染
expect(rows.length).toBeGreaterThanOrEqual(3)
const text = wrapper.text()
// 已知 window token 走 i18n
expect(text).toContain('monitorCommon.quota.windows.5h')
expect(text).toContain('monitorCommon.quota.windows.7d')
// 已知 label token 拼成 label/window
expect(text).toContain('monitorCommon.quota.labels.pro/monitorCommon.quota.windows.7d')
// 未知 label 原样透出(前向兼容)
expect(text).toContain('unknown-token/monitorCommon.quota.windows.weekly')
// 百分比取整
expect(text).toContain('42%')
expect(text).toContain('80%')
expect(text).toContain('95%')
const html = wrapper.html()
// 阈值配色:≥90 红 / ≥75 黄 / 其余绿(与账号页 CNProviderQuotaCell 对齐)
expect(html).toContain('bg-emerald-500')
expect(html).toContain('bg-amber-500')
expect(html).toContain('bg-red-500')
})
it('clamps the tier bar width into 0-100', () => {
const wrapper = mount(MonitorQuotaView, {
props: {
snapshot: makeSnapshot({ tiers: [{ window: '5h', used_percent: 240 }] }),
},
})
expect(wrapper.html()).toContain('width: 100%')
})
it('shows the plan level badge and multi-currency balances', () => {
const wrapper = mount(MonitorQuotaView, {
props: {
snapshot: makeSnapshot({
plan_level: 'Max20',
balances: [
{ currency: 'CNY', balance: 12.5 },
{ currency: 'USD', balance: 0 },
],
}),
},
})
expect(wrapper.text()).toContain('Max20')
expect(wrapper.text()).toContain('12.50 CNY')
expect(wrapper.text()).toContain('0.00 USD')
// 余额为 0 用红色警示
expect(wrapper.html()).toContain('text-red-600')
})
it('falls back to the single balance + currency pair', () => {
const wrapper = mount(MonitorQuotaView, {
props: { snapshot: makeSnapshot({ balance: 3.2, currency: 'CNY' }) },
})
expect(wrapper.text()).toContain('3.20 CNY')
})
it('renders a truncated error state when the fetch failed', () => {
const longError = 'x'.repeat(60)
const wrapper = mount(MonitorQuotaView, {
props: {
snapshot: makeSnapshot({ success: false, error: longError }),
},
})
const error = wrapper.get('[data-testid="monitor-quota-error"]')
expect(error.text()).toBe(`${'x'.repeat(48)}…`)
expect(error.attributes('title')).toBe(longError)
})
it('keeps failed snapshots from rendering tier rows', () => {
const wrapper = mount(MonitorQuotaView, {
props: {
snapshot: makeSnapshot({
success: false,
tiers: [{ window: '5h', used_percent: 10 }],
}),
},
})
expect(wrapper.text()).not.toContain('10%')
})
})
@@ -61,6 +61,7 @@ afterEach(() => {
unmountWrapper = undefined
document.body.innerHTML = ''
setViewportWidth(originalInnerWidth)
vi.useRealTimers()
vi.restoreAllMocks()
})
@@ -113,3 +114,106 @@ describe('Select dropdown viewport constraints', () => {
expect(dropdown?.style.maxWidth).toBe('0px')
})
})
describe('Select remote search', () => {
const mountRemoteSelect = (props: Record<string, unknown> = {}) => {
const wrapper = mount(Select, {
props: {
modelValue: null,
remote: true,
options: [
{ value: 'alpha', label: 'Alpha account' },
{ value: 'beta', label: 'Beta account' },
],
...props,
},
})
unmountWrapper = () => wrapper.unmount()
return wrapper
}
const openDropdown = async () => {
const dropdown = document.body.querySelector<HTMLElement>('.select-dropdown-portal')
expect(dropdown).not.toBeNull()
return dropdown as HTMLElement
}
const typeSearchQuery = async (query: string) => {
const dropdown = await openDropdown()
const input = dropdown.querySelector<HTMLInputElement>('.select-search-input')
expect(input).not.toBeNull()
input!.value = query
input!.dispatchEvent(new Event('input'))
await nextTick()
}
it('emits debounced search events and skips local filtering in remote mode', async () => {
vi.useFakeTimers()
const wrapper = mountRemoteSelect()
await wrapper.get('button').trigger('click')
await nextTick()
await typeSearchQuery('zzz')
// 防抖窗口内不触发。
expect(wrapper.emitted('search')).toBeUndefined()
await vi.advanceTimersByTimeAsync(300)
expect(wrapper.emitted('search')).toEqual([['zzz']])
// 远程模式不做本地过滤:无命中的 query 下选项仍完整展示(由父组件更新 options)。
const dropdown = await openDropdown()
const labels = [...dropdown.querySelectorAll('.select-option-label')].map((el) => el.textContent)
expect(labels).toContain('Alpha account')
expect(labels).toContain('Beta account')
})
it('does not emit search when the dropdown closes and the query resets', async () => {
vi.useFakeTimers()
const wrapper = mountRemoteSelect()
await wrapper.get('button').trigger('click')
await nextTick()
await typeSearchQuery('hidden')
// 关闭下拉:排队中的防抖定时器应被取消,也不应因 query 重置而尾随 emit。
await wrapper.get('button').trigger('click')
await nextTick()
await vi.advanceTimersByTimeAsync(300)
expect(wrapper.emitted('search')).toBeUndefined()
})
it('shows the loading text instead of empty text while loading with no options', async () => {
const wrapper = mountRemoteSelect({ options: [], loading: true })
await wrapper.get('button').trigger('click')
await nextTick()
const dropdown = await openDropdown()
expect(dropdown.querySelector('.select-empty')?.textContent).toContain('common.loading')
})
it('keeps local filtering and emits nothing when remote is not set', async () => {
vi.useFakeTimers()
const wrapper = mount(Select, {
props: {
modelValue: null,
searchable: true,
options: [
{ value: 'alpha', label: 'Alpha account' },
{ value: 'beta', label: 'Beta account' },
],
},
})
unmountWrapper = () => wrapper.unmount()
await wrapper.get('button').trigger('click')
await nextTick()
await typeSearchQuery('alpha')
await vi.advanceTimersByTimeAsync(300)
expect(wrapper.emitted('search')).toBeUndefined()
const dropdown = await openDropdown()
const labels = [...dropdown.querySelectorAll('.select-option-label')].map((el) => el.textContent)
expect(labels).toEqual(['Alpha account'])
})
})
@@ -0,0 +1,91 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import type { UserMonitorView } from '@/api/channelMonitor'
import MonitorCard from '../monitor/MonitorCard.vue'
const { isQuotaVisible } = vi.hoisted(() => ({
isQuotaVisible: vi.fn(() => false),
}))
vi.mock('@/utils/featureFlags', () => ({
isChannelMonitorQuotaVisible: () => isQuotaVisible(),
}))
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({ t: (key: string) => key, te: () => true }),
}
})
function makeItem(overrides: Partial<UserMonitorView> = {}): UserMonitorView {
return {
id: 1,
name: 'claude-main',
provider: 'kimi',
group_name: '',
primary_model: 'quota',
primary_status: 'operational',
primary_latency_ms: null,
primary_ping_latency_ms: null,
availability_7d: 100,
extra_models: [],
timeline: [],
...overrides,
}
}
function mountCard(item: UserMonitorView) {
return mount(MonitorCard, {
props: {
item,
window: '7d',
availabilityValue: 100,
countdownSeconds: 0,
},
global: {
stubs: {
MonitorMetricPair: true,
MonitorAvailabilityRow: true,
MonitorTimeline: true,
},
},
})
}
describe('MonitorCard quota snapshot visibility', () => {
it('hides the quota block when the system switch is off even if data exists', () => {
isQuotaVisible.mockReturnValue(false)
const wrapper = mountCard(
makeItem({
latest_quota: { source: 'cn_quota', success: true, fetched_at: '2026-08-18T00:00:00Z' },
}),
)
expect(wrapper.find('[data-testid="monitor-quota-view"]').exists()).toBe(false)
})
it('renders the quota block when the switch is on and a snapshot exists', () => {
isQuotaVisible.mockReturnValue(true)
const wrapper = mountCard(
makeItem({
latest_quota: {
source: 'cn_quota',
success: true,
plan_level: 'kimi-plus',
tiers: [{ window: 'daily', label: 'requests', used_percent: 60 }],
fetched_at: '2026-08-18T00:00:00Z',
},
}),
)
expect(wrapper.find('[data-testid="monitor-quota-view"]').exists()).toBe(true)
expect(wrapper.text()).toContain('kimi-plus')
})
it('never renders the quota block without a snapshot', () => {
isQuotaVisible.mockReturnValue(true)
const wrapper = mountCard(makeItem())
expect(wrapper.find('[data-testid="monitor-quota-view"]').exists()).toBe(false)
})
})
@@ -54,6 +54,9 @@
secondary-unit="ms"
/>
<!-- 配额模式:最新用量/余额快照(服务端已按系统开关剥离,此处 flag 为纵深防御) -->
<MonitorQuotaView v-if="quotaVisible" :snapshot="item.latest_quota" class="mt-2" />
<!-- Divider -->
<div class="mt-4 border-t border-gray-100 dark:border-dark-700/60"></div>
@@ -80,16 +83,23 @@ import {
useChannelMonitorFormat,
providerGradient,
} from '@/composables/useChannelMonitorFormat'
import { isChannelMonitorQuotaVisible } from '@/utils/featureFlags'
import ProviderIcon from './ProviderIcon.vue'
import MonitorMetricPair from './MonitorMetricPair.vue'
import MonitorAvailabilityRow from './MonitorAvailabilityRow.vue'
import MonitorTimeline from './MonitorTimeline.vue'
import MonitorQuotaView from '@/components/common/MonitorQuotaView.vue'
// 图标配色与 utils/platformColors.ts 的平台色对齐(新 4 家)。
const PROVIDER_TINT: Record<string, string> = {
openai: 'text-emerald-600 dark:text-emerald-300',
anthropic: 'text-orange-600 dark:text-orange-300',
gemini: 'text-sky-600 dark:text-sky-300',
grok: 'text-zinc-700 dark:text-zinc-200',
antigravity: 'text-purple-600 dark:text-purple-300',
kimi: 'text-pink-600 dark:text-pink-300',
zhipu: 'text-indigo-600 dark:text-indigo-300',
deepseek: 'text-teal-600 dark:text-teal-300',
}
const props = defineProps<{
@@ -116,6 +126,10 @@ const providerTintClass = computed(() =>
PROVIDER_TINT[props.item.provider] ?? 'text-gray-500 dark:text-gray-300'
)
const quotaVisible = computed(
() => isChannelMonitorQuotaVisible() && !!props.item.latest_quota
)
const availabilityLabel = computed(() => {
const win = t(`channelStatus.windowTab.${props.window}`)
return `${t('monitorCommon.availabilityPrefix')} · ${win}`
@@ -56,6 +56,28 @@ const PROVIDER_ICONS: Record<Provider, IconData> = {
'M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815',
],
},
// antigravity / kimi / zhipu / deepseek 的官方 logo mark 搬自
// src/components/common/PlatformIcon.vue,保持同步。
antigravity: {
paths: [
'M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96z',
],
},
kimi: {
paths: [
'M21.765.351C22.998.351 24 1.353 24 2.586S22.998 4.82 21.765 4.82h-1.974c-.15 0-.26-.12-.26-.26V2.586A2.237 2.237 0 0 1 21.765.35M9.41 13.388l8.447-8.377c.16-.16.07-.471-.14-.471h-4.55s-.1.02-.14.06l-9.099 9.029c-.14.14-.35.02-.35-.21V4.81c0-.15-.1-.27-.221-.27H.22c-.12 0-.22.12-.22.27v18.57c0 .15.1.27.22.27h3.137c.12 0 .22-.12.22-.27v-3.79c0-.08.03-.16.08-.21l2.826-2.796c.07-.07.16-.08.241-.03l7.546 5.551a8.9 8.9 0 0 0 4.018 1.493c.12.01.23-.11.23-.27V19.76c0-.14-.08-.25-.19-.26a5.8 5.8 0 0 1-2.355-.942l-6.533-4.73c-.14-.09-.15-.32-.03-.441',
],
},
zhipu: {
paths: [
'M11.991 23.503a.24.24 0 0 0-.244.248a.24.24 0 0 0 .244.249a.24.24 0 0 0 .245-.249a.24.24 0 0 0-.22-.247zM9.671 5.365a1.697 1.697 0 0 1 1.099 2.132l-.071.172l-.016.04l-.018.054c-.07.16-.104.32-.104.498c-.035.71.47 1.279 1.186 1.314h.366c1.309.053 2.338 1.173 2.286 2.523c-.052 1.332-1.152 2.38-2.478 2.327h-.174c-.715.018-1.274.64-1.239 1.368c0 .124.018.23.053.337c.209.373.54.658.96.8c.75.23 1.517-.125 1.9-.782l.018-.035c.402-.64 1.17-.96 1.92-.711c.854.284 1.378 1.226 1.099 2.167a1.66 1.66 0 0 1-2.077 1.102a1.7 1.7 0 0 1-.907-.711l-.017-.035c-.2-.323-.463-.58-.851-.711l-.056-.018a1.646 1.646 0 0 0-1.954.746a1.66 1.66 0 0 1-1.065.764a1.677 1.677 0 0 1-1.989-1.279c-.209-.906.332-1.83 1.257-2.043a1.5 1.5 0 0 1 .296-.035h.018c.68-.071 1.151-.622 1.116-1.333a1.3 1.3 0 0 0-.227-.693a2.5 2.5 0 0 1-.366-1.403a2.4 2.4 0 0 1 .366-1.208c.14-.195.21-.444.227-.693c.018-.71-.506-1.261-1.186-1.332l-.07-.018a1.4 1.4 0 0 1-.299-.07l-.05-.019a1.7 1.7 0 0 1-1.047-2.114a1.68 1.68 0 0 1 2.094-1.101m-5.575 10.11c.26-.264.639-.367.994-.27s.633.379.728.74c.095.362-.007.748-.267 1.013c-.402.41-1.053.41-1.455 0a1.06 1.06 0 0 1 0-1.482zm14.845-.294c.359-.09.738.024.992.297c.254.274.344.665.237 1.025s-.396.634-.756.718c-.551.128-1.1-.22-1.23-.781a1.05 1.05 0 0 1 .757-1.26zm-.064-4.39c.314.32.49.753.49 1.206s-.176.886-.49 1.206c-.315.32-.74.5-1.185.5c-.444 0-.87-.18-1.184-.5a1.727 1.727 0 0 1 0-2.412a1.654 1.654 0 0 1 2.369 0m-11.243.163c.364.484.447 1.128.218 1.691a1.665 1.665 0 0 1-2.188.923c-.855-.36-1.26-1.358-.907-2.228a1.68 1.68 0 0 1 1.33-1.038a1.66 1.66 0 0 1 1.547.652m11.545-4.221c.368 0 .708.2.892.524s.184.724 0 1.048a1.03 1.03 0 0 1-.892.524a1.04 1.04 0 0 1-1.03-1.048a1.04 1.04 0 0 1 1.03-1.048m-14.358 0c.368 0 .707.2.891.524s.184.724 0 1.048a1.03 1.03 0 0 1-.891.524a1.04 1.04 0 0 1-1.03-1.048c0-.579.461-1.048 1.03-1.048m10.031-1.475c.925 0 1.675.764 1.675 1.706s-.75 1.705-1.675 1.705s-1.674-.763-1.674-1.705s.75-1.706 1.674-1.706m-2.626-.684c.362-.082.653-.356.761-.718a1.06 1.06 0 0 0-.238-1.028a1.02 1.02 0 0 0-.996-.294c-.547.14-.881.7-.752 1.257c.13.558.675.907 1.225.783m0 16.876c.359-.087.644-.36.75-.72a1.06 1.06 0 0 0-.237-1.019a1.02 1.02 0 0 0-.985-.301a1.04 1.04 0 0 0-.762.717c-.108.361-.017.754.239 1.028c.245.263.606.377.953.305zM17.19 3.5a.63.63 0 0 0 .628-.64a.63.63 0 0 0-.628-.64a.63.63 0 0 0-.628.64c0 .355.28.64.628.64m-10.38 0a.63.63 0 0 0 .628-.64c0-.355-.28-.64-.628-.64a.63.63 0 0 0-.628.64c0 .355.279.64.628.64m-5.182 7.852a.63.63 0 0 0-.628.64c0 .354.28.639.628.639a.63.63 0 0 0 .627-.606l.001-.034a.62.62 0 0 0-.628-.64zm5.182 9.13a.63.63 0 0 0-.628.64c0 .355.279.64.628.64a.63.63 0 0 0 .628-.64c0-.355-.28-.64-.628-.64m10.38.018a.63.63 0 0 0-.628.64c0 .355.28.64.628.64a.63.63 0 0 0 .628-.64a.63.63 0 0 0-.628-.64m5.182-9.148a.63.63 0 0 0-.628.64c0 .354.279.639.628.639a.63.63 0 0 0 .628-.64c0-.355-.28-.64-.628-.64zm-.384-4.992a.24.24 0 0 0 .244-.249a.24.24 0 0 0-.244-.249a.24.24 0 0 0-.244.249c0 .142.122.249.244.249M11.991.497a.24.24 0 0 0 .245-.248A.24.24 0 0 0 11.99 0a.24.24 0 0 0-.244.249c0 .133.108.236.223.247zM2.011 6.36a.24.24 0 0 0 .245-.249a.24.24 0 0 0-.244-.249a.24.24 0 0 0-.244.249a.24.24 0 0 0 .244.249zm0 11.263a.24.24 0 0 0-.243.248a.24.24 0 0 0 .244.249a.24.24 0 0 0 .244-.249a.25.25 0 0 0-.244-.248zm19.995-.018a.24.24 0 0 0-.245.248a.24.24 0 0 0 .245.25a.24.24 0 0 0 .244-.25a.25.25 0 0 0-.244-.248z',
],
},
deepseek: {
paths: [
'M23.748 4.651c-.254-.124-.364.113-.512.233-.051.04-.094.09-.137.137-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.155-.708-.311-.955-.65-.172-.24-.219-.509-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.094.172.187.129.323-.082.28-.18.553-.266.833-.055.179-.137.218-.328.14a5.5 5.5 0 0 1-1.737-1.179c-.857-.828-1.631-1.743-2.597-2.46a12 12 0 0 0-.689-.47c-.985-.957.13-1.743.387-1.836.27-.098.094-.433-.778-.428-.872.003-1.67.295-2.687.685a3 3 0 0 1-.465.136a9.6 9.6 0 0 0-2.883-.101c-1.885.21-3.39 1.1-4.497 2.622C.082 8.776-.231 10.854.152 13.02c.403 2.284 1.568 4.175 3.36 5.653 1.857 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.132-.284 4.994-1.86.47.234.962.328 1.78.398.629.058 1.235-.031 1.705-.129.735-.155.684-.836.418-.961-2.155-1.004-1.682-.595-2.112-.926 1.095-1.295 2.768-3.598 3.284-6.733.05-.346.115-.834.108-1.114-.004-.171.035-.238.23-.257a4.2 4.2 0 0 0 1.545-.475c1.397-.763 1.96-2.016 2.093-3.517.02-.23-.004-.467-.247-.588M11.58 18.168c-2.088-1.642-3.101-2.183-3.52-2.16-.39.024-.32.472-.234.763.09.288.207.487.371.74.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.168-1.361-.801-2.5-1.86-3.301-3.306-.775-1.393-1.225-2.888-1.299-4.482-.02-.385.094-.522.477-.592a4.7 4.7 0 0 1 1.53-.038c2.131.311 3.946 1.264 5.467 2.774.868.86 1.525 1.887 2.202 2.89.72 1.066 1.494 2.082 2.48 2.915.348.291.626.513.892.677-.802.09-2.14.109-3.055-.615zm1.001-6.44a.306.306 0 0 1 .415-.287a.3.3 0 0 1 .113.074a.3.3 0 0 1 .086.214c0 .17-.136.307-.308.307a.303.303 0 0 1-.306-.307m3.11 1.596c-.2.081-.4.151-.591.16a1.25 1.25 0 0 1-.798-.254c-.274-.23-.47-.358-.551-.758a1.7 1.7 0 0 1 .015-.588c.07-.327-.007-.537-.238-.727-.188-.156-.426-.199-.689-.199a.6.6 0 0 1-.254-.078a.253.253 0 0 1-.114-.358a1 1 0 0 1 .192-.21c.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.392.451.462.576.685.915.176.264.336.536.446.848.066.194-.02.353-.25.45',
],
},
}
const props = withDefaults(defineProps<{
@@ -11,12 +11,17 @@
*/
import { useI18n } from 'vue-i18n'
import type { MonitorStatus, Provider } from '@/api/admin/channelMonitor'
import type { CheckMode, MonitorStatus, Provider } from '@/api/admin/channelMonitor'
import {
PROVIDER_OPENAI,
PROVIDER_ANTHROPIC,
PROVIDER_GEMINI,
PROVIDER_GROK,
PROVIDER_ANTIGRAVITY,
PROVIDER_KIMI,
PROVIDER_ZHIPU,
PROVIDER_DEEPSEEK,
PROVIDERS,
STATUS_OPERATIONAL,
STATUS_DEGRADED,
STATUS_FAILED,
@@ -58,17 +63,19 @@ export function useChannelMonitorFormat() {
}
function providerLabel(p: Provider | string): string {
if (
p === PROVIDER_OPENAI ||
p === PROVIDER_ANTHROPIC ||
p === PROVIDER_GEMINI ||
p === PROVIDER_GROK
) {
if (PROVIDERS.includes(p as Provider)) {
return t(`monitorCommon.providers.${p}`)
}
return p || '-'
}
function checkModeLabel(m: CheckMode | string): string {
if (m === 'probe' || m === 'quota' || m === 'quota_probe') {
return t(`monitorCommon.checkMode.${m}`)
}
return m || '-'
}
function providerBadgeClass(p: Provider | string): string {
switch (p) {
case PROVIDER_OPENAI:
@@ -79,6 +86,16 @@ export function useChannelMonitorFormat() {
return 'bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-300'
case PROVIDER_GROK:
return 'bg-zinc-100 text-zinc-700 dark:bg-zinc-500/15 dark:text-zinc-300'
// 配色与 utils/platformColors.ts 的平台色对齐:antigravity=purple /
// kimi=pink / zhipu=indigo / deepseek=teal。
case PROVIDER_ANTIGRAVITY:
return 'bg-purple-100 text-purple-700 dark:bg-purple-500/15 dark:text-purple-300'
case PROVIDER_KIMI:
return 'bg-pink-100 text-pink-700 dark:bg-pink-500/15 dark:text-pink-300'
case PROVIDER_ZHIPU:
return 'bg-indigo-100 text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300'
case PROVIDER_DEEPSEEK:
return 'bg-teal-100 text-teal-700 dark:bg-teal-500/15 dark:text-teal-300'
default:
return NEUTRAL_BADGE
}
@@ -107,6 +124,22 @@ export function useChannelMonitorFormat() {
return active
? 'border-zinc-500 bg-zinc-50 text-zinc-800 dark:bg-zinc-500/15 dark:text-zinc-200 dark:border-zinc-400'
: 'border-gray-200 bg-white text-gray-600 hover:border-zinc-400 hover:text-zinc-800 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-400 dark:hover:border-zinc-500/50'
case PROVIDER_ANTIGRAVITY:
return active
? 'border-purple-500 bg-purple-50 text-purple-700 dark:bg-purple-500/15 dark:text-purple-300 dark:border-purple-400'
: 'border-gray-200 bg-white text-gray-600 hover:border-purple-300 hover:text-purple-700 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-400 dark:hover:border-purple-500/50'
case PROVIDER_KIMI:
return active
? 'border-pink-500 bg-pink-50 text-pink-700 dark:bg-pink-500/15 dark:text-pink-300 dark:border-pink-400'
: 'border-gray-200 bg-white text-gray-600 hover:border-pink-300 hover:text-pink-700 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-400 dark:hover:border-pink-500/50'
case PROVIDER_ZHIPU:
return active
? 'border-indigo-500 bg-indigo-50 text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300 dark:border-indigo-400'
: 'border-gray-200 bg-white text-gray-600 hover:border-indigo-300 hover:text-indigo-700 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-400 dark:hover:border-indigo-500/50'
case PROVIDER_DEEPSEEK:
return active
? 'border-teal-500 bg-teal-50 text-teal-700 dark:bg-teal-500/15 dark:text-teal-300 dark:border-teal-400'
: 'border-gray-200 bg-white text-gray-600 hover:border-teal-300 hover:text-teal-700 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-400 dark:hover:border-teal-500/50'
default:
return active
? 'border-gray-400 bg-gray-50 text-gray-700 dark:border-dark-500 dark:bg-dark-700 dark:text-gray-200'
@@ -147,6 +180,7 @@ export function useChannelMonitorFormat() {
statusLabel,
statusBadgeClass,
providerLabel,
checkModeLabel,
providerBadgeClass,
providerPickerClass,
formatLatency,
@@ -180,6 +214,14 @@ export function providerGradient(provider: string): string {
return 'bg-gradient-to-br from-sky-50 to-indigo-100 dark:from-sky-500/10 dark:to-indigo-500/20'
case PROVIDER_GROK:
return 'bg-gradient-to-br from-zinc-50 to-neutral-200 dark:from-zinc-500/10 dark:to-neutral-500/20'
case PROVIDER_ANTIGRAVITY:
return 'bg-gradient-to-br from-purple-50 to-purple-100 dark:from-purple-500/10 dark:to-purple-500/20'
case PROVIDER_KIMI:
return 'bg-gradient-to-br from-pink-50 to-pink-100 dark:from-pink-500/10 dark:to-pink-500/20'
case PROVIDER_ZHIPU:
return 'bg-gradient-to-br from-indigo-50 to-indigo-100 dark:from-indigo-500/10 dark:to-indigo-500/20'
case PROVIDER_DEEPSEEK:
return 'bg-gradient-to-br from-teal-50 to-teal-100 dark:from-teal-500/10 dark:to-teal-500/20'
default:
return 'bg-gradient-to-br from-gray-100 to-gray-200 dark:from-dark-700 dark:to-dark-600'
}
+27 -1
View File
@@ -7,16 +7,29 @@
* `useChannelMonitorFormat`.
*/
import type { APIMode, Provider, MonitorStatus } from '@/api/admin/channelMonitor'
import type { APIMode, CheckMode, Provider, MonitorStatus } from '@/api/admin/channelMonitor'
export const PROVIDER_OPENAI: Provider = 'openai'
export const PROVIDER_ANTHROPIC: Provider = 'anthropic'
export const PROVIDER_GEMINI: Provider = 'gemini'
export const PROVIDER_GROK: Provider = 'grok'
export const PROVIDER_ANTIGRAVITY: Provider = 'antigravity'
export const PROVIDER_KIMI: Provider = 'kimi'
export const PROVIDER_ZHIPU: Provider = 'zhipu'
export const PROVIDER_DEEPSEEK: Provider = 'deepseek'
export const DEFAULT_GROK_ENDPOINT = 'https://api.x.ai'
export const DEFAULT_GROK_MODEL = 'grok-4.5'
/** 国产 provider 的官方 endpoint(探活模式预填;配额模式可留空)。 */
export const DEFAULT_KIMI_ENDPOINT = 'https://api.moonshot.cn'
export const DEFAULT_ZHIPU_ENDPOINT = 'https://open.bigmodel.cn'
export const DEFAULT_DEEPSEEK_ENDPOINT = 'https://api.deepseek.com'
export const CHECK_MODE_PROBE: CheckMode = 'probe'
export const CHECK_MODE_QUOTA: CheckMode = 'quota'
export const CHECK_MODE_QUOTA_PROBE: CheckMode = 'quota_probe'
export const API_MODE_CHAT_COMPLETIONS: APIMode = 'chat_completions'
export const API_MODE_RESPONSES: APIMode = 'responses'
@@ -25,6 +38,19 @@ export const PROVIDERS: readonly Provider[] = [
PROVIDER_ANTHROPIC,
PROVIDER_GEMINI,
PROVIDER_GROK,
PROVIDER_ANTIGRAVITY,
PROVIDER_KIMI,
PROVIDER_ZHIPU,
PROVIDER_DEEPSEEK,
]
/** 仅支持配额模式(无探活 adapter)的 provider。 */
export const QUOTA_ONLY_PROVIDERS: readonly Provider[] = [PROVIDER_ANTIGRAVITY]
export const CHECK_MODES: readonly CheckMode[] = [
CHECK_MODE_PROBE,
CHECK_MODE_QUOTA,
CHECK_MODE_QUOTA_PROBE,
]
export const API_MODES: readonly APIMode[] = [
@@ -512,6 +512,7 @@ export default {
deleteConfirm: 'Are you sure you want to delete monitor "{name}"? This action cannot be undone.',
nameRequired: 'Please enter a monitor name',
primaryModelRequired: 'Please enter a primary model',
linkedAccountRequired: 'Please select a linked account',
columns: {
name: 'Name',
provider: 'Provider',
@@ -525,6 +526,19 @@ export default {
name: 'Name',
namePlaceholder: 'Enter monitor name',
provider: 'Platform',
checkMode: 'Check Mode',
checkModeProbe: 'Probe',
checkModeProbeHint: 'Sends a lightweight LLM request to measure availability and latency',
checkModeQuota: 'Quota',
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.',
@@ -31,6 +31,9 @@ export default {
hideThroughput: 'Hide throughput rates from users (RPM / TPM)',
hideThroughputHint:
'When on, the user Channel Monitor page and user APIs omit RPM and TPM so fleet volume cannot be reverse-estimated from rates × window. Admins still see full metrics. Error rates, latency, and cache rates remain visible.',
showQuota: 'Show channel usage/balance to users',
showQuotaHint:
'When on, quota-mode channel monitors expose the linked account usage windows/balance on the user Channel Status page. Disabled by default; admins always see it.',
},
availableChannels: {
title: 'Available Channels',
+33 -1
View File
@@ -430,7 +430,39 @@ export default {
openai: 'OpenAI',
anthropic: 'Anthropic',
gemini: 'Gemini',
grok: 'Grok'
grok: 'Grok',
antigravity: 'Antigravity',
kimi: 'Kimi',
zhipu: 'Zhipu GLM',
deepseek: 'DeepSeek'
},
// Check modes (how a monitor performs its checks)
checkMode: {
probe: 'Probe',
quota: 'Quota',
quota_probe: 'Probe + Quota'
},
// Quota snapshot rendering (MonitorQuotaView, shared by admin + user views)
quota: {
unavailable: 'Quota unavailable',
resetSoon: 'resetting',
windows: {
'5h': '5h',
'7d': '7d',
'7dSonnet': '7d Sonnet',
'7dFable': '7d Fable',
weekly: 'Weekly',
daily: 'Daily',
'30d': '30d',
total: 'Total'
},
labels: {
requests: 'Requests',
tokens: 'Tokens',
shared: 'Shared',
pro: 'Pro',
flash: 'Flash'
}
},
extraModelsHeader: 'Extra Models',
extraModelsEmpty: 'No extra models',
@@ -512,6 +512,7 @@ export default {
deleteConfirm: '确定要删除监控「{name}」吗?此操作不可撤销。',
nameRequired: '请输入监控名称',
primaryModelRequired: '请输入主模型',
linkedAccountRequired: '请选择关联账号',
columns: {
name: '名称',
provider: '供应商',
@@ -525,6 +526,19 @@ export default {
name: '名称',
namePlaceholder: '输入监控名称',
provider: '平台',
checkMode: '检查方式',
checkModeProbe: '探活',
checkModeProbeHint: '向上游发送轻量 LLM 请求,检测可用性与延迟',
checkModeQuota: '配额',
checkModeQuotaHint: '只查询关联账号的用量滚动窗口/余额,不发送探活请求',
checkModeQuotaProbe: '探活 + 配额',
checkModeQuotaProbeHint: '探活的同时查询配额,用量快照附加在主模型结果上',
linkedAccount: '关联账号',
linkedAccountPlaceholder: '选择账号',
linkedAccountHint: '配额数据来自所选账号(复用账号管理侧的用量/余额查询)',
linkedAccountEmpty: '当前平台暂无账号,请先在账号管理中添加',
linkedAccountMissing: '关联账号已不存在或不可访问,请重新选择账号',
openAIQuotaProbeHint: '注意:OpenAI 平台的用量查询可能触发 Codex 探测请求,会消耗账号自身的额度(每 10 分钟最多触发一次)',
apiMode: 'OpenAI 协议',
apiModeChatCompletions: 'OpenAI Compatible',
apiModeChatCompletionsHint: '使用 /v1/chat/completions,发送 messages;适合大多数兼容站。',
@@ -31,6 +31,9 @@ export default {
hideThroughput: '对用户隐藏吞吐速率(RPM / TPM)',
hideThroughputHint:
'开启后,用户端渠道监控页面与用户 API 不返回 RPM/TPM,避免用「速率 × 时间窗」反推集群规模。管理员仍可见完整指标;错误率、延迟、缓存率照常展示。',
showQuota: '向用户展示渠道用量/余额',
showQuotaHint:
'开启后,配额模式的渠道监控会在用户端渠道状态页展示关联账号的用量滚动窗口/余额。默认关闭;管理员始终可见。',
},
availableChannels: {
title: '可用渠道',
+33 -1
View File
@@ -435,7 +435,39 @@ export default {
openai: 'OpenAI',
anthropic: 'Anthropic',
gemini: 'Gemini',
grok: 'Grok'
grok: 'Grok',
antigravity: 'Antigravity',
kimi: 'Kimi',
zhipu: '智谱 GLM',
deepseek: 'DeepSeek'
},
// 检查模式(监控条目的工作方式)
checkMode: {
probe: '探活',
quota: '配额',
quota_probe: '探活 + 配额'
},
// 配额快照展示(MonitorQuotaView,管理端与用户端共用)
quota: {
unavailable: '配额信息不可用',
resetSoon: '即将重置',
windows: {
'5h': '5 小时',
'7d': '7 天',
'7dSonnet': '7 天 Sonnet',
'7dFable': '7 天 Fable',
weekly: '周',
daily: '日',
'30d': '30 天',
total: '总量'
},
labels: {
requests: '请求',
tokens: 'Token',
shared: '共享',
pro: 'Pro',
flash: 'Flash'
}
},
extraModelsHeader: '附加模型',
extraModelsEmpty: '无附加模型',
+2
View File
@@ -268,6 +268,8 @@ export interface PublicSettings {
channel_monitor_default_interval_seconds: number
/** When true, user monitor hides RPM/TPM so scale cannot be reverse-estimated. */
channel_monitor_hide_throughput?: boolean
/** When true, user monitor shows account quota/balance snapshots (default off). */
channel_monitor_show_quota?: boolean
available_channels_enabled: boolean
model_plaza_enabled: boolean
model_plaza_require_auth: boolean
+11
View File
@@ -186,3 +186,14 @@ export function isChannelMonitorThroughputHidden(): boolean {
const appStore = useAppStore()
return Boolean(appStore.cachedPublicSettings?.channel_monitor_hide_throughput)
}
/**
* Show quota/balance snapshots on the user-facing monitor page
* (channel_monitor_show_quota, default off). The backend strips
* latest_quota server-side when the switch is off; this flag is
* defense-in-depth only. Admin views always show quota.
*/
export function isChannelMonitorQuotaVisible(): boolean {
const appStore = useAppStore()
return appStore.cachedPublicSettings?.channel_monitor_show_quota === true
}
+18
View File
@@ -7089,6 +7089,18 @@
</div>
<Toggle v-model="form.channel_monitor_hide_throughput" />
</div>
<div v-if="form.channel_monitor_mode === 'v1'" class="flex items-start justify-between gap-4">
<div class="min-w-0">
<p class="text-sm font-medium text-gray-900 dark:text-white">
{{ t('admin.settings.features.channelMonitor.showQuota') }}
</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.features.channelMonitor.showQuotaHint') }}
</p>
</div>
<Toggle v-model="form.channel_monitor_show_quota" />
</div>
</div>
</div>
</div>
@@ -9420,6 +9432,7 @@ type SettingsForm = Omit<
> & {
/** Form always binds a concrete boolean (SystemSettings marks this optional). */
channel_monitor_hide_throughput: boolean;
channel_monitor_show_quota: boolean;
smtp_password: string;
turnstile_secret_key: string;
tencent_captcha_app_secret_key: string;
@@ -9732,6 +9745,7 @@ const form = reactive<SettingsForm>({
channel_monitor_mode: 'v1' as 'v1' | 'v2',
channel_monitor_default_interval_seconds: 60,
channel_monitor_hide_throughput: false,
channel_monitor_show_quota: false,
// Available Channels feature switch
available_channels_enabled: false,
// Model Plaza feature switches + description
@@ -10733,6 +10747,9 @@ async function loadSettings() {
form.channel_monitor_hide_throughput = Boolean(
settings.channel_monitor_hide_throughput
);
form.channel_monitor_show_quota = Boolean(
settings.channel_monitor_show_quota
);
form.login_agreement_updated_at =
settings.login_agreement_updated_at || "2026-03-31";
form.login_agreement_documents =
@@ -11384,6 +11401,7 @@ async function saveSettings() {
channel_monitor_default_interval_seconds:
Number(form.channel_monitor_default_interval_seconds) || 60,
channel_monitor_hide_throughput: Boolean(form.channel_monitor_hide_throughput),
channel_monitor_show_quota: Boolean(form.channel_monitor_show_quota),
// Available Channels feature switch
available_channels_enabled: form.available_channels_enabled,
// Model Plaza feature switches + description
@@ -96,6 +96,8 @@ function makeMonitor(overrides: Partial<ChannelMonitor> = {}): ChannelMonitor {
extra_headers: {},
body_override_mode: 'off',
body_override: null,
check_mode: 'probe',
account_id: null,
...overrides,
}
}
@@ -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 () => {
@@ -93,7 +101,7 @@ describe('channel monitor Grok provider', () => {
expect(PROVIDERS).toContain(PROVIDER_GROK)
const providerButtons = wrapper.findAll('[data-testid^="monitor-provider-"]')
expect(providerButtons).toHaveLength(4)
expect(providerButtons).toHaveLength(8)
expect(providerButtons[0].element.parentElement?.className).toContain('grid-cols-2')
expect(providerButtons[0].element.parentElement?.className).toContain('sm:grid-cols-4')
@@ -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')
})
})