本文将介绍如何在 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,用于点击跳转。
赞赏博主
评论 隐私政策