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>
|
||||||
@@ -6,6 +6,7 @@ import { ArrowRightOutlined, PlusOutlined, DeleteOutlined } from '@ant-design/ic
|
|||||||
import api from '@/api/http'
|
import api from '@/api/http'
|
||||||
import JalaliDatePicker from '@/components/JalaliDatePicker.vue'
|
import JalaliDatePicker from '@/components/JalaliDatePicker.vue'
|
||||||
import MoneyInput from '@/components/MoneyInput.vue'
|
import MoneyInput from '@/components/MoneyInput.vue'
|
||||||
|
import TenantPanelShortcuts from '@/components/TenantPanelShortcuts.vue'
|
||||||
import { formatIranDate, todayIranDateInput, iranDateInputToIso } from '@/utils/datetime'
|
import { formatIranDate, todayIranDateInput, iranDateInputToIso } from '@/utils/datetime'
|
||||||
import { formatMoney } from '@/utils/money'
|
import { formatMoney } from '@/utils/money'
|
||||||
|
|
||||||
@@ -276,6 +277,8 @@ onMounted(loadDetail)
|
|||||||
</a-button>
|
</a-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<TenantPanelShortcuts :tenant-id="tenantId" current="plugin" />
|
||||||
|
|
||||||
<a-spin :spinning="loading">
|
<a-spin :spinning="loading">
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
<aside class="plugin-list">
|
<aside class="plugin-list">
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ArrowRightOutlined, PlusOutlined, DeleteOutlined } from '@ant-design/ic
|
|||||||
import api from '@/api/http'
|
import api from '@/api/http'
|
||||||
import JalaliDatePicker from '@/components/JalaliDatePicker.vue'
|
import JalaliDatePicker from '@/components/JalaliDatePicker.vue'
|
||||||
import MoneyInput from '@/components/MoneyInput.vue'
|
import MoneyInput from '@/components/MoneyInput.vue'
|
||||||
|
import TenantPanelShortcuts from '@/components/TenantPanelShortcuts.vue'
|
||||||
import { formatIranDate, todayIranDateInput, iranDateInputToIso } from '@/utils/datetime'
|
import { formatIranDate, todayIranDateInput, iranDateInputToIso } from '@/utils/datetime'
|
||||||
import { formatMoney } from '@/utils/money'
|
import { formatMoney } from '@/utils/money'
|
||||||
|
|
||||||
@@ -285,6 +286,8 @@ onMounted(loadDetail)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<TenantPanelShortcuts :tenant-id="tenantId" current="server" />
|
||||||
|
|
||||||
<a-spin :spinning="loading">
|
<a-spin :spinning="loading">
|
||||||
<section class="form-section server-picker">
|
<section class="form-section server-picker">
|
||||||
<div class="picker-head">
|
<div class="picker-head">
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { TableColumnsType } from 'ant-design-vue'
|
|||||||
import api from '@/api/http'
|
import api from '@/api/http'
|
||||||
import JalaliDatePicker from '@/components/JalaliDatePicker.vue'
|
import JalaliDatePicker from '@/components/JalaliDatePicker.vue'
|
||||||
import MoneyInput from '@/components/MoneyInput.vue'
|
import MoneyInput from '@/components/MoneyInput.vue'
|
||||||
|
import TenantPanelShortcuts from '@/components/TenantPanelShortcuts.vue'
|
||||||
import { formatIranDateTime, todayIranDateInput } from '@/utils/datetime'
|
import { formatIranDateTime, todayIranDateInput } from '@/utils/datetime'
|
||||||
import { formatMoney, formatNumber } from '@/utils/money'
|
import { formatMoney, formatNumber } from '@/utils/money'
|
||||||
|
|
||||||
@@ -411,6 +412,8 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<TenantPanelShortcuts :tenant-id="tenantId" current="sms" />
|
||||||
|
|
||||||
<a-spin :spinning="loading">
|
<a-spin :spinning="loading">
|
||||||
<template v-if="tenant && summary">
|
<template v-if="tenant && summary">
|
||||||
<section class="stat-grid">
|
<section class="stat-grid">
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ArrowRightOutlined, PlusOutlined, DeleteOutlined } from '@ant-design/ic
|
|||||||
import api from '@/api/http'
|
import api from '@/api/http'
|
||||||
import JalaliDatePicker from '@/components/JalaliDatePicker.vue'
|
import JalaliDatePicker from '@/components/JalaliDatePicker.vue'
|
||||||
import MoneyInput from '@/components/MoneyInput.vue'
|
import MoneyInput from '@/components/MoneyInput.vue'
|
||||||
|
import TenantPanelShortcuts from '@/components/TenantPanelShortcuts.vue'
|
||||||
import { formatIranDate, todayIranDateInput, iranDateInputToIso } from '@/utils/datetime'
|
import { formatIranDate, todayIranDateInput, iranDateInputToIso } from '@/utils/datetime'
|
||||||
import { formatMoney } from '@/utils/money'
|
import { formatMoney } from '@/utils/money'
|
||||||
|
|
||||||
@@ -310,6 +311,8 @@ onMounted(loadDetail)
|
|||||||
</a-button>
|
</a-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<TenantPanelShortcuts :tenant-id="tenantId" current="support" />
|
||||||
|
|
||||||
<a-spin :spinning="loading">
|
<a-spin :spinning="loading">
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
<aside class="support-list">
|
<aside class="support-list">
|
||||||
|
|||||||
@@ -401,3 +401,95 @@ button, input, textarea, select,
|
|||||||
.placeholder-hero.server {
|
.placeholder-hero.server {
|
||||||
background: linear-gradient(135deg, #ff8a8a, #ff6b6b);
|
background: linear-gradient(135deg, #ff8a8a, #ff6b6b);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tenant-panel-shortcuts {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tenant-panel-shortcuts-loading {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #f5f7fb;
|
||||||
|
padding: 12px 14px;
|
||||||
|
color: #7b8798;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tenant-panel-status-card {
|
||||||
|
border: none;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: right;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
color: #17324d;
|
||||||
|
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tenant-panel-status-card:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tenant-panel-status-card .card-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tenant-panel-status-card .card-icon {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 14px;
|
||||||
|
opacity: 0.85;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tenant-panel-status-card strong {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tenant-panel-status-card .card-value,
|
||||||
|
.tenant-panel-status-card .card-meta {
|
||||||
|
color: inherit;
|
||||||
|
opacity: 0.92;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tenant-panel-status-card.healthy {
|
||||||
|
background: linear-gradient(135deg, #dff8ea, #c3efd7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tenant-panel-status-card.warning {
|
||||||
|
background: linear-gradient(135deg, #fff4c7, #ffe39a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tenant-panel-status-card.critical {
|
||||||
|
background: linear-gradient(135deg, #ffd8d8, #ffb9b9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tenant-panel-status-card.dedicated {
|
||||||
|
background: linear-gradient(135deg, #e8eef8, #d6e0f5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tenant-panel-status-card.unknown {
|
||||||
|
background: linear-gradient(135deg, #eef2f7, #dde5ef);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.tenant-panel-shortcuts {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user