Add support panel UI, search filters, and money thousand separators.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { fromGroupedInput, toGroupedDisplay } from '@/utils/money'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
value: number | null | undefined
|
||||
size?: 'large' | 'middle' | 'small'
|
||||
placeholder?: string
|
||||
allowNegative?: boolean
|
||||
disabled?: boolean
|
||||
}>(),
|
||||
{
|
||||
size: 'large',
|
||||
placeholder: '',
|
||||
allowNegative: false,
|
||||
disabled: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:value', value: number): void
|
||||
}>()
|
||||
|
||||
const display = computed(() => toGroupedDisplay(props.value ?? 0))
|
||||
|
||||
function onInput(raw: string | number) {
|
||||
emit('update:value', fromGroupedInput(String(raw ?? ''), props.allowNegative))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-input
|
||||
class="money-input"
|
||||
dir="ltr"
|
||||
:size="size"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:value="display"
|
||||
@update:value="onInput"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.money-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.money-input :deep(input) {
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: left;
|
||||
direction: ltr;
|
||||
}
|
||||
</style>
|
||||
+51
-2
@@ -5,6 +5,7 @@ import {
|
||||
MessageOutlined,
|
||||
CloudServerOutlined,
|
||||
AppstoreAddOutlined,
|
||||
CustomerServiceOutlined,
|
||||
TeamOutlined,
|
||||
HomeOutlined,
|
||||
UserOutlined,
|
||||
@@ -52,12 +53,15 @@ type ExpiringPlugin = {
|
||||
}
|
||||
}
|
||||
|
||||
type ExpiringSupport = ExpiringPlugin
|
||||
|
||||
const router = useRouter()
|
||||
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)
|
||||
@@ -96,16 +100,19 @@ const modules = computed(() => [
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [tenantsRes, lowestRes, expiringRes, pluginExpiringRes] = await Promise.all([
|
||||
const [tenantsRes, lowestRes, expiringRes, pluginExpiringRes, supportExpiringRes] =
|
||||
await Promise.all([
|
||||
api.get('/tenants'),
|
||||
api.get('/sms/lowest', { params: { limit: 5 } }),
|
||||
api.get('/servers/expiring', { params: { limit: 5 } }),
|
||||
api.get('/plugins/expiring', { params: { limit: 5 } }),
|
||||
api.get('/support/expiring', { params: { limit: 5 } }),
|
||||
])
|
||||
tenants.value = tenantsRes.data
|
||||
smsTenants.value = lowestRes.data
|
||||
expiringServers.value = expiringRes.data
|
||||
expiringPlugins.value = pluginExpiringRes.data
|
||||
expiringSupports.value = supportExpiringRes.data
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message || 'خطا در دریافت آمار')
|
||||
} finally {
|
||||
@@ -137,6 +144,14 @@ function openPluginPanel(tenantId?: string) {
|
||||
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':
|
||||
@@ -173,7 +188,7 @@ onMounted(load)
|
||||
<div class="login-kicker">داشبورد کنترل</div>
|
||||
<h1 class="page-title">سلام، خوش آمدید</h1>
|
||||
<p class="page-sub">
|
||||
از اینجا به پنل پیامکی، سرور، افزونه، مدیران و نسخههای همکار / خانگی دسترسی دارید.
|
||||
از اینجا به پنل پیامکی، سرور، افزونه، پشتیبانی، مدیران و نسخههای همکار / خانگی دسترسی دارید.
|
||||
</p>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
@@ -285,6 +300,40 @@ onMounted(load)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div 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>
|
||||
<div class="progress"><span /></div>
|
||||
<div class="module-meta">
|
||||
<span>{{ expiringSupports.length }} مورد پیگیری</span>
|
||||
<span>ورود ←</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-for="item in modules"
|
||||
:key="item.key"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { ArrowRightOutlined, AppstoreAddOutlined } from '@ant-design/icons-vue'
|
||||
import { ArrowRightOutlined, AppstoreAddOutlined, SearchOutlined } from '@ant-design/icons-vue'
|
||||
import api from '@/api/http'
|
||||
|
||||
type ExpiryLevel = 'healthy' | 'warning' | 'critical' | 'unknown' | 'none'
|
||||
@@ -12,6 +12,7 @@ type TenantRow = {
|
||||
product: 'hamkar' | 'khanegi'
|
||||
nameFa: string
|
||||
domain: string
|
||||
slug?: string
|
||||
pluginCount: number
|
||||
summary: {
|
||||
expiryLevel: ExpiryLevel
|
||||
@@ -22,10 +23,22 @@ type TenantRow = {
|
||||
const router = useRouter()
|
||||
const loadingList = ref(false)
|
||||
const tenants = ref<TenantRow[]>([])
|
||||
const q = ref('')
|
||||
|
||||
const filteredTenants = computed(() => {
|
||||
const term = q.value.trim().toLowerCase()
|
||||
if (!term) return tenants.value
|
||||
return tenants.value.filter((t) => {
|
||||
const blob = [t.nameFa, t.domain, t.slug || '', t.summary?.expiryLabel || '']
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
return blob.includes(term)
|
||||
})
|
||||
})
|
||||
|
||||
const groupedTenants = computed(() => ({
|
||||
hamkar: tenants.value.filter((t) => t.product === 'hamkar'),
|
||||
khanegi: tenants.value.filter((t) => t.product === 'khanegi'),
|
||||
hamkar: filteredTenants.value.filter((t) => t.product === 'hamkar'),
|
||||
khanegi: filteredTenants.value.filter((t) => t.product === 'khanegi'),
|
||||
}))
|
||||
|
||||
function toneClass(level?: ExpiryLevel) {
|
||||
@@ -60,11 +73,23 @@ onMounted(loadTenants)
|
||||
<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">
|
||||
<span class="chip healthy">اعتبار کافی</span>
|
||||
<span class="chip warning">نزدیک هشدار</span>
|
||||
|
||||
@@ -5,7 +5,9 @@ import { message, Modal } from 'ant-design-vue'
|
||||
import { ArrowRightOutlined, PlusOutlined, DeleteOutlined } from '@ant-design/icons-vue'
|
||||
import api from '@/api/http'
|
||||
import JalaliDatePicker from '@/components/JalaliDatePicker.vue'
|
||||
import MoneyInput from '@/components/MoneyInput.vue'
|
||||
import { formatIranDate, todayIranDateInput, iranDateInputToIso } from '@/utils/datetime'
|
||||
import { formatMoney } from '@/utils/money'
|
||||
|
||||
type ExpiryLevel = 'healthy' | 'warning' | 'critical' | 'unknown' | 'none'
|
||||
|
||||
@@ -96,11 +98,6 @@ function toneClass(level?: ExpiryLevel) {
|
||||
return level && level !== 'none' ? level : 'unknown'
|
||||
}
|
||||
|
||||
function formatMoney(value: number | null | undefined) {
|
||||
if (value == null) return '—'
|
||||
return `${new Intl.NumberFormat('fa-IR').format(value)} ریال`
|
||||
}
|
||||
|
||||
function fillForm(plugin: PluginItem) {
|
||||
Object.assign(form, {
|
||||
name: plugin.name || '',
|
||||
@@ -352,13 +349,7 @@ onMounted(loadDetail)
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="قیمت دوره (ریال)">
|
||||
<a-input-number
|
||||
v-model:value="form.priceRials"
|
||||
style="width: 100%"
|
||||
size="large"
|
||||
:min="0"
|
||||
:step="100000"
|
||||
/>
|
||||
<MoneyInput v-model:value="form.priceRials" placeholder="مثلاً 1,500,000" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
@@ -404,13 +395,7 @@ onMounted(loadDetail)
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="مبلغ (ریال)">
|
||||
<a-input-number
|
||||
v-model:value="renewForm.priceRials"
|
||||
style="width: 100%"
|
||||
size="large"
|
||||
:min="0"
|
||||
:step="100000"
|
||||
/>
|
||||
<MoneyInput v-model:value="renewForm.priceRials" placeholder="مثلاً 1,500,000" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
@@ -479,13 +464,7 @@ onMounted(loadDetail)
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-form-item label="قیمت دوره (ریال)">
|
||||
<a-input-number
|
||||
v-model:value="createForm.priceRials"
|
||||
style="width: 100%"
|
||||
size="large"
|
||||
:min="0"
|
||||
:step="100000"
|
||||
/>
|
||||
<MoneyInput v-model:value="createForm.priceRials" placeholder="مثلاً 1,500,000" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { ArrowRightOutlined, CloudServerOutlined } from '@ant-design/icons-vue'
|
||||
import { ArrowRightOutlined, CloudServerOutlined, SearchOutlined } from '@ant-design/icons-vue'
|
||||
import api from '@/api/http'
|
||||
|
||||
type ExpiryLevel = 'dedicated' | 'healthy' | 'warning' | 'critical' | 'unknown'
|
||||
@@ -27,10 +27,28 @@ type TenantRow = {
|
||||
const router = useRouter()
|
||||
const loadingList = ref(false)
|
||||
const tenants = ref<TenantRow[]>([])
|
||||
const q = ref('')
|
||||
|
||||
const filteredTenants = computed(() => {
|
||||
const term = q.value.trim().toLowerCase()
|
||||
if (!term) return tenants.value
|
||||
return tenants.value.filter((t) => {
|
||||
const blob = [
|
||||
t.nameFa,
|
||||
t.domain,
|
||||
t.slug,
|
||||
t.server?.serverName || '',
|
||||
t.server?.expiryLabel || '',
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
return blob.includes(term)
|
||||
})
|
||||
})
|
||||
|
||||
const groupedTenants = computed(() => ({
|
||||
hamkar: tenants.value.filter((t) => t.product === 'hamkar'),
|
||||
khanegi: tenants.value.filter((t) => t.product === 'khanegi'),
|
||||
hamkar: filteredTenants.value.filter((t) => t.product === 'hamkar'),
|
||||
khanegi: filteredTenants.value.filter((t) => t.product === 'khanegi'),
|
||||
}))
|
||||
|
||||
function toneClass(level?: ExpiryLevel) {
|
||||
@@ -65,11 +83,23 @@ onMounted(loadTenants)
|
||||
<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">
|
||||
<span class="chip dedicated">اختصاصی</span>
|
||||
<span class="chip healthy">اعتبار کافی — سبز</span>
|
||||
|
||||
@@ -5,7 +5,9 @@ import { message, Modal } from 'ant-design-vue'
|
||||
import { ArrowRightOutlined, PlusOutlined, DeleteOutlined } from '@ant-design/icons-vue'
|
||||
import api from '@/api/http'
|
||||
import JalaliDatePicker from '@/components/JalaliDatePicker.vue'
|
||||
import MoneyInput from '@/components/MoneyInput.vue'
|
||||
import { formatIranDate, todayIranDateInput, iranDateInputToIso } from '@/utils/datetime'
|
||||
import { formatMoney } from '@/utils/money'
|
||||
|
||||
type ExpiryLevel = 'dedicated' | 'healthy' | 'warning' | 'critical' | 'unknown'
|
||||
|
||||
@@ -95,11 +97,6 @@ function toneClass(level?: ExpiryLevel) {
|
||||
return level || 'unknown'
|
||||
}
|
||||
|
||||
function formatMoney(value: number | null | undefined) {
|
||||
if (value == null) return '—'
|
||||
return `${new Intl.NumberFormat('fa-IR').format(value)} ریال`
|
||||
}
|
||||
|
||||
function applyAccount(row: ServerRow | null) {
|
||||
if (!row) {
|
||||
summary.value = null
|
||||
@@ -378,13 +375,7 @@ onMounted(loadDetail)
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="قیمت دوره (ریال)">
|
||||
<a-input-number
|
||||
v-model:value="form.priceRials"
|
||||
style="width: 100%"
|
||||
size="large"
|
||||
:min="0"
|
||||
:step="100000"
|
||||
/>
|
||||
<MoneyInput v-model:value="form.priceRials" placeholder="مثلاً 1,500,000" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
@@ -433,13 +424,7 @@ onMounted(loadDetail)
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="مبلغ (ریال)">
|
||||
<a-input-number
|
||||
v-model:value="renewForm.priceRials"
|
||||
style="width: 100%"
|
||||
size="large"
|
||||
:min="0"
|
||||
:step="100000"
|
||||
/>
|
||||
<MoneyInput v-model:value="renewForm.priceRials" placeholder="مثلاً 1,500,000" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { ArrowRightOutlined, MessageOutlined } from '@ant-design/icons-vue'
|
||||
import { ArrowRightOutlined, MessageOutlined, SearchOutlined } from '@ant-design/icons-vue'
|
||||
import api from '@/api/http'
|
||||
|
||||
type BalanceLevel = 'healthy' | 'warning' | 'critical' | 'unknown'
|
||||
@@ -23,10 +23,22 @@ type TenantRow = {
|
||||
const router = useRouter()
|
||||
const loadingList = ref(false)
|
||||
const tenants = ref<TenantRow[]>([])
|
||||
const q = ref('')
|
||||
|
||||
const filteredTenants = computed(() => {
|
||||
const term = q.value.trim().toLowerCase()
|
||||
if (!term) return tenants.value
|
||||
return tenants.value.filter(
|
||||
(t) =>
|
||||
t.nameFa.toLowerCase().includes(term) ||
|
||||
t.domain.toLowerCase().includes(term) ||
|
||||
t.slug.toLowerCase().includes(term),
|
||||
)
|
||||
})
|
||||
|
||||
const groupedTenants = computed(() => ({
|
||||
hamkar: tenants.value.filter((t) => t.product === 'hamkar'),
|
||||
khanegi: tenants.value.filter((t) => t.product === 'khanegi'),
|
||||
hamkar: filteredTenants.value.filter((t) => t.product === 'hamkar'),
|
||||
khanegi: filteredTenants.value.filter((t) => t.product === 'khanegi'),
|
||||
}))
|
||||
|
||||
function toneClass(level?: BalanceLevel) {
|
||||
@@ -60,10 +72,24 @@ onMounted(loadTenants)
|
||||
</a-button>
|
||||
<div style="flex: 1">
|
||||
<h1 class="page-title">پنل پیامکی</h1>
|
||||
<p class="page-sub">یک نسخه را انتخاب کنید تا کیف پول و تنظیمات SMS همان نسخه باز شود</p>
|
||||
<p class="page-sub">
|
||||
یک نسخه را انتخاب کنید تا کیف پول و تنظیمات SMS همان نسخه باز شود ·
|
||||
{{ 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>
|
||||
|
||||
<a-alert
|
||||
type="info"
|
||||
show-icon
|
||||
@@ -100,6 +126,7 @@ onMounted(loadTenants)
|
||||
<span>{{ t.sms?.balanceLabel }}</span>
|
||||
<small>{{ t.isBlocked ? 'مسدود' : t.domain }}</small>
|
||||
</button>
|
||||
<div v-if="!groupedTenants.hamkar.length" class="empty-hint">نسخهای نیست</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -121,6 +148,7 @@ onMounted(loadTenants)
|
||||
<span>{{ t.sms?.balanceLabel }}</span>
|
||||
<small>{{ t.isBlocked ? 'مسدود' : t.domain }}</small>
|
||||
</button>
|
||||
<div v-if="!groupedTenants.khanegi.length" class="empty-hint">نسخهای نیست</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -198,4 +226,10 @@ onMounted(loadTenants)
|
||||
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>
|
||||
|
||||
@@ -6,7 +6,9 @@ import { ArrowRightOutlined } from '@ant-design/icons-vue'
|
||||
import type { TableColumnsType } from 'ant-design-vue'
|
||||
import api from '@/api/http'
|
||||
import JalaliDatePicker from '@/components/JalaliDatePicker.vue'
|
||||
import MoneyInput from '@/components/MoneyInput.vue'
|
||||
import { formatIranDateTime, todayIranDateInput } from '@/utils/datetime'
|
||||
import { formatMoney, formatNumber } from '@/utils/money'
|
||||
|
||||
type BalanceLevel = 'healthy' | 'warning' | 'critical' | 'unknown'
|
||||
|
||||
@@ -96,42 +98,6 @@ const pageSize = 20
|
||||
|
||||
const tenantId = computed(() => String(route.params.tenantId || ''))
|
||||
|
||||
function toGroupedDisplay(value: number | null | undefined) {
|
||||
if (value == null || Number.isNaN(Number(value))) return ''
|
||||
const n = Math.trunc(Number(value))
|
||||
const neg = n < 0
|
||||
const digits = String(Math.abs(n))
|
||||
const grouped = digits.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
||||
return neg ? `-${grouped}` : grouped
|
||||
}
|
||||
|
||||
function fromGroupedInput(raw: string, allowNegative = false) {
|
||||
const text = String(raw ?? '')
|
||||
const neg = allowNegative && text.trim().startsWith('-')
|
||||
const digits = text.replace(/[^\d]/g, '')
|
||||
if (!digits) return 0
|
||||
const n = Number(digits)
|
||||
return neg ? -n : n
|
||||
}
|
||||
|
||||
function onGroupedMoneyInput(
|
||||
apply: (n: number) => void,
|
||||
value: string | number,
|
||||
allowNegative = false,
|
||||
) {
|
||||
apply(fromGroupedInput(String(value ?? ''), allowNegative))
|
||||
}
|
||||
|
||||
function formatNumber(value: number | null | undefined) {
|
||||
if (value == null) return '—'
|
||||
return new Intl.NumberFormat('fa-IR').format(value)
|
||||
}
|
||||
|
||||
function formatMoney(value: number | null | undefined) {
|
||||
if (value == null) return '—'
|
||||
return `${formatNumber(value)} ریال`
|
||||
}
|
||||
|
||||
function toneClass(level?: BalanceLevel) {
|
||||
return level || 'unknown'
|
||||
}
|
||||
@@ -478,13 +444,7 @@ onMounted(async () => {
|
||||
<div class="subcard sky">
|
||||
<h3>تعرفه پیامک</h3>
|
||||
<div class="inline-actions">
|
||||
<a-input
|
||||
size="large"
|
||||
dir="ltr"
|
||||
class="money-input"
|
||||
:value="toGroupedDisplay(form.pricePerPartRials)"
|
||||
@update:value="(v: string | number) => onGroupedMoneyInput((n) => (form.pricePerPartRials = n), v)"
|
||||
/>
|
||||
<MoneyInput v-model:value="form.pricePerPartRials" placeholder="قیمت هر پارت" />
|
||||
<a-button type="primary" size="large" :loading="saving" @click="savePrice">ذخیره قیمت</a-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -492,13 +452,10 @@ onMounted(async () => {
|
||||
<div class="subcard emerald">
|
||||
<h3>شارژ کیف پول</h3>
|
||||
<div class="charge-grid">
|
||||
<a-input
|
||||
size="large"
|
||||
dir="ltr"
|
||||
class="money-input"
|
||||
<MoneyInput
|
||||
v-model:value="chargeForm.amountRials"
|
||||
allow-negative
|
||||
placeholder="مبلغ (ریال)"
|
||||
:value="toGroupedDisplay(chargeForm.amountRials)"
|
||||
@update:value="(v: string | number) => onGroupedMoneyInput((n) => (chargeForm.amountRials = n), v, true)"
|
||||
/>
|
||||
<a-input v-model:value="chargeForm.note" size="large" placeholder="توضیح (اختیاری)" />
|
||||
<a-button type="primary" size="large" :loading="charging" @click="openChargeConfirm">
|
||||
@@ -542,23 +499,11 @@ onMounted(async () => {
|
||||
<div class="threshold-grid">
|
||||
<label>
|
||||
<span class="tone-label"><i class="dot critical" /> آستانه قرمز</span>
|
||||
<a-input
|
||||
size="large"
|
||||
dir="ltr"
|
||||
class="money-input"
|
||||
:value="toGroupedDisplay(form.alertCriticalRials)"
|
||||
@update:value="(v: string | number) => onGroupedMoneyInput((n) => (form.alertCriticalRials = n), v)"
|
||||
/>
|
||||
<MoneyInput v-model:value="form.alertCriticalRials" />
|
||||
</label>
|
||||
<label>
|
||||
<span class="tone-label"><i class="dot warning" /> آستانه زرد</span>
|
||||
<a-input
|
||||
size="large"
|
||||
dir="ltr"
|
||||
class="money-input"
|
||||
:value="toGroupedDisplay(form.alertWarningRials)"
|
||||
@update:value="(v: string | number) => onGroupedMoneyInput((n) => (form.alertWarningRials = n), v)"
|
||||
/>
|
||||
<MoneyInput v-model:value="form.alertWarningRials" />
|
||||
</label>
|
||||
<a-button type="primary" size="large" :loading="saving" @click="saveThresholds">ذخیره آستانهها</a-button>
|
||||
</div>
|
||||
@@ -1007,10 +952,6 @@ onMounted(async () => {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.money-input :deep(input) {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.money-input,
|
||||
.filter-row :deep(.jalali-date-field),
|
||||
.filter-row :deep(.ant-input),
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { ArrowRightOutlined, CustomerServiceOutlined, 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
|
||||
supportCount: number
|
||||
summary: {
|
||||
expiryLevel: ExpiryLevel
|
||||
expiryLabel: string
|
||||
}
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const loadingList = ref(false)
|
||||
const tenants = ref<TenantRow[]>([])
|
||||
const q = ref('')
|
||||
|
||||
const filteredTenants = computed(() => {
|
||||
const term = q.value.trim().toLowerCase()
|
||||
if (!term) return tenants.value
|
||||
return tenants.value.filter((t) => {
|
||||
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 openTenant(id: string) {
|
||||
router.push({ name: 'support-tenant', params: { tenantId: id } })
|
||||
}
|
||||
|
||||
async function loadTenants() {
|
||||
loadingList.value = true
|
||||
try {
|
||||
const { data } = await api.get('/support/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">
|
||||
<span class="chip healthy">اعتبار کافی</span>
|
||||
<span class="chip warning">نزدیک هشدار</span>
|
||||
<span class="chip critical">۳ روز یا کمتر / منقضی</span>
|
||||
<span class="chip unknown">بدون پشتیبانی یا بدون تاریخ</span>
|
||||
</div>
|
||||
|
||||
<section class="sms-selector">
|
||||
<div class="sms-side-head"><CustomerServiceOutlined /> نسخهها</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.supportCount }} پشتیبانی</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.supportCount }} پشتیبانی</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 {
|
||||
border-radius: 999px;
|
||||
padding: 6px 12px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.chip.healthy { background: #dff8ea; color: #0f7a45; }
|
||||
.chip.warning { background: #fff4c7; color: #9a6b00; }
|
||||
.chip.critical { background: #ffd8d8; color: #b42318; }
|
||||
.chip.unknown { background: #eef2f7; 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>
|
||||
@@ -0,0 +1,665 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import { ArrowRightOutlined, PlusOutlined, DeleteOutlined } from '@ant-design/icons-vue'
|
||||
import api from '@/api/http'
|
||||
import JalaliDatePicker from '@/components/JalaliDatePicker.vue'
|
||||
import MoneyInput from '@/components/MoneyInput.vue'
|
||||
import { formatIranDate, todayIranDateInput, iranDateInputToIso } from '@/utils/datetime'
|
||||
import { formatMoney } from '@/utils/money'
|
||||
|
||||
type ExpiryLevel = 'healthy' | 'warning' | 'critical' | 'unknown' | 'none'
|
||||
|
||||
type SupportItem = {
|
||||
id: string
|
||||
name: string
|
||||
specs: string | null
|
||||
description: string | null
|
||||
priceRials: number | null
|
||||
isFree: boolean
|
||||
chargedAt: string | null
|
||||
planMonths: number | null
|
||||
expiresAt: string | null
|
||||
alertDaysBefore: number
|
||||
isActive: boolean
|
||||
summary: {
|
||||
expiryLevel: ExpiryLevel
|
||||
expiryLabel: string
|
||||
planLabel: string | null
|
||||
expiresAt: string | null
|
||||
}
|
||||
charges: any[]
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const renewing = ref(false)
|
||||
const creating = ref(false)
|
||||
const selectedId = ref<string | null>(null)
|
||||
const showCreate = ref(false)
|
||||
|
||||
const tenant = ref<{
|
||||
id: string
|
||||
product: 'hamkar' | 'khanegi'
|
||||
slug: string
|
||||
nameFa: string
|
||||
domain: string
|
||||
} | null>(null)
|
||||
const supports = ref<SupportItem[]>([])
|
||||
const planOptions = ref<{ months: number; label: string }[]>([])
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
specs: '',
|
||||
description: '',
|
||||
priceRials: 0 as number,
|
||||
isFree: false,
|
||||
chargedAt: null as string | null,
|
||||
planMonths: 1 as number,
|
||||
alertDaysBefore: 3,
|
||||
isActive: true,
|
||||
})
|
||||
|
||||
const createForm = reactive({
|
||||
name: '',
|
||||
specs: '',
|
||||
description: '',
|
||||
priceRials: 0 as number,
|
||||
isFree: false,
|
||||
chargedAt: iranDateInputToIso(todayIranDateInput()) as string | null,
|
||||
planMonths: 1,
|
||||
alertDaysBefore: 3,
|
||||
})
|
||||
|
||||
const renewForm = reactive({
|
||||
chargedAt: iranDateInputToIso(todayIranDateInput()) as string | null,
|
||||
planMonths: 1,
|
||||
priceRials: 0 as number,
|
||||
isFree: false,
|
||||
note: '',
|
||||
})
|
||||
|
||||
const tenantId = computed(() => String(route.params.tenantId || ''))
|
||||
|
||||
const selected = computed(() => supports.value.find((p) => p.id === selectedId.value) || null)
|
||||
|
||||
const planSelectOptions = computed(() =>
|
||||
(planOptions.value.length
|
||||
? planOptions.value
|
||||
: [
|
||||
{ months: 1, label: 'یکماهه' },
|
||||
{ months: 3, label: 'سهماهه' },
|
||||
{ months: 6, label: 'ششماهه' },
|
||||
{ months: 12, label: 'یکساله' },
|
||||
]
|
||||
).map((p) => ({ value: p.months, label: p.label })),
|
||||
)
|
||||
|
||||
function toneClass(level?: ExpiryLevel) {
|
||||
return level && level !== 'none' ? level : 'unknown'
|
||||
}
|
||||
|
||||
function fillForm(item: SupportItem) {
|
||||
Object.assign(form, {
|
||||
name: item.name || '',
|
||||
specs: item.specs || '',
|
||||
description: item.description || '',
|
||||
priceRials: item.isFree ? 0 : (item.priceRials ?? 0),
|
||||
isFree: Boolean(item.isFree),
|
||||
chargedAt: item.chargedAt || null,
|
||||
planMonths: item.planMonths || 1,
|
||||
alertDaysBefore: item.alertDaysBefore ?? 3,
|
||||
isActive: item.isActive !== false,
|
||||
})
|
||||
renewForm.planMonths = item.planMonths || 1
|
||||
renewForm.isFree = Boolean(item.isFree)
|
||||
renewForm.priceRials = item.isFree ? 0 : (item.priceRials ?? 0)
|
||||
renewForm.chargedAt = iranDateInputToIso(todayIranDateInput())
|
||||
renewForm.note = ''
|
||||
}
|
||||
|
||||
function selectSupport(id: string) {
|
||||
selectedId.value = id
|
||||
const item = supports.value.find((p) => p.id === id)
|
||||
if (item) fillForm(item)
|
||||
}
|
||||
|
||||
async function loadDetail() {
|
||||
if (!tenantId.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await api.get(`/support/tenants/${tenantId.value}`)
|
||||
tenant.value = data.tenant
|
||||
supports.value = data.supports || []
|
||||
planOptions.value = data.planOptions || []
|
||||
|
||||
const keep =
|
||||
selectedId.value && supports.value.some((p) => p.id === selectedId.value)
|
||||
? selectedId.value
|
||||
: supports.value[0]?.id || null
|
||||
selectedId.value = keep
|
||||
if (keep) {
|
||||
const item = supports.value.find((p) => p.id === keep)
|
||||
if (item) fillForm(item)
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message || 'خطا در دریافت پشتیبانیها')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createSupport() {
|
||||
if (!createForm.name.trim()) {
|
||||
message.warning('نام پشتیبانی لازم است')
|
||||
return
|
||||
}
|
||||
creating.value = true
|
||||
try {
|
||||
const isFree = Boolean(createForm.isFree)
|
||||
const { data } = await api.post(`/support/tenants/${tenantId.value}`, {
|
||||
name: createForm.name.trim(),
|
||||
specs: createForm.specs || undefined,
|
||||
description: createForm.description || undefined,
|
||||
isFree,
|
||||
priceRials: isFree ? 0 : Number(createForm.priceRials) || 0,
|
||||
chargedAt: createForm.chargedAt || undefined,
|
||||
planMonths: Number(createForm.planMonths),
|
||||
alertDaysBefore: Number(createForm.alertDaysBefore) || 3,
|
||||
})
|
||||
message.success('پشتیبانی اضافه شد')
|
||||
showCreate.value = false
|
||||
createForm.name = ''
|
||||
createForm.specs = ''
|
||||
createForm.description = ''
|
||||
createForm.isFree = false
|
||||
createForm.priceRials = 0
|
||||
createForm.chargedAt = iranDateInputToIso(todayIranDateInput())
|
||||
createForm.planMonths = 1
|
||||
createForm.alertDaysBefore = 3
|
||||
await loadDetail()
|
||||
if (data?.support?.id) selectSupport(data.support.id)
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data?.message
|
||||
message.error(Array.isArray(msg) ? msg.join('، ') : msg || 'ثبت ناموفق')
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSupport() {
|
||||
if (!selectedId.value) return
|
||||
if (!form.name.trim()) {
|
||||
message.warning('نام پشتیبانی لازم است')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const isFree = Boolean(form.isFree)
|
||||
await api.patch(`/support/items/${selectedId.value}`, {
|
||||
name: form.name.trim(),
|
||||
specs: form.specs || null,
|
||||
description: form.description || null,
|
||||
isFree,
|
||||
priceRials: isFree ? 0 : Number(form.priceRials) || 0,
|
||||
chargedAt: form.chargedAt,
|
||||
planMonths: Number(form.planMonths),
|
||||
alertDaysBefore: Number(form.alertDaysBefore) || 3,
|
||||
isActive: form.isActive,
|
||||
})
|
||||
message.success('ذخیره شد')
|
||||
await loadDetail()
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data?.message
|
||||
message.error(Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function renewSupport() {
|
||||
if (!selectedId.value) return
|
||||
if (!renewForm.chargedAt) {
|
||||
message.warning('تاریخ شارژ لازم است')
|
||||
return
|
||||
}
|
||||
renewing.value = true
|
||||
try {
|
||||
const isFree = Boolean(renewForm.isFree)
|
||||
await api.post(`/support/items/${selectedId.value}/renew`, {
|
||||
chargedAt: renewForm.chargedAt,
|
||||
planMonths: Number(renewForm.planMonths),
|
||||
isFree,
|
||||
priceRials: isFree ? 0 : Number(renewForm.priceRials),
|
||||
note: renewForm.note || undefined,
|
||||
})
|
||||
message.success('پشتیبانی شارژ/تمدید شد')
|
||||
renewForm.note = ''
|
||||
await loadDetail()
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data?.message
|
||||
message.error(Array.isArray(msg) ? msg.join('، ') : msg || 'شارژ ناموفق')
|
||||
} finally {
|
||||
renewing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
if (!selected.value) return
|
||||
Modal.confirm({
|
||||
title: 'حذف پشتیبانی',
|
||||
content: `پشتیبانی «${selected.value.name}» و تاریخچه شارژ آن حذف شود؟`,
|
||||
okText: 'حذف',
|
||||
okType: 'danger',
|
||||
cancelText: 'انصراف',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await api.delete(`/support/items/${selected.value!.id}`)
|
||||
message.success('حذف شد')
|
||||
selectedId.value = null
|
||||
await loadDetail()
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message || 'حذف ناموفق')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => form.isFree,
|
||||
(free) => {
|
||||
if (free) form.priceRials = 0
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => createForm.isFree,
|
||||
(free) => {
|
||||
if (free) createForm.priceRials = 0
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => renewForm.isFree,
|
||||
(free) => {
|
||||
if (free) renewForm.priceRials = 0
|
||||
},
|
||||
)
|
||||
|
||||
watch(() => route.params.tenantId, loadDetail)
|
||||
onMounted(loadDetail)
|
||||
</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: 'support-panel' })">
|
||||
<ArrowRightOutlined />
|
||||
</a-button>
|
||||
<div style="flex: 1">
|
||||
<h1 class="page-title">{{ tenant?.nameFa || 'پشتیبانیهای نسخه' }}</h1>
|
||||
<p class="page-sub">
|
||||
{{ tenant?.product === 'hamkar' ? 'همکار' : 'خانگی' }} · {{ tenant?.domain }}
|
||||
</p>
|
||||
</div>
|
||||
<a-button type="primary" @click="showCreate = true">
|
||||
<PlusOutlined /> پشتیبانی جدید
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<div class="layout">
|
||||
<aside class="support-list">
|
||||
<div class="list-head">پشتیبانیها ({{ supports.length }})</div>
|
||||
<button
|
||||
v-for="p in supports"
|
||||
:key="p.id"
|
||||
type="button"
|
||||
class="support-card"
|
||||
:class="[toneClass(p.summary?.expiryLevel), { active: p.id === selectedId, inactive: !p.isActive }]"
|
||||
@click="selectSupport(p.id)"
|
||||
>
|
||||
<strong>
|
||||
{{ p.name }}
|
||||
<a-tag v-if="p.isFree" color="blue" class="free-tag">رایگان</a-tag>
|
||||
</strong>
|
||||
<span>{{ p.summary?.expiryLabel }}</span>
|
||||
<small v-if="!p.isActive">غیرفعال</small>
|
||||
</button>
|
||||
<div v-if="!supports.length" class="empty-hint">
|
||||
هنوز پشتیبانیای ثبت نشده — با «پشتیبانی جدید» شروع کنید.
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div v-if="selected" class="detail">
|
||||
<div class="status-banner" :class="toneClass(selected.summary?.expiryLevel)">
|
||||
<strong>
|
||||
{{ selected.name }} — {{ selected.summary?.expiryLabel }}
|
||||
<a-tag v-if="selected.isFree" color="blue" class="free-tag">رایگان</a-tag>
|
||||
</strong>
|
||||
<span v-if="selected.summary?.expiresAt">
|
||||
انقضا: {{ formatIranDate(selected.summary.expiresAt) }}
|
||||
<template v-if="selected.summary?.planLabel"> · {{ selected.summary.planLabel }}</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<section class="form-section">
|
||||
<div class="section-head">
|
||||
<h3>مشخصات پشتیبانی</h3>
|
||||
<a-button danger type="text" @click="confirmDelete">
|
||||
<DeleteOutlined /> حذف
|
||||
</a-button>
|
||||
</div>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="نام پشتیبانی" required>
|
||||
<a-input v-model:value="form.name" size="large" placeholder="مثلاً پشتیبانی پایه / ویژه" />
|
||||
</a-form-item>
|
||||
<a-form-item label="مشخصات">
|
||||
<a-textarea v-model:value="form.specs" :rows="2" placeholder="جزئیات فنی یا یادداشت داخلی…" />
|
||||
</a-form-item>
|
||||
<a-form-item label="توضیحات">
|
||||
<a-textarea v-model:value="form.description" :rows="2" />
|
||||
</a-form-item>
|
||||
<a-form-item label="فعال">
|
||||
<a-switch v-model:checked="form.isActive" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</section>
|
||||
|
||||
<section class="form-section">
|
||||
<h3>اعتبار و قیمت (وضعیت فعلی)</h3>
|
||||
<a-form layout="vertical">
|
||||
<a-row :gutter="12">
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="تاریخ شارژ (شمسی)">
|
||||
<JalaliDatePicker v-model="form.chargedAt" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="مدت شارژ">
|
||||
<a-select
|
||||
v-model:value="form.planMonths"
|
||||
size="large"
|
||||
:options="planSelectOptions"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="دوره رایگان">
|
||||
<a-switch v-model:checked="form.isFree" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-form-item v-if="!form.isFree" label="قیمت دوره (ریال)">
|
||||
<MoneyInput v-model:value="form.priceRials" placeholder="مثلاً 1,500,000" />
|
||||
</a-form-item>
|
||||
<p v-else class="field-hint">
|
||||
دوره رایگان فعال است — مبلغ صفر میشود. مدت ۱ / ۳ / ۶ / ۱۲ ماه میتواند رایگان باشد.
|
||||
</p>
|
||||
<a-form-item label="شروع هشدار SMS (روز مانده به انقضا)">
|
||||
<a-input-number
|
||||
v-model:value="form.alertDaysBefore"
|
||||
:min="1"
|
||||
:max="30"
|
||||
size="large"
|
||||
style="width: 160px"
|
||||
/>
|
||||
<span class="inline-hint">پیشفرض ۳ روز — روزانه یک پیام تا تمدید</span>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</section>
|
||||
|
||||
<div class="sticky-actions settings-save">
|
||||
<a-button type="primary" size="large" block :loading="saving" @click="saveSupport">
|
||||
ذخیره تنظیمات
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<section class="form-section renew-section">
|
||||
<h3>شارژ / تمدید جدید</h3>
|
||||
<p class="field-hint">
|
||||
با ثبت شارژ، تاریخ انقضا تمدید میشود و هشدارهای قبلی قطع میگردد تا دوباره نزدیک پایان برسد.
|
||||
</p>
|
||||
<a-form layout="vertical">
|
||||
<a-row :gutter="12">
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="تاریخ شروع دوره (شمسی)">
|
||||
<JalaliDatePicker v-model="renewForm.chargedAt" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="مدت">
|
||||
<a-select
|
||||
v-model:value="renewForm.planMonths"
|
||||
size="large"
|
||||
:options="planSelectOptions"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="دوره رایگان">
|
||||
<a-switch v-model:checked="renewForm.isFree" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-form-item v-if="!renewForm.isFree" label="مبلغ (ریال)">
|
||||
<MoneyInput v-model:value="renewForm.priceRials" placeholder="مثلاً 1,500,000" />
|
||||
</a-form-item>
|
||||
<p v-else class="field-hint">
|
||||
دوره رایگان — مبلغ صفر. مدت ۱ / ۳ / ۶ / ۱۲ ماه میتواند رایگان باشد.
|
||||
</p>
|
||||
<a-form-item label="یادداشت">
|
||||
<a-input v-model:value="renewForm.note" />
|
||||
</a-form-item>
|
||||
<a-button type="primary" size="large" :loading="renewing" @click="renewSupport">
|
||||
ثبت شارژ / تمدید
|
||||
</a-button>
|
||||
</a-form>
|
||||
</section>
|
||||
|
||||
<section class="form-section">
|
||||
<h3>تاریخچه شارژ</h3>
|
||||
<div v-if="selected.charges?.length" class="charge-list">
|
||||
<article v-for="item in selected.charges" :key="item.id" class="charge-item">
|
||||
<div>
|
||||
<strong v-if="item.isFree">رایگان</strong>
|
||||
<strong v-else>{{ formatMoney(item.priceRials) }}</strong>
|
||||
<a-tag v-if="item.isFree" color="blue" class="free-tag">رایگان</a-tag>
|
||||
<span> · {{ item.planMonths }} ماهه</span>
|
||||
</div>
|
||||
<div class="charge-meta">
|
||||
از {{ formatIranDate(item.chargedAt) }} تا {{ formatIranDate(item.expiresAt) }}
|
||||
<template v-if="item.note"> · {{ item.note }}</template>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div v-else class="hint-box">هنوز شارژی ثبت نشده است.</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div v-else class="detail empty-detail">
|
||||
پشتیبانیای انتخاب نشده است.
|
||||
</div>
|
||||
</div>
|
||||
</a-spin>
|
||||
|
||||
<a-modal
|
||||
v-model:open="showCreate"
|
||||
title="افزودن پشتیبانی"
|
||||
ok-text="ثبت"
|
||||
cancel-text="انصراف"
|
||||
:confirm-loading="creating"
|
||||
@ok="createSupport"
|
||||
>
|
||||
<a-form layout="vertical" style="margin-top: 12px">
|
||||
<a-form-item label="نام پشتیبانی" required>
|
||||
<a-input v-model:value="createForm.name" size="large" placeholder="نام پشتیبانی" />
|
||||
</a-form-item>
|
||||
<a-form-item label="مشخصات">
|
||||
<a-textarea v-model:value="createForm.specs" :rows="2" />
|
||||
</a-form-item>
|
||||
<a-row :gutter="12">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="تاریخ شارژ">
|
||||
<JalaliDatePicker v-model="createForm.chargedAt" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="مدت">
|
||||
<a-select
|
||||
v-model:value="createForm.planMonths"
|
||||
size="large"
|
||||
:options="planSelectOptions"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-form-item label="دوره رایگان">
|
||||
<a-switch v-model:checked="createForm.isFree" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="!createForm.isFree" label="قیمت دوره (ریال)">
|
||||
<MoneyInput v-model:value="createForm.priceRials" placeholder="مثلاً 1,500,000" />
|
||||
</a-form-item>
|
||||
<p v-else class="field-hint">
|
||||
دوره رایگان — مبلغ صفر. مدت ۱ / ۳ / ۶ / ۱۲ ماه میتواند رایگان باشد.
|
||||
</p>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(200px, 260px) 1fr;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.support-list {
|
||||
border-radius: 18px;
|
||||
background: #fff;
|
||||
border: 1px solid #e8eef5;
|
||||
padding: 12px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.list-head {
|
||||
font-weight: 800;
|
||||
margin-bottom: 4px;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.support-card {
|
||||
appearance: none;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 14px;
|
||||
padding: 12px;
|
||||
text-align: right;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
color: #17324d;
|
||||
}
|
||||
|
||||
.support-card.active {
|
||||
border-color: #3b6fd4;
|
||||
}
|
||||
|
||||
.support-card.inactive {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.support-card.healthy { background: linear-gradient(135deg, #dff8ea, #c3efd7); }
|
||||
.support-card.warning { background: linear-gradient(135deg, #fff4c7, #ffe39a); }
|
||||
.support-card.critical { background: linear-gradient(135deg, #ffd8d8, #ffb9b9); }
|
||||
.support-card.unknown { background: linear-gradient(135deg, #eef2f7, #dde5ef); }
|
||||
|
||||
.free-tag {
|
||||
margin-inline-start: 6px;
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.empty-hint,
|
||||
.empty-detail,
|
||||
.hint-box {
|
||||
color: #94a3b8;
|
||||
padding: 14px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.detail {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.status-banner {
|
||||
border-radius: 18px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 14px;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.status-banner.healthy { background: #dff8ea; color: #0f7a45; }
|
||||
.status-banner.warning { background: #fff4c7; color: #9a6b00; }
|
||||
.status-banner.critical { background: #ffd8d8; color: #b42318; }
|
||||
.status-banner.unknown { background: #eef2f7; color: #64748b; }
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
margin: 0 0 8px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.settings-save {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.renew-section {
|
||||
margin-top: 28px;
|
||||
padding-top: 22px;
|
||||
}
|
||||
|
||||
.inline-hint {
|
||||
margin-right: 10px;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.charge-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.charge-item {
|
||||
border-radius: 14px;
|
||||
background: #f8fafc;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.charge-meta {
|
||||
margin-top: 4px;
|
||||
color: #64748b;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
@@ -49,6 +49,16 @@ const router = createRouter({
|
||||
name: 'plugin-tenant',
|
||||
component: () => import('@/pages/PluginTenantPage.vue'),
|
||||
},
|
||||
{
|
||||
path: 'support',
|
||||
name: 'support-panel',
|
||||
component: () => import('@/pages/SupportPanelPage.vue'),
|
||||
},
|
||||
{
|
||||
path: 'support/:tenantId',
|
||||
name: 'support-tenant',
|
||||
component: () => import('@/pages/SupportTenantPage.vue'),
|
||||
},
|
||||
{
|
||||
path: 'admins',
|
||||
name: 'admins',
|
||||
|
||||
+3
-1
@@ -143,7 +143,7 @@ button, input, textarea, select,
|
||||
|
||||
@media (min-width: 1100px) {
|
||||
.dash-modules {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,6 +185,7 @@ button, input, textarea, select,
|
||||
.module-card.sms .icon-wrap { background: linear-gradient(145deg, #9b7cff, #7c5cfc); }
|
||||
.module-card.server .icon-wrap { background: linear-gradient(145deg, #ff8a8a, #ff6b6b); }
|
||||
.module-card.plugin .icon-wrap { background: linear-gradient(145deg, #5ec8e8, #2aa8cc); }
|
||||
.module-card.support .icon-wrap { background: linear-gradient(145deg, #60a5fa, #3b82f6); }
|
||||
.module-card.admins .icon-wrap { background: linear-gradient(145deg, #5b8def, #3b6fd4); }
|
||||
.module-card.hamkar .icon-wrap { background: linear-gradient(145deg, #3ad4b0, #20c997); }
|
||||
.module-card.khanegi .icon-wrap { background: linear-gradient(145deg, #ffc86b, #ffb84d); }
|
||||
@@ -231,6 +232,7 @@ button, input, textarea, select,
|
||||
.module-card.sms .progress > span { background: #7c5cfc; width: 72%; }
|
||||
.module-card.server .progress > span { background: #ff6b6b; width: 54%; }
|
||||
.module-card.plugin .progress > span { background: #2aa8cc; width: 58%; }
|
||||
.module-card.support .progress > span { background: #3b82f6; width: 62%; }
|
||||
.module-card.admins .progress > span { background: #3b6fd4; width: 66%; }
|
||||
.module-card.hamkar .progress > span { background: #20c997; width: 80%; }
|
||||
.module-card.khanegi .progress > span { background: #ffb84d; width: 68%; }
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/** Display/parse helpers for rial money inputs with thousand separators. */
|
||||
|
||||
export function toGroupedDisplay(value: number | null | undefined) {
|
||||
if (value == null || Number.isNaN(Number(value))) return ''
|
||||
const n = Math.trunc(Number(value))
|
||||
const neg = n < 0
|
||||
const digits = String(Math.abs(n))
|
||||
const grouped = digits.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
||||
return neg ? `-${grouped}` : grouped
|
||||
}
|
||||
|
||||
export function fromGroupedInput(raw: string, allowNegative = false) {
|
||||
const text = String(raw ?? '')
|
||||
const neg = allowNegative && text.trim().startsWith('-')
|
||||
const digits = text.replace(/[^\d]/g, '')
|
||||
if (!digits) return 0
|
||||
const n = Number(digits)
|
||||
return neg ? -n : n
|
||||
}
|
||||
|
||||
export function formatNumber(value: number | null | undefined) {
|
||||
if (value == null) return '—'
|
||||
return new Intl.NumberFormat('fa-IR').format(value)
|
||||
}
|
||||
|
||||
export function formatMoney(value: number | null | undefined) {
|
||||
if (value == null) return '—'
|
||||
return `${formatNumber(value)} ریال`
|
||||
}
|
||||
Reference in New Issue
Block a user