Files
web/src/pages/SmsPanelPage.vue
T

307 lines
9.3 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 { message } from 'ant-design-vue'
import { ArrowRightOutlined, MessageOutlined, SearchOutlined } from '@ant-design/icons-vue'
import api from '@/api/http'
type BalanceLevel = 'healthy' | 'warning' | 'critical' | 'unknown'
type TenantRow = {
id: string
product: 'hamkar' | 'khanegi' | 'other'
slug: string
nameFa: string
domain: string
isBlocked?: boolean
sms: {
balanceLabel: string
balanceLevel: BalanceLevel
}
}
const router = useRouter()
const loadingList = ref(false)
const tenants = ref<TenantRow[]>([])
const q = ref('')
const toneFilter = ref<BalanceLevel | null>(null)
const filteredTenants = computed(() => {
const term = q.value.trim().toLowerCase()
return tenants.value.filter((t) => {
if (toneFilter.value && (t.sms?.balanceLevel || 'unknown') !== toneFilter.value) {
return false
}
if (!term) return true
return (
t.nameFa.toLowerCase().includes(term) ||
t.domain.toLowerCase().includes(term) ||
t.slug.toLowerCase().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?: BalanceLevel) {
return level || 'unknown'
}
function toggleToneFilter(level: BalanceLevel) {
toneFilter.value = toneFilter.value === level ? null : level
}
function openTenant(id: string) {
router.push({ name: 'sms-tenant', params: { tenantId: id } })
}
async function loadTenants() {
loadingList.value = true
try {
const { data } = await api.get('/sms/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">
یک نسخه را انتخاب کنید تا کیف پول و تنظیمات SMS همان نسخه باز شود ·
{{ 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>
<a-alert
type="info"
show-icon
style="margin-bottom: 14px; border-radius: 14px"
message="فعلاً پنل پیامکی همکار و خانگی دست نخورده می‌ماند"
description="بعد از تست سوپرادمین، اتصال به محصولات تصمیم‌گیری می‌شود."
/>
<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 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>
</div>
<section class="sms-selector">
<div class="sms-side-head"><MessageOutlined /> نسخه‌ها</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.sms?.balanceLevel)"
@click="openTenant(t.id)"
>
<strong>{{ t.nameFa }}</strong>
<span>{{ t.sms?.balanceLabel }}</span>
<small>{{ t.isBlocked ? 'مسدود' : 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.sms?.balanceLevel)"
@click="openTenant(t.id)"
>
<strong>{{ t.nameFa }}</strong>
<span>{{ t.sms?.balanceLabel }}</span>
<small>{{ t.isBlocked ? 'مسدود' : 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.sms?.balanceLevel)"
@click="openTenant(t.id)"
>
<strong>{{ t.nameFa }}</strong>
<span>{{ t.sms?.balanceLabel }}</span>
<small>{{ t.isBlocked ? 'مسدود' : t.domain }}</small>
</button>
</div>
</section>
</div>
<a-empty
v-if="!groupedTenants.hamkar.length && !groupedTenants.khanegi.length && !groupedTenants.other.length"
description="نسخه‌ای نیست"
/>
</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.82rem;
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.healthy { background: #dff8ea; color: #0f6b3c; }
.chip.healthy.active { border-color: #0f6b3c; }
.chip.warning { background: #fff4c7; color: #8a6200; }
.chip.warning.active { border-color: #8a6200; }
.chip.critical { background: #ffd8d8; color: #9d1f1f; }
.chip.critical.active { border-color: #9d1f1f; }
.chip.all { background: #eef2f7; color: #334155; }
.chip.all.active { border-color: #334155; }
.sms-selector {
background: #f7f9fc;
border-radius: 18px;
padding: 14px;
border: 1px solid #e8eef5;
}
.sms-side-head {
font-weight: 800;
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 8px;
}
.tenant-groups { display: grid; gap: 16px; }
.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: #8b95a8; font-size: 0.82rem; }
.tenant-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 160px), 1fr));
gap: 8px;
}
.tenant-card {
width: 100%;
text-align: right;
border: 1px solid transparent;
border-radius: 16px;
padding: 12px;
cursor: pointer;
font-family: inherit;
display: grid;
gap: 4px;
color: #17324d;
}
.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); }
.tenant-card:hover {
transform: translateY(-2px);
box-shadow: 0 10px 22px rgba(23, 50, 77, 0.08);
}
.tenant-card small { direction: ltr; text-align: right; }
.empty-hint {
grid-column: 1 / -1;
color: #94a3b8;
padding: 12px;
font-size: 0.85rem;
}
</style>