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
<template>
<div class="flex justify-between gap-2">
<span class="text-gray-500 dark:text-gray-400">{{ label }}</span>
<span class="font-mono">{{ display }}</span>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { formatScaled } from '@/utils/pricing'
const props = withDefaults(
defineProps<{
label: string
value: number | null
unit: string
scale: number
}>(),
{ value: null }
)
const display = computed(() =>
props.value == null ? '-' : `${formatScaled(props.value, props.scale)} ${props.unit}`
)
</script>
<template>
<div class="relative inline-block">
<span
ref="triggerEl"
:class="[
'inline-flex cursor-help items-center gap-1 rounded-md border px-2 py-0.5 text-xs font-medium transition-colors',
effectivePlatform
? platformBadgeClass(effectivePlatform)
: 'border-gray-200 bg-gray-50 text-gray-700 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-300',
]"
@mouseenter="onEnter"
@mouseleave="onLeave"
@focusin="onEnter"
@focusout="onLeave"
tabindex="0"
>
<PlatformIcon
v-if="effectivePlatform"
:platform="effectivePlatform as GroupPlatform"
size="xs"
/>
<span
v-if="showPlatform && model.platform"
class="rounded bg-gray-200/60 px-1 text-[10px] uppercase text-gray-600 dark:bg-dark-700 dark:text-gray-400"
>
{{ model.platform }}
</span>
{{ model.name }}
</span>
<!-- Teleport to body so the popover is not clipped by card/overflow-hidden
ancestors. Fixed-position coords are computed from the trigger's
bounding rect; re-measured on enter / scroll / resize. -->
<Teleport to="body">
<div
v-show="show"
ref="popoverEl"
role="tooltip"
class="pointer-events-none fixed z-[99999] w-80 max-w-[min(22rem,calc(100vw-1rem))] rounded-lg border bg-white text-xs shadow-xl dark:bg-dark-800"
:class="[popoverBorderClass]"
:style="popoverStyle"
>
<!-- Header:平台主题色背景,含模型名 + 平台徽章 -->
<div
class="flex items-center justify-between gap-2 rounded-t-lg border-b px-3 py-2"
:class="[popoverHeaderClass, popoverBorderClass]"
>
<span class="truncate font-semibold">{{ model.name }}</span>
<span
v-if="model.platform"
class="flex-shrink-0 rounded bg-white/70 px-1.5 py-0.5 text-[10px] uppercase tracking-wide dark:bg-dark-900/60"
>
{{ model.platform }}
</span>
</div>
<div class="p-3">
<div v-if="!model.pricing" class="text-gray-500 dark:text-gray-400">
{{ noPricingLabel }}
</div>
<div v-else class="space-y-2 text-gray-700 dark:text-gray-300">
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t(prefixKey('billingMode')) }}</span>
<span>{{ billingModeLabel }}</span>
</div>
<template v-if="model.pricing.billing_mode === BILLING_MODE_TOKEN">
<PricingRow
:label="t(prefixKey('inputPrice'))"
:value="model.pricing.input_price"
:unit="t(prefixKey('unitPerMillion'))"
:scale="perMillionScale"
/>
<PricingRow
:label="t(prefixKey('outputPrice'))"
:value="model.pricing.output_price"
:unit="t(prefixKey('unitPerMillion'))"
:scale="perMillionScale"
/>
<PricingRow
:label="t(prefixKey('cacheWritePrice'))"
:value="model.pricing.cache_write_price"
:unit="t(prefixKey('unitPerMillion'))"
:scale="perMillionScale"
/>
<PricingRow
:label="t(prefixKey('cacheReadPrice'))"
:value="model.pricing.cache_read_price"
:unit="t(prefixKey('unitPerMillion'))"
:scale="perMillionScale"
/>
<PricingRow
v-if="model.pricing.image_output_price != null && model.pricing.image_output_price > 0"
:label="t(prefixKey('imageOutputPrice'))"
:value="model.pricing.image_output_price"
:unit="t(prefixKey('unitPerMillion'))"
:scale="perMillionScale"
/>
</template>
<PricingRow
v-if="
model.pricing.billing_mode === BILLING_MODE_PER_REQUEST &&
model.pricing.per_request_price != null
"
:label="t(prefixKey('perRequestPrice'))"
:value="model.pricing.per_request_price"
:unit="t(prefixKey('unitPerRequest'))"
:scale="1"
/>
<PricingRow
v-if="
model.pricing.billing_mode === BILLING_MODE_IMAGE &&
model.pricing.image_output_price != null
"
:label="t(prefixKey('imageOutputPrice'))"
:value="model.pricing.image_output_price"
:unit="t(prefixKey('unitPerRequest'))"
:scale="1"
/>
<div
v-if="model.pricing.intervals && model.pricing.intervals.length > 0"
class="mt-2 border-t pt-2"
:class="[popoverBorderClass]"
>
<div class="mb-1 font-medium text-gray-600 dark:text-gray-400">
{{ t(prefixKey('intervals')) }}
</div>
<div class="space-y-1">
<div
v-for="(iv, idx) in model.pricing.intervals"
:key="idx"
class="flex justify-between text-[11px]"
>
<span class="text-gray-500 dark:text-gray-400">
<template v-if="iv.tier_label">{{ iv.tier_label }}</template>
<template v-else>{{ formatRange(iv.min_tokens, iv.max_tokens) }}</template>
</span>
<span>{{ formatInterval(iv, model.pricing.billing_mode) }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import PricingRow from './PricingRow.vue'
import { formatScaled } from '@/utils/pricing'
import {
BILLING_MODE_TOKEN,
BILLING_MODE_PER_REQUEST,
BILLING_MODE_IMAGE,
type BillingMode
} from '@/constants/channel'
// 复用 api/channels.ts 的用户侧最小形态 DTO。
// admin 侧 ChannelModelPricing 字段更多,但结构上是用户 DTO 的超集,admin 视图传入可直接通过结构化子类型检查。
import type { UserPricingInterval, UserSupportedModel } from '@/api/channels'
import PlatformIcon from '@/components/common/PlatformIcon.vue'
import type { GroupPlatform } from '@/types'
import { platformBadgeClass, platformBorderClass, platformBadgeLightClass } from '@/utils/platformColors'
const props = withDefaults(
defineProps<{
model: UserSupportedModel
/** i18n 前缀:管理端传 `admin.availableChannels.pricing`,用户端传 `availableChannels.pricing`。 */
pricingKeyPrefix?: string
noPricingLabel?: string
showPlatform?: boolean
/**
* 当 model.platform 缺失(如 admin 聚合场景)时,用父行的平台作为兜底着色。
* 仅用于视觉,不影响业务逻辑。
*/
platformHint?: string
}>(),
{
pricingKeyPrefix: 'availableChannels.pricing',
noPricingLabel: '',
showPlatform: true,
platformHint: ''
}
)
const effectivePlatform = computed<string>(() => props.model.platform || props.platformHint || '')
const { t } = useI18n()
/** 按 token 定价展示时的换算单位:每百万 token。 */
const perMillionScale = 1_000_000
// Popover border + header classes echo the platform theme so each card reads
// at a glance which model family it belongs to.
const popoverBorderClass = computed(() =>
effectivePlatform.value
? platformBorderClass(effectivePlatform.value)
: 'border-gray-200 dark:border-dark-600',
)
const popoverHeaderClass = computed(() =>
effectivePlatform.value
? platformBadgeLightClass(effectivePlatform.value)
: 'bg-gray-50 text-gray-700 dark:bg-dark-700/60 dark:text-gray-300',
)
function prefixKey(k: string): string {
return `${props.pricingKeyPrefix}.${k}`
}
const billingModeLabel = computed(() => {
const mode = props.model.pricing?.billing_mode
switch (mode) {
case BILLING_MODE_TOKEN:
return t(prefixKey('billingModeToken'))
case BILLING_MODE_PER_REQUEST:
return t(prefixKey('billingModePerRequest'))
case BILLING_MODE_IMAGE:
return t(prefixKey('billingModeImage'))
default:
return '-'
}
})
function formatRange(min: number, max: number | null): string {
const maxLabel = max == null ? '' : String(max)
return `(${min}, ${maxLabel}]`
}
function formatInterval(iv: UserPricingInterval, mode: BillingMode): string {
if (mode === BILLING_MODE_PER_REQUEST || mode === BILLING_MODE_IMAGE) {
return formatScaled(iv.per_request_price, 1)
}
const input = formatScaled(iv.input_price, perMillionScale)
const output = formatScaled(iv.output_price, perMillionScale)
return `${input} / ${output}`
}
// ── Popover positioning ─────────────────────────────────────────────
// Teleport-to-body + fixed positioning avoids being clipped by
// overflow-hidden ancestors (the parent table card). We re-measure on
// hover enter, scroll, and resize. Pinning to the trigger's top-center
// with a flip when the viewport edge is near keeps it aligned without a
// full-blown positioning lib.
const show = ref(false)
const triggerEl = ref<HTMLElement | null>(null)
const popoverEl = ref<HTMLElement | null>(null)
const popoverStyle = ref<Record<string, string>>({ top: '0px', left: '0px' })
function updatePosition() {
const trigger = triggerEl.value
if (!trigger) return
const rect = trigger.getBoundingClientRect()
const margin = 8
const popover = popoverEl.value
const popWidth = popover?.offsetWidth ?? 320
const popHeight = popover?.offsetHeight ?? 240
const vw = window.innerWidth
const vh = window.innerHeight
let top = rect.bottom + margin
// Flip upward if it would overflow below.
if (top + popHeight > vh - margin) {
top = Math.max(margin, rect.top - popHeight - margin)
}
let left = rect.left + rect.width / 2 - popWidth / 2
if (left < margin) left = margin
if (left + popWidth > vw - margin) left = vw - margin - popWidth
popoverStyle.value = {
top: `${Math.round(top)}px`,
left: `${Math.round(left)}px`,
}
}
function onEnter() {
show.value = true
nextTick(() => {
updatePosition()
window.addEventListener('scroll', updatePosition, true)
window.addEventListener('resize', updatePosition)
})
}
function onLeave() {
show.value = false
window.removeEventListener('scroll', updatePosition, true)
window.removeEventListener('resize', updatePosition)
}
onBeforeUnmount(() => {
window.removeEventListener('scroll', updatePosition, true)
window.removeEventListener('resize', updatePosition)
})
</script>
<template>
<div class="relative" ref="dropdownRef">
<button
@click="showDropdown = !showDropdown"
class="inline-flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-2.5 py-1.5 text-xs font-medium text-gray-700 shadow-sm transition-colors hover:bg-gray-50 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-300 dark:hover:bg-dark-700"
:title="t('common.autoRefresh.title')"
>
<svg
class="h-3.5 w-3.5"
:class="enabled ? 'animate-spin' : ''"
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"
>
<path fill-rule="evenodd" d="M15.312 11.424a5.5 5.5 0 01-9.201 2.466l-.312-.311h2.433a.75.75 0 000-1.5H4.598a.75.75 0 00-.75.75v3.634a.75.75 0 001.5 0v-2.033l.312.312a7 7 0 0011.712-3.138.75.75 0 00-1.449-.39zm-10.624-2.848a5.5 5.5 0 019.201-2.466l.312.311H11.768a.75.75 0 000 1.5h3.634a.75.75 0 00.75-.75V3.537a.75.75 0 00-1.5 0v2.034l-.312-.312A7 7 0 002.628 8.397a.75.75 0 001.449.39z" clip-rule="evenodd" />
</svg>
<span>
{{ enabled
? t('common.autoRefresh.countdown', { seconds: countdown })
: t('common.autoRefresh.title')
}}
</span>
</button>
<div
v-if="showDropdown"
class="absolute right-0 z-20 mt-1 w-44 rounded-lg border border-gray-200 bg-white shadow-lg dark:border-dark-600 dark:bg-dark-800"
>
<div class="p-1.5">
<button
@click="$emit('update:enabled', !enabled)"
class="flex w-full items-center justify-between rounded-md px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700"
>
<span>{{ t('common.autoRefresh.enable') }}</span>
<svg v-if="enabled" class="h-4 w-4 text-primary-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z" clip-rule="evenodd" />
</svg>
</button>
<div class="my-1 border-t border-gray-100 dark:border-gray-700"></div>
<button
v-for="sec in intervals"
:key="sec"
@click="$emit('update:interval', sec)"
class="flex w-full items-center justify-between rounded-md px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700"
>
<span>{{ t('common.autoRefresh.seconds', { n: sec }) }}</span>
<svg v-if="intervalSeconds === sec" class="h-4 w-4 text-primary-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z" clip-rule="evenodd" />
</svg>
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
defineProps<{
enabled: boolean
intervalSeconds: number
countdown: number
intervals: readonly number[]
}>()
defineEmits<{
(e: 'update:enabled', value: boolean): void
(e: 'update:interval', value: number): void
}>()
const { t } = useI18n()
const showDropdown = ref(false)
const dropdownRef = ref<HTMLElement | null>(null)
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.value && !dropdownRef.value.contains(event.target as Node)) {
showDropdown.value = false
}
}
onMounted(() => document.addEventListener('click', handleClickOutside))
onBeforeUnmount(() => document.removeEventListener('click', handleClickOutside))
</script>
...@@ -37,13 +37,20 @@ interface Props { ...@@ -37,13 +37,20 @@ interface Props {
userRateMultiplier?: number | null // 用户专属倍率 userRateMultiplier?: number | null // 用户专属倍率
showRate?: boolean showRate?: boolean
daysRemaining?: number | null // 剩余天数(订阅类型时使用) daysRemaining?: number | null // 剩余天数(订阅类型时使用)
/**
* 订阅分组默认在右侧 label 展示"订阅"或剩余天数;
* 开启后订阅分组也改为显示倍率(保留订阅主题色 label,配合可用渠道这类
* 只关心费率、不关心有效期的场景)。
*/
alwaysShowRate?: boolean
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
subscriptionType: 'standard', subscriptionType: 'standard',
showRate: true, showRate: true,
daysRemaining: null, daysRemaining: null,
userRateMultiplier: null userRateMultiplier: null,
alwaysShowRate: false
}) })
const { t } = useI18n() const { t } = useI18n()
...@@ -71,7 +78,8 @@ const showLabel = computed(() => { ...@@ -71,7 +78,8 @@ const showLabel = computed(() => {
// Label text // Label text
const labelText = computed(() => { const labelText = computed(() => {
if (isSubscription.value) { const rateLabel = props.rateMultiplier !== undefined ? `${props.rateMultiplier}x` : ''
if (isSubscription.value && !props.alwaysShowRate) {
// 如果有剩余天数,显示天数 // 如果有剩余天数,显示天数
if (props.daysRemaining !== null && props.daysRemaining !== undefined) { if (props.daysRemaining !== null && props.daysRemaining !== undefined) {
if (props.daysRemaining <= 0) { if (props.daysRemaining <= 0) {
...@@ -82,7 +90,7 @@ const labelText = computed(() => { ...@@ -82,7 +90,7 @@ const labelText = computed(() => {
// 否则显示"订阅" // 否则显示"订阅"
return t('groups.subscription') return t('groups.subscription')
} }
return props.rateMultiplier !== undefined ? `${props.rateMultiplier}x` : '' return rateLabel
}) })
// Label style based on type and days remaining // Label style based on type and days remaining
......
...@@ -38,7 +38,7 @@ ...@@ -38,7 +38,7 @@
'sidebar-link-collapsed': sidebarCollapsed 'sidebar-link-collapsed': sidebarCollapsed
}" }"
:title="sidebarCollapsed ? item.label : undefined" :title="sidebarCollapsed ? item.label : undefined"
@click="sidebarCollapsed ? undefined : toggleGroup(item)" @click="handleGroupClick(item)"
> >
<component :is="item.icon" class="h-5 w-5 flex-shrink-0" /> <component :is="item.icon" class="h-5 w-5 flex-shrink-0" />
<span <span
...@@ -181,11 +181,12 @@ ...@@ -181,11 +181,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, h, onMounted, ref, watch } from 'vue' import { computed, h, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAdminSettingsStore, useAppStore, useAuthStore, useOnboardingStore } from '@/stores' import { useAdminSettingsStore, useAppStore, useAuthStore, useOnboardingStore } from '@/stores'
import VersionBadge from '@/components/common/VersionBadge.vue' import VersionBadge from '@/components/common/VersionBadge.vue'
import { sanitizeSvg } from '@/utils/sanitize' import { sanitizeSvg } from '@/utils/sanitize'
import { FeatureFlags, makeSidebarFlag } from '@/utils/featureFlags'
interface NavItem { interface NavItem {
path: string path: string
...@@ -194,11 +195,39 @@ interface NavItem { ...@@ -194,11 +195,39 @@ interface NavItem {
iconSvg?: string iconSvg?: string
hideInSimpleMode?: boolean hideInSimpleMode?: boolean
children?: NavItem[] children?: NavItem[]
/**
* When true, the parent item only toggles the expand/collapse state and
* does NOT navigate to its `path`. The `path` is purely a stable key.
*/
expandOnly?: boolean
/**
* 可选的功能开关 getter。返回 false 时菜单项被隐藏;返回 undefined/true 时显示。
* 宽容策略(undefined → 显示)避免 public settings 未加载完成时菜单闪烁消失。
* Getter 里访问的 reactive 来源(store / composable)会被 computed 自动追踪,
* 开关切换时菜单自动更新。
*/
featureFlag?: () => boolean | undefined
}
// applyFeatureFlags 递归过滤掉 featureFlag() === false 的节点(含子节点)。
// 使用 `!== false` 宽容语义:undefined(设置未加载)或 true 都视为显示。
function applyFeatureFlags(items: NavItem[]): NavItem[] {
const out: NavItem[] = []
for (const item of items) {
if (item.featureFlag && item.featureFlag() === false) continue
if (item.children) {
out.push({ ...item, children: applyFeatureFlags(item.children) })
} else {
out.push(item)
}
}
return out
} }
const { t } = useI18n() const { t } = useI18n()
const route = useRoute() const route = useRoute()
const router = useRouter()
const appStore = useAppStore() const appStore = useAppStore()
const authStore = useAuthStore() const authStore = useAuthStore()
const onboardingStore = useOnboardingStore() const onboardingStore = useOnboardingStore()
...@@ -549,6 +578,41 @@ const ChevronDoubleRightIcon = { ...@@ -549,6 +578,41 @@ const ChevronDoubleRightIcon = {
) )
} }
const SignalIcon = {
render: () =>
h(
'svg',
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
[
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
d: 'M9.348 14.651a3.75 3.75 0 010-5.303m5.304 0a3.75 3.75 0 010 5.303m-7.425 2.122a6.75 6.75 0 010-9.546m9.546 0a6.75 6.75 0 010 9.546M5.106 18.894c-3.808-3.807-3.808-9.98 0-13.788m13.788 0c3.808 3.807 3.808 9.98 0 13.788M12 12h.008v.008H12V12zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z'
})
]
)
}
const PriceTagIcon = {
render: () =>
h(
'svg',
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
[
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
d: 'M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 005.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 009.568 3z'
}),
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
d: 'M6 6h.008v.008H6V6z'
})
]
)
}
const ChevronDownIcon = { const ChevronDownIcon = {
render: () => render: () =>
h( h(
...@@ -564,33 +628,33 @@ const ChevronDownIcon = { ...@@ -564,33 +628,33 @@ const ChevronDownIcon = {
) )
} }
// User navigation items (for regular users) // Public-settings flags go through the registry in utils/featureFlags.ts,
const userNavItems = computed((): NavItem[] => { // which handles the opt-in vs opt-out fallback when settings haven't loaded
const items: NavItem[] = [ // yet. Admin-only flags (not in public settings) stay inline below.
{ path: '/dashboard', label: t('nav.dashboard'), icon: DashboardIcon }, const flagChannelMonitor = makeSidebarFlag(FeatureFlags.channelMonitor)
const flagPayment = makeSidebarFlag(FeatureFlags.payment)
const flagAvailableChannels = makeSidebarFlag(FeatureFlags.availableChannels)
const flagOpsMonitoring = () => adminSettingsStore.opsMonitoringEnabled
const flagAdminPayment = () => adminSettingsStore.paymentEnabled
// buildSelfNavItems 构造用户自己的导航项(用户端主菜单和管理员的"我的账户"子菜单共享这组声明)。
// withDashboard=true 时包含仪表盘(用户端),false 时不含(管理员的个人区已经有独立仪表盘入口)。
//
// 条目顺序:密钥 → 用量 → 可用渠道 → 渠道状态 → 订阅/支付 → 兑换/资料。
// 可用渠道紧挨渠道状态之上,让用户"先看自己能用什么、再看对应状态"。
function buildSelfNavItems(withDashboard: boolean): NavItem[] {
const items: NavItem[] = []
if (withDashboard) {
items.push({ path: '/dashboard', label: t('nav.dashboard'), icon: DashboardIcon })
}
items.push(
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon }, { path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true }, { path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true },
{ path: '/available-channels', label: t('nav.availableChannels'), icon: ChannelIcon, hideInSimpleMode: true, featureFlag: flagAvailableChannels },
{ path: '/monitor', label: t('nav.channelStatus'), icon: SignalIcon, featureFlag: flagChannelMonitor },
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true }, { path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
...(appStore.cachedPublicSettings?.payment_enabled { path: '/purchase', label: t('nav.buySubscription'), icon: RechargeSubscriptionIcon, hideInSimpleMode: true, featureFlag: flagPayment },
? [ { path: '/orders', label: t('nav.myOrders'), icon: OrderListIcon, hideInSimpleMode: true, featureFlag: flagPayment },
{
path: '/purchase',
label: t('nav.buySubscription'),
icon: RechargeSubscriptionIcon,
hideInSimpleMode: true
},
]
: []),
...(appStore.cachedPublicSettings?.payment_enabled
? [
{
path: '/orders',
label: t('nav.myOrders'),
icon: OrderListIcon,
hideInSimpleMode: true
},
]
: []),
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true }, { path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true },
{ path: '/profile', label: t('nav.profile'), icon: UserIcon }, { path: '/profile', label: t('nav.profile'), icon: UserIcon },
...customMenuItemsForUser.value.map((item): NavItem => ({ ...customMenuItemsForUser.value.map((item): NavItem => ({
...@@ -599,47 +663,23 @@ const userNavItems = computed((): NavItem[] => { ...@@ -599,47 +663,23 @@ const userNavItems = computed((): NavItem[] => {
icon: null, icon: null,
iconSvg: item.icon_svg, iconSvg: item.icon_svg,
})), })),
] )
return authStore.isSimpleMode ? items.filter(item => !item.hideInSimpleMode) : items return items
}) }
// Personal navigation items (for admin's "My Account" section, without Dashboard) // finalizeNav 合并三重过滤:featureFlag 过滤 + simple 模式过滤。
const personalNavItems = computed((): NavItem[] => { function finalizeNav(items: NavItem[]): NavItem[] {
const items: NavItem[] = [ const visible = applyFeatureFlags(items)
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon }, return authStore.isSimpleMode ? visible.filter(item => !item.hideInSimpleMode) : visible
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true }, }
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
...(appStore.cachedPublicSettings?.payment_enabled // User navigation items (for regular users)
? [ const userNavItems = computed((): NavItem[] => finalizeNav(buildSelfNavItems(true)))
{
path: '/purchase', // Personal navigation items (for admin's "My Account" section, without Dashboard).
label: t('nav.buySubscription'), // Admins access 可用渠道 from this section just like regular users — there is no
icon: RechargeSubscriptionIcon, // separate admin entry, since the page is purely a user-facing view.
hideInSimpleMode: true const personalNavItems = computed((): NavItem[] => finalizeNav(buildSelfNavItems(false)))
},
]
: []),
...(appStore.cachedPublicSettings?.payment_enabled
? [
{
path: '/orders',
label: t('nav.myOrders'),
icon: OrderListIcon,
hideInSimpleMode: true
},
]
: []),
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true },
{ path: '/profile', label: t('nav.profile'), icon: UserIcon },
...customMenuItemsForUser.value.map((item): NavItem => ({
path: `/custom/${item.id}`,
label: item.label,
icon: null,
iconSvg: item.icon_svg,
})),
]
return authStore.isSimpleMode ? items.filter(item => !item.hideInSimpleMode) : items
})
// Custom menu items filtered by visibility // Custom menu items filtered by visibility
const customMenuItemsForUser = computed(() => { const customMenuItemsForUser = computed(() => {
...@@ -659,54 +699,60 @@ const customMenuItemsForAdmin = computed(() => { ...@@ -659,54 +699,60 @@ const customMenuItemsForAdmin = computed(() => {
const adminNavItems = computed((): NavItem[] => { const adminNavItems = computed((): NavItem[] => {
const baseItems: NavItem[] = [ const baseItems: NavItem[] = [
{ path: '/admin/dashboard', label: t('nav.dashboard'), icon: DashboardIcon }, { path: '/admin/dashboard', label: t('nav.dashboard'), icon: DashboardIcon },
...(adminSettingsStore.opsMonitoringEnabled { path: '/admin/ops', label: t('nav.ops'), icon: ChartIcon, featureFlag: flagOpsMonitoring },
? [{ path: '/admin/ops', label: t('nav.ops'), icon: ChartIcon }]
: []),
{ path: '/admin/users', label: t('nav.users'), icon: UsersIcon, hideInSimpleMode: true }, { path: '/admin/users', label: t('nav.users'), icon: UsersIcon, hideInSimpleMode: true },
{ path: '/admin/groups', label: t('nav.groups'), icon: FolderIcon, hideInSimpleMode: true }, { path: '/admin/groups', label: t('nav.groups'), icon: FolderIcon, hideInSimpleMode: true },
{ path: '/admin/channels', label: t('nav.channels', '渠道管理'), icon: ChannelIcon, hideInSimpleMode: true }, {
path: '/admin/channels',
label: t('nav.channelManagement'),
icon: ChannelIcon,
hideInSimpleMode: true,
expandOnly: true,
children: [
{ path: '/admin/channels/pricing', label: t('nav.channelPricing'), icon: PriceTagIcon },
{ path: '/admin/channels/monitor', label: t('nav.channelMonitor'), icon: SignalIcon, featureFlag: flagChannelMonitor },
],
},
{ path: '/admin/subscriptions', label: t('nav.subscriptions'), icon: CreditCardIcon, hideInSimpleMode: true }, { path: '/admin/subscriptions', label: t('nav.subscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
{ path: '/admin/accounts', label: t('nav.accounts'), icon: GlobeIcon }, { path: '/admin/accounts', label: t('nav.accounts'), icon: GlobeIcon },
{ path: '/admin/announcements', label: t('nav.announcements'), icon: BellIcon }, { path: '/admin/announcements', label: t('nav.announcements'), icon: BellIcon },
{ path: '/admin/proxies', label: t('nav.proxies'), icon: ServerIcon }, { path: '/admin/proxies', label: t('nav.proxies'), icon: ServerIcon },
{ path: '/admin/redeem', label: t('nav.redeemCodes'), icon: TicketIcon, hideInSimpleMode: true }, { path: '/admin/redeem', label: t('nav.redeemCodes'), icon: TicketIcon, hideInSimpleMode: true },
{ path: '/admin/promo-codes', label: t('nav.promoCodes'), icon: GiftIcon, hideInSimpleMode: true }, { path: '/admin/promo-codes', label: t('nav.promoCodes'), icon: GiftIcon, hideInSimpleMode: true },
...(adminSettingsStore.paymentEnabled {
? [ path: '/admin/orders',
{ label: t('nav.orderManagement'),
path: '/admin/orders', icon: OrderIcon,
label: t('nav.orderManagement'), hideInSimpleMode: true,
icon: OrderIcon, expandOnly: true,
hideInSimpleMode: true, featureFlag: flagAdminPayment,
children: [ children: [
{ path: '/admin/orders/dashboard', label: t('nav.paymentDashboard'), icon: ChartIcon }, { path: '/admin/orders/dashboard', label: t('nav.paymentDashboard'), icon: ChartIcon },
{ path: '/admin/orders', label: t('nav.orderManagement'), icon: OrderIcon }, { path: '/admin/orders', label: t('nav.orderManagement'), icon: OrderIcon },
{ path: '/admin/orders/plans', label: t('nav.paymentPlans'), icon: CreditCardIcon }, { path: '/admin/orders/plans', label: t('nav.paymentPlans'), icon: CreditCardIcon },
], ],
}, },
]
: []),
{ path: '/admin/usage', label: t('nav.usage'), icon: ChartIcon } { path: '/admin/usage', label: t('nav.usage'), icon: ChartIcon }
] ]
const visible = applyFeatureFlags(baseItems)
// 简单模式下,在系统设置前插入 API密钥 // 简单模式下,在系统设置前插入 API密钥
if (authStore.isSimpleMode) { if (authStore.isSimpleMode) {
const filtered = baseItems.filter(item => !item.hideInSimpleMode) const filtered = visible.filter(item => !item.hideInSimpleMode)
filtered.push({ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon }) filtered.push({ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon })
filtered.push({ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon }) filtered.push({ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon })
// Add admin custom menu items after settings
for (const cm of customMenuItemsForAdmin.value) { for (const cm of customMenuItemsForAdmin.value) {
filtered.push({ path: `/custom/${cm.id}`, label: cm.label, icon: null, iconSvg: cm.icon_svg }) filtered.push({ path: `/custom/${cm.id}`, label: cm.label, icon: null, iconSvg: cm.icon_svg })
} }
return filtered return filtered
} }
baseItems.push({ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon }) visible.push({ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon })
// Add admin custom menu items after settings
for (const cm of customMenuItemsForAdmin.value) { for (const cm of customMenuItemsForAdmin.value) {
baseItems.push({ path: `/custom/${cm.id}`, label: cm.label, icon: null, iconSvg: cm.icon_svg }) visible.push({ path: `/custom/${cm.id}`, label: cm.label, icon: null, iconSvg: cm.icon_svg })
} }
return baseItems return visible
}) })
function toggleSidebar() { function toggleSidebar() {
...@@ -764,6 +810,28 @@ function toggleGroup(item: NavItem) { ...@@ -764,6 +810,28 @@ function toggleGroup(item: NavItem) {
} }
} }
/**
* Click handler for collapsible parent items.
* - When sidebar is collapsed: do nothing (children are not visible).
* - When `expandOnly` is true: only toggle expand state.
* - Otherwise (default, e.g. /admin/orders): navigate to the parent path
* (router-link semantics) and ensure the group is expanded.
*/
function handleGroupClick(item: NavItem) {
if (sidebarCollapsed.value) return
if (item.expandOnly) {
toggleGroup(item)
return
}
// Push to path and ensure expanded
if (route.path !== item.path) {
router.push(item.path)
}
if (!expandedGroups.value.has(item.path)) {
expandedGroups.value.add(item.path)
}
}
// Initialize theme // Initialize theme
const savedTheme = localStorage.getItem('theme') const savedTheme = localStorage.getItem('theme')
if ( if (
......
<template>
<BaseDialog
:show="show"
:title="title"
width="wide"
@close="$emit('close')"
>
<div v-if="loading" class="py-8 text-center text-sm text-gray-500">
{{ t('common.loading') }}
</div>
<div v-else-if="!detail" class="py-8 text-center text-sm text-gray-500">
{{ t('channelStatus.detailLoadError') }}
</div>
<div v-else class="overflow-x-auto">
<table class="w-full text-left text-sm">
<thead class="border-b border-gray-200 dark:border-dark-700">
<tr class="text-xs uppercase tracking-wider text-gray-500 dark:text-gray-400">
<th class="py-2 pr-3">{{ t('channelStatus.detailColumns.model') }}</th>
<th class="py-2 pr-3">{{ t('channelStatus.detailColumns.latestStatus') }}</th>
<th class="py-2 pr-3">{{ t('channelStatus.detailColumns.latestLatency') }}</th>
<th class="py-2 pr-3">{{ t('channelStatus.detailColumns.availability7d') }}</th>
<th class="py-2 pr-3">{{ t('channelStatus.detailColumns.availability15d') }}</th>
<th class="py-2 pr-3">{{ t('channelStatus.detailColumns.availability30d') }}</th>
<th class="py-2 pr-3">{{ t('channelStatus.detailColumns.avgLatency7d') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="m in detail.models"
:key="m.model"
class="border-b border-gray-100 dark:border-dark-800"
>
<td class="py-2 pr-3 font-medium text-gray-900 dark:text-gray-100">{{ m.model }}</td>
<td class="py-2 pr-3">
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-[11px]"
:class="statusBadgeClass(m.latest_status)"
>
{{ statusLabel(m.latest_status) }}
</span>
</td>
<td class="py-2 pr-3 text-gray-700 dark:text-gray-300">{{ formatLatency(m.latest_latency_ms) }}</td>
<td class="py-2 pr-3 text-gray-700 dark:text-gray-300">{{ formatPercent(m.availability_7d) }}</td>
<td class="py-2 pr-3 text-gray-700 dark:text-gray-300">{{ formatPercent(m.availability_15d) }}</td>
<td class="py-2 pr-3 text-gray-700 dark:text-gray-300">{{ formatPercent(m.availability_30d) }}</td>
<td class="py-2 pr-3 text-gray-700 dark:text-gray-300">{{ formatLatency(m.avg_latency_7d_ms) }}</td>
</tr>
</tbody>
</table>
</div>
<template #footer>
<div class="flex justify-end">
<button @click="$emit('close')" class="btn btn-secondary">
{{ t('channelStatus.closeDetail') }}
</button>
</div>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { extractApiErrorMessage } from '@/utils/apiError'
import {
status as fetchChannelMonitorDetail,
type UserMonitorDetail,
} from '@/api/channelMonitor'
import BaseDialog from '@/components/common/BaseDialog.vue'
import { useChannelMonitorFormat } from '@/composables/useChannelMonitorFormat'
const props = defineProps<{
show: boolean
monitorId: number | null
title: string
}>()
defineEmits<{
(e: 'close'): void
}>()
const { t } = useI18n()
const appStore = useAppStore()
const { statusLabel, statusBadgeClass, formatLatency, formatPercent } = useChannelMonitorFormat()
const detail = ref<UserMonitorDetail | null>(null)
const loading = ref(false)
async function load(id: number) {
detail.value = null
loading.value = true
try {
detail.value = await fetchChannelMonitorDetail(id)
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('channelStatus.detailLoadError')))
} finally {
loading.value = false
}
}
watch(
() => [props.show, props.monitorId] as const,
([show, id]) => {
if (!show) {
detail.value = null
return
}
if (id != null) void load(id)
},
{ immediate: true },
)
</script>
<template>
<div class="mt-3 flex items-end justify-between">
<div class="text-[11px] uppercase tracking-widest text-gray-400">
{{ windowLabel }}
</div>
<div class="flex items-baseline gap-0.5">
<span
class="text-3xl font-bold tabular-nums leading-none"
:style="colorStyle"
>
{{ displayValue }}
</span>
<span
class="text-base font-semibold leading-none"
:style="colorStyle"
>%</span>
</div>
</div>
<div
v-if="samplesLabel"
class="mt-1 text-[11px] text-gray-400 text-right"
>
{{ samplesLabel }}
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { hslForPct } from '@/composables/useChannelMonitorFormat'
const props = defineProps<{
windowLabel: string
value: number | null
samplesLabel?: string
}>()
const { t } = useI18n()
const displayValue = computed(() => {
if (props.value === null || Number.isNaN(props.value)) return t('monitorCommon.latencyEmpty')
return props.value.toFixed(2)
})
const colorStyle = computed(() => {
const colour = hslForPct(props.value)
return colour ? { color: colour } : { color: 'rgb(156 163 175)' }
})
</script>
<template>
<button
type="button"
class="group text-left p-5 rounded-2xl min-h-[280px] w-full bg-white/70 backdrop-blur-xl border border-gray-200/80 shadow-card dark:bg-dark-800/60 dark:border-dark-700/70 hover:-translate-y-1 hover:shadow-card-hover dark:hover:border-primary-500/30 hover:border-gray-300 transition-all duration-300 ease-out flex flex-col"
@click="emit('click')"
>
<!-- Header: icon + name/model + status chip -->
<div class="flex items-start gap-3">
<span
class="w-9 h-9 rounded-xl ring-1 ring-black/5 dark:ring-white/10 grid place-items-center flex-shrink-0"
:class="[providerGradient(item.provider), providerTintClass]"
>
<ProviderIcon :provider="item.provider" :size="20" />
</span>
<div class="flex-1 min-w-0">
<div class="text-base font-semibold truncate text-gray-900 dark:text-gray-100">
{{ item.name }}
</div>
<div class="mt-0.5 flex items-center gap-1.5 min-w-0">
<span
class="inline-flex items-center rounded-md px-1.5 py-0.5 text-[10px] font-medium flex-shrink-0"
:class="providerBadgeClass(item.provider)"
>
{{ providerLabel(item.provider) }}
</span>
<span class="font-mono text-xs truncate text-gray-500 dark:text-gray-400">
{{ item.primary_model }}
</span>
<span
v-if="item.group_name"
class="inline-flex items-center rounded-md px-1.5 py-0.5 text-[10px] font-medium bg-gray-100 text-gray-600 dark:bg-dark-700 dark:text-gray-300 flex-shrink-0"
>
{{ item.group_name }}
</span>
</div>
</div>
<span
class="px-2.5 py-1 rounded-full text-xs font-semibold flex-shrink-0"
:class="statusBadgeClass(item.primary_status)"
>
{{ statusLabel(item.primary_status) }}
</span>
</div>
<!-- Metrics -->
<MonitorMetricPair
primary-icon="bolt"
:primary-label="t('monitorCommon.dialogLatency')"
:primary-value="formatLatency(item.primary_latency_ms)"
primary-unit="ms"
secondary-icon="globe"
:secondary-label="t('monitorCommon.endpointPing')"
:secondary-value="formatLatency(item.primary_ping_latency_ms)"
secondary-unit="ms"
/>
<!-- Divider -->
<div class="mt-4 border-t border-gray-100 dark:border-dark-700/60"></div>
<!-- Availability row -->
<MonitorAvailabilityRow
:window-label="availabilityLabel"
:value="availabilityValue"
:samples-label="extraModelsCountLabel"
/>
<!-- Timeline -->
<MonitorTimeline
:buckets="item.timeline"
:countdown-seconds="countdownSeconds"
/>
</button>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { UserMonitorView } from '@/api/channelMonitor'
import {
useChannelMonitorFormat,
providerGradient,
} from '@/composables/useChannelMonitorFormat'
import ProviderIcon from './ProviderIcon.vue'
import MonitorMetricPair from './MonitorMetricPair.vue'
import MonitorAvailabilityRow from './MonitorAvailabilityRow.vue'
import MonitorTimeline from './MonitorTimeline.vue'
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',
}
const props = defineProps<{
item: UserMonitorView
window: '7d' | '15d' | '30d'
availabilityValue: number | null
countdownSeconds: number
}>()
const emit = defineEmits<{
(e: 'click'): void
}>()
const { t } = useI18n()
const {
statusLabel,
statusBadgeClass,
providerLabel,
providerBadgeClass,
formatLatency,
} = useChannelMonitorFormat()
const providerTintClass = computed(() =>
PROVIDER_TINT[props.item.provider] ?? 'text-gray-500 dark:text-gray-300'
)
const availabilityLabel = computed(() => {
const win = t(`channelStatus.windowTab.${props.window}`)
return `${t('monitorCommon.availabilityPrefix')} · ${win}`
})
const extraModelsCountLabel = computed(() => {
const count = props.item.extra_models?.length ?? 0
if (count === 0) return undefined
return t('monitorCommon.extraModelsCount', { n: count })
})
</script>
<template>
<div>
<div
v-if="loading && items.length === 0"
class="grid gap-5 grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"
>
<div
v-for="i in 6"
:key="i"
class="p-5 rounded-2xl min-h-[280px] bg-white/70 dark:bg-dark-800/60 border border-gray-200/80 dark:border-dark-700/70 animate-pulse"
>
<div class="flex items-start gap-3">
<div class="w-9 h-9 rounded-xl bg-gray-200 dark:bg-dark-700"></div>
<div class="flex-1 space-y-2">
<div class="h-4 w-2/3 rounded bg-gray-200 dark:bg-dark-700"></div>
<div class="h-3 w-1/2 rounded bg-gray-200 dark:bg-dark-700"></div>
</div>
<div class="h-6 w-16 rounded-full bg-gray-200 dark:bg-dark-700"></div>
</div>
<div class="mt-5 grid grid-cols-2 gap-2">
<div class="h-16 rounded-xl bg-gray-100 dark:bg-dark-900/40"></div>
<div class="h-16 rounded-xl bg-gray-100 dark:bg-dark-900/40"></div>
</div>
<div class="mt-6 h-5 w-full rounded bg-gray-100 dark:bg-dark-900/40"></div>
</div>
</div>
<EmptyState
v-else-if="items.length === 0"
:title="t('channelStatus.empty.title')"
:description="t('channelStatus.empty.description')"
/>
<div
v-else
class="grid gap-5 grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"
>
<MonitorCard
v-for="item in items"
:key="item.id"
:item="item"
:window="window"
:availability-value="resolveAvailability(item)"
:countdown-seconds="countdownSeconds"
@click="emit('cardClick', item)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { UserMonitorView, UserMonitorDetail } from '@/api/channelMonitor'
import EmptyState from '@/components/common/EmptyState.vue'
import MonitorCard from './MonitorCard.vue'
const props = defineProps<{
items: UserMonitorView[]
window: '7d' | '15d' | '30d'
countdownSeconds: number
loading: boolean
detailCache: Record<number, UserMonitorDetail>
}>()
const emit = defineEmits<{
(e: 'cardClick', item: UserMonitorView): void
}>()
const { t } = useI18n()
function resolveAvailability(item: UserMonitorView): number | null {
if (props.window === '7d') {
return item.availability_7d ?? null
}
const detail = props.detailCache[item.id]
if (!detail) return null
const primary = detail.models.find(m => m.model === item.primary_model)
if (!primary) return null
return props.window === '15d' ? primary.availability_15d ?? null : primary.availability_30d ?? null
}
</script>
<template>
<section class="py-3 md:py-4">
<div class="flex items-center justify-end gap-3 flex-wrap">
<div
role="tablist"
class="inline-flex p-0.5 rounded-xl bg-gray-100 dark:bg-dark-800 border border-gray-200/60 dark:border-dark-700/60 text-xs"
>
<button
v-for="opt in windowOptions"
:key="opt.value"
type="button"
role="tab"
:aria-selected="window === opt.value"
class="px-3 py-1 rounded-lg transition-colors"
:class="window === opt.value
? 'bg-white dark:bg-dark-700 shadow-sm text-gray-900 dark:text-white font-semibold'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
@click="emit('update:window', opt.value)"
>
{{ opt.label }}
</button>
</div>
<span
class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold tracking-wider uppercase"
:class="overallChipClass"
>
<span
class="w-1.5 h-1.5 rounded-full mr-1.5"
:class="overallDotClass"
></span>
{{ overallLabel }}
</span>
<button
type="button"
class="h-8 w-8 rounded-lg flex items-center justify-center text-gray-500 hover:text-gray-700 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-200 dark:hover:bg-dark-700 transition-colors disabled:opacity-50"
:disabled="loading"
:title="t('common.refresh')"
@click="emit('refresh')"
>
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
</button>
<AutoRefreshButton
v-if="autoRefresh"
:enabled="autoRefresh.enabled.value"
:interval-seconds="autoRefresh.intervalSeconds.value"
:countdown="autoRefresh.countdown.value"
:intervals="autoRefresh.intervals"
@update:enabled="autoRefresh.setEnabled"
@update:interval="autoRefresh.setInterval"
/>
</div>
</section>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Icon from '@/components/icons/Icon.vue'
import AutoRefreshButton from '@/components/common/AutoRefreshButton.vue'
export type MonitorWindow = '7d' | '15d' | '30d'
export type OverallStatus = 'operational' | 'degraded'
const props = defineProps<{
overallStatus: OverallStatus
intervalSeconds: number
window: MonitorWindow
loading: boolean
autoRefresh?: {
enabled: { value: boolean }
intervalSeconds: { value: number }
countdown: { value: number }
intervals: readonly number[]
setEnabled: (v: boolean) => void
setInterval: (v: number) => void
}
}>()
const emit = defineEmits<{
(e: 'update:window', value: MonitorWindow): void
(e: 'refresh'): void
}>()
const { t } = useI18n()
const windowOptions = computed<{ value: MonitorWindow; label: string }[]>(() => [
{ value: '7d', label: t('channelStatus.windowTab.7d') },
{ value: '15d', label: t('channelStatus.windowTab.15d') },
{ value: '30d', label: t('channelStatus.windowTab.30d') },
])
const overallLabel = computed(() => t(`channelStatus.overall.${props.overallStatus}`))
const overallChipClass = computed(() => {
switch (props.overallStatus) {
case 'operational':
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300'
case 'degraded':
default:
return 'bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300'
}
})
const overallDotClass = computed(() => {
switch (props.overallStatus) {
case 'operational':
return 'bg-emerald-500 animate-pulse'
case 'degraded':
default:
return 'bg-amber-500 animate-pulse'
}
})
</script>
<template>
<div class="mt-5 grid grid-cols-2 gap-2">
<div
class="rounded-xl p-3 bg-gray-50/80 dark:bg-dark-900/40 border border-gray-100 dark:border-dark-700/50"
>
<div
class="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-gray-400"
>
<Icon :name="primaryIcon" size="xs" />
<span>{{ primaryLabel }}</span>
</div>
<div class="mt-1.5 text-lg font-bold font-mono tabular-nums text-gray-900 dark:text-gray-100">
{{ primaryValue }}<span class="text-xs font-normal text-gray-400 ml-0.5">{{ primaryUnit }}</span>
</div>
</div>
<div
class="rounded-xl p-3 bg-gray-50/80 dark:bg-dark-900/40 border border-gray-100 dark:border-dark-700/50"
>
<div
class="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-gray-400"
>
<Icon :name="secondaryIcon" size="xs" />
<span>{{ secondaryLabel }}</span>
</div>
<div class="mt-1.5 text-lg font-bold font-mono tabular-nums text-gray-900 dark:text-gray-100">
{{ secondaryValue }}<span class="text-xs font-normal text-gray-400 ml-0.5">{{ secondaryUnit }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Icon from '@/components/icons/Icon.vue'
defineProps<{
primaryLabel: string
primaryValue: string
primaryUnit: string
primaryIcon: 'bolt' | 'globe' | 'clock' | 'link'
secondaryLabel: string
secondaryValue: string
secondaryUnit: string
secondaryIcon: 'bolt' | 'globe' | 'clock' | 'link'
}>()
</script>
<template>
<div class="mt-4 pt-3 border-t border-gray-100 dark:border-dark-700/60">
<div
class="flex justify-between text-[10px] font-semibold uppercase tracking-widest text-gray-400 mb-2"
>
<span>{{ t('monitorCommon.history60pts', { n: length }) }}</span>
<span class="tabular-nums">{{ t('monitorCommon.nextUpdateIn', { n: countdownSeconds }) }}</span>
</div>
<div
v-if="maintenance"
class="flex h-5 w-full items-center justify-center rounded border border-dashed border-gray-300 dark:border-dark-600 text-[10px] uppercase tracking-widest text-gray-400"
>
{{ t('monitorCommon.maintenancePaused') }}
</div>
<div v-else class="flex items-end gap-[2px] h-5 w-full">
<div
v-for="(bar, idx) in displayBars"
:key="idx"
class="flex-1 min-w-[3px] rounded-sm"
:class="bar.colorClass"
:style="{ height: bar.heightPct + '%' }"
:title="bar.title"
></div>
</div>
<div
class="mt-1 flex justify-between text-[9px] uppercase tracking-widest text-gray-400"
>
<span>{{ t('monitorCommon.past') }}</span>
<span>{{ t('monitorCommon.now') }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { MonitorTimelinePoint } from '@/api/channelMonitor'
import { useChannelMonitorFormat } from '@/composables/useChannelMonitorFormat'
const props = withDefaults(defineProps<{
buckets?: MonitorTimelinePoint[]
countdownSeconds: number
length?: number
maintenance?: boolean
}>(), {
buckets: () => [],
length: 60,
maintenance: false,
})
const { t } = useI18n()
const { statusLabel, formatLatency, formatRelativeTime } = useChannelMonitorFormat()
interface Bar {
colorClass: string
heightPct: number
title: string
}
// 4 级高度 + 颜色双重编码:高=好+绿,短=坏+红,灰=未测试。
// 长绿(正常) > 中黄(降级) > 短红(失败/系统错误) > 很短灰(未测试)。
const STATUS_HEIGHT: Record<string, number> = {
operational: 100,
degraded: 65,
failed: 35,
error: 35,
empty: 15,
}
const STATUS_COLOR: Record<string, string> = {
operational: 'bg-emerald-500',
degraded: 'bg-amber-500',
failed: 'bg-red-500',
error: 'bg-red-500',
empty: 'bg-gray-300 dark:bg-dark-600',
}
const displayBars = computed<Bar[]>(() => {
// Real points come newest-first; convert to oldest-first so the rightmost
// bar represents "now". Pad the left with empty placeholders to keep the
// bar count stable at `length`.
const real = [...(props.buckets ?? [])]
.slice(0, props.length)
.reverse()
const padCount = Math.max(0, props.length - real.length)
const bars: Bar[] = []
for (let i = 0; i < padCount; i += 1) {
bars.push({
colorClass: STATUS_COLOR.empty,
heightPct: STATUS_HEIGHT.empty,
title: '',
})
}
for (const point of real) {
const status = point.status as keyof typeof STATUS_HEIGHT
const colorClass = STATUS_COLOR[status] ?? STATUS_COLOR.empty
const heightPct = STATUS_HEIGHT[status] ?? STATUS_HEIGHT.empty
const latency = formatLatency(point.latency_ms)
const relative = formatRelativeTime(point.checked_at)
const label = statusLabel(point.status)
bars.push({
colorClass,
heightPct,
title: `${relative} · ${label} · ${latency}ms`,
})
}
return bars
})
</script>
<template>
<svg
v-if="iconInfo"
:width="size"
:height="size"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
fill-rule="evenodd"
aria-hidden="true"
>
<path
v-for="(p, idx) in iconInfo.paths"
:key="idx"
:d="p"
/>
</svg>
<span
v-else
class="inline-flex items-center justify-center font-bold text-gray-500"
:style="{ width: `${size}px`, height: `${size}px`, fontSize: `${Math.round(size * 0.5)}px` }"
>
{{ fallbackText }}
</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Provider } from '@/api/admin/channelMonitor'
interface IconData {
paths: string[]
}
// Provider SVG paths extracted from src/components/common/ModelIcon.vue (which
// in turn pulls from @lobehub/icons Mono.js). Keep in sync if upstream changes.
// SVG uses fill="currentColor" so the wrapper controls the icon tint.
const PROVIDER_ICONS: Record<Provider, IconData> = {
openai: {
paths: [
'M21.55 10.004a5.416 5.416 0 00-.478-4.501c-1.217-2.09-3.662-3.166-6.05-2.66A5.59 5.59 0 0010.831 1C8.39.995 6.224 2.546 5.473 4.838A5.553 5.553 0 001.76 7.496a5.487 5.487 0 00.691 6.5 5.416 5.416 0 00.477 4.502c1.217 2.09 3.662 3.165 6.05 2.66A5.586 5.586 0 0013.168 23c2.443.006 4.61-1.546 5.361-3.84a5.553 5.553 0 003.715-2.66 5.488 5.488 0 00-.693-6.497v.001zm-8.381 11.558a4.199 4.199 0 01-2.675-.954c.034-.018.093-.05.132-.074l4.44-2.53a.71.71 0 00.364-.623v-6.176l1.877 1.069c.02.01.033.029.036.05v5.115c-.003 2.274-1.87 4.118-4.174 4.123zM4.192 17.78a4.059 4.059 0 01-.498-2.763c.032.02.09.055.131.078l4.44 2.53c.225.13.504.13.73 0l5.42-3.088v2.138a.068.068 0 01-.027.057L9.9 19.288c-1.999 1.136-4.552.46-5.707-1.51h-.001zM3.023 8.216A4.15 4.15 0 015.198 6.41l-.002.151v5.06a.711.711 0 00.364.624l5.42 3.087-1.876 1.07a.067.067 0 01-.063.005l-4.489-2.559c-1.995-1.14-2.679-3.658-1.53-5.63h.001zm15.417 3.54l-5.42-3.088L14.896 7.6a.067.067 0 01.063-.006l4.489 2.557c1.998 1.14 2.683 3.662 1.529 5.633a4.163 4.163 0 01-2.174 1.807V12.38a.71.71 0 00-.363-.623zm1.867-2.773a6.04 6.04 0 00-.132-.078l-4.44-2.53a.731.731 0 00-.729 0l-5.42 3.088V7.325a.068.068 0 01.027-.057L14.1 4.713c2-1.137 4.555-.46 5.707 1.513.487.833.664 1.809.499 2.757h.001zm-11.741 3.81l-1.877-1.068a.065.065 0 01-.036-.051V6.559c.001-2.277 1.873-4.122 4.181-4.12.976 0 1.92.338 2.671.954-.034.018-.092.05-.131.073l-4.44 2.53a.71.71 0 00-.365.623l-.003 6.173v.002zm1.02-2.168L12 9.25l2.414 1.375v2.75L12 14.75l-2.415-1.375v-2.75z',
],
},
anthropic: {
paths: [
'M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z',
],
},
gemini: {
paths: [
'M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z',
],
},
}
const props = withDefaults(defineProps<{
provider: Provider | string
size?: number
}>(), {
size: 20,
})
const iconInfo = computed<IconData | null>(() => {
const key = props.provider as Provider
return PROVIDER_ICONS[key] ?? null
})
const fallbackText = computed(() =>
(props.provider || '?').charAt(0).toUpperCase()
)
</script>
import { ref, onBeforeUnmount, type Ref } from 'vue'
export interface UseAutoRefreshOptions {
storageKey: string
intervals?: readonly number[]
defaultInterval?: number
onRefresh: () => Promise<void> | void
/** Skip tick when this returns true (e.g. modal open, document hidden). */
shouldPause?: () => boolean
}
export function useAutoRefresh(options: UseAutoRefreshOptions) {
const {
storageKey,
intervals = [5, 10, 15, 30] as const,
defaultInterval,
onRefresh,
shouldPause,
} = options
const enabled = ref(false)
const intervalSeconds = ref(defaultInterval ?? intervals[intervals.length - 1])
const countdown = ref(0)
const fetching = ref(false)
let timerId: number | undefined
function loadFromStorage() {
try {
const saved = localStorage.getItem(storageKey)
if (!saved) return
const parsed = JSON.parse(saved) as { enabled?: boolean; interval_seconds?: number }
enabled.value = parsed.enabled === true
const iv = Number(parsed.interval_seconds)
if (intervals.includes(iv as any)) intervalSeconds.value = iv
} catch { /* ignore */ }
}
function saveToStorage() {
try {
localStorage.setItem(storageKey, JSON.stringify({
enabled: enabled.value,
interval_seconds: intervalSeconds.value,
}))
} catch { /* ignore */ }
}
async function tick() {
if (!enabled.value) return
if (shouldPause?.()) return
if (fetching.value) return
if (countdown.value <= 0) {
countdown.value = intervalSeconds.value
fetching.value = true
try { await onRefresh() } finally { fetching.value = false }
return
}
countdown.value -= 1
}
function start() {
if (timerId !== undefined) return
timerId = setInterval(tick, 1000) as unknown as number
}
function stop() {
if (timerId !== undefined) {
clearInterval(timerId)
timerId = undefined
}
}
function setEnabled(value: boolean) {
enabled.value = value
saveToStorage()
if (value) {
countdown.value = intervalSeconds.value
start()
} else {
stop()
countdown.value = 0
}
}
function setInterval_(seconds: number) {
intervalSeconds.value = seconds
saveToStorage()
if (enabled.value) countdown.value = seconds
}
function resetCountdown() {
countdown.value = intervalSeconds.value
}
loadFromStorage()
onBeforeUnmount(stop)
return {
enabled: enabled as Ref<boolean>,
intervalSeconds: intervalSeconds as Ref<number>,
countdown: countdown as Ref<number>,
fetching: fetching as Ref<boolean>,
intervals,
setEnabled,
setInterval: setInterval_,
resetCountdown,
start,
stop,
}
}
/**
* Shared formatting helpers for channel monitor views (admin + user).
*
* Centralises:
* - status / provider label + badge class lookups
* - latency / availability / percent number formatting
* - dashboard-style helpers (HSL for availability, provider gradient, relative time)
*
* i18n keys live under `monitorCommon.*` so admin and user views share the
* same translation source.
*/
import { useI18n } from 'vue-i18n'
import type { MonitorStatus, Provider } from '@/api/admin/channelMonitor'
import {
PROVIDER_OPENAI,
PROVIDER_ANTHROPIC,
PROVIDER_GEMINI,
STATUS_OPERATIONAL,
STATUS_DEGRADED,
STATUS_FAILED,
STATUS_ERROR,
} from '@/constants/channelMonitor'
const NEUTRAL_BADGE = 'bg-gray-100 text-gray-800 dark:bg-dark-700 dark:text-gray-300'
/** Availability HSL hue multiplier: 0%=red(0) / 50%=yellow(60) / 100%=green(120). */
const HSL_HUE_PER_PERCENT = 1.2
const HSL_SATURATION = 72
const HSL_LIGHTNESS = 42
export interface AvailabilityRow {
primary_status: MonitorStatus | ''
availability_7d: number | null | undefined
}
export function useChannelMonitorFormat() {
const { t } = useI18n()
function statusLabel(s: MonitorStatus | ''): string {
if (!s) return t('monitorCommon.status.unknown')
return t(`monitorCommon.status.${s}`)
}
function statusBadgeClass(s: MonitorStatus | ''): string {
switch (s) {
case STATUS_OPERATIONAL:
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300'
case STATUS_DEGRADED:
return 'bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300'
case STATUS_FAILED:
return 'bg-red-100 text-red-700 dark:bg-red-500/15 dark:text-red-300'
case STATUS_ERROR:
default:
return NEUTRAL_BADGE
}
}
function providerLabel(p: Provider | string): string {
if (p === PROVIDER_OPENAI || p === PROVIDER_ANTHROPIC || p === PROVIDER_GEMINI) {
return t(`monitorCommon.providers.${p}`)
}
return p || '-'
}
function providerBadgeClass(p: Provider | string): string {
switch (p) {
case PROVIDER_OPENAI:
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300'
case PROVIDER_ANTHROPIC:
return 'bg-orange-100 text-orange-700 dark:bg-orange-500/15 dark:text-orange-300'
case PROVIDER_GEMINI:
return 'bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-300'
default:
return NEUTRAL_BADGE
}
}
/**
* Tailwind class for a provider radio-button-style picker (active/inactive state).
* Reuses the same emerald/orange/sky palette as providerBadgeClass to keep
* visual semantics consistent across badges and pickers.
*/
function providerPickerClass(p: Provider | string, active: boolean): string {
switch (p) {
case PROVIDER_OPENAI:
return active
? 'border-emerald-500 bg-emerald-50 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300 dark:border-emerald-400'
: 'border-gray-200 bg-white text-gray-600 hover:border-emerald-300 hover:text-emerald-700 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-400 dark:hover:border-emerald-500/50'
case PROVIDER_ANTHROPIC:
return active
? 'border-orange-500 bg-orange-50 text-orange-700 dark:bg-orange-500/15 dark:text-orange-300 dark:border-orange-400'
: 'border-gray-200 bg-white text-gray-600 hover:border-orange-300 hover:text-orange-700 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-400 dark:hover:border-orange-500/50'
case PROVIDER_GEMINI:
return active
? 'border-sky-500 bg-sky-50 text-sky-700 dark:bg-sky-500/15 dark:text-sky-300 dark:border-sky-400'
: 'border-gray-200 bg-white text-gray-600 hover:border-sky-300 hover:text-sky-700 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-400 dark:hover:border-sky-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'
: 'border-gray-200 bg-white text-gray-600 hover:border-gray-300 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-400'
}
}
function formatLatency(ms: number | null | undefined): string {
if (ms == null) return t('monitorCommon.latencyEmpty')
return String(Math.round(ms))
}
function formatPercent(v: number | null | undefined): string {
if (v == null || Number.isNaN(v)) return '-'
return `${v.toFixed(2)}%`
}
function formatAvailability(row: AvailabilityRow): string {
if (!row.primary_status) return '-'
return formatPercent(row.availability_7d)
}
function formatRelativeTime(iso: string | null | undefined): string {
if (!iso) return t('monitorCommon.latencyEmpty')
const ts = Date.parse(iso)
if (Number.isNaN(ts)) return t('monitorCommon.latencyEmpty')
const diffSec = Math.max(0, Math.floor((Date.now() - ts) / 1000))
if (diffSec < 60) return t('monitorCommon.relativeSecondsAgo', { n: diffSec })
const diffMin = Math.floor(diffSec / 60)
if (diffMin < 60) return t('monitorCommon.relativeMinutesAgo', { n: diffMin })
const diffHour = Math.floor(diffMin / 60)
if (diffHour < 24) return t('monitorCommon.relativeHoursAgo', { n: diffHour })
const diffDay = Math.floor(diffHour / 24)
return t('monitorCommon.relativeDaysAgo', { n: diffDay })
}
return {
statusLabel,
statusBadgeClass,
providerLabel,
providerBadgeClass,
providerPickerClass,
formatLatency,
formatPercent,
formatAvailability,
formatRelativeTime,
}
}
/**
* Map availability percent to an HSL colour (red -> yellow -> green).
* Returns undefined for null/NaN so callers can fall back to a neutral colour.
*/
export function hslForPct(pct: number | null | undefined): string | undefined {
if (pct === null || pct === undefined || Number.isNaN(pct)) return undefined
const clamped = Math.max(0, Math.min(100, pct))
const hue = clamped * HSL_HUE_PER_PERCENT
return `hsl(${hue} ${HSL_SATURATION}% ${HSL_LIGHTNESS}%)`
}
/**
* Tailwind gradient class for the provider icon tile background.
*/
export function providerGradient(provider: string): string {
switch (provider) {
case PROVIDER_OPENAI:
return 'bg-gradient-to-br from-emerald-50 to-emerald-100 dark:from-emerald-500/10 dark:to-emerald-500/20'
case PROVIDER_ANTHROPIC:
return 'bg-gradient-to-br from-orange-50 to-amber-100 dark:from-orange-500/10 dark:to-amber-500/20'
case PROVIDER_GEMINI:
return 'bg-gradient-to-br from-sky-50 to-indigo-100 dark:from-sky-500/10 dark:to-indigo-500/20'
default:
return 'bg-gradient-to-br from-gray-100 to-gray-200 dark:from-dark-700 dark:to-dark-600'
}
}
/** Channel status values (must match service.Status* constants in Go). */
export const CHANNEL_STATUS_ACTIVE = 'active' as const
export const CHANNEL_STATUS_DISABLED = 'disabled' as const
export type ChannelStatus = typeof CHANNEL_STATUS_ACTIVE | typeof CHANNEL_STATUS_DISABLED
/** Billing mode values (must match service.BillingMode* constants in Go). */
export const BILLING_MODE_TOKEN = 'token' as const
export const BILLING_MODE_PER_REQUEST = 'per_request' as const
export const BILLING_MODE_IMAGE = 'image' as const
export type BillingMode =
| typeof BILLING_MODE_TOKEN
| typeof BILLING_MODE_PER_REQUEST
| typeof BILLING_MODE_IMAGE
/** Billing-model-source values (must match service.BillingModelSource* constants in Go). */
export const BILLING_MODEL_SOURCE_REQUESTED = 'requested' as const
export const BILLING_MODEL_SOURCE_UPSTREAM = 'upstream' as const
export const BILLING_MODEL_SOURCE_CHANNEL_MAPPED = 'channel_mapped' as const
export type BillingModelSource =
| typeof BILLING_MODEL_SOURCE_REQUESTED
| typeof BILLING_MODEL_SOURCE_UPSTREAM
| typeof BILLING_MODEL_SOURCE_CHANNEL_MAPPED
/**
* Channel monitor shared constants.
*
* Single source of truth for provider/status string values used by both the
* admin (`views/admin/ChannelMonitorView.vue`) and user-facing
* (`views/user/ChannelStatusView.vue`) screens, plus the shared composable
* `useChannelMonitorFormat`.
*/
import type { 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 PROVIDERS: readonly Provider[] = [
PROVIDER_OPENAI,
PROVIDER_ANTHROPIC,
PROVIDER_GEMINI,
]
export const STATUS_OPERATIONAL: MonitorStatus = 'operational'
export const STATUS_DEGRADED: MonitorStatus = 'degraded'
export const STATUS_FAILED: MonitorStatus = 'failed'
export const STATUS_ERROR: MonitorStatus = 'error'
export const MONITOR_STATUSES: readonly MonitorStatus[] = [
STATUS_OPERATIONAL,
STATUS_DEGRADED,
STATUS_FAILED,
STATUS_ERROR,
]
/** Default polling interval (seconds) for new monitors. */
export const DEFAULT_INTERVAL_SECONDS = 60
...@@ -245,6 +245,7 @@ export default { ...@@ -245,6 +245,7 @@ export default {
// Common // Common
common: { common: {
loading: 'Loading...', loading: 'Loading...',
submitting: 'Submitting...',
justNow: 'just now', justNow: 'just now',
save: 'Save', save: 'Save',
saved: 'Saved successfully', saved: 'Saved successfully',
...@@ -272,6 +273,7 @@ export default { ...@@ -272,6 +273,7 @@ export default {
no: 'No', no: 'No',
all: 'All', all: 'All',
none: 'None', none: 'None',
selectAll: 'Select all',
noData: 'No data', noData: 'No data',
expand: 'Expand', expand: 'Expand',
collapse: 'Collapse', collapse: 'Collapse',
...@@ -306,6 +308,12 @@ export default { ...@@ -306,6 +308,12 @@ export default {
saving: 'Saving...', saving: 'Saving...',
selectedCount: '({count} selected)', selectedCount: '({count} selected)',
refresh: 'Refresh', refresh: 'Refresh',
autoRefresh: {
title: 'Auto Refresh',
enable: 'Enable auto refresh',
countdown: 'Auto refresh: {seconds}s',
seconds: '{n} seconds',
},
view: 'View', view: 'View',
settings: 'Settings', settings: 'Settings',
chooseFile: 'Choose File', chooseFile: 'Choose File',
...@@ -342,6 +350,7 @@ export default { ...@@ -342,6 +350,7 @@ export default {
users: 'Users', users: 'Users',
groups: 'Groups', groups: 'Groups',
channels: 'Channels', channels: 'Channels',
availableChannels: 'Available Channels',
subscriptions: 'Subscriptions', subscriptions: 'Subscriptions',
accounts: 'Accounts', accounts: 'Accounts',
proxies: 'Proxies', proxies: 'Proxies',
...@@ -363,7 +372,11 @@ export default { ...@@ -363,7 +372,11 @@ export default {
orderManagement: 'Orders', orderManagement: 'Orders',
paymentDashboard: 'Payment Dashboard', paymentDashboard: 'Payment Dashboard',
paymentConfig: 'Payment Config', paymentConfig: 'Payment Config',
paymentPlans: 'Plans' paymentPlans: 'Plans',
channelManagement: 'Channels',
channelPricing: 'Channel Pricing',
channelMonitor: 'Channel Monitor',
channelStatus: 'Channel Status',
}, },
// Auth // Auth
...@@ -846,6 +859,119 @@ export default { ...@@ -846,6 +859,119 @@ export default {
userAgent: 'User-Agent' userAgent: 'User-Agent'
}, },
// Shared keys for channel monitor (admin + user views)
monitorCommon: {
status: {
operational: 'Operational',
degraded: 'Degraded',
failed: 'Failed',
error: 'Error',
unknown: '-'
},
providers: {
openai: 'OpenAI',
anthropic: 'Anthropic',
gemini: 'Gemini'
},
extraModelsHeader: 'Extra Models',
extraModelsEmpty: 'No extra models',
latencyEmpty: '-',
availabilityPrefix: 'Availability',
dialogLatency: 'Dialog Latency',
endpointPing: 'Endpoint PING',
history60pts: 'HISTORY ({n} PTS)',
nextUpdateIn: 'NEXT UPDATE IN {n}s',
past: 'PAST',
now: 'NOW',
maintenancePaused: 'Maintenance · timeline paused',
extraModelsCount: '+ {n} models',
pollEvery: '{n}s polling',
updatedAt: 'Updated {time}',
relativeSecondsAgo: '{n}s ago',
relativeMinutesAgo: '{n}m ago',
relativeHoursAgo: '{n}h ago',
relativeDaysAgo: '{n}d ago'
},
// Channel Status (user-facing read-only view)
channelStatus: {
title: 'Channel Status',
description: 'Inspect channel availability, latency and recent status',
searchPlaceholder: 'Search channels...',
allProviders: 'All Providers',
loadError: 'Failed to load channel status',
detailLoadError: 'Failed to load channel detail',
detailTitle: 'Channel Detail',
closeDetail: 'Close',
windowTab: {
'7d': '7 days',
'15d': '15 days',
'30d': '30 days'
},
overall: {
operational: 'OPERATIONAL',
degraded: 'DEGRADED',
unavailable: 'UNAVAILABLE'
},
columns: {
name: 'Name',
provider: 'Provider',
groupName: 'Group',
primaryModel: 'Primary Model',
availability7d: '7d Availability',
latency: 'Latency (ms)'
},
detailColumns: {
model: 'Model',
latestStatus: 'Latest Status',
latestLatency: 'Latest Latency (ms)',
availability7d: '7d Availability',
availability15d: '15d Availability',
availability30d: '30d Availability',
avgLatency7d: '7d Avg Latency (ms)'
},
empty: {
title: 'No channels available',
description: 'No monitored channels have been configured yet.'
}
},
// Available Channels (user-facing)
availableChannels: {
title: 'Available Channels',
description: 'Channels you can access, along with their supported models and pricing',
searchPlaceholder: 'Search channels or models...',
empty: 'No available channels',
noModels: 'No models configured',
noPricing: 'Pricing not configured',
exclusive: 'Exclusive',
public: 'Public',
exclusiveTooltip: 'Exclusive groups granted to you by an admin',
publicTooltip: 'Groups open to all users',
columns: {
name: 'Channel',
description: 'Description',
platform: 'Platform',
groups: 'Your Accessible Groups',
supportedModels: 'Supported Models'
},
pricing: {
billingMode: 'Billing Mode',
billingModeToken: 'Per Token',
billingModePerRequest: 'Per Request',
billingModeImage: 'Per Image',
inputPrice: 'Input',
outputPrice: 'Output',
cacheWritePrice: 'Cache Write',
cacheReadPrice: 'Cache Read',
imageOutputPrice: 'Image Output',
perRequestPrice: 'Per Request',
intervals: 'Tiered Pricing',
unitPerMillion: '/ 1M tokens',
unitPerRequest: '/ request'
}
},
// Redeem // Redeem
redeem: { redeem: {
title: 'Redeem Code', title: 'Redeem Code',
...@@ -1917,6 +2043,46 @@ export default { ...@@ -1917,6 +2043,46 @@ export default {
} }
}, },
// Available Channels (aggregated read-only view)
availableChannels: {
title: 'Available Channels',
description: 'Aggregated view: each channel with its linked groups and supported models (wildcards expanded)',
searchPlaceholder: 'Search channels or models...',
columns: {
name: 'Channel',
status: 'Status',
billingSource: 'Billing Model Source',
groups: 'Linked Groups',
supportedModels: 'Supported Models'
},
empty: 'No data',
noGroups: 'No linked groups',
noModels: 'No model mapping configured',
noPricing: 'Pricing not configured',
statusActive: 'Active',
statusDisabled: 'Disabled',
billingSource: {
requested: 'Requested model',
upstream: 'Upstream model',
channel_mapped: 'Channel-mapped model'
},
pricing: {
billingMode: 'Billing Mode',
billingModeToken: 'Per Token',
billingModePerRequest: 'Per Request',
billingModeImage: 'Per Image',
inputPrice: 'Input',
outputPrice: 'Output',
cacheWritePrice: 'Cache Write',
cacheReadPrice: 'Cache Read',
imageOutputPrice: 'Image Output',
perRequestPrice: 'Per Request',
intervals: 'Tiered Pricing',
unitPerMillion: '/ 1M tokens',
unitPerRequest: '/ request'
}
},
// Channel Management // Channel Management
channels: { channels: {
title: 'Channel Management', title: 'Channel Management',
...@@ -2034,6 +2200,130 @@ export default { ...@@ -2034,6 +2200,130 @@ export default {
} }
}, },
// Channel Monitor
channelMonitor: {
title: 'Channel Monitor',
description: 'Monitor channel availability, latency and status',
searchPlaceholder: 'Search monitor name...',
allProviders: 'All Providers',
allStatus: 'All Status',
enabledFilter: 'Enabled',
onlyEnabled: 'Enabled only',
onlyDisabled: 'Disabled only',
createButton: 'Create Monitor',
createTitle: 'Create Channel Monitor',
editTitle: 'Edit Channel Monitor',
runNow: 'Run Now',
runSuccess: 'Check completed',
runFailed: 'Check failed',
apiKeyDecryptFailed: 'API Key decryption failed. Please re-edit this monitor with a fresh key.',
createSuccess: 'Monitor created',
updateSuccess: 'Monitor updated',
deleteSuccess: 'Monitor deleted',
loadError: 'Failed to load monitors',
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',
columns: {
name: 'Name',
provider: 'Provider',
primaryModel: 'Primary Model',
availability7d: '7d Availability',
latency: 'Latency (ms)',
enabled: 'Enabled',
actions: 'Actions'
},
form: {
name: 'Name',
namePlaceholder: 'Enter monitor name',
provider: 'Platform',
endpoint: 'Endpoint',
endpointPlaceholder: 'https://api.example.com',
useCurrentDomain: 'Use current service',
apiKey: 'API Key',
apiKeyPlaceholder: 'Enter API Key',
apiKeyEditPlaceholder: 'Leave blank to keep current key',
useMyKey: 'Use my key',
selectKeyTitle: 'Select my API Key',
selectKeyHint: 'Only your active, non-expired keys are listed.',
noActiveKey: 'No active API keys available',
primaryModel: 'Primary Model',
primaryModelPlaceholder: 'gpt-4o-mini',
extraModels: 'Extra Models',
extraModelsPlaceholder: 'Press Enter to add extra model',
groupName: 'Group Name',
groupNamePlaceholder: 'Optional, used to group rows in user view',
intervalSeconds: 'Interval (seconds)',
intervalSecondsHint: 'Range: 15 - 3600 seconds',
enabled: 'Enable monitor',
kindRequired: 'Please select a provider'
},
runResultTitle: 'Check Result',
noMonitorsYet: 'No monitors yet',
createFirstMonitor: 'Create your first monitor to track channel availability',
advanced: {
section: 'Advanced (optional)',
sectionHint: 'Customize request headers and body to bypass upstream client-detection (e.g. "only Claude Code clients allowed").',
headers: 'Custom request headers',
headersPlaceholder: 'User-Agent: claude-cli/1.0.83 (external, cli)\nx-app: cli\nanthropic-beta: claude-code-20250219',
headerNamePlaceholder: 'Header name',
headerValuePlaceholder: 'Value',
headerAddRow: 'Add header',
headerNameInvalid: 'Header name cannot contain whitespace or colon: {name}',
headersHint: 'Merged on top of adapter defaults (user wins). Hop-by-hop headers (Host / Content-Length / ...) are ignored.',
headersParseError: 'Cannot parse line: {line}',
bodyMode: 'Body handling',
bodyModeOff: 'Default',
bodyModeMerge: 'Merge',
bodyModeReplace: 'Replace',
bodyModeHintOff: 'Use the adapter default body (includes challenge validation).',
bodyModeHintMerge: 'Shallow-merge with the default body; user fields win but model / messages / contents are protected (use Replace to change those).',
bodyModeHintReplace: 'Use the JSON below as the complete body. Challenge validation is skipped; HTTP 2xx + non-empty response text is treated as operational.',
bodyJson: 'Body JSON',
bodyJsonFormat: 'Format',
bodyJsonHint: 'Parsed on blur. Empty means no override.',
bodyJsonError: 'JSON parse failed',
bodyJsonObjectError: 'Body must be a JSON object (no arrays or primitives)'
},
templateField: {
label: 'Request template',
none: 'No template',
placeholder: 'Pick a template (filtered by current provider)',
applyHint: 'Picking a template copies its headers and body to this monitor (snapshot). Later template edits are not auto-synced.'
},
template: {
manageButton: 'Templates',
managerTitle: 'Request template manager',
createButton: 'New template',
emptyState: 'No templates for this provider yet',
missingName: 'Template name is required',
createSuccess: 'Template created',
updateSuccess: 'Template updated',
deleteSuccess: 'Template deleted',
applyButton: 'Apply to monitors',
applyTooltip: 'Overwrite snapshot fields on associated monitors',
applyTitle: 'Apply template',
applyConfirm: 'Apply',
applyConfirmMessage: 'Overwrite {n} associated monitor(s) with the current configuration of "{name}"? Any local customizations on those monitors will be discarded.',
applySuccess: 'Applied to {n} monitor(s)',
applyPickerTitle: 'Apply template "{name}"',
applyPickerHint: 'Select which monitors to overwrite (all selected by default). Any local customizations will be discarded.',
applyPickerEmpty: 'No monitors are currently associated to this template',
applyPickerConfirm: 'Apply to {n} monitor(s)',
selectNone: 'Select none',
selectedCount: 'Selected {n} / {total}',
deleteConfirm: 'Delete template "{name}"? {n} associated monitor(s) will be disassociated but keep their current snapshot and continue running.',
associatedCount: '{n} associated monitor(s)',
headersSummary: '{n} custom header(s)',
form: {
name: 'Template name',
namePlaceholder: 'e.g. Claude Code mimicry',
description: 'Description',
descriptionPlaceholder: 'Optional: what this template is for, capture date, etc.'
}
}
},
// Subscriptions // Subscriptions
subscriptions: { subscriptions: {
title: 'Subscription Management', title: 'Subscription Management',
...@@ -4406,6 +4696,7 @@ export default { ...@@ -4406,6 +4696,7 @@ export default {
description: 'Manage registration, email verification, default values, and SMTP settings', description: 'Manage registration, email verification, default values, and SMTP settings',
tabs: { tabs: {
general: 'General', general: 'General',
features: 'Feature Switches',
security: 'Security', security: 'Security',
users: 'Users', users: 'Users',
gateway: 'Gateway', gateway: 'Gateway',
...@@ -4413,6 +4704,24 @@ export default { ...@@ -4413,6 +4704,24 @@ export default {
backup: 'Backup', backup: 'Backup',
payment: 'Payment', payment: 'Payment',
}, },
features: {
channelMonitor: {
title: 'Channel Monitor',
description: 'Periodically probe configured channels and surface availability / latency to users. Turning it off stops the scheduler and returns an empty list on the user page.',
configureLink: 'Configure monitors in Channel Management > Channel Monitor',
enabled: 'Enable Channel Monitor',
enabledHint: 'Disabling stops background checks; existing history is preserved.',
defaultInterval: 'Default check interval (seconds)',
defaultIntervalHint: 'Pre-fills the interval when creating a new monitor; each monitor can override it. Range 15 – 3600.',
},
availableChannels: {
title: 'Available Channels',
description: 'Show logged-in users an aggregate view of the channels, models and pricing they can access. Disabled by default.',
configureLink: 'Configure model pricing in Channel Management > Channel Pricing',
enabled: 'Enable Available Channels',
enabledHint: 'When off, the sidebar entry is hidden and the endpoint returns an empty list.',
},
},
emailTabDisabledTitle: 'Email Verification Not Enabled', emailTabDisabledTitle: 'Email Verification Not Enabled',
emailTabDisabledHint: 'Enable email verification in the Security tab to configure SMTP settings.', emailTabDisabledHint: 'Enable email verification in the Security tab to configure SMTP settings.',
registration: { registration: {
......
...@@ -245,6 +245,7 @@ export default { ...@@ -245,6 +245,7 @@ export default {
// Common // Common
common: { common: {
loading: '加载中...', loading: '加载中...',
submitting: '提交中...',
justNow: '刚刚', justNow: '刚刚',
save: '保存', save: '保存',
saved: '保存成功', saved: '保存成功',
...@@ -272,6 +273,7 @@ export default { ...@@ -272,6 +273,7 @@ export default {
no: '', no: '',
all: '全部', all: '全部',
none: '', none: '',
selectAll: '全选',
noData: '暂无数据', noData: '暂无数据',
expand: '展开', expand: '展开',
collapse: '收起', collapse: '收起',
...@@ -306,6 +308,12 @@ export default { ...@@ -306,6 +308,12 @@ export default {
saving: '保存中...', saving: '保存中...',
selectedCount: '(已选 {count} 个)', selectedCount: '(已选 {count} 个)',
refresh: '刷新', refresh: '刷新',
autoRefresh: {
title: '自动刷新',
enable: '启用自动刷新',
countdown: '自动刷新: {seconds}s',
seconds: '{n} 秒',
},
view: '查看', view: '查看',
settings: '设置', settings: '设置',
chooseFile: '选择文件', chooseFile: '选择文件',
...@@ -342,6 +350,7 @@ export default { ...@@ -342,6 +350,7 @@ export default {
users: '用户管理', users: '用户管理',
groups: '分组管理', groups: '分组管理',
channels: '渠道管理', channels: '渠道管理',
availableChannels: '可用渠道',
subscriptions: '订阅管理', subscriptions: '订阅管理',
accounts: '账号管理', accounts: '账号管理',
proxies: 'IP管理', proxies: 'IP管理',
...@@ -363,7 +372,11 @@ export default { ...@@ -363,7 +372,11 @@ export default {
orderManagement: '订单管理', orderManagement: '订单管理',
paymentDashboard: '支付概览', paymentDashboard: '支付概览',
paymentConfig: '支付配置', paymentConfig: '支付配置',
paymentPlans: '订阅套餐' paymentPlans: '订阅套餐',
channelManagement: '渠道管理',
channelPricing: '渠道定价',
channelMonitor: '渠道监控',
channelStatus: '渠道状态',
}, },
// Auth // Auth
...@@ -850,6 +863,119 @@ export default { ...@@ -850,6 +863,119 @@ export default {
userAgent: 'User-Agent' userAgent: 'User-Agent'
}, },
// Shared keys for channel monitor (admin + user views)
monitorCommon: {
status: {
operational: '正常',
degraded: '降级',
failed: '失败',
error: '错误',
unknown: '-'
},
providers: {
openai: 'OpenAI',
anthropic: 'Anthropic',
gemini: 'Gemini'
},
extraModelsHeader: '附加模型',
extraModelsEmpty: '无附加模型',
latencyEmpty: '-',
availabilityPrefix: '可用性',
dialogLatency: '对话延迟',
endpointPing: '端点 PING',
history60pts: '近 {n} 次记录',
nextUpdateIn: '{n}s 后刷新',
past: 'PAST',
now: 'NOW',
maintenancePaused: '维护中 · 已暂停时间线采集',
extraModelsCount: '+ {n} 模型',
pollEvery: '{n}s 轮询',
updatedAt: '更新于 {time}',
relativeSecondsAgo: '{n} 秒前',
relativeMinutesAgo: '{n} 分钟前',
relativeHoursAgo: '{n} 小时前',
relativeDaysAgo: '{n} 天前'
},
// Channel Status (user-facing read-only view)
channelStatus: {
title: '渠道状态',
description: '查看渠道可用性、延迟和近期状态',
searchPlaceholder: '搜索渠道...',
allProviders: '全部供应商',
loadError: '加载渠道状态失败',
detailLoadError: '加载渠道详情失败',
detailTitle: '渠道详情',
closeDetail: '关闭',
windowTab: {
'7d': '7 天',
'15d': '15 天',
'30d': '30 天'
},
overall: {
operational: 'OPERATIONAL',
degraded: 'DEGRADED',
unavailable: 'UNAVAILABLE'
},
columns: {
name: '名称',
provider: '供应商',
groupName: '分组',
primaryModel: '主模型',
availability7d: '7 天可用率',
latency: '延迟 (ms)'
},
detailColumns: {
model: '模型',
latestStatus: '最新状态',
latestLatency: '最新延迟 (ms)',
availability7d: '7 天可用率',
availability15d: '15 天可用率',
availability30d: '30 天可用率',
avgLatency7d: '7 天平均延迟 (ms)'
},
empty: {
title: '暂无可显示的渠道',
description: '管理员尚未配置可监控的渠道。'
}
},
// Available Channels (user-facing)
availableChannels: {
title: '可用渠道',
description: '查看您可访问的渠道与其支持的模型、定价',
searchPlaceholder: '搜索渠道或模型...',
empty: '暂无可用渠道',
noModels: '未配置模型',
noPricing: '未配置定价',
exclusive: '专属',
public: '公开',
exclusiveTooltip: '管理员授权给你的专属分组',
publicTooltip: '对所有用户公开的分组',
columns: {
name: '渠道名',
description: '描述',
platform: '平台',
groups: '我可访问的分组',
supportedModels: '支持模型'
},
pricing: {
billingMode: '计费模式',
billingModeToken: '按 Token',
billingModePerRequest: '按次',
billingModeImage: '按图片',
inputPrice: '输入',
outputPrice: '输出',
cacheWritePrice: '缓存写入',
cacheReadPrice: '缓存读取',
imageOutputPrice: '图片输出',
perRequestPrice: '每次请求',
intervals: '阶梯定价',
unitPerMillion: '/ 1M token',
unitPerRequest: '/ 次'
}
},
// Redeem // Redeem
redeem: { redeem: {
title: '兑换码', title: '兑换码',
...@@ -1994,6 +2120,46 @@ export default { ...@@ -1994,6 +2120,46 @@ export default {
} }
}, },
// Available Channels (aggregated read-only view)
availableChannels: {
title: '可用渠道',
description: '按渠道聚合查看关联分组与支持模型(已展开通配符)',
searchPlaceholder: '搜索渠道或模型...',
columns: {
name: '渠道名',
status: '状态',
billingSource: '计费模型来源',
groups: '关联分组',
supportedModels: '支持模型'
},
empty: '暂无数据',
noGroups: '未关联分组',
noModels: '未配置模型映射',
noPricing: '未配置定价',
statusActive: '启用',
statusDisabled: '停用',
billingSource: {
requested: '请求模型',
upstream: '上游模型',
channel_mapped: '映射后模型'
},
pricing: {
billingMode: '计费模式',
billingModeToken: '按 Token',
billingModePerRequest: '按次',
billingModeImage: '按图片',
inputPrice: '输入',
outputPrice: '输出',
cacheWritePrice: '缓存写入',
cacheReadPrice: '缓存读取',
imageOutputPrice: '图片输出',
perRequestPrice: '每次请求',
intervals: '阶梯定价',
unitPerMillion: '/ 1M token',
unitPerRequest: '/ 次'
}
},
// Channel Management // Channel Management
channels: { channels: {
title: '渠道管理', title: '渠道管理',
...@@ -2111,6 +2277,130 @@ export default { ...@@ -2111,6 +2277,130 @@ export default {
} }
}, },
// Channel Monitor
channelMonitor: {
title: '渠道监控',
description: '监测各渠道的可用性、延迟和状态',
searchPlaceholder: '搜索监控名称...',
allProviders: '全部供应商',
allStatus: '全部状态',
enabledFilter: '启用状态',
onlyEnabled: '仅启用',
onlyDisabled: '仅禁用',
createButton: '新增监控',
createTitle: '新增渠道监控',
editTitle: '编辑渠道监控',
runNow: '立即检测',
runSuccess: '检测完成',
runFailed: '检测失败',
apiKeyDecryptFailed: 'API Key 解密失败,请重新编辑该监控并填入新的 Key',
createSuccess: '监控创建成功',
updateSuccess: '监控更新成功',
deleteSuccess: '监控删除成功',
loadError: '加载监控列表失败',
deleteConfirm: '确定要删除监控「{name}」吗?此操作不可撤销。',
nameRequired: '请输入监控名称',
primaryModelRequired: '请输入主模型',
columns: {
name: '名称',
provider: '供应商',
primaryModel: '主模型',
availability7d: '7 天可用率',
latency: '延迟 (ms)',
enabled: '启用',
actions: '操作'
},
form: {
name: '名称',
namePlaceholder: '输入监控名称',
provider: '平台',
endpoint: '上游地址',
endpointPlaceholder: 'https://api.example.com',
useCurrentDomain: '使用当前服务',
apiKey: 'API Key',
apiKeyPlaceholder: '请输入 API Key',
apiKeyEditPlaceholder: '留空表示不修改',
useMyKey: '使用我的 Key',
selectKeyTitle: '选择我的 API Key',
selectKeyHint: '仅显示当前账号下处于「启用」状态且未过期的 Key。',
noActiveKey: '没有可用的启用状态 Key',
primaryModel: '主模型',
primaryModelPlaceholder: 'gpt-4o-mini',
extraModels: '附加模型',
extraModelsPlaceholder: '回车添加附加模型',
groupName: '分组名称',
groupNamePlaceholder: '可选,用于在用户视图中聚合显示',
intervalSeconds: '检测间隔 (秒)',
intervalSecondsHint: '范围:15 - 3600 秒',
enabled: '启用监控',
kindRequired: '请选择供应商'
},
runResultTitle: '检测结果',
noMonitorsYet: '暂无监控',
createFirstMonitor: '创建第一个监控来跟踪渠道可用性',
advanced: {
section: '高级(可选)',
sectionHint: '自定义请求头和请求体,用于突破上游的客户端识别限制(如仅允许 Claude Code 客户端)。',
headers: '自定义请求头',
headersPlaceholder: 'User-Agent: claude-cli/1.0.83 (external, cli)\nx-app: cli\nanthropic-beta: claude-code-20250219',
headerNamePlaceholder: 'Header 名',
headerValuePlaceholder: 'Value',
headerAddRow: '添加 Header',
headerNameInvalid: 'Header 名不能包含空格或冒号:{name}',
headersHint: '与默认请求头合并,用户值优先。hop-by-hop 类 header(Host/Content-Length/...)会被忽略。',
headersParseError: '无法解析这一行:{line}',
bodyMode: '请求体处理',
bodyModeOff: '默认',
bodyModeMerge: '合并',
bodyModeReplace: '覆盖',
bodyModeHintOff: '使用 adapter 默认请求体(带 challenge 数学题校验)。',
bodyModeHintMerge: '与默认请求体浅合并,用户字段优先;但 model / messages / contents 会被保护不允许覆盖(动这些字段请用「覆盖」模式)。',
bodyModeHintReplace: '完全用下方 JSON 作为请求体。注意:此模式下跳过 challenge 校验,改为 HTTP 2xx + 响应文本非空即视为可用。',
bodyJson: 'Body JSON',
bodyJsonFormat: '格式化',
bodyJsonHint: '失焦时自动解析校验。留空等价于没有覆盖。',
bodyJsonError: 'JSON 解析失败',
bodyJsonObjectError: '请求体必须是一个 JSON 对象(不能是数组或基本类型)'
},
templateField: {
label: '请求模板',
none: '不使用模板',
placeholder: '选择一个模板(按当前平台过滤)',
applyHint: '选中模板后,会把模板的请求头和请求体拷贝到此监控(快照)。后续模板变动不自动同步。'
},
template: {
manageButton: '模板管理',
managerTitle: '请求模板管理',
createButton: '新建模板',
emptyState: '当前平台下还没有请求模板',
missingName: '请输入模板名称',
createSuccess: '模板创建成功',
updateSuccess: '模板更新成功',
deleteSuccess: '模板删除成功',
applyButton: '应用到关联监控',
applyTooltip: '把当前模板配置覆盖到所有关联的监控上',
applyTitle: '应用模板',
applyConfirm: '确认应用',
applyConfirmMessage: '将把模板「{name}」的当前配置覆盖到 {n} 个关联监控。监控本地已编辑的自定义修改会被丢弃,是否继续?',
applySuccess: '已应用到 {n} 个监控',
applyPickerTitle: '应用模板「{name}」',
applyPickerHint: '勾选要覆盖请求头/请求体的监控(默认全选)。监控本地已编辑的自定义修改会被丢弃。',
applyPickerEmpty: '当前模板没有关联监控',
applyPickerConfirm: '应用到 {n} 个监控',
selectNone: '全不选',
selectedCount: '已选 {n} / {total}',
deleteConfirm: '确定要删除模板「{name}」吗?{n} 个关联监控会解除关联但保留自己的快照继续工作。',
associatedCount: '{n} 个关联监控',
headersSummary: '{n} 个自定义请求头',
form: {
name: '模板名称',
namePlaceholder: '例:Claude Code 伪装',
description: '说明',
descriptionPlaceholder: '可选:说明这个模板的用途和来源(抓包日期等)'
}
}
},
// Subscriptions Management // Subscriptions Management
subscriptions: { subscriptions: {
title: '订阅管理', title: '订阅管理',
...@@ -4569,6 +4859,7 @@ export default { ...@@ -4569,6 +4859,7 @@ export default {
description: '管理注册、邮箱验证、默认值和 SMTP 设置', description: '管理注册、邮箱验证、默认值和 SMTP 设置',
tabs: { tabs: {
general: '通用设置', general: '通用设置',
features: '功能开关',
security: '安全与认证', security: '安全与认证',
users: '用户默认值', users: '用户默认值',
gateway: '网关服务', gateway: '网关服务',
...@@ -4576,6 +4867,24 @@ export default { ...@@ -4576,6 +4867,24 @@ export default {
backup: '数据备份', backup: '数据备份',
payment: '支付设置', payment: '支付设置',
}, },
features: {
channelMonitor: {
title: '渠道监控',
description: '定期对配置的渠道发起健康检查,向用户展示可用性与延迟。关闭后调度器停止扫描,用户端列表为空。',
configureLink: '前往 渠道管理 > 渠道监控 配置监控项',
enabled: '启用渠道监控',
enabledHint: '关闭后后台不再执行定时检测,已有数据保留。',
defaultInterval: '默认检测间隔(秒)',
defaultIntervalHint: '新建渠道监控时表单的默认值,可被单个渠道覆盖。范围 15 – 3600 秒。',
},
availableChannels: {
title: '可用渠道',
description: '向已登录用户展示他们能访问的渠道、模型和定价聚合视图。默认关闭。',
configureLink: '前往 渠道管理 > 渠道定价 配置模型价格',
enabled: '启用可用渠道',
enabledHint: '关闭后用户端侧边栏入口隐藏,接口返回空数组。',
},
},
emailTabDisabledTitle: '邮箱验证未启用', emailTabDisabledTitle: '邮箱验证未启用',
emailTabDisabledHint: '请在「安全与认证」选项卡中启用邮箱验证后,再配置 SMTP 设置。', emailTabDisabledHint: '请在「安全与认证」选项卡中启用邮箱验证后,再配置 SMTP 设置。',
registration: { registration: {
......
...@@ -197,6 +197,18 @@ const routes: RouteRecordRaw[] = [ ...@@ -197,6 +197,18 @@ const routes: RouteRecordRaw[] = [
descriptionKey: 'redeem.description' descriptionKey: 'redeem.description'
} }
}, },
{
path: '/available-channels',
name: 'UserAvailableChannels',
component: () => import('@/views/user/AvailableChannelsView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: false,
title: 'Available Channels',
titleKey: 'availableChannels.title',
descriptionKey: 'availableChannels.description'
}
},
{ {
path: '/profile', path: '/profile',
name: 'Profile', name: 'Profile',
...@@ -360,6 +372,10 @@ const routes: RouteRecordRaw[] = [ ...@@ -360,6 +372,10 @@ const routes: RouteRecordRaw[] = [
}, },
{ {
path: '/admin/channels', path: '/admin/channels',
redirect: '/admin/channels/pricing'
},
{
path: '/admin/channels/pricing',
name: 'AdminChannels', name: 'AdminChannels',
component: () => import('@/views/admin/ChannelsView.vue'), component: () => import('@/views/admin/ChannelsView.vue'),
meta: { meta: {
...@@ -370,6 +386,29 @@ const routes: RouteRecordRaw[] = [ ...@@ -370,6 +386,29 @@ const routes: RouteRecordRaw[] = [
descriptionKey: 'admin.channels.description' descriptionKey: 'admin.channels.description'
} }
}, },
{
path: '/admin/channels/monitor',
name: 'AdminChannelMonitor',
component: () => import('@/views/admin/ChannelMonitorView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: true,
title: 'Channel Monitor',
titleKey: 'admin.channelMonitor.title',
descriptionKey: 'admin.channelMonitor.description'
}
},
{
path: '/monitor',
name: 'ChannelStatus',
component: () => import('@/views/user/ChannelStatusView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: false,
title: 'Channel Status',
titleKey: 'nav.channelStatus'
}
},
{ {
path: '/admin/subscriptions', path: '/admin/subscriptions',
name: 'AdminSubscriptions', name: 'AdminSubscriptions',
......
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