Support multiple servers per tenant in server panel UI.

Add server chips, create/delete flows, and show server count or name on list and home expiring cards.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-06 19:23:38 +03:30
co-authored by Cursor
parent 7889a7905f
commit 99c219e57e
3 changed files with 400 additions and 165 deletions
+9 -2
View File
@@ -26,6 +26,7 @@ type ServerSummary = {
expiryLevel: ExpiryLevel expiryLevel: ExpiryLevel
expiryLabel: string expiryLabel: string
isDedicated?: boolean isDedicated?: boolean
serverName?: string | null
} }
type Tenant = { type Tenant = {
@@ -33,6 +34,7 @@ type Tenant = {
product: 'hamkar' | 'khanegi' product: 'hamkar' | 'khanegi'
slug?: string slug?: string
nameFa?: string nameFa?: string
serverAccountId?: string
sms?: SmsSummary sms?: SmsSummary
server?: ServerSummary server?: ServerSummary
} }
@@ -221,14 +223,19 @@ onMounted(load)
<div class="sms-mini-grid"> <div class="sms-mini-grid">
<button <button
v-for="tenant in expiringServers" v-for="tenant in expiringServers"
:key="tenant.id" :key="`${tenant.id}-${tenant.serverAccountId || tenant.server?.serverName || ''}`"
type="button" type="button"
class="sms-mini-card" class="sms-mini-card"
:class="serverTone(tenant.server?.expiryLevel)" :class="serverTone(tenant.server?.expiryLevel)"
@click.stop="openServerPanel(tenant.id)" @click.stop="openServerPanel(tenant.id)"
> >
<strong>{{ tenant.nameFa }}</strong> <strong>{{ tenant.nameFa }}</strong>
<span>{{ tenant.product === 'hamkar' ? 'همکار' : 'خانگی' }}</span> <span>
{{ tenant.product === 'hamkar' ? 'همکار' : 'خانگی' }}
<template v-if="tenant.server?.serverName">
· {{ tenant.server.serverName }}
</template>
</span>
<small>{{ tenant.server?.expiryLabel || 'نامشخص' }}</small> <small>{{ tenant.server?.expiryLabel || 'نامشخص' }}</small>
</button> </button>
<div v-if="!expiringServers.length" class="sms-mini-empty"> <div v-if="!expiringServers.length" class="sms-mini-empty">
+13 -3
View File
@@ -13,12 +13,14 @@ type TenantRow = {
slug: string slug: string
nameFa: string nameFa: string
domain: string domain: string
serversCount?: number
server: { server: {
isDedicated: boolean isDedicated: boolean
expiryLevel: ExpiryLevel expiryLevel: ExpiryLevel
expiryLabel: string expiryLabel: string
serverName?: string | null serverName?: string | null
planLabel?: string | null planLabel?: string | null
serversCount?: number
} }
} }
@@ -62,7 +64,9 @@ 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">وضعیت اختصاصی یا مدیریت‌شده، شارژ و هشدار نزدیک پایان اعتبار</p> <p class="page-sub">
هر نسخه می‌تواند چند سرور داشته باشد — وضعیت، شارژ و هشدار نزدیک پایان اعتبار
</p>
</div> </div>
</div> </div>
@@ -94,7 +98,10 @@ onMounted(loadTenants)
> >
<strong>{{ t.nameFa }}</strong> <strong>{{ t.nameFa }}</strong>
<span>{{ t.server?.expiryLabel }}</span> <span>{{ t.server?.expiryLabel }}</span>
<small v-if="t.server?.serverName">{{ t.server.serverName }}</small> <small v-if="(t.serversCount || 0) > 1">
{{ t.serversCount }} سرور
</small>
<small v-else-if="t.server?.serverName">{{ t.server.serverName }}</small>
<small v-else>{{ t.domain }}</small> <small v-else>{{ t.domain }}</small>
</button> </button>
<div v-if="!groupedTenants.hamkar.length" class="empty-hint">نسخه‌ای نیست</div> <div v-if="!groupedTenants.hamkar.length" class="empty-hint">نسخه‌ای نیست</div>
@@ -117,7 +124,10 @@ onMounted(loadTenants)
> >
<strong>{{ t.nameFa }}</strong> <strong>{{ t.nameFa }}</strong>
<span>{{ t.server?.expiryLabel }}</span> <span>{{ t.server?.expiryLabel }}</span>
<small v-if="t.server?.serverName">{{ t.server.serverName }}</small> <small v-if="(t.serversCount || 0) > 1">
{{ t.serversCount }} سرور
</small>
<small v-else-if="t.server?.serverName">{{ t.server.serverName }}</small>
<small v-else>{{ t.domain }}</small> <small v-else>{{ t.domain }}</small>
</button> </button>
<div v-if="!groupedTenants.khanegi.length" class="empty-hint">نسخه‌ای نیست</div> <div v-if="!groupedTenants.khanegi.length" class="empty-hint">نسخه‌ای نیست</div>
+378 -160
View File
@@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue' import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { message } from 'ant-design-vue' import { message, Modal } from 'ant-design-vue'
import { ArrowRightOutlined } 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 { formatIranDate, todayIranDateInput, iranDateInputToIso } from '@/utils/datetime' import { formatIranDate, todayIranDateInput, iranDateInputToIso } from '@/utils/datetime'
@@ -10,12 +10,29 @@ import { formatIranDate, todayIranDateInput, iranDateInputToIso } from '@/utils/
type ExpiryLevel = 'dedicated' | 'healthy' | 'warning' | 'critical' | 'unknown' type ExpiryLevel = 'dedicated' | 'healthy' | 'warning' | 'critical' | 'unknown'
type Summary = { type Summary = {
id?: string
isDedicated: boolean isDedicated: boolean
expiryLevel: ExpiryLevel expiryLevel: ExpiryLevel
expiryLabel: string expiryLabel: string
daysLeft: number | null daysLeft: number | null
planLabel: string | null planLabel: string | null
expiresAt: string | null expiresAt: string | null
serverName?: string | null
}
type ServerRow = {
account: {
id: string
isDedicated: boolean
serverName: string | null
specs: string | null
priceRials: number | null
chargedAt: string | null
planMonths: number | null
description: string | null
alertDaysBefore: number
}
summary: Summary
} }
const route = useRoute() const route = useRoute()
@@ -23,6 +40,8 @@ const router = useRouter()
const loading = ref(false) const loading = ref(false)
const saving = ref(false) const saving = ref(false)
const renewing = ref(false) const renewing = ref(false)
const creating = ref(false)
const deleting = ref(false)
const tenant = ref<{ const tenant = ref<{
id: string id: string
product: 'hamkar' | 'khanegi' product: 'hamkar' | 'khanegi'
@@ -30,6 +49,8 @@ const tenant = ref<{
nameFa: string nameFa: string
domain: string domain: string
} | null>(null) } | null>(null)
const servers = ref<ServerRow[]>([])
const selectedId = ref<string | null>(null)
const summary = ref<Summary | null>(null) const summary = ref<Summary | null>(null)
const charges = ref<any[]>([]) const charges = ref<any[]>([])
const planOptions = ref<{ months: number; label: string }[]>([]) const planOptions = ref<{ months: number; label: string }[]>([])
@@ -54,6 +75,10 @@ const renewForm = reactive({
const tenantId = computed(() => String(route.params.tenantId || '')) const tenantId = computed(() => String(route.params.tenantId || ''))
const selectedServer = computed(
() => servers.value.find((s) => s.account.id === selectedId.value) || null,
)
const planSelectOptions = computed(() => const planSelectOptions = computed(() =>
(planOptions.value.length (planOptions.value.length
? planOptions.value ? planOptions.value
@@ -75,28 +100,55 @@ function formatMoney(value: number | null | undefined) {
return `${new Intl.NumberFormat('fa-IR').format(value)} ریال` return `${new Intl.NumberFormat('fa-IR').format(value)} ریال`
} }
function applyAccount(row: ServerRow | null) {
if (!row) {
summary.value = null
charges.value = []
return
}
selectedId.value = row.account.id
summary.value = row.summary
Object.assign(form, {
isDedicated: Boolean(row.account.isDedicated),
serverName: row.account.serverName || '',
specs: row.account.specs || '',
priceRials: row.account.priceRials ?? 0,
chargedAt: row.account.chargedAt || null,
planMonths: row.account.planMonths || 1,
description: row.account.description || '',
alertDaysBefore: row.account.alertDaysBefore ?? 3,
})
renewForm.planMonths = row.account.planMonths || 1
renewForm.priceRials = row.account.priceRials ?? 0
renewForm.chargedAt = iranDateInputToIso(todayIranDateInput())
}
async function loadCharges(accountId: string) {
try {
const { data } = await api.get(`/servers/accounts/${accountId}`)
charges.value = data.charges || []
if (data.summary) summary.value = data.summary
} catch {
charges.value = []
}
}
async function loadDetail() { async function loadDetail() {
if (!tenantId.value) return if (!tenantId.value) return
loading.value = true loading.value = true
try { try {
const { data } = await api.get(`/servers/tenants/${tenantId.value}`) const { data } = await api.get(`/servers/tenants/${tenantId.value}`)
tenant.value = data.tenant tenant.value = data.tenant
summary.value = data.summary servers.value = data.servers || []
charges.value = data.charges || []
planOptions.value = data.planOptions || [] planOptions.value = data.planOptions || []
Object.assign(form, {
isDedicated: Boolean(data.account.isDedicated), const keep =
serverName: data.account.serverName || '', (selectedId.value &&
specs: data.account.specs || '', servers.value.find((s) => s.account.id === selectedId.value)) ||
priceRials: data.account.priceRials ?? 0, servers.value[0] ||
chargedAt: data.account.chargedAt || null, null
planMonths: data.account.planMonths || 1, applyAccount(keep)
description: data.account.description || '', if (keep) await loadCharges(keep.account.id)
alertDaysBefore: data.account.alertDaysBefore ?? 3,
})
renewForm.planMonths = data.account.planMonths || 1
renewForm.priceRials = data.account.priceRials ?? 0
renewForm.chargedAt = iranDateInputToIso(todayIranDateInput())
} catch (e: any) { } catch (e: any) {
message.error(e?.response?.data?.message || 'خطا در دریافت سرور') message.error(e?.response?.data?.message || 'خطا در دریافت سرور')
} finally { } finally {
@@ -104,10 +156,71 @@ async function loadDetail() {
} }
} }
async function selectServer(id: string) {
const row = servers.value.find((s) => s.account.id === id)
if (!row) return
applyAccount(row)
await loadCharges(id)
}
async function addServer() {
creating.value = true
try {
const { data } = await api.post(`/servers/tenants/${tenantId.value}/servers`, {
serverName: `سرور ${(servers.value.length || 0) + 1}`,
isDedicated: false,
alertDaysBefore: 3,
})
message.success('سرور جدید اضافه شد')
await loadDetail()
if (data?.account?.id) {
selectedId.value = data.account.id
await selectServer(data.account.id)
}
} catch (e: any) {
const msg = e?.response?.data?.message
message.error(Array.isArray(msg) ? msg.join('، ') : msg || 'افزودن ناموفق')
} finally {
creating.value = false
}
}
function confirmDelete() {
if (!selectedId.value) return
Modal.confirm({
title: 'حذف این سرور؟',
content: 'تاریخچه شارژ این سرور هم حذف می‌شود. این کار قابل بازگشت نیست.',
okText: 'حذف',
okType: 'danger',
cancelText: 'انصراف',
onOk: () => deleteServer(),
})
}
async function deleteServer() {
if (!selectedId.value) return
deleting.value = true
try {
await api.delete(`/servers/accounts/${selectedId.value}`)
message.success('سرور حذف شد')
selectedId.value = null
await loadDetail()
} catch (e: any) {
const msg = e?.response?.data?.message
message.error(Array.isArray(msg) ? msg.join('، ') : msg || 'حذف ناموفق')
} finally {
deleting.value = false
}
}
async function saveAccount() { async function saveAccount() {
if (!selectedId.value) {
message.warning('ابتدا یک سرور اضافه یا انتخاب کنید')
return
}
saving.value = true saving.value = true
try { try {
await api.patch(`/servers/tenants/${tenantId.value}`, { await api.patch(`/servers/accounts/${selectedId.value}`, {
isDedicated: form.isDedicated, isDedicated: form.isDedicated,
serverName: form.serverName || null, serverName: form.serverName || null,
specs: form.specs || null, specs: form.specs || null,
@@ -128,6 +241,7 @@ async function saveAccount() {
} }
async function renewServer() { async function renewServer() {
if (!selectedId.value) return
if (form.isDedicated) { if (form.isDedicated) {
message.warning('برای شارژ مدیریت‌شده، ابتدا سوئیچ اختصاصی را خاموش کنید') message.warning('برای شارژ مدیریت‌شده، ابتدا سوئیچ اختصاصی را خاموش کنید')
return return
@@ -138,7 +252,7 @@ async function renewServer() {
} }
renewing.value = true renewing.value = true
try { try {
await api.post(`/servers/tenants/${tenantId.value}/renew`, { await api.post(`/servers/accounts/${selectedId.value}/renew`, {
chargedAt: renewForm.chargedAt, chargedAt: renewForm.chargedAt,
planMonths: Number(renewForm.planMonths), planMonths: Number(renewForm.planMonths),
priceRials: Number(renewForm.priceRials), priceRials: Number(renewForm.priceRials),
@@ -169,161 +283,246 @@ onMounted(loadDetail)
<h1 class="page-title">{{ tenant?.nameFa || 'سرور نسخه' }}</h1> <h1 class="page-title">{{ tenant?.nameFa || 'سرور نسخه' }}</h1>
<p class="page-sub"> <p class="page-sub">
{{ tenant?.product === 'hamkar' ? 'همکار' : 'خانگی' }} · {{ tenant?.domain }} {{ tenant?.product === 'hamkar' ? 'همکار' : 'خانگی' }} · {{ tenant?.domain }}
· هر نسخه می‌تواند چند سرور جدا داشته باشد
</p> </p>
</div> </div>
</div> </div>
<a-spin :spinning="loading"> <a-spin :spinning="loading">
<div v-if="summary" class="status-banner" :class="toneClass(summary.expiryLevel)"> <section class="form-section server-picker">
<strong>{{ summary.expiryLabel }}</strong> <div class="picker-head">
<span v-if="summary.expiresAt && !summary.isDedicated"> <h3>سرورهای این نسخه</h3>
انقضا: {{ formatIranDate(summary.expiresAt) }} <a-button type="primary" :loading="creating" @click="addServer">
<template v-if="summary.planLabel"> · {{ summary.planLabel }}</template> <PlusOutlined /> افزودن سرور
</span>
</div>
<section class="form-section">
<h3>نوع سرور</h3>
<a-form-item label="سرور اختصاصی (مدیریت و شارژ با مشتری)">
<a-switch v-model:checked="form.isDedicated" />
</a-form-item>
<p class="field-hint">
اگر روشن باشد فقط ثبت می‌شود که سرور اختصاصی است و هشدار/شارژ از طرف ما لازم نیست.
اگر خاموش باشد، مشخصات و دوره اعتبار را اینجا مدیریت کنید.
</p>
</section>
<section class="form-section">
<h3>مشخصات</h3>
<a-form layout="vertical">
<a-form-item label="نام سرور">
<a-input v-model:value="form.serverName" size="large" placeholder="مثلاً Hetzner CX31 / ایران‌سرور" />
</a-form-item>
<a-form-item label="مشخصات">
<a-textarea
v-model:value="form.specs"
:rows="3"
placeholder="CPU / RAM / دیسک / IP / دیتاسنتر…"
/>
</a-form-item>
<a-form-item label="توضیحات">
<a-textarea v-model:value="form.description" :rows="3" />
</a-form-item>
</a-form>
</section>
<section v-if="!form.isDedicated" 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-input-number
v-model:value="form.priceRials"
style="width: 100%"
size="large"
:min="0"
:step="100000"
/>
</a-form-item>
</a-col>
</a-row>
<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="saveAccount">
ذخیره تنظیمات
</a-button>
</div>
<section v-if="!form.isDedicated" 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-input-number
v-model:value="renewForm.priceRials"
style="width: 100%"
size="large"
:min="0"
:step="100000"
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="یادداشت">
<a-input v-model:value="renewForm.note" />
</a-form-item>
<a-button type="primary" size="large" :loading="renewing" @click="renewServer">
ثبت شارژ / تمدید
</a-button> </a-button>
</a-form> </div>
<div v-if="servers.length" class="server-chips">
<button
v-for="row in servers"
:key="row.account.id"
type="button"
class="server-chip"
:class="[
toneClass(row.summary.expiryLevel),
{ active: row.account.id === selectedId },
]"
@click="selectServer(row.account.id)"
>
<strong>{{ row.account.serverName || 'بدون نام' }}</strong>
<span>{{ row.summary.expiryLabel }}</span>
</button>
</div>
<div v-else class="hint-box">هنوز سروری ثبت نشده. با «افزودن سرور» شروع کنید.</div>
</section> </section>
<section v-if="!form.isDedicated" class="form-section"> <template v-if="selectedServer">
<h3>تاریخچه شارژ</h3> <div v-if="summary" class="status-banner" :class="toneClass(summary.expiryLevel)">
<div v-if="charges.length" class="charge-list"> <strong>{{ summary.expiryLabel }}</strong>
<article v-for="item in charges" :key="item.id" class="charge-item"> <span v-if="summary.expiresAt && !summary.isDedicated">
<div> انقضا: {{ formatIranDate(summary.expiresAt) }}
<strong>{{ formatMoney(item.priceRials) }}</strong> <template v-if="summary.planLabel"> · {{ summary.planLabel }}</template>
<span> · {{ item.planMonths }} ماهه</span> </span>
</div>
<div class="charge-meta">
از {{ formatIranDate(item.chargedAt) }} تا {{ formatIranDate(item.expiresAt) }}
<template v-if="item.note"> · {{ item.note }}</template>
</div>
</article>
</div> </div>
<div v-else class="hint-box">هنوز شارژی ثبت نشده است.</div>
</section> <section class="form-section">
<h3>نوع سرور</h3>
<a-form-item label="سرور اختصاصی (مدیریت و شارژ با مشتری)">
<a-switch v-model:checked="form.isDedicated" />
</a-form-item>
<p class="field-hint">
اگر روشن باشد فقط ثبت می‌شود که سرور اختصاصی است و هشدار/شارژ از طرف ما لازم نیست.
اگر خاموش باشد، مشخصات و دوره اعتبار را اینجا مدیریت کنید.
</p>
</section>
<section class="form-section">
<h3>مشخصات</h3>
<a-form layout="vertical">
<a-form-item label="نام سرور">
<a-input
v-model:value="form.serverName"
size="large"
placeholder="مثلاً Hetzner CX31 / ایران‌سرور / DB"
/>
</a-form-item>
<a-form-item label="مشخصات">
<a-textarea
v-model:value="form.specs"
:rows="3"
placeholder="CPU / RAM / دیسک / IP / دیتاسنتر…"
/>
</a-form-item>
<a-form-item label="توضیحات">
<a-textarea v-model:value="form.description" :rows="3" />
</a-form-item>
</a-form>
</section>
<section v-if="!form.isDedicated" 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-input-number
v-model:value="form.priceRials"
style="width: 100%"
size="large"
:min="0"
:step="100000"
/>
</a-form-item>
</a-col>
</a-row>
<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 actions-row">
<a-button type="primary" size="large" :loading="saving" @click="saveAccount">
ذخیره تنظیمات
</a-button>
<a-button danger size="large" :loading="deleting" @click="confirmDelete">
<DeleteOutlined /> حذف سرور
</a-button>
</div>
<section v-if="!form.isDedicated" 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-input-number
v-model:value="renewForm.priceRials"
style="width: 100%"
size="large"
:min="0"
:step="100000"
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="یادداشت">
<a-input v-model:value="renewForm.note" />
</a-form-item>
<a-button type="primary" size="large" :loading="renewing" @click="renewServer">
ثبت شارژ / تمدید
</a-button>
</a-form>
</section>
<section v-if="!form.isDedicated" class="form-section">
<h3>تاریخچه شارژ</h3>
<div v-if="charges.length" class="charge-list">
<article v-for="item in charges" :key="item.id" class="charge-item">
<div>
<strong>{{ formatMoney(item.priceRials) }}</strong>
<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>
</template>
</a-spin> </a-spin>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.server-picker {
margin-bottom: 8px;
}
.picker-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.picker-head h3 {
margin: 0;
}
.server-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.server-chip {
appearance: none;
border: 2px solid transparent;
border-radius: 14px;
padding: 10px 12px;
text-align: right;
cursor: pointer;
font-family: inherit;
display: grid;
gap: 2px;
min-width: 140px;
}
.server-chip.active {
border-color: #17324d;
box-shadow: 0 0 0 1px #17324d inset;
}
.server-chip.dedicated { background: #e8eef8; color: #3b5bdb; }
.server-chip.healthy { background: #dff8ea; color: #0f7a45; }
.server-chip.warning { background: #fff4c7; color: #9a6b00; }
.server-chip.critical { background: #ffd8d8; color: #b42318; }
.server-chip.unknown { background: #eef2f7; color: #64748b; }
.server-chip strong { font-size: 0.9rem; }
.server-chip span { font-size: 0.78rem; opacity: 0.9; }
.status-banner { .status-banner {
border-radius: 18px; border-radius: 18px;
padding: 14px 16px; padding: 14px 16px;
@@ -349,6 +548,17 @@ onMounted(loadDetail)
margin-bottom: 8px; margin-bottom: 8px;
} }
.actions-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.actions-row > .ant-btn {
flex: 1;
min-width: 160px;
}
.renew-section { .renew-section {
margin-top: 28px; margin-top: 28px;
padding-top: 22px; padding-top: 22px;
@@ -376,4 +586,12 @@ onMounted(loadDetail)
color: #64748b; color: #64748b;
font-size: 0.85rem; font-size: 0.85rem;
} }
.hint-box {
border-radius: 14px;
background: #f8fafc;
color: #64748b;
padding: 14px;
font-size: 0.9rem;
}
</style> </style>