本文将介绍如何在 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
赞赏博主
评论 隐私政策
