256 lines
9.2 KiB
Vue
256 lines
9.2 KiB
Vue
<script setup lang="ts">
|
|
import { NButton, NSpin } from 'naive-ui'
|
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
|
|
|
import { getAbout } from '@/api/settings'
|
|
import AppLogo from '@/components/AppLogo.vue'
|
|
import { fmtBytes, fmtTime } from '@/composables/useFormat'
|
|
import { useParticles } from '@/composables/useParticles'
|
|
import { useAppStore } from '@/stores/app'
|
|
import type { AboutInfo } from '@/types/api'
|
|
|
|
// 点击 logo 在星爆 / 波浪封印两款间切换(全局生效并记忆)
|
|
const app = useAppStore()
|
|
|
|
// GitHub 仓库地址;空串时按钮禁用并显示占位
|
|
const REPO_URL = 'https://github.com/wangdefaa/oci-portal'
|
|
|
|
const info = ref<AboutInfo | null>(null)
|
|
const loading = ref(true)
|
|
|
|
const pcanvas = ref<HTMLCanvasElement | null>(null)
|
|
useParticles(pcanvas, { count: 26 })
|
|
|
|
// 运行时长随查看自增:记录拉取时刻,30s 粒度刷新本地流逝时间
|
|
const loadedAt = ref(0)
|
|
const nowTick = ref(0)
|
|
let upTimer: ReturnType<typeof setInterval> | null = null
|
|
|
|
onMounted(async () => {
|
|
try {
|
|
info.value = await getAbout()
|
|
loadedAt.value = nowTick.value = Date.now()
|
|
upTimer = setInterval(() => (nowTick.value = Date.now()), 30_000)
|
|
} catch {
|
|
/* 构建信息加载失败不阻塞页面,chips 显示占位 */
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
})
|
|
onUnmounted(() => {
|
|
if (upTimer) clearInterval(upTimer)
|
|
})
|
|
|
|
/** 运行时长分段:天(为 0 时省略)/ 小时 / 分 */
|
|
const uptimeParts = computed(() => {
|
|
const base = info.value?.uptimeSeconds
|
|
if (base === undefined) return []
|
|
const sec = base + Math.max(0, (nowTick.value - loadedAt.value) / 1000)
|
|
const d = Math.floor(sec / 86400)
|
|
const h = Math.floor((sec % 86400) / 3600)
|
|
const parts: { v: number; u: string }[] = []
|
|
if (d > 0) parts.push({ v: d, u: '天' })
|
|
if (d > 0 || h > 0) parts.push({ v: h, u: '小时' })
|
|
parts.push({ v: Math.floor((sec % 3600) / 60), u: '分' })
|
|
return parts
|
|
})
|
|
|
|
const DB_LABEL: Record<string, string> = { sqlite: 'SQLite', mysql: 'MySQL', postgres: 'PostgreSQL' }
|
|
|
|
/** 四格资源指标:值与单位拆开渲染,sub 为口径说明 */
|
|
const statCells = computed(() => {
|
|
const r = info.value?.resources
|
|
if (!r) return []
|
|
const heap = fmtBytes(r.memHeapBytes).split(' ')
|
|
const store = r.dbBytes >= 0 ? fmtBytes(r.dbBytes).split(' ') : ['—', '']
|
|
const db = DB_LABEL[r.dbEngine] ?? r.dbEngine
|
|
return [
|
|
{ label: 'CPU 均值', value: String(r.cpuAvgPercent), unit: '%', sub: `${r.numCpu} 核 · 占单核百分比` },
|
|
{ label: '内存(堆)', value: heap[0], unit: heap[1], sub: `向系统申请 ${fmtBytes(r.memSysBytes)}` },
|
|
{ label: '存储占用', value: store[0], unit: store[1], sub: r.dbBytes >= 0 ? `${db} 含 WAL 日志` : `${db} 外部数据库,不可度量` },
|
|
{ label: 'Goroutines', value: String(r.goroutines), unit: '', sub: '运行时并发任务数' },
|
|
]
|
|
})
|
|
|
|
function fmtBuild(t: string) {
|
|
return t ? t.replace('T', ' ').replace('Z', ' UTC') : '—'
|
|
}
|
|
|
|
function openRepo() {
|
|
if (REPO_URL) window.open(REPO_URL, '_blank', 'noopener')
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="flex max-w-[860px] flex-col gap-4">
|
|
<!-- Hero:印章 + 名称 + 版本 chips,粒子与轨道环装饰 -->
|
|
<div class="panel relative overflow-hidden px-6 py-12 text-center">
|
|
<div class="pointer-events-none absolute inset-0" aria-hidden="true">
|
|
<div class="glow" style="width: 420px; height: 420px; left: 50%; top: -180px; transform: translateX(-50%)"></div>
|
|
<div class="ring-dash spin" style="width: 380px; height: 380px; left: calc(50% - 190px); top: -60px"></div>
|
|
<canvas ref="pcanvas" class="absolute inset-0 h-full w-full"></canvas>
|
|
</div>
|
|
<div class="relative">
|
|
<button
|
|
type="button"
|
|
class="floaty logo-btn mx-auto block w-fit"
|
|
title="切换 logo 款式"
|
|
aria-label="切换 logo 款式"
|
|
@click="app.toggleLogoVariant()"
|
|
>
|
|
<AppLogo :size="84" />
|
|
</button>
|
|
<div class="mt-5 font-serif text-[34px] leading-tight font-semibold">OCI Portal</div>
|
|
<div class="mt-1 text-[13px] text-ink-3">OCI 账号批量管理面板 · 自托管</div>
|
|
<div v-if="loading" class="mt-6 flex justify-center"><NSpin size="small" /></div>
|
|
<div v-else class="mt-6 flex flex-wrap justify-center gap-2">
|
|
<span class="chip mono">{{ info?.version || 'dev' }}</span>
|
|
<span class="chip mono">构建 {{ fmtBuild(info?.buildTime ?? '') }}</span>
|
|
<span class="chip mono">{{ info?.platform || '—' }} · {{ info?.goVersion || '' }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 运行状态:运行时长随查看自增,资源指标为进程自启动累计口径 -->
|
|
<div v-if="info?.resources" class="panel">
|
|
<div
|
|
class="flex items-center justify-between gap-3 border-b border-line-soft px-5 py-3.5 max-md:flex-col max-md:items-start max-md:gap-1"
|
|
>
|
|
<div class="flex items-center gap-2">
|
|
<span class="live-dot"></span>
|
|
<span class="text-[13px] font-semibold">运行状态</span>
|
|
</div>
|
|
<span class="text-xs text-ink-3">
|
|
自 {{ fmtTime(info.startedAt) }} 启动 · 指标为进程自启动累计口径
|
|
</span>
|
|
</div>
|
|
<div class="px-5 pt-[22px] pb-5">
|
|
<div class="text-xs font-medium text-ink-2">运行时间</div>
|
|
<div class="mt-1.5 text-[38px] leading-[1.1] font-semibold tracking-[-0.6px]">
|
|
<template v-for="p in uptimeParts" :key="p.u"
|
|
>{{ p.v }}<span class="uptime-unit">{{ p.u }}</span></template
|
|
>
|
|
</div>
|
|
</div>
|
|
<div class="mx-5 h-px bg-line-soft"></div>
|
|
<div class="grid grid-cols-4 max-md:grid-cols-2">
|
|
<div v-for="c in statCells" :key="c.label" class="statcell flex flex-col gap-[5px] px-5 pt-4 pb-[18px]">
|
|
<div class="text-xs font-medium text-ink-2">{{ c.label }}</div>
|
|
<div class="text-[26px] leading-[1.15] font-semibold tracking-[-0.3px]">
|
|
<span>{{ c.value }}</span
|
|
><span v-if="c.unit" class="ml-0.5 text-sm font-medium text-ink-2">{{ c.unit }}</span>
|
|
</div>
|
|
<div class="text-[11.5px] text-ink-3">{{ c.sub }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- GitHub 仓库 -->
|
|
<div class="panel flex items-center gap-3.5 px-5 py-4">
|
|
<span class="flex h-10 w-10 flex-none items-center justify-center rounded-[9px] bg-wash">
|
|
<svg class="h-5 w-5" viewBox="0 0 16 16" fill="currentColor">
|
|
<path
|
|
d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"
|
|
></path>
|
|
</svg>
|
|
</span>
|
|
<span class="min-w-0 flex-1">
|
|
<span class="block text-[13px] font-semibold">GitHub 仓库</span>
|
|
<span class="mono mt-0.5 block truncate text-xs text-ink-3">{{ REPO_URL || 'github.com/…(待填充)' }}</span>
|
|
</span>
|
|
<NButton size="small" :disabled="!REPO_URL" @click="openRepo">前往仓库 ↗</NButton>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.live-dot {
|
|
width: 7px;
|
|
height: 7px;
|
|
border-radius: 50%;
|
|
background: var(--color-ok);
|
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-ok) 18%, transparent);
|
|
}
|
|
.uptime-unit {
|
|
margin: 0 8px 0 3px;
|
|
font-size: 16px;
|
|
font-weight: 500;
|
|
letter-spacing: 0;
|
|
color: var(--color-ink-2);
|
|
}
|
|
.uptime-unit:last-of-type {
|
|
margin-right: 0;
|
|
}
|
|
/* 四格分栏:桌面竖线分隔;窄屏 2 列换行时第二行改上边框接续 */
|
|
.statcell + .statcell {
|
|
border-left: 1px solid var(--color-line-soft);
|
|
}
|
|
@media (width < 768px) {
|
|
.statcell:nth-child(3) {
|
|
border-left: none;
|
|
}
|
|
.statcell:nth-child(n + 3) {
|
|
border-top: 1px solid var(--color-line-soft);
|
|
}
|
|
}
|
|
.chip {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
border: 1px solid var(--color-line);
|
|
border-radius: 999px;
|
|
background: var(--color-card);
|
|
padding: 6px 13px;
|
|
font-size: 12px;
|
|
color: var(--color-ink-2);
|
|
box-shadow: 0 2px 8px rgba(20, 20, 19, 0.06);
|
|
}
|
|
.glow {
|
|
position: absolute;
|
|
border-radius: 50%;
|
|
background: rgba(201, 100, 66, 0.13);
|
|
filter: blur(80px);
|
|
}
|
|
.ring-dash {
|
|
position: absolute;
|
|
border-radius: 50%;
|
|
border: 1.5px dashed color-mix(in srgb, var(--color-line) 70%, var(--color-accent) 30%);
|
|
}
|
|
/* logo 即款式开关:去按钮底色,悬停轻微放大提示可点 */
|
|
.logo-btn {
|
|
cursor: pointer;
|
|
border: none;
|
|
background: none;
|
|
padding: 0;
|
|
line-height: 0;
|
|
transition: scale 0.25s ease;
|
|
}
|
|
.logo-btn:hover {
|
|
scale: 1.06;
|
|
}
|
|
.logo-btn:active {
|
|
scale: 0.96;
|
|
}
|
|
@media (prefers-reduced-motion: no-preference) {
|
|
.spin {
|
|
animation: about-sp 52s linear infinite;
|
|
}
|
|
.floaty {
|
|
animation: about-fl 7s ease-in-out infinite;
|
|
}
|
|
@keyframes about-sp {
|
|
to {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
@keyframes about-fl {
|
|
0%,
|
|
100% {
|
|
transform: translateY(0);
|
|
}
|
|
50% {
|
|
transform: translateY(-8px);
|
|
}
|
|
}
|
|
}
|
|
</style>
|