本文将介绍如何在 Curve 主题中添加一个基于 Better Stack 的服务状态监控组件。

效果预览 ​

该组件支持显示多种状态(Operational, Degraded, Partial, Major, Maintenance 等),并在鼠标悬停时提供交互效果。

核心代码 ​

1. 组件代码 ​

创建 app/components/SystemStatus.vue,实现前端展示逻辑:

vue
<template>
  <!-- 配置了状态页 URL:点击跳转 -->
  <a
    v-if="statusPageUrl"
    class="system-status"
    :class="statusClass"
    :href="statusPageUrl"
    target="_blank"
    rel="noopener noreferrer"
    :title="`查看服务状态 - ${label}`"
  >
    <div class="status-dot-wrapper">
      <span
        v-if="!loading"
        class="status-dot-ping"
        :class="`ping--${status}`"
      />
      <span class="status-dot" :class="dotClass" />
    </div>

    <span class="status-label">
      {{ loading ? 'Checking...' : label }}
    </span>
  </a>

  <!-- 未配置状态页 URL:点击刷新 -->
  <div
    v-else
    class="system-status"
    :class="statusClass"
    title="点击刷新状态"
    @click="handleRefresh"
  >
    <div class="status-dot-wrapper">
      <span
        v-if="!loading"
        class="status-dot-ping"
        :class="`ping--${status}`"
      />
      <span class="status-dot" :class="dotClass" />
    </div>

    <span class="status-label">
      {{ loading ? 'Checking...' : label }}
    </span>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useData } from 'vitepress'

// 获取状态页 URL
// 通过 useData 获取 theme 配置,假设配置在该处,或者直接使用 env
// VitePress 注入
// 这里为了简单,如果有 public配置可以尝试获取,否则依靠构建时注入或硬编码
// 用户原始代码是 Nuxt,这里适配 VitePress
const { theme } = useData()
// 尝试从 themeConfig 获取,或者 import.meta.env
const statusPageUrl = import.meta.env.VITE_BETTER_STACK_STATUS_PAGE_URL || ''

// 定义 API 返回类型
interface StatusResponse {
  status:
    | 'operational'
    | 'degraded'
    | 'partial'
    | 'major'
    | 'maintenance'
    | 'error'
    | 'unknown'
  label: string
  updatedAt: string | null
}

// 用于强制刷新的时间戳
const refreshKey = ref(Date.now())
const data = ref<StatusResponse | null>(null)
const pending = ref(true)
const error = ref(false)

const fetchData = async () => {
  pending.value = true
  error.value = false
  try {
    // 在开发环境模拟数据,避免 fetch 本地文件报错
    if (import.meta.env.DEV) {
      await new Promise((resolve) => setTimeout(resolve, 500))
      data.value = {
        status: 'operational',
        label: '开发环境正常',
        updatedAt: new Date().toISOString()
      }
      return
    }

    const res = await fetch(`/api/status?t=${refreshKey.value}`)
    if (!res.ok) throw new Error('Network response was not ok')
    data.value = await res.json()
  } catch (e) {
    console.error(e)
    error.value = true
  } finally {
    pending.value = false
  }
}

// 初始加载
onMounted(() => {
  fetchData()
})

// 计算属性:处理加载状态和错误状态
const loading = computed(() => pending.value)

// 提取状态核心字段
const status = computed(() => {
  if (error.value) return 'error'
  return data.value?.status || 'unknown'
})

const label = computed(() => data.value?.label || 'Unknown')

// 动态样式类
const statusClass = computed(() => {
  if (loading.value) return 'status--loading'
  return `status--${status.value}`
})

const dotClass = computed(() => {
  if (loading.value) return 'dot--loading'
  return `dot--${status.value}`
})

// 限流:5秒内最多3次
const clickTimestamps = ref<number[]>([])
const RATE_LIMIT_WINDOW = 5000 // 5秒
const RATE_LIMIT_MAX = 3 // 最多3次

// 点击刷新(仅在未配置状态页时生效)
const handleRefresh = () => {
  if (loading.value) return

  const now = Date.now()

  // 清理超过5秒的记录
  clickTimestamps.value = clickTimestamps.value.filter(
    (ts) => now - ts < RATE_LIMIT_WINDOW
  )

  // 检查是否超过限制
  if (clickTimestamps.value.length >= RATE_LIMIT_MAX) {
    return // 已达到限制,不执行刷新
  }

  // 记录本次点击
  clickTimestamps.value.push(now)

  // 更新 refreshKey 触发强制刷新
  refreshKey.value = now
  fetchData()
}
</script>

<style lang="scss" scoped>
.system-status {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 6px 12px;
  border-radius: 20px;
  background: var(--main-card-second-background);
  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
  cursor: pointer;
  user-select: none;
  font-size: 13px;
  text-decoration: none;
  color: var(--main-font-color);
  line-height: 1.5;

  &:hover {
    transform: translateY(-1px);
  }

  &:active {
    background: var(--main-border-shadow);
    transform: translateY(0);
  }

  // 服务正常 - 绿色
  &.status--operational {
    background: var(--main-success-color-gray);
    color: var(--main-success-color);
  }

  // 性能下降 - 黄色
  &.status--degraded {
    background: var(--main-warning-color-gray);
    color: var(--main-warning-color);
  }

  // 部分问题 - 黄色
  &.status--partial {
    background: var(--main-warning-color-gray);
    color: var(--main-warning-color);
  }

  // 重大事故 - 红色
  &.status--major {
    background: var(--main-error-color-gray);
    color: var(--main-error-color);
  }

  // 正在检修 - 蓝色
  &.status--maintenance {
    background: var(--main-info-color-gray);
    color: var(--main-info-color);
  }

  // 错误/未知状态 - 灰色
  &.status--error,
  &.status--unknown {
    background: var(--main-card-second-background);
    color: var(--main-font-second-color);
  }
}

.status-dot-wrapper {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  height: 8px;
  width: 8px;
  margin: 0 4px;
  overflow: visible;
}

.status-dot-ping {
  position: absolute;
  top: 0;
  left: 0;
  height: 8px;
  width: 8px;
  border-radius: 50%;
  transform-origin: center;
  animation: status-ping 1.5s ease-out infinite;

  &.ping--operational {
    background-color: var(--main-success-color);
  }
  &.ping--degraded {
    background-color: var(--main-warning-color);
  }
  &.ping--partial {
    background-color: var(--main-warning-color);
  }
  &.ping--major {
    background-color: var(--main-error-color);
  }
  &.ping--maintenance {
    background-color: var(--main-info-color);
  }
  &.ping--error,
  &.ping--unknown {
    background-color: var(--main-font-second-color);
  }
}

.status-dot {
  position: relative;
  display: inline-flex;
  border-radius: 50%;
  height: 8px;
  width: 8px;
  transition: background-color 0.3s ease;

  &.dot--loading {
    background-color: var(--main-font-second-color);
    animation: status-pulse 1.2s ease-in-out infinite;
  }

  &.dot--operational {
    background-color: var(--main-success-color);
  }
  &.dot--degraded {
    background-color: var(--main-warning-color);
  }
  &.dot--partial {
    background-color: var(--main-warning-color);
  }
  &.dot--major {
    background-color: var(--main-error-color);
  }
  &.dot--maintenance {
    background-color: var(--main-info-color);
  }
  &.dot--error,
  &.dot--unknown {
    background-color: var(--main-font-second-color);
  }
}

.status-label {
  font-weight: 500;
  letter-spacing: 0.02em;
  transition: color 0.3s ease;
  white-space: nowrap;
  // inherit color from parent which handles overrides
  color: inherit;
}

@keyframes status-ping {
  0% {
    transform: scale(1);
    opacity: 0.75;
  }
  100% {
    transform: scale(2);
    opacity: 0;
  }
}

@keyframes status-pulse {
  0%,
  100% {
    opacity: 1;
    transform: scale(1);
  }
  50% {
    opacity: 0.5;
    transform: scale(0.85);
  }
}
</style>

2. 后端接口 (Vercel Serverless Function) ​

创建 api/status.ts,用于代理请求并保护 API Token:

typescript
import type { VercelRequest, VercelResponse } from '@vercel/node'

// Define Status Type
type StatusType =
  'operational' | 'degraded' | 'partial' | 'major' | 'maintenance'

// Status Map
const STATUS_MAP: Record<StatusType, { status: string; label: string }> = {
  operational: { status: 'operational', label: '服务正常' },
  degraded: { status: 'degraded', label: '性能下降' },
  partial: { status: 'partial', label: '部分问题' },
  major: { status: 'major', label: '重大事故' },
  maintenance: { status: 'maintenance', label: '正在检修' }
}

export default async function handler(req: VercelRequest, res: VercelResponse) {
  const apiToken = process.env.BETTER_STACK_API_TOKEN

  // Use Vercel's caching
  res.setHeader('Cache-Control', 's-maxage=60, stale-while-revalidate=30')

  if (!apiToken) {
    return res.status(200).json({
      status: 'unknown',
      label: '未配置状态',
      updatedAt: null
    })
  }

  try {
    const response = await fetch(
      'https://uptime.betterstack.com/api/v2/monitors',
      {
        headers: {
          Authorization: `Bearer ${apiToken}`
        }
      }
    )

    if (!response.ok) {
      throw new Error(`Better Stack API error: ${response.statusText}`)
    }

    const data = (await response.json()) as {
      data: Array<{
        id: string
        attributes: {
          url: string
          pronounceable_name: string
          status:
            'paused' | 'pending' | 'maintenance' | 'up' | 'validating' | 'down'
        }
      }>
    }

    const monitors = data.data || []
    const totalCount = monitors.length
    const downCount = monitors.filter(
      (m) => m.attributes.status === 'down'
    ).length
    const maintenanceCount = monitors.filter(
      (m) => m.attributes.status === 'maintenance'
    ).length
    const validatingCount = monitors.filter(
      (m) => m.attributes.status === 'validating'
    ).length
    const upCount = monitors.filter((m) => m.attributes.status === 'up').length

    let statusType: StatusType

    if (totalCount === 0) {
      return res.status(200).json({
        status: 'unknown',
        label: '无监控项',
        updatedAt: new Date().toISOString()
      })
    }

    if (downCount > totalCount / 2) {
      statusType = 'major'
    } else if (downCount > 0) {
      statusType = 'partial'
    } else if (maintenanceCount > 0) {
      statusType = 'maintenance'
    } else if (validatingCount > 0) {
      statusType = 'degraded'
    } else if (upCount === totalCount) {
      statusType = 'operational'
    } else {
      statusType = 'partial'
    }

    const statusInfo = STATUS_MAP[statusType]

    return res.status(200).json({
      status: statusInfo.status,
      label: statusInfo.label,
      updatedAt: new Date().toISOString()
    })
  } catch (error) {
    console.error('Fetch status failed:', error)
    return res.status(200).json({
      status: 'error',
      label: '无法获取状态',
      updatedAt: null
    })
  }
}

在 Footer.vue 中引入并使用组件:

vue
<script setup>
import SystemStatus from '@/components/SystemStatus.vue'
// ...
</script>

<template>
  <!-- ... -->
  <div class="copyright">
    <!-- ... -->
    <a class="icp link" href="...">...</a>
    <!-- 添加组件 -->
    <SystemStatus />
  </div>
  <!-- ... -->
</template>

4. 环境变量配置 ​

在 Vercel 仪表盘中配置以下环境变量:

  • BETTER_STACK_API_TOKEN: Better Stack 的 API Token。
  • VITE_BETTER_STACK_STATUS_PAGE_URL: (可选) 你的状态页 URL,用于点击跳转。
赞赏博主
评论 隐私政策