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

<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>