本文将介绍如何在 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,
});
}
}3. 集成到 Footer
在 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,用于点击跳转。
给你的 Curve 主题添加Footer状态https://chiyu.it/posts/2025/1230
赞赏博主
评论 隐私政策
