Files
sub2api/frontend/src/views/user/ChannelStatusView.vue
T
erio a1425b457d feat(channel-monitor): redesign user dashboard as card grid
Reference check-cx UI: INTELLIGENCE MONITOR hero + 3-column card grid
with 60-point timeline bars.

Backend:
- Add PrimaryPingLatencyMs + Timeline[60] to UserMonitorView
- ListRecentHistoryForMonitors: batch CTE + ROW_NUMBER() window query
- indexLatestByModel / indexAvailabilityByModel helpers

Frontend:
- 7 new components: ProviderIcon, MonitorMetricPair, MonitorAvailabilityRow,
  MonitorTimeline, MonitorHero, MonitorCard, MonitorCardGrid
- ChannelStatusView 381→~180 lines (delegated to subcomponents)
- AbortController reload concurrency protection
- HSL 0-120° availability color mapping
- Replace emoji with Icon component (bolt / globe)
- i18n: monitorCommon.* shared namespace, channelStatus.hero.*

Bump VERSION to 0.1.114.24
2026-04-20 23:38:59 +08:00

174 lines
5.0 KiB
Vue

<template>
<AppLayout>
<MonitorHero
:overall-status="overallStatus"
:updated-at="updatedAt"
:interval-seconds="DEFAULT_INTERVAL_SECONDS"
:window="currentWindow"
:loading="loading"
@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'
const { t } = useI18n()
const appStore = useAppStore()
// ── State ──
const items = ref<UserMonitorView[]>([])
const loading = ref(false)
const updatedAt = ref<string | null>(null)
const currentWindow = ref<MonitorWindow>('7d')
const detailCache = reactive<Record<number, UserMonitorDetail>>({})
const countdown = ref(DEFAULT_INTERVAL_SECONDS)
const showDetail = ref(false)
const detailTarget = ref<UserMonitorView | null>(null)
let countdownTimer: number | undefined
let abortController: AbortController | null = null
// ── Computed ──
const overallStatus = computed<OverallStatus>(() => {
if (items.value.length === 0) return 'operational'
let hasFailure = false
let hasDegraded = false
for (const it of items.value) {
if (it.primary_status === 'failed' || it.primary_status === 'error') hasFailure = true
else if (it.primary_status !== STATUS_OPERATIONAL) hasDegraded = true
}
if (hasFailure) return 'unavailable'
if (hasDegraded) 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 || []
updatedAt.value = new Date().toISOString()
} 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
}
// ── Polling ──
function tick() {
if (countdown.value <= 1) {
void reload(true)
return
}
countdown.value -= 1
}
watch(items, () => {
// Lazily load detail entries when window requires it and the list refreshes.
void ensureDetailsForWindow()
})
// ── Lifecycle ──
onMounted(() => {
void reload(false)
countdownTimer = setInterval(tick, 1000) as unknown as number
})
onBeforeUnmount(() => {
if (countdownTimer !== undefined) clearInterval(countdownTimer)
if (abortController) abortController.abort()
})
</script>