web-font/src/StatsPanel.vue
崮生(子虚) 09f434bbcf feat: 离线裁剪页面 + 增量预览 + DEV预览页 + tempUploads统计 + SDK缓存破坏 + UI优化
- 新增离线裁剪页面 /offline-subset(纯浏览器端字体裁剪)
- SDK subsetProvider 抽象,离线页面注入本地裁剪 provider
- 预览用增量裁剪(SDK自动去重),下载用全量裁剪
- 拖放区域支持点击和拖拽选择字体文件
- 新增 DEV 预览页 /__dev(仅 dev 模式路由可达)
- FontDebugPreview 从首页提取为独立页面
- tempUploads 统计字段(全链路+持久化)
- webfont-sdk.js 构建时戳缓存破坏
- 首页导航栏新增离线裁剪入口
- UploadSection 离线裁剪入口合并到游客上传卡片
- SDK 文档代码示例补充 HTML 用法
- 导航栏窄屏换行适配
2026-08-03 15:18:01 +08:00

137 lines
4.6 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import { fetchStats, type ServerStats } from "./api";
import { t, locale } from "./i18n";
function formatUptime(seconds: number): string {
if (locale.value === "en") {
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h < 24) return `${h}h ${m}m ${s}s`;
const d = Math.floor(h / 24);
return `${d}d ${h % 24}h`;
}
if (seconds < 60) return `${seconds}`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}${seconds % 60}`;
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h < 24) return `${h}${m}${s}`;
const d = Math.floor(h / 24);
return `${d}${h % 24}${m}`;
}
const data = ref<ServerStats | null>(null);
/** 进度条动画 key —— 每次刷新后递增以重置 CSS animation */
const progressKey = ref(0);
/** 组件根元素 ref用于 IntersectionObserver 观测) */
const rootRef = ref<HTMLElement | null>(null);
let timer: ReturnType<typeof setInterval> | null = null;
/** 组件是否在视口内可见 */
let inViewport = false;
/** 轮询周期(毫秒),与进度条 animation-duration 保持一致 */
const POLL_INTERVAL = 10_000;
/** 首次加载数据(不受视口/可见性限制mount 时立即调用) */
async function initialLoad() {
const s = await fetchStats().catch(() => null);
if (s) {
data.value = s;
progressKey.value++;
}
}
async function load() {
const s = await fetchStats().catch(() => null);
if (s) {
data.value = s;
/** 重启进度条动画 */
progressKey.value++;
}
}
function startPolling() {
if (timer) return;
load();
timer = setInterval(load, POLL_INTERVAL);
}
function stopPolling() {
if (timer) {
clearInterval(timer);
timer = null;
}
}
/** 评估是否应该轮询:组件在视口内 且 页面标签可见 */
function evaluatePolling() {
const shouldPoll = inViewport && document.visibilityState === "visible";
if (shouldPoll) startPolling();
else stopPolling();
}
function onVisibilityChange() {
evaluatePolling();
}
/** IntersectionObserver 实例 */
let intersectionObserver: IntersectionObserver | null = null;
onMounted(() => {
/** 立即首次加载,确保卡片尽快出现 */
initialLoad();
/** 监听页面标签可见性 */
document.addEventListener("visibilitychange", onVisibilityChange);
/** 监听组件是否进入视口 */
if (rootRef.value) {
intersectionObserver = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
inViewport = entry.isIntersecting;
}
evaluatePolling();
},
{ rootMargin: "50px" },
);
intersectionObserver.observe(rootRef.value);
}
});
onUnmounted(() => {
stopPolling();
document.removeEventListener("visibilitychange", onVisibilityChange);
intersectionObserver?.disconnect();
});
</script>
<template>
<div ref="rootRef">
<section v-if="data" style="margin-top: 24px; margin-bottom: 28px; padding: 12px 16px; background: #f0f0f0; border-radius: 8px; position: relative; overflow: hidden">
<div style="font-size: 13px; font-weight: 600; color: #333; margin-bottom: 4px">{{ t('serverStatus') }}</div>
<div style="display: flex; gap: 20px; flex-wrap: wrap; font-size: 13px; color: #555; line-height: 2">
<span><b style="color: #333">{{ t('uptime') }}</b> {{ formatUptime(data.uptime) }}</span>
<span><b style="color: #333">{{ t('requests') }}</b> {{ data.totalRequests }} {{ t('times') }}</span>
<span><b style="color: #333">{{ t('subset') }}</b> {{ data.subsetRequests }} {{ t('times') }}</span>
<span><b style="color: #333">{{ t('chars') }}</b> {{ data.totalChars }} {{ t('charUnit') }}</span>
<span><b style="color: #333">上传</b> {{ data.tempUploads ?? 0 }} </span>
<span><b style="color: #333">{{ t('cacheHit') }}</b> {{ data.subsetRequests > 0 ? ((data.subsetCacheHits / data.subsetRequests) * 100).toFixed(1) : '0.0' }}%</span>
</div>
<!-- 底部进度条每轮询周期走一轮走完触发下次刷新 -->
<div :key="progressKey" style="position: absolute; bottom: 0; left: 0; width: 100%; height: 2px; background: #1677ff; transform-origin: left; animation: stats-progress 10s linear" />
</section>
</div>
</template>
<style>
@keyframes stats-progress {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
</style>