Files
web/src/pages/PluginPanelPage.vue
T

578 lines
17 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, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { ArrowRightOutlined, AppstoreAddOutlined, SearchOutlined } from '@ant-design/icons-vue'
import api from '@/api/http'
import { useAuthStore } from '@/stores/auth'
import PluginCatalogPage from '@/pages/PluginCatalogPage.vue'
type ExpiryLevel = 'healthy' | 'warning' | 'critical' | 'unknown' | 'none'
type TenantRow = {
id: string
product: 'hamkar' | 'khanegi'
nameFa: string
domain: string
slug?: string
pluginCount: number
pluginStoreEnabled?: boolean
summary: {
expiryLevel: ExpiryLevel
expiryLabel: string
}
}
type RequestRow = {
id: string
tenantName: string
domain: string
pluginTitle: string
durationDays: number
finalPriceRials: number
status: 'pending' | 'approved' | 'rejected' | 'cancelled'
receiptUrl?: string | null
createdAt: string
}
type MainTab = 'khanegi' | 'hamkar' | 'requests' | 'catalog'
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
const canWrite = computed(() => auth.can('plugins:write'))
const storeBusyId = ref<string | null>(null)
const loadingList = ref(false)
const tenants = ref<TenantRow[]>([])
const q = ref('')
const toneFilter = ref<'healthy' | 'warning' | 'critical' | 'unknown' | null>(null)
const mainTab = ref<MainTab>('khanegi')
const requests = ref<RequestRow[]>([])
const loadingRequests = ref(false)
const actingId = ref<string | null>(null)
const autoCut = ref(true)
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 onStoreToggle(tenant: TenantRow, checked: boolean) {
if (!canWrite.value) return
storeBusyId.value = tenant.id
const previous = tenant.pluginStoreEnabled === true
tenant.pluginStoreEnabled = checked
try {
const { data } = await api.patch(`/plugins/tenants/${tenant.id}/store-enabled`, {
enabled: checked,
})
tenant.pluginStoreEnabled = data.pluginStoreEnabled === true
message.success(
tenant.pluginStoreEnabled ? 'فروشگاه افزونه روشن شد' : 'فروشگاه افزونه خاموش شد',
)
} catch (e: any) {
tenant.pluginStoreEnabled = previous
message.error(e?.response?.data?.message || 'تغییر فروشگاه افزونه ناموفق')
} finally {
storeBusyId.value = null
}
}
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
}
}
const statusLabel: Record<RequestRow['status'], string> = {
pending: 'در انتظار',
approved: 'تأیید شده',
rejected: 'رد شده',
cancelled: 'لغو شده',
}
async function loadRequests() {
loadingRequests.value = true
try {
const { data } = await api.get('/plugins/store/requests')
requests.value = data
} catch (e: any) {
message.error(e?.response?.data?.message || 'خطا در دریافت درخواست‌ها')
} finally {
loadingRequests.value = false
}
}
async function approve(row: RequestRow) {
actingId.value = row.id
try {
await api.post(`/plugins/store/requests/${row.id}/approve`, {
autoCutOnExpiry: autoCut.value,
})
message.success('تأیید شد و سوییچ روشن شد')
await loadRequests()
await loadTenants()
} catch (e: any) {
const msg = e?.response?.data?.message
message.error(Array.isArray(msg) ? msg.join('، ') : msg || 'تأیید ناموفق')
} finally {
actingId.value = null
}
}
async function reject(row: RequestRow) {
actingId.value = row.id
try {
await api.post(`/plugins/store/requests/${row.id}/reject`)
message.success('رد شد')
await loadRequests()
} catch (e: any) {
const msg = e?.response?.data?.message
message.error(Array.isArray(msg) ? msg.join('، ') : msg || 'رد ناموفق')
} finally {
actingId.value = null
}
}
function setTab(tab: MainTab) {
mainTab.value = tab
const query = tab === 'khanegi' ? {} : { tab }
void router.replace({ query })
if (tab === 'requests') void loadRequests()
}
function tabFromQuery(raw: unknown): MainTab {
if (raw === 'hamkar' || raw === 'requests' || raw === 'catalog') return raw
return 'khanegi'
}
watch(
() => route.query.tab,
(tab) => {
const next = tabFromQuery(tab)
if (mainTab.value === next) return
mainTab.value = next
if (next === 'requests') void loadRequests()
},
{ immediate: true },
)
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="main-tabs">
<button type="button" :class="{ active: mainTab === 'khanegi' }" @click="setTab('khanegi')">خانگی</button>
<button type="button" :class="{ active: mainTab === 'hamkar' }" @click="setTab('hamkar')">همکار</button>
<button type="button" :class="{ active: mainTab === 'requests' }" @click="setTab('requests')">درخواست‌ها</button>
<button type="button" :class="{ active: mainTab === 'catalog' }" @click="setTab('catalog')">قیمت فروشگاه</button>
</div>
<div v-if="mainTab === 'khanegi' || mainTab === 'hamkar'" class="toolbar">
<a-input
v-model:value="q"
allow-clear
placeholder="جستجو نام، دامنه یا slug"
>
<template #prefix><SearchOutlined /></template>
</a-input>
</div>
<div v-if="mainTab === 'khanegi' || mainTab === 'hamkar'" 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>
<button
type="button"
class="chip unknown"
:class="{ active: toneFilter === 'unknown' }"
@click="toggleToneFilter('unknown')"
>
بدون افزونه یا بدون تاریخ
</button>
</div>
<section v-if="mainTab === 'requests'" class="sms-selector">
<div class="sms-side-head">درخواست‌های خانگی</div>
<div class="req-toolbar">
<a-checkbox v-model:checked="autoCut">قطع خودکار بعد از انقضا (موقع تأیید)</a-checkbox>
</div>
<a-spin :spinning="loadingRequests">
<div class="req-table-wrap">
<table class="req-table">
<thead>
<tr>
<th>نسخه</th>
<th>افزونه</th>
<th>مدت</th>
<th>مبلغ</th>
<th>وضعیت</th>
<th>فیش</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="row in requests" :key="row.id">
<td>
<strong>{{ row.tenantName }}</strong>
<small>{{ row.domain }}</small>
</td>
<td>{{ row.pluginTitle }}</td>
<td>{{ row.durationDays }} روز</td>
<td>{{ Number(row.finalPriceRials || 0).toLocaleString('fa-IR') }}</td>
<td>{{ statusLabel[row.status] }}</td>
<td>
<a-image
v-if="row.receiptUrl"
:src="row.receiptUrl"
:width="44"
:preview="{ zIndex: 1200 }"
/>
<span v-else>—</span>
</td>
<td>
<template v-if="row.status === 'pending'">
<a-button size="small" type="primary" :loading="actingId === row.id" @click="approve(row)">تأیید</a-button>
<a-button size="small" danger :loading="actingId === row.id" @click="reject(row)">رد</a-button>
</template>
</td>
</tr>
<tr v-if="!requests.length">
<td colspan="7" class="empty-hint">درخواستی نیست</td>
</tr>
</tbody>
</table>
</div>
</a-spin>
</section>
<section v-else-if="mainTab === 'catalog'" class="sms-selector">
<div class="sms-side-head">قیمت فروشگاه خانگی</div>
<PluginCatalogPage embedded />
</section>
<section v-else class="sms-selector">
<div class="sms-side-head"><AppstoreAddOutlined /> نسخه‌ها</div>
<a-spin :spinning="loadingList">
<div class="tenant-groups">
<section v-if="mainTab === 'hamkar'" 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 v-if="mainTab === 'khanegi'" 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)"
>
<div class="sms-toggle" :class="{ off: !t.pluginStoreEnabled }" @click.stop>
<div class="sms-toggle-text">
<span class="sms-toggle-label">فروشگاه افزونه</span>
<small class="sms-toggle-domain" dir="ltr">{{ t.domain }}</small>
</div>
<a-switch
v-if="canWrite"
:checked="t.pluginStoreEnabled === true"
:loading="storeBusyId === t.id"
checked-children="روشن"
un-checked-children="خاموش"
@change="(v: boolean) => onStoreToggle(t, v)"
/>
</div>
<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>
.main-tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
@media (max-width: 640px) {
.main-tabs {
display: grid;
grid-template-columns: 1fr 1fr;
}
.main-tabs button { width: 100%; }
}
.main-tabs button {
appearance: none;
border: 1px solid #d7e0ea;
background: #fff;
border-radius: 999px;
padding: 8px 16px;
font: inherit;
font-weight: 800;
cursor: pointer;
}
.main-tabs button.active {
background: #17324d;
color: #fff;
border-color: #17324d;
}
.req-toolbar { margin-bottom: 12px; }
.req-table-wrap { overflow-x: auto; }
.req-table { width: 100%; border-collapse: collapse; font-size: 0.88rem; }
.req-table th, .req-table td { padding: 8px 6px; text-align: right; border-bottom: 1px solid #eef2f7; }
.req-table small { display: block; color: #94a3b8; }
.req-table .ant-btn + .ant-btn { margin-right: 6px; }
.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; }
.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: 1fr;
gap: 10px;
}
@media (min-width: 640px) {
.tenant-grid {
grid-template-columns: repeat(auto-fill, minmax(min(100%, 210px), 1fr));
}
}
.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;
min-width: 0;
box-shadow: 0 6px 16px rgba(23, 50, 77, 0.08);
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.tenant-card:hover {
transform: translateY(-1px);
box-shadow: 0 10px 22px rgba(23, 50, 77, 0.12);
}
.sms-toggle {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 10px;
border-radius: 999px;
background: #e8f8f1;
border: 1px solid #b7ebd4;
margin-bottom: 6px;
}
.sms-toggle.off {
background: #fff1f0;
border-color: #ffccc7;
}
.sms-toggle-label {
font-size: 0.75rem;
font-weight: 700;
color: #334155;
white-space: nowrap;
}
.sms-toggle-text {
display: grid;
gap: 2px;
min-width: 0;
flex: 1;
}
.sms-toggle-domain {
font-size: 0.7rem;
font-weight: 600;
color: #64748b;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tenant-card strong {
font-size: 0.95rem;
overflow-wrap: break-word;
}
.tenant-card span,
.tenant-card small { opacity: 0.92; font-size: 0.82rem; overflow-wrap: break-word; }
.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>