Files
web/src/pages/HomePage.vue
T

489 lines
15 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>