Files
web/src/pages/PluginPanelPage.vue
T
2026-08-08 12:52:02 +03:30

282 lines
8.1 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, AppstoreAddOutlined, SearchOutlined } from '@ant-design/icons-vue'
import api from '@/api/http'
type ExpiryLevel = 'healthy' | 'warning' | 'critical' | 'unknown' | 'none'
type TenantRow = {
id: string
product: 'hamkar' | 'khanegi'
nameFa: string
domain: string
slug?: string
pluginCount: number
summary: {
expiryLevel: ExpiryLevel
expiryLabel: string
}
}
const router = useRouter()
const loadingList = ref(false)
const tenants = ref<TenantRow[]>([])
const q = ref('')
const toneFilter = ref<'healthy' | 'warning' | 'critical' | 'unknown' | null>(null)
const filteredTenants = computed(() => {
const term = q.value.trim().toLowerCase()
return tenants.value.filter((t) => {
const tone = toneClass(t.summary?.expiryLevel)
if (toneFilter.value && tone !== toneFilter.value) return false
if (!term) return true
const blob = [t.nameFa, t.domain, t.slug || '', t.summary?.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'),
}))
function toneClass(level?: ExpiryLevel) {
return level && level !== 'none' ? level : 'unknown'
}
function toggleToneFilter(level: 'healthy' | 'warning' | 'critical' | 'unknown') {
toneFilter.value = toneFilter.value === level ? null : level
}
function openTenant(id: string) {
router.push({ name: 'plugin-tenant', params: { tenantId: id } })
}
async function loadTenants() {
loadingList.value = true
try {
const { data } = await api.get('/plugins/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 style="display: flex; align-items: flex-start; gap: 8px; margin-bottom: 16px">
<a-button type="text" @click="router.push({ name: 'home' })">
<ArrowRightOutlined />
</a-button>
<div style="flex: 1">
<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"
style="flex: 1; min-width: 180px"
>
<template #prefix><SearchOutlined /></template>
</a-input>
</div>
<div class="legend" role="group" aria-label="فیلتر وضعیت افزونه">
<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"><AppstoreAddOutlined /> نسخه‌ها</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.summary?.expiryLevel)"
@click="openTenant(t.id)"
>
<strong>{{ t.nameFa }}</strong>
<span>{{ t.summary?.expiryLabel }}</span>
<small>{{ t.pluginCount }} افزونه</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.summary?.expiryLevel)"
@click="openTenant(t.id)"
>
<strong>{{ t.nameFa }}</strong>
<span>{{ t.summary?.expiryLabel }}</span>
<small>{{ t.pluginCount }} افزونه</small>
</button>
<div v-if="!groupedTenants.khanegi.length" class="empty-hint">نسخه‌ای نیست</div>
</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.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; }
.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.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>