Unverified Commit ac114738 authored by Wesley Liddick's avatar Wesley Liddick Committed by GitHub
Browse files

Merge pull request #1850 from touwaeriol/feat/channel-insights

feat(monitor): channel monitor with available channels & feature flags
parents 0a80ec80 09fd83ab
......@@ -352,6 +352,9 @@ export const useAppStore = defineStore('app', () => {
balance_low_notify_enabled: false,
account_quota_notify_enabled: false,
balance_low_notify_threshold: 0,
channel_monitor_enabled: true,
channel_monitor_default_interval_seconds: 60,
available_channels_enabled: false,
}
}
......
......@@ -186,6 +186,9 @@ export interface PublicSettings {
balance_low_notify_enabled: boolean
account_quota_notify_enabled: boolean
balance_low_notify_threshold: number
channel_monitor_enabled: boolean
channel_monitor_default_interval_seconds: number
available_channels_enabled: boolean
}
export interface AuthResponse {
......
import { describe, expect, it } from 'vitest'
import { enqueueUsageRequest } from '../usageLoadQueue'
import type { Account } from '@/types'
/** Helper to create a minimal Account with proxy info */
function makeAccount(
platform: string,
type: string = 'oauth',
proxy?: { host: string; port: number; username?: string | null } | null
): Account {
return {
id: Math.floor(Math.random() * 10000),
platform,
type,
name: 'test',
status: 'active',
proxy_id: proxy ? 1 : null,
proxy: proxy
? { id: 1, name: 'p', protocol: 'http', host: proxy.host, port: proxy.port, username: proxy.username ?? null, status: 'active', created_at: '', updated_at: '' }
: undefined,
credentials: {},
created_at: '',
updated_at: ''
} as unknown as Account
}
describe('usageLoadQueue', () => {
// ─── Anthropic 账号:按代理出口排队 ───
it('Anthropic 同代理出口串行执行,间隔 >= 1s', async () => {
const timestamps: number[] = []
const makeFn = () => async () => {
timestamps.push(Date.now())
return 'ok'
}
const acc = makeAccount('anthropic', 'oauth', { host: '1.2.3.4', port: 8080, username: 'u1' })
const p1 = enqueueUsageRequest(acc, makeFn())
const p2 = enqueueUsageRequest(acc, makeFn())
const p3 = enqueueUsageRequest(acc, makeFn())
await Promise.all([p1, p2, p3])
expect(timestamps).toHaveLength(3)
expect(timestamps[1] - timestamps[0]).toBeGreaterThanOrEqual(950)
expect(timestamps[1] - timestamps[0]).toBeLessThan(2100)
expect(timestamps[2] - timestamps[1]).toBeGreaterThanOrEqual(950)
expect(timestamps[2] - timestamps[1]).toBeLessThan(2100)
})
it('Anthropic 不同代理出口并行执行', async () => {
const timestamps: Record<string, number> = {}
const makeTracked = (key: string) => async () => {
timestamps[key] = Date.now()
return key
}
const acc1 = makeAccount('anthropic', 'oauth', { host: '1.2.3.4', port: 8080, username: 'u1' })
const acc2 = makeAccount('anthropic', 'oauth', { host: '5.6.7.8', port: 3128, username: 'u2' })
const p1 = enqueueUsageRequest(acc1, makeTracked('proxy1'))
const p2 = enqueueUsageRequest(acc2, makeTracked('proxy2'))
await Promise.all([p1, p2])
const spread = Math.abs(timestamps['proxy1'] - timestamps['proxy2'])
expect(spread).toBeLessThan(50)
})
it('Anthropic 相同代理连接信息的不同账号归为同一队列', async () => {
const timestamps: number[] = []
const makeFn = () => async () => {
timestamps.push(Date.now())
return 'ok'
}
const acc1 = makeAccount('anthropic', 'oauth', { host: '10.0.0.1', port: 3128, username: 'admin' })
const acc2 = makeAccount('anthropic', 'setup-token', { host: '10.0.0.1', port: 3128, username: 'admin' })
const p1 = enqueueUsageRequest(acc1, makeFn())
const p2 = enqueueUsageRequest(acc2, makeFn())
await Promise.all([p1, p2])
expect(timestamps).toHaveLength(2)
expect(timestamps[1] - timestamps[0]).toBeGreaterThanOrEqual(950)
})
it('Anthropic 直连(无代理)的账号归为同一队列', async () => {
const order: number[] = []
const makeFn = (n: number) => async () => {
order.push(n)
return n
}
const acc1 = makeAccount('anthropic', 'oauth')
const acc2 = makeAccount('anthropic', 'setup-token')
const p1 = enqueueUsageRequest(acc1, makeFn(1))
const p2 = enqueueUsageRequest(acc2, makeFn(2))
await Promise.all([p1, p2])
expect(order).toEqual([1, 2])
})
it('Anthropic 请求失败时 reject,后续任务继续执行', async () => {
const results: string[] = []
const acc = makeAccount('anthropic', 'oauth', { host: '99.99.99.99', port: 1234 })
const p1 = enqueueUsageRequest(acc, async () => {
throw new Error('fail')
})
const p2 = enqueueUsageRequest(acc, async () => {
results.push('second')
return 'ok'
})
await expect(p1).rejects.toThrow('fail')
await p2
expect(results).toEqual(['second'])
})
// ─── 非 Anthropic 平台:直接执行,不排队 ───
it('非 Anthropic 平台直接执行,不排队', async () => {
const timestamps: number[] = []
const makeFn = () => async () => {
timestamps.push(Date.now())
return 'ok'
}
// 同一代理的 Gemini 账号 — 应当并行,不排队
const acc1 = makeAccount('gemini', 'oauth', { host: '1.2.3.4', port: 8080 })
const acc2 = makeAccount('gemini', 'oauth', { host: '1.2.3.4', port: 8080 })
const p1 = enqueueUsageRequest(acc1, makeFn())
const p2 = enqueueUsageRequest(acc2, makeFn())
await Promise.all([p1, p2])
expect(timestamps).toHaveLength(2)
// 并行执行,几乎同时完成
expect(Math.abs(timestamps[1] - timestamps[0])).toBeLessThan(50)
})
it('OpenAI 平台直接执行,不排队', async () => {
const timestamps: number[] = []
const makeFn = () => async () => {
timestamps.push(Date.now())
return 'ok'
}
const acc1 = makeAccount('openai', 'oauth', { host: '1.2.3.4', port: 8080 })
const acc2 = makeAccount('openai', 'oauth', { host: '1.2.3.4', port: 8080 })
const p1 = enqueueUsageRequest(acc1, makeFn())
const p2 = enqueueUsageRequest(acc2, makeFn())
await Promise.all([p1, p2])
expect(timestamps).toHaveLength(2)
expect(Math.abs(timestamps[1] - timestamps[0])).toBeLessThan(50)
})
// ─── Anthropic apikey 类型不排队 ───
it('Anthropic apikey 类型直接执行,不排队', async () => {
const timestamps: number[] = []
const makeFn = () => async () => {
timestamps.push(Date.now())
return 'ok'
}
const acc1 = makeAccount('anthropic', 'apikey', { host: '1.2.3.4', port: 8080 })
const acc2 = makeAccount('anthropic', 'apikey', { host: '1.2.3.4', port: 8080 })
const p1 = enqueueUsageRequest(acc1, makeFn())
const p2 = enqueueUsageRequest(acc2, makeFn())
await Promise.all([p1, p2])
expect(timestamps).toHaveLength(2)
expect(Math.abs(timestamps[1] - timestamps[0])).toBeLessThan(50)
})
// ─── 返回值透传 ───
it('返回值正确透传', async () => {
const acc = makeAccount('anthropic', 'oauth')
const result = await enqueueUsageRequest(acc, async () => {
return { usage: 42 }
})
expect(result).toEqual({ usage: 42 })
})
it('非 Anthropic 返回值正确透传', async () => {
const acc = makeAccount('gemini', 'oauth')
const result = await enqueueUsageRequest(acc, async () => {
return { quota: 100 }
})
expect(result).toEqual({ quota: 100 })
})
})
/**
* Feature flag registry — single source of truth for public-settings-driven
* feature switches used by the sidebar, routes, and views.
*
* ## Why this module exists
*
* `public settings` reach the frontend through two channels:
*
* 1. **SSR injection** — the backend embeds `window.__APP_CONFIG__` into the
* HTML. `main.ts` calls `appStore.initFromInjectedConfig()` synchronously
* before Vue mounts, so `cachedPublicSettings` is populated on first
* render.
* 2. **Async API** — `App.vue` awaits `appStore.fetchPublicSettings()` on
* mount as a fallback (used when injection is missing or stale).
*
* If the SSR injection struct forgets to include a feature flag field — the
* exact bug that hid the "可用渠道" menu after every refresh — the frontend
* reads `undefined` until the async call resolves. An opt-in flag written as
* `settings?.xxx_enabled === true` then evaluates to `false` and the menu
* disappears. An opt-out flag written as `settings?.xxx_enabled !== false`
* evaluates to `true` (menu stays) but will flicker off if the backend sends
* `false`.
*
* This module hides that `undefined` handling behind two explicit modes.
*
* ## Modes
*
* - **`opt-out`** (default enabled) — menu visible when settings unloaded,
* hidden only when the backend explicitly sends `false`. Use for features
* that ship enabled by default (Channel Monitor, Payment).
* - **`opt-in`** (default disabled) — menu hidden when settings unloaded,
* visible only when the backend explicitly sends `true`. Use for features
* that ship disabled (Available Channels).
*
* For `opt-in` flags to render immediately on refresh, the backend **must**
* inject the field through `PublicSettingsInjectionPayload`. A drift test in
* `backend/internal/handler/dto/public_settings_injection_schema_test.go`
* catches omissions.
*
* ## Adding a new flag
*
* 1. Backend `service/domain_constants.go` → `SettingKey<Name>Enabled`
* 2. Backend `service/settings_view.go` → `PublicSettings` + `SystemSettings`
* 3. Backend `service/setting_service.go` → `GetPublicSettings` / `UpdateSettings` /
* `GetAllSettings` / `InitDefaultSettings` /
* **`PublicSettingsInjectionPayload`**
* (the drift test enforces this)
* 4. Backend `handler/dto/settings.go` → `PublicSettings` + `SystemSettings`
* 5. Backend `handler/setting_handler.go` → handler response
* 6. Backend `handler/admin/setting_handler.go` → update request + audit diff
* 7. Frontend `types/index.ts` → `PublicSettings` typings
* 8. Frontend `api/admin/settings.ts` → admin DTO typings
* 9. **Frontend `utils/featureFlags.ts` (this file)** → register via `defineFlag`
* 10. Frontend `views/admin/SettingsView.vue` → Toggle UI + form defaults + save payload
* 11. Frontend `components/layout/AppSidebar.vue` → attach via `makeSidebarFlag`
*
* ## Usage
*
* ```ts
* import { FeatureFlags, makeSidebarFlag } from '@/utils/featureFlags'
*
* const flagAvailableChannels = makeSidebarFlag(FeatureFlags.availableChannels)
* // ...
* { path: '/available-channels', label: ..., featureFlag: flagAvailableChannels }
* ```
*
* `isFeatureFlagEnabled(flag)` returns the resolved boolean (`true` = show).
* `makeSidebarFlag(flag)` returns a `() => boolean | undefined` compatible with
* `AppSidebar.NavItem.featureFlag`, where `false` hides the menu entry.
*/
import { useAppStore } from '@/stores/app'
import type { PublicSettings } from '@/types'
export type FeatureFlagMode = 'opt-in' | 'opt-out'
export interface FeatureFlagDefinition {
/** Public-settings key used for lookup. */
readonly key: keyof PublicSettings
/** Resolution mode when the key is missing/undefined. */
readonly mode: FeatureFlagMode
/** Short human label for logs and debug tooling. */
readonly label: string
}
function defineFlag<K extends keyof PublicSettings>(
def: { key: K; mode: FeatureFlagMode; label: string },
): FeatureFlagDefinition {
return def
}
/**
* Registered feature flags. Add a new entry here when introducing a new
* public-settings-driven switch; see the "Adding a new flag" checklist above.
*/
export const FeatureFlags = {
channelMonitor: defineFlag({
key: 'channel_monitor_enabled',
mode: 'opt-out',
label: 'Channel Monitor',
}),
availableChannels: defineFlag({
key: 'available_channels_enabled',
mode: 'opt-in',
label: 'Available Channels',
}),
payment: defineFlag({
key: 'payment_enabled',
mode: 'opt-out',
label: 'Payment',
}),
} as const
export type RegisteredFeatureFlag = keyof typeof FeatureFlags
/**
* Read the current value of a flag, honoring the mode's fallback.
* `true` → the feature is enabled (menu/route should render).
* `false` → the feature is disabled (menu/route should hide).
*/
export function isFeatureFlagEnabled(flag: FeatureFlagDefinition): boolean {
const appStore = useAppStore()
const raw = appStore.cachedPublicSettings?.[flag.key] as
| boolean
| undefined
if (typeof raw === 'boolean') return raw
// Settings not yet loaded → fall back to the flag's declared mode:
// opt-out → visible by default, opt-in → hidden by default.
return flag.mode === 'opt-out'
}
/**
* Sidebar NavItem.featureFlag accepts a getter that returns
* `false` to hide. Keeping the same contract lets callers swap in
* registry-backed flags without changing AppSidebar's filter logic.
*/
export function makeSidebarFlag(flag: FeatureFlagDefinition): () => boolean {
return () => isFeatureFlagEnabled(flag)
}
// Mask an API key for display: reveals first 6 + last 4; short keys (≤12) show `first 4 + ***`.
export function maskApiKey(key: string): string {
if (!key) return ''
if (key.length <= 12) return `${key.slice(0, 4)}***`
return `${key.slice(0, 6)}...${key.slice(-4)}`
}
/**
* formatScaled formats a per-token (or per-request) USD price scaled by `scale`.
*
* formatScaled(0.000003, 1_000_000) → "$3" // per 1M tokens
* formatScaled(0.5, 1) → "$0.5" // per request
* formatScaled(null, 1_000_000) → "-"
*
* Uses toPrecision(10) then strips trailing zeros to avoid IEEE 754 display noise.
*/
export function formatScaled(value: number | null, scale: number): string {
if (value == null) return '-'
return `$${(value * scale).toPrecision(10).replace(/\.?0+$/, '')}`
}
<template>
<AppLayout>
<TablePageLayout>
<template #filters>
<MonitorFiltersBar
v-model:search="searchQuery"
v-model:provider="providerFilter"
v-model:enabled="enabledFilter"
:loading="loading"
@reload="reload"
@create="openCreateDialog"
@manage-templates="showTemplateManager = true"
@search-input="handleSearch"
/>
</template>
<template #table>
<DataTable :columns="columns" :data="monitors" :loading="loading">
<template #cell-name="{ row, value }">
<div class="flex items-center gap-1.5">
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
<HelpTooltip v-if="row.api_key_decrypt_failed" :content="t('admin.channelMonitor.apiKeyDecryptFailed')">
<Icon name="exclamationTriangle" size="sm" class="text-red-500" />
</HelpTooltip>
</div>
</template>
<template #cell-provider="{ row }">
<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>
</template>
<template #cell-primary_model="{ row }">
<MonitorPrimaryModelCell :row="row" />
</template>
<template #cell-availability_7d="{ row }">
<span class="text-sm text-gray-900 dark:text-gray-100">{{ formatAvailability(row) }}</span>
</template>
<template #cell-latency="{ row }">
<span class="text-sm text-gray-900 dark:text-gray-100">{{ formatLatency(row.primary_latency_ms) }}</span>
</template>
<template #cell-enabled="{ row }">
<Toggle :modelValue="row.enabled" @update:modelValue="toggleEnabled(row)" />
</template>
<template #cell-actions="{ row }">
<MonitorActionsCell
:row="row"
:running="runningId === row.id"
@run="handleRunNow"
@edit="openEditDialog"
@delete="handleDelete"
/>
</template>
<template #empty>
<EmptyState
:title="t('admin.channelMonitor.noMonitorsYet')"
:description="t('admin.channelMonitor.createFirstMonitor')"
:action-text="t('admin.channelMonitor.createButton')"
@action="openCreateDialog"
/>
</template>
</DataTable>
</template>
<template #pagination>
<Pagination
v-if="pagination.total > 0"
:page="pagination.page"
:total="pagination.total"
:page-size="pagination.page_size"
@update:page="onPageChange"
@update:pageSize="onPageSizeChange"
/>
</template>
</TablePageLayout>
<MonitorFormDialog
:show="showDialog"
:monitor="editing"
@close="closeDialog"
@saved="reload"
/>
<MonitorTemplateManagerDialog
:show="showTemplateManager"
@close="showTemplateManager = false"
@updated="reload"
/>
<MonitorRunResultDialog
:show="showRunResult"
:results="runResults"
@close="showRunResult = false"
/>
<ConfirmDialog
:show="showDeleteDialog"
:title="t('common.delete')"
:message="deleteConfirmMessage"
:confirm-text="t('common.delete')"
:cancel-text="t('common.cancel')"
:danger="true"
@confirm="confirmDelete"
@cancel="showDeleteDialog = false"
/>
</AppLayout>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { extractApiErrorMessage } from '@/utils/apiError'
import { adminAPI } from '@/api/admin'
import type {
ChannelMonitor,
CheckResult,
ListParams,
Provider,
} from '@/api/admin/channelMonitor'
import type { Column } from '@/components/common/types'
import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
import DataTable from '@/components/common/DataTable.vue'
import Pagination from '@/components/common/Pagination.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import HelpTooltip from '@/components/common/HelpTooltip.vue'
import Icon from '@/components/icons/Icon.vue'
import Toggle from '@/components/common/Toggle.vue'
import MonitorFiltersBar from '@/components/admin/monitor/MonitorFiltersBar.vue'
import MonitorFormDialog from '@/components/admin/monitor/MonitorFormDialog.vue'
import MonitorTemplateManagerDialog from '@/components/admin/monitor/MonitorTemplateManagerDialog.vue'
import MonitorRunResultDialog from '@/components/admin/monitor/MonitorRunResultDialog.vue'
import MonitorPrimaryModelCell from '@/components/admin/monitor/MonitorPrimaryModelCell.vue'
import MonitorActionsCell from '@/components/admin/monitor/MonitorActionsCell.vue'
import { getPersistedPageSize } from '@/composables/usePersistedPageSize'
import { useChannelMonitorFormat } from '@/composables/useChannelMonitorFormat'
const { t } = useI18n()
const appStore = useAppStore()
const {
providerLabel,
providerBadgeClass,
formatLatency,
formatAvailability,
} = useChannelMonitorFormat()
const monitors = ref<ChannelMonitor[]>([])
const loading = ref(false)
const runningId = ref<number | null>(null)
const searchQuery = ref('')
const providerFilter = ref<Provider | ''>('')
const enabledFilter = ref<'' | 'true' | 'false'>('')
const pagination = reactive({ page: 1, page_size: getPersistedPageSize(), total: 0 })
const showDialog = ref(false)
const showTemplateManager = ref(false)
const editing = ref<ChannelMonitor | null>(null)
const showDeleteDialog = ref(false)
const deleting = ref<ChannelMonitor | null>(null)
const showRunResult = ref(false)
const runResults = ref<CheckResult[]>([])
let abortController: AbortController | null = null
let searchTimeout: ReturnType<typeof setTimeout> | null = null
const columns = computed<Column[]>(() => [
{ key: 'name', label: t('admin.channelMonitor.columns.name'), sortable: false },
{ key: 'provider', label: t('admin.channelMonitor.columns.provider'), sortable: false },
{ key: 'primary_model', label: t('admin.channelMonitor.columns.primaryModel'), sortable: false },
{ key: 'availability_7d', label: t('admin.channelMonitor.columns.availability7d'), sortable: false },
{ key: 'latency', label: t('admin.channelMonitor.columns.latency'), sortable: false },
{ key: 'enabled', label: t('admin.channelMonitor.columns.enabled'), sortable: false },
{ key: 'actions', label: t('admin.channelMonitor.columns.actions'), sortable: false },
])
const deleteConfirmMessage = computed(() => {
const name = deleting.value?.name || ''
return t('admin.channelMonitor.deleteConfirm', { name })
})
async function reload() {
if (abortController) abortController.abort()
const ctrl = new AbortController()
abortController = ctrl
loading.value = true
try {
const params: ListParams = {
page: pagination.page,
page_size: pagination.page_size,
}
if (providerFilter.value) params.provider = providerFilter.value
if (enabledFilter.value === 'true') params.enabled = true
if (enabledFilter.value === 'false') params.enabled = false
if (searchQuery.value.trim()) params.search = searchQuery.value.trim()
const res = await adminAPI.channelMonitor.list(params, { signal: ctrl.signal })
if (ctrl.signal.aborted || abortController !== ctrl) return
monitors.value = res.items || []
pagination.total = res.total
} catch (err: unknown) {
const e = err as { name?: string; code?: string }
if (e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return
appStore.showError(extractApiErrorMessage(err, t('admin.channelMonitor.loadError')))
} finally {
if (abortController === ctrl) {
loading.value = false
abortController = null
}
}
}
function handleSearch() {
if (searchTimeout) clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
pagination.page = 1
reload()
}, 300)
}
function onPageChange(page: number) {
pagination.page = page
reload()
}
function onPageSizeChange(size: number) {
pagination.page_size = size
pagination.page = 1
reload()
}
function openCreateDialog() {
editing.value = null
showDialog.value = true
}
function openEditDialog(row: ChannelMonitor) {
editing.value = row
showDialog.value = true
}
function closeDialog() {
showDialog.value = false
editing.value = null
}
async function toggleEnabled(row: ChannelMonitor) {
const next = !row.enabled
try {
await adminAPI.channelMonitor.update(row.id, { enabled: next })
row.enabled = next
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
}
}
async function handleRunNow(row: ChannelMonitor) {
if (runningId.value != null) return
runningId.value = row.id
try {
const res = await adminAPI.channelMonitor.runNow(row.id)
runResults.value = res.results || []
showRunResult.value = true
appStore.showSuccess(t('admin.channelMonitor.runSuccess'))
// Refresh row to get latest status from backend
void reload()
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('admin.channelMonitor.runFailed')))
} finally {
runningId.value = null
}
}
function handleDelete(row: ChannelMonitor) {
deleting.value = row
showDeleteDialog.value = true
}
async function confirmDelete() {
if (!deleting.value) return
try {
await adminAPI.channelMonitor.del(deleting.value.id)
appStore.showSuccess(t('admin.channelMonitor.deleteSuccess'))
showDeleteDialog.value = false
deleting.value = null
reload()
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
}
}
onMounted(reload)
onUnmounted(() => {
if (searchTimeout) clearTimeout(searchTimeout)
abortController?.abort()
})
</script>
......@@ -3767,6 +3767,94 @@
</div>
<!-- /Tab: General -->
<!-- Tab: Features (功能开关) -->
<div v-show="activeTab === 'features'" class="space-y-6">
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t('admin.settings.features.channelMonitor.title') }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.features.channelMonitor.description') }}
</p>
<p class="mt-1.5 text-xs">
<router-link
to="/admin/channels/monitor"
class="inline-flex items-center gap-1 text-primary-600 hover:underline dark:text-primary-400"
>
{{ t('admin.settings.features.channelMonitor.configureLink') }}
<span aria-hidden="true">→</span>
</router-link>
</p>
</div>
<div class="space-y-5 p-6">
<div class="flex items-center justify-between">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.features.channelMonitor.enabled') }}
</label>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.features.channelMonitor.enabledHint') }}
</p>
</div>
<Toggle v-model="form.channel_monitor_enabled" />
</div>
<div v-if="form.channel_monitor_enabled">
<label class="input-label">
{{ t('admin.settings.features.channelMonitor.defaultInterval') }}
<span class="text-red-500">*</span>
</label>
<input
v-model.number="form.channel_monitor_default_interval_seconds"
type="number"
min="15"
max="3600"
class="input"
/>
<p class="mt-1 text-xs text-gray-400">
{{ t('admin.settings.features.channelMonitor.defaultIntervalHint') }}
</p>
</div>
</div>
</div>
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t('admin.settings.features.availableChannels.title') }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.features.availableChannels.description') }}
</p>
<p class="mt-1.5 text-xs">
<router-link
to="/admin/channels/pricing"
class="inline-flex items-center gap-1 text-primary-600 hover:underline dark:text-primary-400"
>
{{ t('admin.settings.features.availableChannels.configureLink') }}
<span aria-hidden="true">→</span>
</router-link>
</p>
</div>
<div class="space-y-5 p-6">
<div class="flex items-center justify-between">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.features.availableChannels.enabled') }}
</label>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.features.availableChannels.enabledHint') }}
</p>
</div>
<Toggle v-model="form.available_channels_enabled" />
</div>
</div>
</div>
</div><!-- /Tab: Features -->
<!-- Tab: Email -->
<!-- Tab: Payment -->
<div v-show="activeTab === 'payment'" class="space-y-6">
......@@ -4755,6 +4843,7 @@ const paymentMethodsHref = computed(() =>
type SettingsTab =
| "general"
| "features"
| "security"
| "users"
| "gateway"
......@@ -4764,6 +4853,7 @@ type SettingsTab =
const activeTab = ref<SettingsTab>("general");
const settingsTabs = [
{ key: "general" as SettingsTab, icon: "home" as const },
{ key: "features" as SettingsTab, icon: "bolt" as const },
{ key: "security" as SettingsTab, icon: "shield" as const },
{ key: "users" as SettingsTab, icon: "user" as const },
{ key: "gateway" as SettingsTab, icon: "server" as const },
......@@ -5024,6 +5114,11 @@ const form = reactive<SettingsForm>({
balance_low_notify_recharge_url: "",
account_quota_notify_enabled: false,
account_quota_notify_emails: [] as NotifyEmailEntry[],
// Channel Monitor feature switch
channel_monitor_enabled: true,
channel_monitor_default_interval_seconds: 60,
// Available Channels feature switch
available_channels_enabled: false,
});
const authSourceDefaults = reactive<AuthSourceDefaultsState>(
......@@ -5932,6 +6027,12 @@ async function saveSettings() {
account_quota_notify_emails: (
form.account_quota_notify_emails || []
).filter((e) => e.email.trim() !== ""),
// Channel Monitor feature switch
channel_monitor_enabled: form.channel_monitor_enabled,
channel_monitor_default_interval_seconds:
Number(form.channel_monitor_default_interval_seconds) || 60,
// Available Channels feature switch
available_channels_enabled: form.available_channels_enabled,
};
appendAuthSourceDefaultsToUpdateRequest(payload, authSourceDefaults);
......
<template>
<AppLayout>
<TablePageLayout>
<template #filters>
<div class="flex flex-col justify-between gap-4 lg:flex-row lg:items-start">
<div class="flex flex-1 flex-wrap items-center gap-3">
<div class="relative w-full sm:w-80">
<Icon
name="search"
size="md"
class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500"
/>
<input
v-model="searchQuery"
type="text"
:placeholder="t('availableChannels.searchPlaceholder')"
class="input pl-10"
/>
</div>
</div>
<div class="flex w-full flex-shrink-0 flex-wrap items-center justify-end gap-3 lg:w-auto">
<button
@click="loadChannels"
:disabled="loading"
class="btn btn-secondary"
:title="t('common.refresh', 'Refresh')"
>
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
</button>
</div>
</div>
</template>
<template #table>
<AvailableChannelsTable
:columns="columnLabels"
:rows="filteredChannels"
:loading="loading"
:user-group-rates="userGroupRates"
pricing-key-prefix="availableChannels.pricing"
:no-pricing-label="t('availableChannels.noPricing')"
:no-models-label="t('availableChannels.noModels')"
:empty-label="t('availableChannels.empty')"
/>
</template>
</TablePageLayout>
</AppLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
import Icon from '@/components/icons/Icon.vue'
import AvailableChannelsTable from '@/components/channels/AvailableChannelsTable.vue'
import userChannelsAPI, { type UserAvailableChannel } from '@/api/channels'
import userGroupsAPI from '@/api/groups'
import { useAppStore } from '@/stores/app'
import { extractApiErrorMessage } from '@/utils/apiError'
const { t } = useI18n()
const appStore = useAppStore()
const channels = ref<UserAvailableChannel[]>([])
const userGroupRates = ref<Record<number, number>>({})
const loading = ref(false)
const searchQuery = ref('')
const columnLabels = computed(() => ({
name: t('availableChannels.columns.name'),
description: t('availableChannels.columns.description'),
platform: t('availableChannels.columns.platform'),
groups: t('availableChannels.columns.groups'),
supportedModels: t('availableChannels.columns.supportedModels'),
}))
/**
* 搜索过滤:
* - 命中渠道名/描述 → 整个渠道(所有 platforms)都保留
* - 否则按 platform/group/model 维度在 sections 里过滤,保留有匹配的 section
* - 所有 sections 都不匹配时,渠道本身被过滤掉
*/
const filteredChannels = computed(() => {
const q = searchQuery.value.trim().toLowerCase()
if (!q) return channels.value
return channels.value
.map((ch) => {
const nameHit = ch.name.toLowerCase().includes(q)
const descHit = (ch.description || '').toLowerCase().includes(q)
if (nameHit || descHit) return ch
const matchingSections = ch.platforms.filter(
(p) =>
p.platform.toLowerCase().includes(q) ||
p.groups.some((g) => g.name.toLowerCase().includes(q)) ||
p.supported_models.some((m) => m.name.toLowerCase().includes(q)),
)
if (matchingSections.length === 0) return null
return { ...ch, platforms: matchingSections }
})
.filter((ch): ch is UserAvailableChannel => ch !== null)
})
async function loadChannels() {
loading.value = true
try {
// 渠道列表和用户专属倍率并发拉取。专属倍率失败不阻塞渠道展示——
// 失败时只是无法渲染专属倍率角标,降级为仅显示默认倍率。
const [list, rates] = await Promise.all([
userChannelsAPI.getAvailable(),
userGroupsAPI.getUserGroupRates().catch((err: unknown) => {
console.error('Failed to load user group rates:', err)
return {} as Record<number, number>
}),
])
channels.value = list
userGroupRates.value = rates
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
} finally {
loading.value = false
}
}
onMounted(loadChannels)
</script>
<template>
<AppLayout>
<MonitorHero
:overall-status="overallStatus"
:interval-seconds="DEFAULT_INTERVAL_SECONDS"
:window="currentWindow"
:loading="loading"
:auto-refresh="autoRefresh"
@update:window="handleWindowChange"
@refresh="manualReload"
/>
<MonitorCardGrid
:items="items"
:window="currentWindow"
:countdown-seconds="countdown"
:loading="loading"
:detail-cache="detailCache"
@card-click="openDetail"
/>
<MonitorDetailDialog
:show="showDetail"
:monitor-id="detailTarget?.id ?? null"
:title="detailTitle"
@close="closeDetail"
/>
</AppLayout>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { extractApiErrorMessage } from '@/utils/apiError'
import {
list as listChannelMonitorViews,
status as fetchChannelMonitorDetail,
type UserMonitorView,
type UserMonitorDetail,
} from '@/api/channelMonitor'
import AppLayout from '@/components/layout/AppLayout.vue'
import MonitorHero, {
type MonitorWindow,
type OverallStatus,
} from '@/components/user/monitor/MonitorHero.vue'
import MonitorCardGrid from '@/components/user/monitor/MonitorCardGrid.vue'
import MonitorDetailDialog from '@/components/user/MonitorDetailDialog.vue'
import { DEFAULT_INTERVAL_SECONDS, STATUS_OPERATIONAL } from '@/constants/channelMonitor'
import { useAutoRefresh } from '@/composables/useAutoRefresh'
const { t } = useI18n()
const appStore = useAppStore()
// ── State ──
const items = ref<UserMonitorView[]>([])
const loading = ref(false)
const currentWindow = ref<MonitorWindow>('7d')
const detailCache = reactive<Record<number, UserMonitorDetail>>({})
const showDetail = ref(false)
const detailTarget = ref<UserMonitorView | null>(null)
let abortController: AbortController | null = null
const autoRefresh = useAutoRefresh({
storageKey: 'channel-status-auto-refresh',
intervals: [30, 60, 120] as const,
defaultInterval: DEFAULT_INTERVAL_SECONDS,
onRefresh: () => reload(true),
shouldPause: () => document.hidden || loading.value,
})
const countdown = autoRefresh.countdown
// ── Computed ──
const overallStatus = computed<OverallStatus>(() => {
if (items.value.length === 0) return 'operational'
for (const it of items.value) {
if (it.primary_status === 'failed' || it.primary_status === 'error') return 'degraded'
if (it.primary_status !== STATUS_OPERATIONAL) return 'degraded'
}
return 'operational'
})
const detailTitle = computed(() => {
return detailTarget.value?.name || t('channelStatus.detailTitle')
})
// ── Loaders ──
async function reload(silent = false) {
if (abortController) abortController.abort()
const ctrl = new AbortController()
abortController = ctrl
if (!silent) loading.value = true
try {
const res = await listChannelMonitorViews({ signal: ctrl.signal })
if (ctrl.signal.aborted || abortController !== ctrl) return
items.value = res.items || []
} catch (err: unknown) {
const e = err as { name?: string; code?: string }
if (e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return
appStore.showError(extractApiErrorMessage(err, t('channelStatus.loadError')))
} finally {
if (abortController === ctrl) {
if (!silent) loading.value = false
countdown.value = DEFAULT_INTERVAL_SECONDS
abortController = null
}
}
}
async function manualReload() {
await reload(false)
// After base reload, refresh any cached detail records so non-7d availability
// values stay in sync without forcing the user to switch tabs again.
if (currentWindow.value !== '7d') {
await Promise.all(items.value.map(it => loadDetail(it.id, true)))
}
}
async function loadDetail(id: number, force = false) {
if (!force && detailCache[id]) return
try {
detailCache[id] = await fetchChannelMonitorDetail(id)
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('channelStatus.detailLoadError')))
}
}
async function ensureDetailsForWindow() {
if (currentWindow.value === '7d') return
await Promise.all(items.value.map(it => loadDetail(it.id)))
}
// ── Handlers ──
async function handleWindowChange(value: MonitorWindow) {
currentWindow.value = value
await ensureDetailsForWindow()
}
function openDetail(row: UserMonitorView) {
detailTarget.value = row
showDetail.value = true
}
function closeDetail() {
showDetail.value = false
detailTarget.value = null
}
watch(items, () => {
void ensureDetailsForWindow()
})
watch(
() => appStore.cachedPublicSettings?.channel_monitor_enabled,
(enabled) => {
if (enabled === false) autoRefresh.stop()
else if (autoRefresh.enabled.value) autoRefresh.start()
},
)
onMounted(() => {
void reload(false)
if (appStore.cachedPublicSettings?.channel_monitor_enabled !== false) {
autoRefresh.setEnabled(true)
}
})
onBeforeUnmount(() => {
if (abortController) abortController.abort()
})
</script>
......@@ -61,7 +61,7 @@
<template #cell-key="{ value, row }">
<div class="flex items-center gap-2">
<code class="code text-xs">
{{ maskKey(value) }}
{{ maskApiKey(value) }}
</code>
<button
@click="copyToClipboard(value, row.id)"
......@@ -1072,6 +1072,7 @@ import TablePageLayout from '@/components/layout/TablePageLayout.vue'
import type { Column } from '@/components/common/types'
import type { BatchApiKeyUsageStats } from '@/api/usage'
import { formatDateTime } from '@/utils/format'
import { maskApiKey } from '@/utils/maskApiKey'
// Helper to format date for datetime-local input
const formatDateTimeLocal = (isoDate: string): string => {
......@@ -1260,11 +1261,6 @@ const filteredGroupOptions = computed(() => {
})
})
const maskKey = (key: string): string => {
if (key.length <= 12) return key
return `${key.slice(0, 8)}...${key.slice(-4)}`
}
const copyToClipboard = async (text: string, keyId: number) => {
const success = await clipboardCopy(text, t('keys.copied'))
if (success) {
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment