Skip to content

💻 官方仪表盘源码 (Open Source Dashboard)

为了让全世界的开发者能够更直观地理解如何接入《标准猫历》(SFC) API,我们完全开源了官方实时控制台的 Vue 3 单文件组件 (SFC) 源码。

此组件内置了物理网络延迟 (RTT) 测算本地补帧倒计时以及实时进度条换算逻辑。您可以直接复制以下代码,零配置集成到您自己的 Vue、VitePress 或 Nuxt 项目中。

SfcDashboard.vue

vue
<template>
  <div class="sfc-dashboard-wrapper">
    <div class="header">
      <div class="api-status" :class="{ 'error': hasError }">
        <span class="status-dot"></span>
        {{ apiStatusText }}
      </div>
    </div>

    <h3 v-if="loading" class="loading-text">正在同步全球边缘节点数据...</h3>

    <div v-else class="dashboard-grid">
      <div class="card">
        <div class="card-title"><span>🕒 时空坐标</span> <span class="node-name">{{ meta.node }}</span></div>
        <div class="time-display">
          <div class="feline-year">猫历 {{ feline.feline_year }} 年</div>
          <div class="feline-season">{{ feline.current_season.name_zh }}</div>
          <div class="feline-hour">{{ feline.current_hour.name_zh }}</div>
          <div class="earth-time-pill">
            <span class="earth-icon">🌍</span> 
            <span class="earth-time-text">{{ currentEarthTime }}</span>
          </div>
        </div>
      </div>

      <div class="card">
        <div class="card-title"><span>📊 流逝进度</span></div>
        <div class="progress-group">
          <div class="progress-label"><span>猫时 ({{ feline.current_hour.name_zh }})</span> <span class="pct-num">{{ progress.hour_percentage }}%</span></div>
          <div class="progress-bar-bg"><div class="progress-bar-fill fill-hour" :style="{ width: progress.hour_percentage + '%' }"></div></div>
        </div>
        <div class="progress-group">
          <div class="progress-label"><span>猫季 ({{ feline.current_season.name_zh }})</span> <span class="pct-num">{{ progress.season_percentage }}%</span></div>
          <div class="progress-bar-bg"><div class="progress-bar-fill fill-season" :style="{ width: progress.season_percentage + '%' }"></div></div>
        </div>
        <div class="progress-group">
          <div class="progress-label"><span>本猫年</span> <span class="pct-num">{{ progress.feline_year_percentage }}%</span></div>
          <div class="progress-bar-bg"><div class="progress-bar-fill fill-year" :style="{ width: progress.feline_year_percentage + '%' }"></div></div>
        </div>
      </div>

      <div class="card">
        <div class="card-title"><span>🐈 行为指数</span></div>
        <div class="behavior-item"><span>狩猎欲</span> <span class="behavior-value">{{ behavior.hunting_drive }}</span></div>
        <div class="behavior-item"><span>睡眠欲</span> <span class="behavior-value">{{ behavior.sleepiness }}</span></div>
        <div class="behavior-item">
          <span>跑酷预警</span> 
          <span class="behavior-value" :class="{ danger: parseInt(behavior.zoomies_probability) >= 80 }">{{ behavior.zoomies_probability }}</span>
        </div>
        <div class="behavior-item"><span>掉毛率</span> <span class="behavior-value text-sm">{{ behavior.shedding_rate }}</span></div>
      </div>

      <div class="card">
        <div class="card-title"><span>⏳ 事件视界</span></div>
        <div class="countdown-box">
          <div class="countdown-title">距离下一个 [{{ nextEvents.next_feline_hour.name_zh }}]</div>
          <div class="countdown-timer">{{ formattedCdHour }}</div>
        </div>
        <div class="countdown-box">
          <div class="countdown-title">距离 [{{ nextEvents.next_feline_season.name_zh }}] 降临</div>
          <div class="countdown-timer">{{ formattedCdSeason }}</div>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted, computed } from 'vue'

// 💡 开发者提示:请将此处替换为您自己的业务节点
const API_URL = '[https://global.sfc-time.encore.baby](https://global.sfc-time.encore.baby)'

const loading = ref(true)
const hasError = ref(false)
const apiStatusText = ref('正在连接神经漫游者...')

const meta = ref({})
const feline = ref({ current_season: {}, current_hour: {} })
const progress = ref({})
const behavior = ref({})
const nextEvents = ref({ next_feline_hour: {}, next_feline_season: {} })

const currentEarthTime = ref('--')
const cdHourSecs = ref(0)
const cdSeasonSecs = ref(0)
let timer = null
let syncTimer = null

const formatSecs = (sec) => {
  const h = Math.floor(sec / 3600).toString().padStart(2, '0')
  const m = Math.floor((sec % 3600) / 60).toString().padStart(2, '0')
  const s = Math.floor(sec % 60).toString().padStart(2, '0')
  return `${h}:${m}:${s}`
}

const formattedCdHour = computed(() => formatSecs(cdHourSecs.value))
const formattedCdSeason = computed(() => formatSecs(cdSeasonSecs.value))

const fetchData = async () => {
  try {
    const requestStartTime = performance.now()
    const res = await fetch(API_URL)
    if (!res.ok) throw new Error('Network response was not ok')
    const data = await res.json()
    const requestEndTime = performance.now()
    
    // 精确计算 RTT 网络延迟
    const networkLatencyMs = Math.round(requestEndTime - requestStartTime)
    
    meta.value = data.meta
    feline.value = data.feline_time
    progress.value = data.progress
    behavior.value = data.behavior_index
    nextEvents.value = data.next_events
    cdHourSecs.value = data.next_events.next_feline_hour.countdown_seconds
    cdSeasonSecs.value = data.next_events.next_feline_season.countdown_seconds
    
    apiStatusText.value = `网络延迟 ${networkLatencyMs}ms | 节点执行 ${data.meta.server_processing_time_ms}ms | 时区: ${data.meta.client_timezone}`
    hasError.value = false
    loading.value = false
  } catch (err) {
    console.error(err)
    apiStatusText.value = 'API 连接断开,请检查网络'
    hasError.value = true
  }
}

onMounted(() => {
  fetchData()
  timer = setInterval(() => {
    if (!loading.value) {
      cdHourSecs.value = Math.max(0, cdHourSecs.value - 1)
      cdSeasonSecs.value = Math.max(0, cdSeasonSecs.value - 1)
      const now = new Date();
      const timeString = now.toLocaleTimeString('zh-CN', { hour12: false });
      const dateString = `${now.getFullYear()}/${(now.getMonth()+1).toString().padStart(2,'0')}/${now.getDate().toString().padStart(2,'0')}`;
      currentEarthTime.value = `${dateString} ${timeString}`;
      
      if (cdHourSecs.value === 0) fetchData()
    }
  }, 1000)
  syncTimer = setInterval(fetchData, 30000)
})

onUnmounted(() => {
  clearInterval(timer)
  clearInterval(syncTimer)
})
</script>

<style scoped>
.sfc-dashboard-wrapper {
  --card-bg: var(--vp-c-bg-soft);
  --text-muted: var(--vp-c-text-2);
  --accent-primary: #3b82f6;
  --accent-warning: #f59e0b;
  --accent-danger: #ef4444;
  --accent-success: #10b981;
  font-family: system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
  margin-top: 2rem;
}

.header { text-align: center; margin-bottom: 2rem; }

.api-status { 
  display: inline-flex; align-items: center; gap: 6px;
  padding: 0.4rem 1rem; border-radius: 999px; font-size: 0.8rem; 
  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
  background: rgba(16, 185, 129, 0.1); color: var(--accent-success); 
  border: 1px solid rgba(16, 185, 129, 0.2);
}

.status-dot {
  width: 8px; height: 8px; background-color: var(--accent-success);
  border-radius: 50%; box-shadow: 0 0 8px var(--accent-success);
  animation: breathe 2s infinite ease-in-out;
}

.api-status.error { background: rgba(239, 68, 68, 0.1); color: var(--accent-danger); border-color: rgba(239, 68, 68, 0.2); }
.api-status.error .status-dot { background-color: var(--accent-danger); box-shadow: 0 0 8px var(--accent-danger); }

.loading-text { text-align: center; color: var(--text-muted); }
.dashboard-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1.5rem; }
.card { background: var(--card-bg); border-radius: 12px; padding: 1.5rem; border: 1px solid var(--vp-c-divider); box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05); }
.card-title { font-size: 0.9rem; color: var(--text-muted); margin-bottom: 1.5rem; text-transform: uppercase; letter-spacing: 1px; display: flex; justify-content: space-between; align-items: center; }
.node-name { font-size: 0.75rem; background: var(--vp-c-default-soft); padding: 2px 6px; border-radius: 4px; font-family: monospace; }
.time-display { display: flex; flex-direction: column; align-items: center; gap: 0.8rem; padding: 0.5rem 0; }
.feline-year { font-size: 1.1rem; color: var(--text-muted); letter-spacing: 3px; }
.feline-season { font-size: 3.2rem; font-weight: 900; line-height: 1; color: var(--accent-warning); text-shadow: 0 4px 15px rgba(245, 158, 11, 0.25); letter-spacing: 2px; }
.feline-hour { font-size: 1.8rem; font-weight: 700; color: var(--accent-primary); letter-spacing: 4px; }
.earth-time-pill { margin-top: 0.8rem; background: var(--vp-c-default-soft); padding: 0.4rem 1rem; border-radius: 20px; display: flex; align-items: center; gap: 8px; border: 1px solid var(--vp-c-divider); }
.earth-time-text { font-size: 0.85rem; color: var(--vp-c-text-1); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
.progress-group { margin-bottom: 1.2rem; }
.progress-label { display: flex; justify-content: space-between; margin-bottom: 0.5rem; font-size: 0.85rem; color: var(--vp-c-text-1); }
.pct-num { font-family: ui-monospace, monospace; font-size: 0.8rem; color: var(--text-muted); }
.progress-bar-bg { width: 100%; height: 6px; background: var(--vp-c-default-soft); border-radius: 4px; overflow: hidden; }
.progress-bar-fill { height: 100%; border-radius: 4px; transition: width 0.5s ease-out; }
.fill-year { background: var(--text-muted); }
.fill-season { background: var(--accent-warning); }
.fill-hour { background: var(--accent-primary); }
.behavior-item { display: flex; justify-content: space-between; align-items: center; padding: 0.7rem 0; border-bottom: 1px solid var(--vp-c-divider); font-size: 0.95rem; }
.behavior-item:last-child { border-bottom: none; }
.behavior-value { font-weight: bold; font-size: 1.1rem; font-family: ui-monospace, monospace; }
.text-sm { font-size: 0.9rem; }
.danger { color: var(--accent-danger); animation: danger-pulse 1.5s infinite; text-shadow: 0 0 8px rgba(239, 68, 68, 0.4); }
.countdown-box { background: var(--vp-c-default-soft); padding: 1rem; border-radius: 8px; margin-bottom: 1rem; text-align: center; border: 1px solid rgba(255,255,255,0.02); }
.countdown-title { font-size: 0.85rem; color: var(--text-muted); margin-bottom: 0.5rem; }
.countdown-timer { font-size: 1.6rem; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-weight: bold; color: var(--vp-c-text-1); letter-spacing: 2px; }

@keyframes breathe { 0% { opacity: 0.4; transform: scale(0.9); } 50% { opacity: 1; transform: scale(1.1); } 100% { opacity: 0.4; transform: scale(0.9); } }
@keyframes danger-pulse { 0% { opacity: 1; transform: scale(1); } 50% { opacity: 0.8; color: #ff0000; transform: scale(1.05); } 100% { opacity: 1; transform: scale(1); } }
</style>