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 JalaliDatePicker from '@/components/JalaliDatePicker.vue'
|
||||
import MoneyInput from '@/components/MoneyInput.vue'
|
||||
import TenantPanelShortcuts from '@/components/TenantPanelShortcuts.vue'
|
||||
import { formatIranDate, todayIranDateInput, iranDateInputToIso } from '@/utils/datetime'
|
||||
import { formatMoney } from '@/utils/money'
|
||||
|
||||
@@ -276,6 +277,8 @@ onMounted(loadDetail)
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<TenantPanelShortcuts :tenant-id="tenantId" current="plugin" />
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<div class="layout">
|
||||
<aside class="plugin-list">
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ArrowRightOutlined, PlusOutlined, DeleteOutlined } from '@ant-design/ic
|
||||
import api from '@/api/http'
|
||||
import JalaliDatePicker from '@/components/JalaliDatePicker.vue'
|
||||
import MoneyInput from '@/components/MoneyInput.vue'
|
||||
import TenantPanelShortcuts from '@/components/TenantPanelShortcuts.vue'
|
||||
import { formatIranDate, todayIranDateInput, iranDateInputToIso } from '@/utils/datetime'
|
||||
import { formatMoney } from '@/utils/money'
|
||||
|
||||
@@ -285,6 +286,8 @@ onMounted(loadDetail)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TenantPanelShortcuts :tenant-id="tenantId" current="server" />
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<section class="form-section server-picker">
|
||||
<div class="picker-head">
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { TableColumnsType } from 'ant-design-vue'
|
||||
import api from '@/api/http'
|
||||
import JalaliDatePicker from '@/components/JalaliDatePicker.vue'
|
||||
import MoneyInput from '@/components/MoneyInput.vue'
|
||||
import TenantPanelShortcuts from '@/components/TenantPanelShortcuts.vue'
|
||||
import { formatIranDateTime, todayIranDateInput } from '@/utils/datetime'
|
||||
import { formatMoney, formatNumber } from '@/utils/money'
|
||||
|
||||
@@ -411,6 +412,8 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TenantPanelShortcuts :tenant-id="tenantId" current="sms" />
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<template v-if="tenant && summary">
|
||||
<section class="stat-grid">
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ArrowRightOutlined, PlusOutlined, DeleteOutlined } from '@ant-design/ic
|
||||
import api from '@/api/http'
|
||||
import JalaliDatePicker from '@/components/JalaliDatePicker.vue'
|
||||
import MoneyInput from '@/components/MoneyInput.vue'
|
||||
import TenantPanelShortcuts from '@/components/TenantPanelShortcuts.vue'
|
||||
import { formatIranDate, todayIranDateInput, iranDateInputToIso } from '@/utils/datetime'
|
||||
import { formatMoney } from '@/utils/money'
|
||||
|
||||
@@ -310,6 +311,8 @@ onMounted(loadDetail)
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<TenantPanelShortcuts :tenant-id="tenantId" current="support" />
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<div class="layout">
|
||||
<aside class="support-list">
|
||||
|
||||
@@ -401,3 +401,95 @@ button, input, textarea, select,
|
||||
.placeholder-hero.server {
|
||||
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