You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
142 lines
6.1 KiB
142 lines
6.1 KiB
<template>
|
|
<!-- 顶栏通知铃铛:第三方服务巡检的异常提醒。
|
|
只对有「服务巡检」页面权限的账号(超管/管理员/运营)拉取与展示,
|
|
品牌商/渠道商这类外部角色既看不到菜单,也不该在这里看到平台的服务健康。 -->
|
|
<div class="dropdown dropdown-end">
|
|
<div tabindex="0" class="btn btn-ghost btn-sm btn-circle relative" @click="onOpen">
|
|
<svg class="h-5 w-5 text-slate-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
|
</svg>
|
|
<span v-if="total > 0" class="bell-badge" :class="errorCount > 0 ? 'lv-error' : 'lv-warn'">
|
|
{{ total > 99 ? '99+' : total }}
|
|
</span>
|
|
</div>
|
|
|
|
<div tabindex="0" class="dropdown-content z-[2] shadow-xl bg-white rounded-xl w-96 border overflow-hidden">
|
|
<div class="flex items-center justify-between px-4 py-3 border-b">
|
|
<span class="text-sm font-semibold text-slate-700">系统通知</span>
|
|
<span class="text-[11px] text-slate-400">巡检 {{ lastRunText }}</span>
|
|
</div>
|
|
|
|
<div v-if="!canSee || total === 0" class="px-3 py-6 text-center text-slate-400 text-xs">暂无新通知</div>
|
|
|
|
<template v-else>
|
|
<div class="px-4 py-2.5 text-xs" :class="errorCount > 0 ? 'bg-rose-50 text-rose-700' : 'bg-amber-50 text-amber-700'">
|
|
<span class="font-semibold">
|
|
{{ errorCount > 0 ? `${errorCount} 项第三方服务不可用` : `${warnCount} 项第三方服务存在隐患` }}
|
|
</span>
|
|
<span v-if="errorCount > 0 && warnCount > 0" class="opacity-70">,另有 {{ warnCount }} 项隐患</span>
|
|
</div>
|
|
|
|
<div class="bell-list">
|
|
<NuxtLink v-for="a in alerts" :key="rowKey(a)" to="/svcinspect" class="bell-item">
|
|
<span class="bell-dot" :class="a.status === 'error' ? 'd-error' : 'd-warn'"></span>
|
|
<div class="min-w-0 flex-1">
|
|
<div class="flex items-center gap-1.5">
|
|
<span class="bell-name">{{ a.name || a.svc_id }}</span>
|
|
<span class="bell-chip">{{ scopeText(a) }}</span>
|
|
<span v-if="a.fail_streak > 1" class="bell-streak">连续 {{ a.fail_streak }} 次</span>
|
|
</div>
|
|
<div class="bell-msg">{{ reasonOf(a) || '巡检异常' }}</div>
|
|
</div>
|
|
</NuxtLink>
|
|
</div>
|
|
|
|
<NuxtLink to="/svcinspect" class="bell-foot">
|
|
{{ truncated ? `仅显示前 ${alerts.length} 条 · 前往服务巡检` : '前往服务巡检' }}
|
|
</NuxtLink>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
|
import { useApi } from '~/composables/useApi'
|
|
import { useAuthStore } from '~/stores/auth'
|
|
import { REGION_LABEL, healthReason, healthLastRunText, type SvcHealth } from '~/utils/svchealth'
|
|
|
|
const { webApi } = useApi()
|
|
const auth = useAuthStore()
|
|
|
|
const alerts = ref<SvcHealth[]>([])
|
|
const errorCount = ref(0)
|
|
const warnCount = ref(0)
|
|
const lastRun = ref(0)
|
|
const truncated = ref(false)
|
|
|
|
const LIMIT = 8
|
|
const POLL_MS = 5 * 60 * 1000
|
|
|
|
// 只有能进「服务巡检」页的账号才拉这份数据(品牌商/渠道商无此菜单)。
|
|
const canSee = computed(() => auth.hasAccess('svcinspect'))
|
|
const total = computed(() => errorCount.value + warnCount.value)
|
|
const lastRunText = computed(() => healthLastRunText(lastRun.value))
|
|
|
|
function rowKey(a: SvcHealth) { return `${a.app_name}/${a.svc_id}/${a.region}` }
|
|
|
|
function scopeText(a: SvcHealth): string {
|
|
const app = a.app_name ? a.app_name : '全局'
|
|
return a.region ? `${app} · ${REGION_LABEL[a.region] || '区域' + a.region}` : app
|
|
}
|
|
|
|
function reasonOf(a: SvcHealth): string {
|
|
return healthReason(a)
|
|
}
|
|
|
|
async function load() {
|
|
if (!canSee.value || !auth.token) return
|
|
try {
|
|
const res = await webApi<{
|
|
alerts: SvcHealth[]
|
|
error_count: number
|
|
warn_count: number
|
|
last_run: number
|
|
}>('getsvcalerts', { limit: LIMIT })
|
|
alerts.value = res.alerts || []
|
|
errorCount.value = res.error_count || 0
|
|
warnCount.value = res.warn_count || 0
|
|
lastRun.value = res.last_run || 0
|
|
truncated.value = total.value > alerts.value.length
|
|
} catch {
|
|
// 巡检数据拿不到不该影响顶栏,静默降级为「无通知」。
|
|
alerts.value = []
|
|
errorCount.value = 0
|
|
warnCount.value = 0
|
|
truncated.value = false
|
|
}
|
|
}
|
|
|
|
// 点开时刷一次,避免看到几分钟前的旧状态。
|
|
function onOpen() { load() }
|
|
|
|
let timer: ReturnType<typeof setInterval> | null = null
|
|
onMounted(() => {
|
|
load()
|
|
timer = setInterval(load, POLL_MS)
|
|
})
|
|
onBeforeUnmount(() => { if (timer) clearInterval(timer) })
|
|
</script>
|
|
|
|
<style scoped>
|
|
.bell-badge {
|
|
position:absolute; top:-1px; right:-1px; min-width:16px; height:16px; padding:0 4px;
|
|
border-radius:999px; color:#fff; font-size:10px; font-weight:700; line-height:16px;
|
|
display:flex; align-items:center; justify-content:center; border:1.5px solid #fff;
|
|
}
|
|
.lv-error { background:#f43f5e; }
|
|
.lv-warn { background:#f59e0b; }
|
|
|
|
.bell-list { max-height:320px; overflow-y:auto; }
|
|
.bell-item { display:flex; align-items:flex-start; gap:8px; padding:9px 14px; border-bottom:1px solid #f8fafc; }
|
|
.bell-item:hover { background:#f8fafc; }
|
|
.bell-dot { width:7px; height:7px; border-radius:50%; flex-shrink:0; margin-top:6px; }
|
|
.d-error { background:#f43f5e; }
|
|
.d-warn { background:#f59e0b; }
|
|
.bell-name { font-size:12.5px; font-weight:600; color:#1e293b; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
.bell-chip { font-size:10.5px; padding:0 6px; border-radius:999px; background:#e0e7ff; color:#4338ca; white-space:nowrap; }
|
|
.bell-streak { font-size:10.5px; color:#be123c; font-weight:600; white-space:nowrap; }
|
|
.bell-msg { font-size:11.5px; color:#64748b; margin-top:2px; line-height:1.45; }
|
|
.bell-foot { display:block; text-align:center; font-size:12px; color:#4338ca; padding:9px; background:#f8fafc; }
|
|
.bell-foot:hover { background:#f1f5f9; }
|
|
</style>
|
|
|