489 lines
15 KiB
Vue
489 lines
15 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, ref } from 'vue'
|
||
import { useRouter } from 'vue-router'
|
||
import {
|
||
MessageOutlined,
|
||
CloudServerOutlined,
|
||
AppstoreAddOutlined,
|
||
CustomerServiceOutlined,
|
||
TeamOutlined,
|
||
HomeOutlined,
|
||
GlobalOutlined,
|
||
LoginOutlined,
|
||
UserOutlined,
|
||
} from '@ant-design/icons-vue'
|
||
import { message } from 'ant-design-vue'
|
||
import api from '@/api/http'
|
||
import { useAuthStore } from '@/stores/auth'
|
||
import { productLabel, type Product } from '@/utils/product'
|
||
|
||
type BalanceLevel = 'healthy' | 'warning' | 'critical' | 'unknown'
|
||
|
||
type ExpiryLevel = 'dedicated' | 'healthy' | 'warning' | 'critical' | 'unknown'
|
||
|
||
type SmsSummary = {
|
||
balanceRials: number | null
|
||
balanceLabel: string
|
||
balanceLevel: BalanceLevel
|
||
}
|
||
|
||
type ServerSummary = {
|
||
expiryLevel: ExpiryLevel
|
||
expiryLabel: string
|
||
isDedicated?: boolean
|
||
serverName?: string | null
|
||
}
|
||
|
||
type Tenant = {
|
||
id: string
|
||
product: Product
|
||
slug?: string
|
||
nameFa?: string
|
||
serverAccountId?: string
|
||
sms?: SmsSummary
|
||
server?: ServerSummary
|
||
}
|
||
|
||
type ExpiringPlugin = {
|
||
id: string
|
||
tenantId: string
|
||
name: string
|
||
expiryLevel: ExpiryLevel
|
||
expiryLabel: string
|
||
tenant: {
|
||
id: string
|
||
product: Product
|
||
nameFa: string
|
||
}
|
||
}
|
||
|
||
type ExpiringSupport = ExpiringPlugin
|
||
|
||
const router = useRouter()
|
||
const auth = useAuthStore()
|
||
const loading = ref(true)
|
||
const tenants = ref<Tenant[]>([])
|
||
const smsTenants = ref<Tenant[]>([])
|
||
const expiringServers = ref<Tenant[]>([])
|
||
const expiringPlugins = ref<ExpiringPlugin[]>([])
|
||
const expiringSupports = ref<ExpiringSupport[]>([])
|
||
|
||
const hamkarCount = computed(() => tenants.value.filter((t) => t.product === 'hamkar').length)
|
||
const khanegiCount = computed(() => tenants.value.filter((t) => t.product === 'khanegi').length)
|
||
const othersCount = computed(() => tenants.value.filter((t) => t.product === 'other').length)
|
||
const lowestSmsTenants = computed(() => smsTenants.value.slice(0, 5))
|
||
|
||
const canSms = computed(() => auth.can('sms:read'))
|
||
const canServers = computed(() => auth.can('servers:read'))
|
||
const canPlugins = computed(() => auth.can('plugins:read'))
|
||
const canSupport = computed(() => auth.can('support:read'))
|
||
const canAdmins = computed(() => auth.can('admins:read'))
|
||
const canHamkar = computed(() => auth.can('hamkar:read'))
|
||
const canKhanegi = computed(() => auth.can('khanegi:read'))
|
||
const canOthers = computed(() => auth.can('others:read'))
|
||
|
||
const modules = computed(() =>
|
||
[
|
||
{
|
||
key: 'admins',
|
||
className: 'admins',
|
||
title: 'مدیران',
|
||
desc: 'افزودن و ویرایش مدیران؛ تعیین سطح دسترسی',
|
||
meta: 'دسترسی',
|
||
icon: UserOutlined,
|
||
to: { name: 'admins' as const },
|
||
visible: canAdmins.value,
|
||
},
|
||
{
|
||
key: 'hamkar',
|
||
className: 'hamkar',
|
||
title: 'همکار',
|
||
desc: 'مدیریت نسخههای فروشی پروژه همکار',
|
||
meta: `${hamkarCount.value} نسخه`,
|
||
icon: TeamOutlined,
|
||
to: { name: 'product-versions' as const, params: { product: 'hamkar' } },
|
||
visible: canHamkar.value,
|
||
},
|
||
{
|
||
key: 'khanegi',
|
||
className: 'khanegi',
|
||
title: 'خانگی',
|
||
desc: 'مدیریت نسخههای فروشی و ظاهر اپ خانگی',
|
||
meta: `${khanegiCount.value} نسخه`,
|
||
icon: HomeOutlined,
|
||
to: { name: 'product-versions' as const, params: { product: 'khanegi' } },
|
||
visible: canKhanegi.value,
|
||
},
|
||
{
|
||
key: 'others',
|
||
className: 'others',
|
||
title: 'سایتهای دیگر',
|
||
desc: 'سایتهای متفرقه؛ فقط مشخصات و پیگیری سرور، افزونه، پشتیبانی و پیامک',
|
||
meta: `${othersCount.value} سایت`,
|
||
icon: GlobalOutlined,
|
||
to: { name: 'product-versions' as const, params: { product: 'other' } },
|
||
visible: canOthers.value,
|
||
},
|
||
].filter((item) => item.visible),
|
||
)
|
||
|
||
async function load() {
|
||
loading.value = true
|
||
try {
|
||
const tasks: Array<Promise<void>> = []
|
||
|
||
if (canHamkar.value || canKhanegi.value || canOthers.value) {
|
||
tasks.push(
|
||
api.get('/tenants').then((res) => {
|
||
tenants.value = res.data
|
||
}),
|
||
)
|
||
}
|
||
if (canSms.value) {
|
||
tasks.push(
|
||
api.get('/sms/lowest', { params: { limit: 5 } }).then((res) => {
|
||
smsTenants.value = res.data
|
||
}),
|
||
)
|
||
}
|
||
if (canServers.value) {
|
||
tasks.push(
|
||
api.get('/servers/expiring', { params: { limit: 5 } }).then((res) => {
|
||
expiringServers.value = res.data
|
||
}),
|
||
)
|
||
}
|
||
if (canPlugins.value) {
|
||
tasks.push(
|
||
api.get('/plugins/expiring', { params: { limit: 5 } }).then((res) => {
|
||
expiringPlugins.value = res.data
|
||
}),
|
||
)
|
||
}
|
||
if (canSupport.value) {
|
||
tasks.push(
|
||
api.get('/support/expiring', { params: { limit: 5 } }).then((res) => {
|
||
expiringSupports.value = res.data
|
||
}),
|
||
)
|
||
}
|
||
|
||
const results = await Promise.allSettled(tasks)
|
||
const failed = results.some((r) => r.status === 'rejected')
|
||
if (failed) {
|
||
message.error('خطا در دریافت بخشی از آمار داشبورد')
|
||
}
|
||
} catch (e: any) {
|
||
message.error(e?.response?.data?.message || 'خطا در دریافت آمار')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
function openSmsPanel(tenantId?: string) {
|
||
if (tenantId) {
|
||
router.push({ name: 'sms-tenant', params: { tenantId } })
|
||
return
|
||
}
|
||
router.push({ name: 'sms-panel' })
|
||
}
|
||
|
||
function openServerPanel(tenantId?: string) {
|
||
if (tenantId) {
|
||
router.push({ name: 'server-tenant', params: { tenantId } })
|
||
return
|
||
}
|
||
router.push({ name: 'server-panel' })
|
||
}
|
||
|
||
function openPluginPanel(tenantId?: string) {
|
||
if (tenantId) {
|
||
router.push({ name: 'plugin-tenant', params: { tenantId } })
|
||
return
|
||
}
|
||
router.push({ name: 'plugin-panel' })
|
||
}
|
||
|
||
function openSupportPanel(tenantId?: string) {
|
||
if (tenantId) {
|
||
router.push({ name: 'support-tenant', params: { tenantId } })
|
||
return
|
||
}
|
||
router.push({ name: 'support-panel' })
|
||
}
|
||
|
||
function smsTone(level?: BalanceLevel) {
|
||
switch (level) {
|
||
case 'healthy':
|
||
return 'healthy'
|
||
case 'warning':
|
||
return 'warning'
|
||
case 'critical':
|
||
return 'critical'
|
||
default:
|
||
return 'unknown'
|
||
}
|
||
}
|
||
|
||
function serverTone(level?: ExpiryLevel) {
|
||
switch (level) {
|
||
case 'dedicated':
|
||
return 'dedicated'
|
||
case 'healthy':
|
||
return 'healthy'
|
||
case 'warning':
|
||
return 'warning'
|
||
case 'critical':
|
||
return 'critical'
|
||
default:
|
||
return 'unknown'
|
||
}
|
||
}
|
||
|
||
onMounted(load)
|
||
</script>
|
||
|
||
<template>
|
||
<div class="page-shell">
|
||
<div class="login-kicker">داشبورد کنترل</div>
|
||
<h1 class="page-title">سلام، خوش آمدید</h1>
|
||
<p class="page-sub">
|
||
از اینجا به پنل پیامکی، سرور، افزونه، پشتیبانی، مدیران، نسخههای همکار / خانگی و سایتهای دیگر دسترسی دارید.
|
||
</p>
|
||
|
||
<a-spin :spinning="loading">
|
||
<div class="dash-modules">
|
||
<div v-if="canSms" class="module-card sms sms-dashboard-card" @click="openSmsPanel()">
|
||
<div>
|
||
<div class="icon-wrap">
|
||
<MessageOutlined />
|
||
</div>
|
||
<h3>پنل پیامکی</h3>
|
||
<p>۵ نسخه با کمترین موجودی برای پیگیری سریع شارژ و وضعیت پیامک</p>
|
||
</div>
|
||
<div class="sms-mini-grid">
|
||
<button
|
||
v-for="tenant in lowestSmsTenants"
|
||
:key="tenant.id"
|
||
type="button"
|
||
class="sms-mini-card"
|
||
:class="smsTone(tenant.sms?.balanceLevel)"
|
||
@click.stop="openSmsPanel(tenant.id)"
|
||
>
|
||
<strong>{{ tenant.nameFa }}</strong>
|
||
<span>{{ productLabel(tenant.product) }}</span>
|
||
<small>{{ tenant.sms?.balanceLabel || 'نامشخص' }}</small>
|
||
</button>
|
||
<div v-if="!lowestSmsTenants.length" class="sms-mini-empty">
|
||
هنوز نسخهای برای نمایش موجودی ثبت نشده است
|
||
</div>
|
||
</div>
|
||
<div class="module-foot">
|
||
<div class="progress"><span /></div>
|
||
<div class="module-meta">
|
||
<span>{{ lowestSmsTenants.length }} نسخه کمموجودی</span>
|
||
<span class="module-enter"><LoginOutlined /> ورود</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="canServers" class="module-card server sms-dashboard-card" @click="openServerPanel()">
|
||
<div>
|
||
<div class="icon-wrap">
|
||
<CloudServerOutlined />
|
||
</div>
|
||
<h3>پنل سرور</h3>
|
||
<p>نسخههایی که اعتبار سرورشان رو به پایان است</p>
|
||
</div>
|
||
<div class="sms-mini-grid">
|
||
<button
|
||
v-for="tenant in expiringServers"
|
||
:key="`${tenant.id}-${tenant.serverAccountId || tenant.server?.serverName || ''}`"
|
||
type="button"
|
||
class="sms-mini-card"
|
||
:class="serverTone(tenant.server?.expiryLevel)"
|
||
@click.stop="openServerPanel(tenant.id)"
|
||
>
|
||
<strong>{{ tenant.nameFa }}</strong>
|
||
<span>
|
||
{{ productLabel(tenant.product) }}
|
||
<template v-if="tenant.server?.serverName">
|
||
· {{ tenant.server.serverName }}
|
||
</template>
|
||
</span>
|
||
<small>{{ tenant.server?.expiryLabel || 'نامشخص' }}</small>
|
||
</button>
|
||
<div v-if="!expiringServers.length" class="sms-mini-empty">
|
||
سرور نزدیکانقضا یا بدون تاریخ ثبتشده نیست
|
||
</div>
|
||
</div>
|
||
<div class="module-foot">
|
||
<div class="progress"><span /></div>
|
||
<div class="module-meta">
|
||
<span>{{ expiringServers.length }} مورد پیگیری</span>
|
||
<span class="module-enter"><LoginOutlined /> ورود</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="canPlugins" class="module-card plugin sms-dashboard-card" @click="openPluginPanel()">
|
||
<div>
|
||
<div class="icon-wrap">
|
||
<AppstoreAddOutlined />
|
||
</div>
|
||
<h3>پنل افزونه</h3>
|
||
<p>افزونههایی که اعتبارشان رو به پایان است (هر نسخه چند افزونه)</p>
|
||
</div>
|
||
<div class="sms-mini-grid">
|
||
<button
|
||
v-for="item in expiringPlugins"
|
||
:key="item.id"
|
||
type="button"
|
||
class="sms-mini-card"
|
||
:class="serverTone(item.expiryLevel)"
|
||
@click.stop="openPluginPanel(item.tenantId)"
|
||
>
|
||
<strong>{{ item.tenant?.nameFa }}</strong>
|
||
<span>{{ item.name }}</span>
|
||
<small>{{ item.expiryLabel || 'نامشخص' }}</small>
|
||
</button>
|
||
<div v-if="!expiringPlugins.length" class="sms-mini-empty">
|
||
افزونه نزدیکانقضا یا بدون تاریخ ثبتشده نیست
|
||
</div>
|
||
</div>
|
||
<div class="module-foot">
|
||
<div class="progress"><span /></div>
|
||
<div class="module-meta">
|
||
<span>{{ expiringPlugins.length }} مورد پیگیری</span>
|
||
<span class="module-enter"><LoginOutlined /> ورود</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="canSupport" class="module-card support sms-dashboard-card" @click="openSupportPanel()">
|
||
<div>
|
||
<div class="icon-wrap">
|
||
<CustomerServiceOutlined />
|
||
</div>
|
||
<h3>پنل پشتیبانی</h3>
|
||
<p>پشتیبانیهایی که اعتبارشان رو به پایان است (قابل ثبت رایگان)</p>
|
||
</div>
|
||
<div class="sms-mini-grid">
|
||
<button
|
||
v-for="item in expiringSupports"
|
||
:key="item.id"
|
||
type="button"
|
||
class="sms-mini-card"
|
||
:class="serverTone(item.expiryLevel)"
|
||
@click.stop="openSupportPanel(item.tenantId)"
|
||
>
|
||
<strong>{{ item.tenant?.nameFa }}</strong>
|
||
<span>{{ item.name }}</span>
|
||
<small>{{ item.expiryLabel || 'نامشخص' }}</small>
|
||
</button>
|
||
<div v-if="!expiringSupports.length" class="sms-mini-empty">
|
||
پشتیبانی نزدیکانقضا یا بدون تاریخ ثبتشده نیست
|
||
</div>
|
||
</div>
|
||
<div class="module-foot">
|
||
<div class="progress"><span /></div>
|
||
<div class="module-meta">
|
||
<span>{{ expiringSupports.length }} مورد پیگیری</span>
|
||
<span class="module-enter"><LoginOutlined /> ورود</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
v-for="item in modules"
|
||
:key="item.key"
|
||
type="button"
|
||
class="module-card"
|
||
:class="item.className"
|
||
@click="router.push(item.to)"
|
||
>
|
||
<div>
|
||
<div class="icon-wrap">
|
||
<component :is="item.icon" />
|
||
</div>
|
||
<h3>{{ item.title }}</h3>
|
||
<p>{{ item.desc }}</p>
|
||
</div>
|
||
<div class="module-foot">
|
||
<div class="progress"><span /></div>
|
||
<div class="module-meta">
|
||
<span>{{ item.meta }}</span>
|
||
<span class="module-enter"><LoginOutlined /> ورود</span>
|
||
</div>
|
||
</div>
|
||
</button>
|
||
</div>
|
||
</a-spin>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.sms-mini-grid {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.sms-mini-card {
|
||
border: none;
|
||
border-radius: 10px;
|
||
padding: 7px 9px;
|
||
text-align: right;
|
||
font-family: inherit;
|
||
cursor: pointer;
|
||
display: grid;
|
||
gap: 1px;
|
||
color: #17324d;
|
||
min-width: 0;
|
||
flex: 1 1 calc(50% - 6px);
|
||
}
|
||
|
||
.sms-mini-card strong {
|
||
font-size: 0.82rem;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.sms-mini-card span,
|
||
.sms-mini-card small {
|
||
color: inherit;
|
||
opacity: 0.9;
|
||
}
|
||
|
||
.sms-mini-card.healthy {
|
||
background: linear-gradient(135deg, #dff8ea, #c3efd7);
|
||
}
|
||
|
||
.sms-mini-card.warning {
|
||
background: linear-gradient(135deg, #fff4c7, #ffe39a);
|
||
}
|
||
|
||
.sms-mini-card.critical {
|
||
background: linear-gradient(135deg, #ffd8d8, #ffb9b9);
|
||
}
|
||
|
||
.sms-mini-card.dedicated {
|
||
background: linear-gradient(135deg, #e8eef8, #d6e0f5);
|
||
}
|
||
|
||
.sms-mini-card.unknown {
|
||
background: linear-gradient(135deg, #eef2f7, #dde5ef);
|
||
}
|
||
|
||
.sms-mini-empty {
|
||
flex: 1 1 100%;
|
||
border-radius: 10px;
|
||
background: #f5f7fb;
|
||
padding: 10px 12px;
|
||
color: #7b8798;
|
||
font-size: 0.82rem;
|
||
}
|
||
</style>
|