Add cross-panel tenant shortcuts with live status colors and values.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
MessageOutlined,
|
||||
CloudServerOutlined,
|
||||
AppstoreAddOutlined,
|
||||
CustomerServiceOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import api from '@/api/http'
|
||||
|
||||
export type TenantPanelKey = 'sms' | 'server' | 'plugin' | 'support'
|
||||
|
||||
type Tone = 'healthy' | 'warning' | 'critical' | 'unknown' | 'dedicated'
|
||||
|
||||
type ShortcutCard = {
|
||||
key: TenantPanelKey
|
||||
label: string
|
||||
routeName: string
|
||||
icon: typeof MessageOutlined
|
||||
tone: Tone
|
||||
value: string
|
||||
meta?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
tenantId: string
|
||||
current: TenantPanelKey
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const cards = ref<ShortcutCard[]>([])
|
||||
|
||||
const EXPIRY_RANK: Record<string, number> = {
|
||||
critical: 0,
|
||||
warning: 1,
|
||||
unknown: 2,
|
||||
none: 3,
|
||||
healthy: 4,
|
||||
dedicated: 5,
|
||||
}
|
||||
|
||||
const panelDefs: {
|
||||
key: TenantPanelKey
|
||||
label: string
|
||||
routeName: string
|
||||
icon: typeof MessageOutlined
|
||||
}[] = [
|
||||
{ key: 'sms', label: 'پیامک', routeName: 'sms-tenant', icon: MessageOutlined },
|
||||
{ key: 'server', label: 'سرور', routeName: 'server-tenant', icon: CloudServerOutlined },
|
||||
{ key: 'plugin', label: 'افزونه', routeName: 'plugin-tenant', icon: AppstoreAddOutlined },
|
||||
{ key: 'support', label: 'پشتیبانی', routeName: 'support-tenant', icon: CustomerServiceOutlined },
|
||||
]
|
||||
|
||||
const visible = computed(() => cards.value.filter((c) => c.key !== props.current))
|
||||
|
||||
function asTone(level?: string | null): Tone {
|
||||
if (
|
||||
level === 'healthy' ||
|
||||
level === 'warning' ||
|
||||
level === 'critical' ||
|
||||
level === 'dedicated' ||
|
||||
level === 'unknown'
|
||||
) {
|
||||
return level
|
||||
}
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
function worstLevel(levels: string[]): Tone {
|
||||
if (!levels.length) return 'unknown'
|
||||
let worst = levels[0]
|
||||
for (const level of levels.slice(1)) {
|
||||
if ((EXPIRY_RANK[level] ?? 99) < (EXPIRY_RANK[worst] ?? 99)) worst = level
|
||||
}
|
||||
return asTone(worst === 'none' ? 'unknown' : worst)
|
||||
}
|
||||
|
||||
function pluginSupportLabel(
|
||||
kind: 'plugin' | 'support',
|
||||
count: number,
|
||||
level: Tone,
|
||||
): string {
|
||||
const empty = kind === 'plugin' ? 'بدون افزونه' : 'بدون پشتیبانی'
|
||||
const noun = kind === 'plugin' ? 'افزونه' : 'پشتیبانی'
|
||||
if (count === 0) return empty
|
||||
if (level === 'critical') return `${noun} نزدیک انقضا / منقضی`
|
||||
if (level === 'warning') return `${noun} در بازه هشدار`
|
||||
if (level === 'unknown') return `${noun} بدون تاریخ`
|
||||
return `${count} ${noun} فعال`
|
||||
}
|
||||
|
||||
async function loadSms(): Promise<ShortcutCard> {
|
||||
const def = panelDefs.find((p) => p.key === 'sms')!
|
||||
try {
|
||||
const { data } = await api.get(`/sms/tenants/${props.tenantId}`)
|
||||
return {
|
||||
...def,
|
||||
tone: asTone(data?.summary?.balanceLevel),
|
||||
value: data?.summary?.balanceLabel || 'نامشخص',
|
||||
meta: data?.account?.isBlocked ? 'ارسال مسدود' : undefined,
|
||||
}
|
||||
} catch {
|
||||
return { ...def, tone: 'unknown', value: 'خطا در دریافت' }
|
||||
}
|
||||
}
|
||||
|
||||
async function loadServer(): Promise<ShortcutCard> {
|
||||
const def = panelDefs.find((p) => p.key === 'server')!
|
||||
try {
|
||||
const { data } = await api.get(`/servers/tenants/${props.tenantId}`)
|
||||
const servers: Array<{
|
||||
summary?: {
|
||||
expiryLevel?: string
|
||||
expiryLabel?: string
|
||||
serverName?: string | null
|
||||
expiresAt?: string | null
|
||||
isDedicated?: boolean
|
||||
}
|
||||
}> = data?.servers || []
|
||||
|
||||
if (!servers.length) {
|
||||
return { ...def, tone: 'unknown', value: 'بدون سرور' }
|
||||
}
|
||||
|
||||
let worst = servers[0].summary
|
||||
for (const row of servers.slice(1)) {
|
||||
const s = row.summary
|
||||
if (!s || !worst) continue
|
||||
const wr = EXPIRY_RANK[worst.expiryLevel || 'unknown'] ?? 99
|
||||
const sr = EXPIRY_RANK[s.expiryLevel || 'unknown'] ?? 99
|
||||
if (sr < wr) worst = s
|
||||
else if (
|
||||
sr === wr &&
|
||||
s.expiresAt &&
|
||||
worst.expiresAt &&
|
||||
Date.parse(s.expiresAt) < Date.parse(worst.expiresAt)
|
||||
) {
|
||||
worst = s
|
||||
}
|
||||
}
|
||||
|
||||
const names = servers
|
||||
.map((s) => s.summary?.serverName)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2) as string[]
|
||||
const meta =
|
||||
servers.length > 1
|
||||
? `${servers.length} سرور${names.length ? ` · ${names.join('، ')}` : ''}`
|
||||
: names[0] || undefined
|
||||
|
||||
return {
|
||||
...def,
|
||||
tone: asTone(worst?.expiryLevel),
|
||||
value: worst?.expiryLabel || 'نامشخص',
|
||||
meta,
|
||||
}
|
||||
} catch {
|
||||
return { ...def, tone: 'unknown', value: 'خطا در دریافت' }
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPluginLike(
|
||||
key: 'plugin' | 'support',
|
||||
): Promise<ShortcutCard> {
|
||||
const def = panelDefs.find((p) => p.key === key)!
|
||||
const path = key === 'plugin' ? 'plugins' : 'support'
|
||||
const listKey = key === 'plugin' ? 'plugins' : 'supports'
|
||||
try {
|
||||
const { data } = await api.get(`/${path}/tenants/${props.tenantId}`)
|
||||
const items: Array<{ isActive?: boolean; summary?: { expiryLevel?: string } }> =
|
||||
data?.[listKey] || []
|
||||
const active = items.filter((i) => i.isActive !== false)
|
||||
const levels = active.map((i) => i.summary?.expiryLevel || 'unknown')
|
||||
const tone = worstLevel(levels)
|
||||
return {
|
||||
...def,
|
||||
tone,
|
||||
value: pluginSupportLabel(key, active.length, tone),
|
||||
meta: active.length ? `${active.length} مورد` : undefined,
|
||||
}
|
||||
} catch {
|
||||
return { ...def, tone: 'unknown', value: 'خطا در دریافت' }
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
if (!props.tenantId) {
|
||||
cards.value = []
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const loaders: Promise<ShortcutCard>[] = []
|
||||
if (props.current !== 'sms') loaders.push(loadSms())
|
||||
if (props.current !== 'server') loaders.push(loadServer())
|
||||
if (props.current !== 'plugin') loaders.push(loadPluginLike('plugin'))
|
||||
if (props.current !== 'support') loaders.push(loadPluginLike('support'))
|
||||
const order: TenantPanelKey[] = ['sms', 'server', 'plugin', 'support']
|
||||
const loaded = await Promise.all(loaders)
|
||||
cards.value = order
|
||||
.map((key) => loaded.find((c) => c.key === key))
|
||||
.filter(Boolean) as ShortcutCard[]
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openPanel(routeName: string) {
|
||||
if (!props.tenantId) return
|
||||
router.push({ name: routeName, params: { tenantId: props.tenantId } })
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.tenantId, props.current] as const,
|
||||
() => {
|
||||
void loadAll()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav
|
||||
v-if="tenantId"
|
||||
class="tenant-panel-shortcuts"
|
||||
aria-label="میانبر پنلهای همین نسخه"
|
||||
>
|
||||
<div v-if="loading && !visible.length" class="tenant-panel-shortcuts-loading">
|
||||
در حال بارگذاری میانبرها…
|
||||
</div>
|
||||
<button
|
||||
v-for="item in visible"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
class="tenant-panel-status-card"
|
||||
:class="item.tone"
|
||||
:title="`رفتن به پنل ${item.label} همین نسخه`"
|
||||
@click="openPanel(item.routeName)"
|
||||
>
|
||||
<div class="card-top">
|
||||
<span class="card-icon"><component :is="item.icon" /></span>
|
||||
<strong>{{ item.label }}</strong>
|
||||
</div>
|
||||
<span class="card-value">{{ item.value }}</span>
|
||||
<small v-if="item.meta" class="card-meta">{{ item.meta }}</small>
|
||||
</button>
|
||||
</nav>
|
||||
</template>
|
||||
Reference in New Issue
Block a user