Files
web/src/pages/ServerPanelPage.vue
T

360 lines
10 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.
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { ArrowRightOutlined, CloudServerOutlined, SearchOutlined } from '@ant-design/icons-vue'
import api from '@/api/http'
type ExpiryLevel = 'dedicated' | 'healthy' | 'warning' | 'critical' | 'unknown'
type TenantRow = {
id: string
product: 'hamkar' | 'khanegi' | 'other'
slug: string
nameFa: string
domain: string
serversCount?: number
server: {
isDedicated: boolean
expiryLevel: ExpiryLevel
expiryLabel: string
serverName?: string | null
planLabel?: string | null
serversCount?: number
}
}
const router = useRouter()
const loadingList = ref(false)
const tenants = ref<TenantRow[]>([])
const q = ref('')
const toneFilter = ref<ExpiryLevel | null>(null)
const filteredTenants = computed(() => {
const term = q.value.trim().toLowerCase()
return tenants.value.filter((t) => {
if (toneFilter.value && (t.server?.expiryLevel || 'unknown') !== toneFilter.value) {
return false
}
if (!term) return true
const blob = [
t.nameFa,
t.domain,
t.slug,
t.server?.serverName || '',
t.server?.expiryLabel || '',
]
.join(' ')
.toLowerCase()
return blob.includes(term)
})
})
const groupedTenants = computed(() => ({
hamkar: filteredTenants.value.filter((t) => t.product === 'hamkar'),
khanegi: filteredTenants.value.filter((t) => t.product === 'khanegi'),
other: filteredTenants.value.filter((t) => t.product === 'other'),
}))
function toneClass(level?: ExpiryLevel) {
return level || 'unknown'
}
function toggleToneFilter(level: ExpiryLevel) {
toneFilter.value = toneFilter.value === level ? null : level
}
function openTenant(id: string) {
router.push({ name: 'server-tenant', params: { tenantId: id } })
}
async function loadTenants() {
loadingList.value = true
try {
const { data } = await api.get('/servers/tenants')
tenants.value = data
} catch (e: any) {
message.error(e?.response?.data?.message || 'خطا در دریافت سرورها')
} finally {
loadingList.value = false
}
}
onMounted(loadTenants)
</script>
<template>
<div class="page-shell">
<div class="page-head">
<a-button type="text" class="page-head-back" @click="router.push({ name: 'home' })">
<ArrowRightOutlined />
</a-button>
<div class="page-head-text">
<h1 class="page-title">پنل سرور</h1>
<p class="page-sub">
هر نسخه می‌تواند چند سرور داشته باشد — وضعیت، شارژ و هشدار نزدیک پایان اعتبار ·
{{ filteredTenants.length }} مورد
</p>
</div>
</div>
<div class="toolbar">
<a-input
v-model:value="q"
allow-clear
placeholder="جستجو نام، دامنه، slug یا نام سرور"
>
<template #prefix><SearchOutlined /></template>
</a-input>
</div>
<div class="legend" role="group" aria-label="فیلتر وضعیت سرور">
<button
type="button"
class="chip all"
:class="{ active: toneFilter === null }"
@click="toneFilter = null"
>
همه
</button>
<button
type="button"
class="chip dedicated"
:class="{ active: toneFilter === 'dedicated' }"
@click="toggleToneFilter('dedicated')"
>
اختصاصی
</button>
<button
type="button"
class="chip healthy"
:class="{ active: toneFilter === 'healthy' }"
@click="toggleToneFilter('healthy')"
>
اعتبار کافی — سبز
</button>
<button
type="button"
class="chip warning"
:class="{ active: toneFilter === 'warning' }"
@click="toggleToneFilter('warning')"
>
نزدیک هشدار — زرد
</button>
<button
type="button"
class="chip critical"
:class="{ active: toneFilter === 'critical' }"
@click="toggleToneFilter('critical')"
>
۳ روز یا کمتر / منقضی — قرمز
</button>
<button
type="button"
class="chip unknown"
:class="{ active: toneFilter === 'unknown' }"
@click="toggleToneFilter('unknown')"
>
بدون تاریخ
</button>
</div>
<section class="sms-selector">
<div class="sms-side-head"><CloudServerOutlined /> نسخه‌ها</div>
<a-spin :spinning="loadingList">
<div class="tenant-groups">
<section class="tenant-group">
<div class="tenant-group-head">
<h3>همکار</h3>
<span>{{ groupedTenants.hamkar.length }} نسخه</span>
</div>
<div class="tenant-grid">
<button
v-for="t in groupedTenants.hamkar"
:key="t.id"
type="button"
class="tenant-card"
:class="toneClass(t.server?.expiryLevel)"
@click="openTenant(t.id)"
>
<strong>{{ t.nameFa }}</strong>
<span>{{ t.server?.expiryLabel }}</span>
<small v-if="(t.serversCount || 0) > 1">
{{ t.serversCount }} سرور
</small>
<small v-else-if="t.server?.serverName">{{ t.server.serverName }}</small>
<small v-else>{{ t.domain }}</small>
</button>
<div v-if="!groupedTenants.hamkar.length" class="empty-hint">نسخه‌ای نیست</div>
</div>
</section>
<section class="tenant-group">
<div class="tenant-group-head">
<h3>خانگی</h3>
<span>{{ groupedTenants.khanegi.length }} نسخه</span>
</div>
<div class="tenant-grid">
<button
v-for="t in groupedTenants.khanegi"
:key="t.id"
type="button"
class="tenant-card"
:class="toneClass(t.server?.expiryLevel)"
@click="openTenant(t.id)"
>
<strong>{{ t.nameFa }}</strong>
<span>{{ t.server?.expiryLabel }}</span>
<small v-if="(t.serversCount || 0) > 1">
{{ t.serversCount }} سرور
</small>
<small v-else-if="t.server?.serverName">{{ t.server.serverName }}</small>
<small v-else>{{ t.domain }}</small>
</button>
<div v-if="!groupedTenants.khanegi.length" class="empty-hint">نسخه‌ای نیست</div>
</div>
</section>
<section v-if="groupedTenants.other.length" class="tenant-group">
<div class="tenant-group-head">
<h3>سایت‌های دیگر</h3>
<span>{{ groupedTenants.other.length }} سایت</span>
</div>
<div class="tenant-grid">
<button
v-for="t in groupedTenants.other"
:key="t.id"
type="button"
class="tenant-card"
:class="toneClass(t.server?.expiryLevel)"
@click="openTenant(t.id)"
>
<strong>{{ t.nameFa }}</strong>
<span>{{ t.server?.expiryLabel }}</span>
<small v-if="(t.serversCount || 0) > 1">
{{ t.serversCount }} سرور
</small>
<small v-else-if="t.server?.serverName">{{ t.server.serverName }}</small>
<small v-else>{{ t.domain }}</small>
</button>
</div>
</section>
</div>
</a-spin>
</section>
</div>
</template>
<style scoped>
.legend {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 14px;
}
.chip {
appearance: none;
border: 2px solid transparent;
border-radius: 999px;
padding: 6px 12px;
font: inherit;
font-size: 0.8rem;
font-weight: 700;
cursor: pointer;
transition: box-shadow 0.15s ease, border-color 0.15s ease, transform 0.15s ease;
}
.chip:hover { transform: translateY(-1px); }
.chip.active { box-shadow: 0 0 0 2px rgba(23, 50, 77, 0.18); }
.chip.dedicated { background: #e8eef8; color: #3b5bdb; }
.chip.dedicated.active { border-color: #3b5bdb; }
.chip.healthy { background: #dff8ea; color: #0f7a45; }
.chip.healthy.active { border-color: #0f7a45; }
.chip.warning { background: #fff4c7; color: #9a6b00; }
.chip.warning.active { border-color: #9a6b00; }
.chip.critical { background: #ffd8d8; color: #b42318; }
.chip.critical.active { border-color: #b42318; }
.chip.unknown { background: #eef2f7; color: #64748b; }
.chip.unknown.active { border-color: #64748b; }
.chip.all { background: #e8edf5; color: #334155; }
.chip.all.active { border-color: #334155; }
.sms-selector {
border-radius: 22px;
background: #fff;
border: 1px solid #e8eef5;
padding: 16px;
}
.sms-side-head {
font-weight: 800;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 8px;
}
.tenant-groups {
display: grid;
gap: 18px;
}
.tenant-group-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.tenant-group-head h3 {
margin: 0;
font-size: 1rem;
}
.tenant-group-head span {
color: #94a3b8;
font-size: 0.85rem;
}
.tenant-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 160px), 1fr));
gap: 10px;
}
.tenant-card {
appearance: none;
border: none;
border-radius: 16px;
padding: 14px;
text-align: right;
cursor: pointer;
font-family: inherit;
display: grid;
gap: 4px;
color: #17324d;
}
.tenant-card strong { font-size: 0.95rem; }
.tenant-card span,
.tenant-card small {
opacity: 0.92;
font-size: 0.82rem;
}
.tenant-card.dedicated { background: linear-gradient(135deg, #e8eef8, #d6e0f5); }
.tenant-card.healthy { background: linear-gradient(135deg, #dff8ea, #c3efd7); }
.tenant-card.warning { background: linear-gradient(135deg, #fff4c7, #ffe39a); }
.tenant-card.critical { background: linear-gradient(135deg, #ffd8d8, #ffb9b9); }
.tenant-card.unknown { background: linear-gradient(135deg, #eef2f7, #dde5ef); }
.empty-hint {
grid-column: 1 / -1;
color: #94a3b8;
padding: 12px;
font-size: 0.85rem;
}
</style>