Add support panel UI, search filters, and money thousand separators.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-08 11:20:26 +03:30
co-authored by Cursor
parent ab4d9ef133
commit d0d621502f
13 changed files with 1169 additions and 132 deletions
+54
View File
@@ -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
View File
@@ -5,6 +5,7 @@ import {
MessageOutlined, MessageOutlined,
CloudServerOutlined, CloudServerOutlined,
AppstoreAddOutlined, AppstoreAddOutlined,
CustomerServiceOutlined,
TeamOutlined, TeamOutlined,
HomeOutlined, HomeOutlined,
UserOutlined, UserOutlined,
@@ -52,12 +53,15 @@ type ExpiringPlugin = {
} }
} }
type ExpiringSupport = ExpiringPlugin
const router = useRouter() const router = useRouter()
const loading = ref(true) const loading = ref(true)
const tenants = ref<Tenant[]>([]) const tenants = ref<Tenant[]>([])
const smsTenants = ref<Tenant[]>([]) const smsTenants = ref<Tenant[]>([])
const expiringServers = ref<Tenant[]>([]) const expiringServers = ref<Tenant[]>([])
const expiringPlugins = ref<ExpiringPlugin[]>([]) const expiringPlugins = ref<ExpiringPlugin[]>([])
const expiringSupports = ref<ExpiringSupport[]>([])
const hamkarCount = computed(() => tenants.value.filter((t) => t.product === 'hamkar').length) const hamkarCount = computed(() => tenants.value.filter((t) => t.product === 'hamkar').length)
const khanegiCount = computed(() => tenants.value.filter((t) => t.product === 'khanegi').length) const khanegiCount = computed(() => tenants.value.filter((t) => t.product === 'khanegi').length)
@@ -96,16 +100,19 @@ const modules = computed(() => [
async function load() { async function load() {
loading.value = true loading.value = true
try { try {
const [tenantsRes, lowestRes, expiringRes, pluginExpiringRes] = await Promise.all([ const [tenantsRes, lowestRes, expiringRes, pluginExpiringRes, supportExpiringRes] =
await Promise.all([
api.get('/tenants'), api.get('/tenants'),
api.get('/sms/lowest', { params: { limit: 5 } }), api.get('/sms/lowest', { params: { limit: 5 } }),
api.get('/servers/expiring', { params: { limit: 5 } }), api.get('/servers/expiring', { params: { limit: 5 } }),
api.get('/plugins/expiring', { params: { limit: 5 } }), api.get('/plugins/expiring', { params: { limit: 5 } }),
api.get('/support/expiring', { params: { limit: 5 } }),
]) ])
tenants.value = tenantsRes.data tenants.value = tenantsRes.data
smsTenants.value = lowestRes.data smsTenants.value = lowestRes.data
expiringServers.value = expiringRes.data expiringServers.value = expiringRes.data
expiringPlugins.value = pluginExpiringRes.data expiringPlugins.value = pluginExpiringRes.data
expiringSupports.value = supportExpiringRes.data
} catch (e: any) { } catch (e: any) {
message.error(e?.response?.data?.message || 'خطا در دریافت آمار') message.error(e?.response?.data?.message || 'خطا در دریافت آمار')
} finally { } finally {
@@ -137,6 +144,14 @@ function openPluginPanel(tenantId?: string) {
router.push({ name: 'plugin-panel' }) 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) { function smsTone(level?: BalanceLevel) {
switch (level) { switch (level) {
case 'healthy': case 'healthy':
@@ -173,7 +188,7 @@ onMounted(load)
<div class="login-kicker">داشبورد کنترل</div> <div class="login-kicker">داشبورد کنترل</div>
<h1 class="page-title">سلام، خوش آمدید</h1> <h1 class="page-title">سلام، خوش آمدید</h1>
<p class="page-sub"> <p class="page-sub">
از اینجا به پنل پیامکی، سرور، افزونه، مدیران و نسخه‌های همکار / خانگی دسترسی دارید. از اینجا به پنل پیامکی، سرور، افزونه، پشتیبانی، مدیران و نسخه‌های همکار / خانگی دسترسی دارید.
</p> </p>
<a-spin :spinning="loading"> <a-spin :spinning="loading">
@@ -285,6 +300,40 @@ onMounted(load)
</div> </div>
</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 <button
v-for="item in modules" v-for="item in modules"
:key="item.key" :key="item.key"
+29 -4
View File
@@ -2,7 +2,7 @@
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue' 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' import api from '@/api/http'
type ExpiryLevel = 'healthy' | 'warning' | 'critical' | 'unknown' | 'none' type ExpiryLevel = 'healthy' | 'warning' | 'critical' | 'unknown' | 'none'
@@ -12,6 +12,7 @@ type TenantRow = {
product: 'hamkar' | 'khanegi' product: 'hamkar' | 'khanegi'
nameFa: string nameFa: string
domain: string domain: string
slug?: string
pluginCount: number pluginCount: number
summary: { summary: {
expiryLevel: ExpiryLevel expiryLevel: ExpiryLevel
@@ -22,10 +23,22 @@ type TenantRow = {
const router = useRouter() const router = useRouter()
const loadingList = ref(false) const loadingList = ref(false)
const tenants = ref<TenantRow[]>([]) 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(() => ({ const groupedTenants = computed(() => ({
hamkar: tenants.value.filter((t) => t.product === 'hamkar'), hamkar: filteredTenants.value.filter((t) => t.product === 'hamkar'),
khanegi: tenants.value.filter((t) => t.product === 'khanegi'), khanegi: filteredTenants.value.filter((t) => t.product === 'khanegi'),
})) }))
function toneClass(level?: ExpiryLevel) { function toneClass(level?: ExpiryLevel) {
@@ -60,11 +73,23 @@ onMounted(loadTenants)
<div style="flex: 1"> <div style="flex: 1">
<h1 class="page-title">پنل افزونه</h1> <h1 class="page-title">پنل افزونه</h1>
<p class="page-sub"> <p class="page-sub">
اشتراک افزونه‌های نسخه‌ها — هر نسخه می‌تواند چند افزونه داشته باشد یا هیچ‌کدام اشتراک افزونه‌های نسخه‌ها — هر نسخه می‌تواند چند افزونه داشته باشد یا هیچ‌کدام ·
{{ filteredTenants.length }} مورد
</p> </p>
</div> </div>
</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"> <div class="legend">
<span class="chip healthy">اعتبار کافی</span> <span class="chip healthy">اعتبار کافی</span>
<span class="chip warning">نزدیک هشدار</span> <span class="chip warning">نزدیک هشدار</span>
+5 -26
View File
@@ -5,7 +5,9 @@ import { message, Modal } from 'ant-design-vue'
import { ArrowRightOutlined, PlusOutlined, DeleteOutlined } from '@ant-design/icons-vue' import { ArrowRightOutlined, PlusOutlined, DeleteOutlined } from '@ant-design/icons-vue'
import api from '@/api/http' import api from '@/api/http'
import JalaliDatePicker from '@/components/JalaliDatePicker.vue' import JalaliDatePicker from '@/components/JalaliDatePicker.vue'
import MoneyInput from '@/components/MoneyInput.vue'
import { formatIranDate, todayIranDateInput, iranDateInputToIso } from '@/utils/datetime' import { formatIranDate, todayIranDateInput, iranDateInputToIso } from '@/utils/datetime'
import { formatMoney } from '@/utils/money'
type ExpiryLevel = 'healthy' | 'warning' | 'critical' | 'unknown' | 'none' type ExpiryLevel = 'healthy' | 'warning' | 'critical' | 'unknown' | 'none'
@@ -96,11 +98,6 @@ function toneClass(level?: ExpiryLevel) {
return level && level !== 'none' ? level : 'unknown' 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) { function fillForm(plugin: PluginItem) {
Object.assign(form, { Object.assign(form, {
name: plugin.name || '', name: plugin.name || '',
@@ -352,13 +349,7 @@ onMounted(loadDetail)
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
<a-form-item label="قیمت دوره (ریال)"> <a-form-item label="قیمت دوره (ریال)">
<a-input-number <MoneyInput v-model:value="form.priceRials" placeholder="مثلاً 1,500,000" />
v-model:value="form.priceRials"
style="width: 100%"
size="large"
:min="0"
:step="100000"
/>
</a-form-item> </a-form-item>
</a-col> </a-col>
</a-row> </a-row>
@@ -404,13 +395,7 @@ onMounted(loadDetail)
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
<a-form-item label="مبلغ (ریال)"> <a-form-item label="مبلغ (ریال)">
<a-input-number <MoneyInput v-model:value="renewForm.priceRials" placeholder="مثلاً 1,500,000" />
v-model:value="renewForm.priceRials"
style="width: 100%"
size="large"
:min="0"
:step="100000"
/>
</a-form-item> </a-form-item>
</a-col> </a-col>
</a-row> </a-row>
@@ -479,13 +464,7 @@ onMounted(loadDetail)
</a-col> </a-col>
</a-row> </a-row>
<a-form-item label="قیمت دوره (ریال)"> <a-form-item label="قیمت دوره (ریال)">
<a-input-number <MoneyInput v-model:value="createForm.priceRials" placeholder="مثلاً 1,500,000" />
v-model:value="createForm.priceRials"
style="width: 100%"
size="large"
:min="0"
:step="100000"
/>
</a-form-item> </a-form-item>
</a-form> </a-form>
</a-modal> </a-modal>
+34 -4
View File
@@ -2,7 +2,7 @@
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue' 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' import api from '@/api/http'
type ExpiryLevel = 'dedicated' | 'healthy' | 'warning' | 'critical' | 'unknown' type ExpiryLevel = 'dedicated' | 'healthy' | 'warning' | 'critical' | 'unknown'
@@ -27,10 +27,28 @@ type TenantRow = {
const router = useRouter() const router = useRouter()
const loadingList = ref(false) const loadingList = ref(false)
const tenants = ref<TenantRow[]>([]) 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(() => ({ const groupedTenants = computed(() => ({
hamkar: tenants.value.filter((t) => t.product === 'hamkar'), hamkar: filteredTenants.value.filter((t) => t.product === 'hamkar'),
khanegi: tenants.value.filter((t) => t.product === 'khanegi'), khanegi: filteredTenants.value.filter((t) => t.product === 'khanegi'),
})) }))
function toneClass(level?: ExpiryLevel) { function toneClass(level?: ExpiryLevel) {
@@ -65,11 +83,23 @@ onMounted(loadTenants)
<div style="flex: 1"> <div style="flex: 1">
<h1 class="page-title">پنل سرور</h1> <h1 class="page-title">پنل سرور</h1>
<p class="page-sub"> <p class="page-sub">
هر نسخه می‌تواند چند سرور داشته باشد — وضعیت، شارژ و هشدار نزدیک پایان اعتبار هر نسخه می‌تواند چند سرور داشته باشد — وضعیت، شارژ و هشدار نزدیک پایان اعتبار ·
{{ filteredTenants.length }} مورد
</p> </p>
</div> </div>
</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"> <div class="legend">
<span class="chip dedicated">اختصاصی</span> <span class="chip dedicated">اختصاصی</span>
<span class="chip healthy">اعتبار کافی — سبز</span> <span class="chip healthy">اعتبار کافی — سبز</span>
+4 -19
View File
@@ -5,7 +5,9 @@ import { message, Modal } from 'ant-design-vue'
import { ArrowRightOutlined, PlusOutlined, DeleteOutlined } from '@ant-design/icons-vue' import { ArrowRightOutlined, PlusOutlined, DeleteOutlined } from '@ant-design/icons-vue'
import api from '@/api/http' import api from '@/api/http'
import JalaliDatePicker from '@/components/JalaliDatePicker.vue' import JalaliDatePicker from '@/components/JalaliDatePicker.vue'
import MoneyInput from '@/components/MoneyInput.vue'
import { formatIranDate, todayIranDateInput, iranDateInputToIso } from '@/utils/datetime' import { formatIranDate, todayIranDateInput, iranDateInputToIso } from '@/utils/datetime'
import { formatMoney } from '@/utils/money'
type ExpiryLevel = 'dedicated' | 'healthy' | 'warning' | 'critical' | 'unknown' type ExpiryLevel = 'dedicated' | 'healthy' | 'warning' | 'critical' | 'unknown'
@@ -95,11 +97,6 @@ function toneClass(level?: ExpiryLevel) {
return level || 'unknown' 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) { function applyAccount(row: ServerRow | null) {
if (!row) { if (!row) {
summary.value = null summary.value = null
@@ -378,13 +375,7 @@ onMounted(loadDetail)
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
<a-form-item label="قیمت دوره (ریال)"> <a-form-item label="قیمت دوره (ریال)">
<a-input-number <MoneyInput v-model:value="form.priceRials" placeholder="مثلاً 1,500,000" />
v-model:value="form.priceRials"
style="width: 100%"
size="large"
:min="0"
:step="100000"
/>
</a-form-item> </a-form-item>
</a-col> </a-col>
</a-row> </a-row>
@@ -433,13 +424,7 @@ onMounted(loadDetail)
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
<a-form-item label="مبلغ (ریال)"> <a-form-item label="مبلغ (ریال)">
<a-input-number <MoneyInput v-model:value="renewForm.priceRials" placeholder="مثلاً 1,500,000" />
v-model:value="renewForm.priceRials"
style="width: 100%"
size="large"
:min="0"
:step="100000"
/>
</a-form-item> </a-form-item>
</a-col> </a-col>
</a-row> </a-row>
+38 -4
View File
@@ -2,7 +2,7 @@
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue' 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' import api from '@/api/http'
type BalanceLevel = 'healthy' | 'warning' | 'critical' | 'unknown' type BalanceLevel = 'healthy' | 'warning' | 'critical' | 'unknown'
@@ -23,10 +23,22 @@ type TenantRow = {
const router = useRouter() const router = useRouter()
const loadingList = ref(false) const loadingList = ref(false)
const tenants = ref<TenantRow[]>([]) 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(() => ({ const groupedTenants = computed(() => ({
hamkar: tenants.value.filter((t) => t.product === 'hamkar'), hamkar: filteredTenants.value.filter((t) => t.product === 'hamkar'),
khanegi: tenants.value.filter((t) => t.product === 'khanegi'), khanegi: filteredTenants.value.filter((t) => t.product === 'khanegi'),
})) }))
function toneClass(level?: BalanceLevel) { function toneClass(level?: BalanceLevel) {
@@ -60,10 +72,24 @@ onMounted(loadTenants)
</a-button> </a-button>
<div style="flex: 1"> <div style="flex: 1">
<h1 class="page-title">پنل پیامکی</h1> <h1 class="page-title">پنل پیامکی</h1>
<p class="page-sub">یک نسخه را انتخاب کنید تا کیف پول و تنظیمات SMS همان نسخه باز شود</p> <p class="page-sub">
یک نسخه را انتخاب کنید تا کیف پول و تنظیمات SMS همان نسخه باز شود ·
{{ filteredTenants.length }} مورد
</p>
</div> </div>
</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 <a-alert
type="info" type="info"
show-icon show-icon
@@ -100,6 +126,7 @@ onMounted(loadTenants)
<span>{{ t.sms?.balanceLabel }}</span> <span>{{ t.sms?.balanceLabel }}</span>
<small>{{ t.isBlocked ? 'مسدود' : t.domain }}</small> <small>{{ t.isBlocked ? 'مسدود' : t.domain }}</small>
</button> </button>
<div v-if="!groupedTenants.hamkar.length" class="empty-hint">نسخه‌ای نیست</div>
</div> </div>
</section> </section>
@@ -121,6 +148,7 @@ onMounted(loadTenants)
<span>{{ t.sms?.balanceLabel }}</span> <span>{{ t.sms?.balanceLabel }}</span>
<small>{{ t.isBlocked ? 'مسدود' : t.domain }}</small> <small>{{ t.isBlocked ? 'مسدود' : t.domain }}</small>
</button> </button>
<div v-if="!groupedTenants.khanegi.length" class="empty-hint">نسخه‌ای نیست</div>
</div> </div>
</section> </section>
</div> </div>
@@ -198,4 +226,10 @@ onMounted(loadTenants)
box-shadow: 0 10px 22px rgba(23, 50, 77, 0.08); box-shadow: 0 10px 22px rgba(23, 50, 77, 0.08);
} }
.tenant-card small { direction: ltr; text-align: right; } .tenant-card small { direction: ltr; text-align: right; }
.empty-hint {
grid-column: 1 / -1;
color: #94a3b8;
padding: 12px;
font-size: 0.85rem;
}
</style> </style>
+8 -67
View File
@@ -6,7 +6,9 @@ import { ArrowRightOutlined } from '@ant-design/icons-vue'
import type { TableColumnsType } from 'ant-design-vue' import type { TableColumnsType } from 'ant-design-vue'
import api from '@/api/http' import api from '@/api/http'
import JalaliDatePicker from '@/components/JalaliDatePicker.vue' import JalaliDatePicker from '@/components/JalaliDatePicker.vue'
import MoneyInput from '@/components/MoneyInput.vue'
import { formatIranDateTime, todayIranDateInput } from '@/utils/datetime' import { formatIranDateTime, todayIranDateInput } from '@/utils/datetime'
import { formatMoney, formatNumber } from '@/utils/money'
type BalanceLevel = 'healthy' | 'warning' | 'critical' | 'unknown' type BalanceLevel = 'healthy' | 'warning' | 'critical' | 'unknown'
@@ -96,42 +98,6 @@ const pageSize = 20
const tenantId = computed(() => String(route.params.tenantId || '')) 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) { function toneClass(level?: BalanceLevel) {
return level || 'unknown' return level || 'unknown'
} }
@@ -478,13 +444,7 @@ onMounted(async () => {
<div class="subcard sky"> <div class="subcard sky">
<h3>تعرفه پیامک</h3> <h3>تعرفه پیامک</h3>
<div class="inline-actions"> <div class="inline-actions">
<a-input <MoneyInput v-model:value="form.pricePerPartRials" placeholder="قیمت هر پارت" />
size="large"
dir="ltr"
class="money-input"
:value="toGroupedDisplay(form.pricePerPartRials)"
@update:value="(v: string | number) => onGroupedMoneyInput((n) => (form.pricePerPartRials = n), v)"
/>
<a-button type="primary" size="large" :loading="saving" @click="savePrice">ذخیره قیمت</a-button> <a-button type="primary" size="large" :loading="saving" @click="savePrice">ذخیره قیمت</a-button>
</div> </div>
</div> </div>
@@ -492,13 +452,10 @@ onMounted(async () => {
<div class="subcard emerald"> <div class="subcard emerald">
<h3>شارژ کیف پول</h3> <h3>شارژ کیف پول</h3>
<div class="charge-grid"> <div class="charge-grid">
<a-input <MoneyInput
size="large" v-model:value="chargeForm.amountRials"
dir="ltr" allow-negative
class="money-input"
placeholder="مبلغ (ریال)" 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-input v-model:value="chargeForm.note" size="large" placeholder="توضیح (اختیاری)" />
<a-button type="primary" size="large" :loading="charging" @click="openChargeConfirm"> <a-button type="primary" size="large" :loading="charging" @click="openChargeConfirm">
@@ -542,23 +499,11 @@ onMounted(async () => {
<div class="threshold-grid"> <div class="threshold-grid">
<label> <label>
<span class="tone-label"><i class="dot critical" /> آستانه قرمز</span> <span class="tone-label"><i class="dot critical" /> آستانه قرمز</span>
<a-input <MoneyInput v-model:value="form.alertCriticalRials" />
size="large"
dir="ltr"
class="money-input"
:value="toGroupedDisplay(form.alertCriticalRials)"
@update:value="(v: string | number) => onGroupedMoneyInput((n) => (form.alertCriticalRials = n), v)"
/>
</label> </label>
<label> <label>
<span class="tone-label"><i class="dot warning" /> آستانه زرد</span> <span class="tone-label"><i class="dot warning" /> آستانه زرد</span>
<a-input <MoneyInput v-model:value="form.alertWarningRials" />
size="large"
dir="ltr"
class="money-input"
:value="toGroupedDisplay(form.alertWarningRials)"
@update:value="(v: string | number) => onGroupedMoneyInput((n) => (form.alertWarningRials = n), v)"
/>
</label> </label>
<a-button type="primary" size="large" :loading="saving" @click="saveThresholds">ذخیره آستانه‌ها</a-button> <a-button type="primary" size="large" :loading="saving" @click="saveThresholds">ذخیره آستانه‌ها</a-button>
</div> </div>
@@ -1007,10 +952,6 @@ onMounted(async () => {
font-size: 0.78rem; font-size: 0.78rem;
} }
.money-input :deep(input) {
font-variant-numeric: tabular-nums;
}
.money-input, .money-input,
.filter-row :deep(.jalali-date-field), .filter-row :deep(.jalali-date-field),
.filter-row :deep(.ant-input), .filter-row :deep(.ant-input),
+234
View File
@@ -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>
+665
View File
@@ -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>
+10
View File
@@ -49,6 +49,16 @@ const router = createRouter({
name: 'plugin-tenant', name: 'plugin-tenant',
component: () => import('@/pages/PluginTenantPage.vue'), 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', path: 'admins',
name: 'admins', name: 'admins',
+3 -1
View File
@@ -143,7 +143,7 @@ button, input, textarea, select,
@media (min-width: 1100px) { @media (min-width: 1100px) {
.dash-modules { .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.sms .icon-wrap { background: linear-gradient(145deg, #9b7cff, #7c5cfc); }
.module-card.server .icon-wrap { background: linear-gradient(145deg, #ff8a8a, #ff6b6b); } .module-card.server .icon-wrap { background: linear-gradient(145deg, #ff8a8a, #ff6b6b); }
.module-card.plugin .icon-wrap { background: linear-gradient(145deg, #5ec8e8, #2aa8cc); } .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.admins .icon-wrap { background: linear-gradient(145deg, #5b8def, #3b6fd4); }
.module-card.hamkar .icon-wrap { background: linear-gradient(145deg, #3ad4b0, #20c997); } .module-card.hamkar .icon-wrap { background: linear-gradient(145deg, #3ad4b0, #20c997); }
.module-card.khanegi .icon-wrap { background: linear-gradient(145deg, #ffc86b, #ffb84d); } .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.sms .progress > span { background: #7c5cfc; width: 72%; }
.module-card.server .progress > span { background: #ff6b6b; width: 54%; } .module-card.server .progress > span { background: #ff6b6b; width: 54%; }
.module-card.plugin .progress > span { background: #2aa8cc; width: 58%; } .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.admins .progress > span { background: #3b6fd4; width: 66%; }
.module-card.hamkar .progress > span { background: #20c997; width: 80%; } .module-card.hamkar .progress > span { background: #20c997; width: 80%; }
.module-card.khanegi .progress > span { background: #ffb84d; width: 68%; } .module-card.khanegi .progress > span { background: #ffb84d; width: 68%; }
+29
View File
@@ -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)} ریال`
}