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:
@@ -26,6 +26,7 @@ type ServerSummary = {
|
||||
expiryLevel: ExpiryLevel
|
||||
expiryLabel: string
|
||||
isDedicated?: boolean
|
||||
serverName?: string | null
|
||||
}
|
||||
|
||||
type Tenant = {
|
||||
@@ -33,6 +34,7 @@ type Tenant = {
|
||||
product: 'hamkar' | 'khanegi'
|
||||
slug?: string
|
||||
nameFa?: string
|
||||
serverAccountId?: string
|
||||
sms?: SmsSummary
|
||||
server?: ServerSummary
|
||||
}
|
||||
@@ -221,14 +223,19 @@ onMounted(load)
|
||||
<div class="sms-mini-grid">
|
||||
<button
|
||||
v-for="tenant in expiringServers"
|
||||
:key="tenant.id"
|
||||
:key="`${tenant.id}-${tenant.serverAccountId || tenant.server?.serverName || ''}`"
|
||||
type="button"
|
||||
class="sms-mini-card"
|
||||
:class="serverTone(tenant.server?.expiryLevel)"
|
||||
@click.stop="openServerPanel(tenant.id)"
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
<div v-if="!expiringServers.length" class="sms-mini-empty">
|
||||
|
||||
@@ -13,12 +13,14 @@ type TenantRow = {
|
||||
slug: string
|
||||
nameFa: string
|
||||
domain: string
|
||||
serversCount?: number
|
||||
server: {
|
||||
isDedicated: boolean
|
||||
expiryLevel: ExpiryLevel
|
||||
expiryLabel: string
|
||||
serverName?: string | null
|
||||
planLabel?: string | null
|
||||
serversCount?: number
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +64,9 @@ onMounted(loadTenants)
|
||||
</a-button>
|
||||
<div style="flex: 1">
|
||||
<h1 class="page-title">پنل سرور</h1>
|
||||
<p class="page-sub">وضعیت اختصاصی یا مدیریتشده، شارژ و هشدار نزدیک پایان اعتبار</p>
|
||||
<p class="page-sub">
|
||||
هر نسخه میتواند چند سرور داشته باشد — وضعیت، شارژ و هشدار نزدیک پایان اعتبار
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -94,7 +98,10 @@ onMounted(loadTenants)
|
||||
>
|
||||
<strong>{{ t.nameFa }}</strong>
|
||||
<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>
|
||||
</button>
|
||||
<div v-if="!groupedTenants.hamkar.length" class="empty-hint">نسخهای نیست</div>
|
||||
@@ -117,7 +124,10 @@ onMounted(loadTenants)
|
||||
>
|
||||
<strong>{{ t.nameFa }}</strong>
|
||||
<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>
|
||||
</button>
|
||||
<div v-if="!groupedTenants.khanegi.length" class="empty-hint">نسخهای نیست</div>
|
||||
|
||||
+378
-160
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { ArrowRightOutlined } from '@ant-design/icons-vue'
|
||||
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 { formatIranDate, todayIranDateInput, iranDateInputToIso } from '@/utils/datetime'
|
||||
@@ -10,12 +10,29 @@ import { formatIranDate, todayIranDateInput, iranDateInputToIso } from '@/utils/
|
||||
type ExpiryLevel = 'dedicated' | 'healthy' | 'warning' | 'critical' | 'unknown'
|
||||
|
||||
type Summary = {
|
||||
id?: string
|
||||
isDedicated: boolean
|
||||
expiryLevel: ExpiryLevel
|
||||
expiryLabel: string
|
||||
daysLeft: number | null
|
||||
planLabel: 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()
|
||||
@@ -23,6 +40,8 @@ const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const renewing = ref(false)
|
||||
const creating = ref(false)
|
||||
const deleting = ref(false)
|
||||
const tenant = ref<{
|
||||
id: string
|
||||
product: 'hamkar' | 'khanegi'
|
||||
@@ -30,6 +49,8 @@ const tenant = ref<{
|
||||
nameFa: string
|
||||
domain: string
|
||||
} | null>(null)
|
||||
const servers = ref<ServerRow[]>([])
|
||||
const selectedId = ref<string | null>(null)
|
||||
const summary = ref<Summary | null>(null)
|
||||
const charges = ref<any[]>([])
|
||||
const planOptions = ref<{ months: number; label: string }[]>([])
|
||||
@@ -54,6 +75,10 @@ const renewForm = reactive({
|
||||
|
||||
const tenantId = computed(() => String(route.params.tenantId || ''))
|
||||
|
||||
const selectedServer = computed(
|
||||
() => servers.value.find((s) => s.account.id === selectedId.value) || null,
|
||||
)
|
||||
|
||||
const planSelectOptions = computed(() =>
|
||||
(planOptions.value.length
|
||||
? planOptions.value
|
||||
@@ -75,28 +100,55 @@ function formatMoney(value: number | null | undefined) {
|
||||
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() {
|
||||
if (!tenantId.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await api.get(`/servers/tenants/${tenantId.value}`)
|
||||
tenant.value = data.tenant
|
||||
summary.value = data.summary
|
||||
charges.value = data.charges || []
|
||||
servers.value = data.servers || []
|
||||
planOptions.value = data.planOptions || []
|
||||
Object.assign(form, {
|
||||
isDedicated: Boolean(data.account.isDedicated),
|
||||
serverName: data.account.serverName || '',
|
||||
specs: data.account.specs || '',
|
||||
priceRials: data.account.priceRials ?? 0,
|
||||
chargedAt: data.account.chargedAt || null,
|
||||
planMonths: data.account.planMonths || 1,
|
||||
description: data.account.description || '',
|
||||
alertDaysBefore: data.account.alertDaysBefore ?? 3,
|
||||
})
|
||||
renewForm.planMonths = data.account.planMonths || 1
|
||||
renewForm.priceRials = data.account.priceRials ?? 0
|
||||
renewForm.chargedAt = iranDateInputToIso(todayIranDateInput())
|
||||
|
||||
const keep =
|
||||
(selectedId.value &&
|
||||
servers.value.find((s) => s.account.id === selectedId.value)) ||
|
||||
servers.value[0] ||
|
||||
null
|
||||
applyAccount(keep)
|
||||
if (keep) await loadCharges(keep.account.id)
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message || 'خطا در دریافت سرور')
|
||||
} 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() {
|
||||
if (!selectedId.value) {
|
||||
message.warning('ابتدا یک سرور اضافه یا انتخاب کنید')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
await api.patch(`/servers/tenants/${tenantId.value}`, {
|
||||
await api.patch(`/servers/accounts/${selectedId.value}`, {
|
||||
isDedicated: form.isDedicated,
|
||||
serverName: form.serverName || null,
|
||||
specs: form.specs || null,
|
||||
@@ -128,6 +241,7 @@ async function saveAccount() {
|
||||
}
|
||||
|
||||
async function renewServer() {
|
||||
if (!selectedId.value) return
|
||||
if (form.isDedicated) {
|
||||
message.warning('برای شارژ مدیریتشده، ابتدا سوئیچ اختصاصی را خاموش کنید')
|
||||
return
|
||||
@@ -138,7 +252,7 @@ async function renewServer() {
|
||||
}
|
||||
renewing.value = true
|
||||
try {
|
||||
await api.post(`/servers/tenants/${tenantId.value}/renew`, {
|
||||
await api.post(`/servers/accounts/${selectedId.value}/renew`, {
|
||||
chargedAt: renewForm.chargedAt,
|
||||
planMonths: Number(renewForm.planMonths),
|
||||
priceRials: Number(renewForm.priceRials),
|
||||
@@ -169,161 +283,246 @@ onMounted(loadDetail)
|
||||
<h1 class="page-title">{{ tenant?.nameFa || 'سرور نسخه' }}</h1>
|
||||
<p class="page-sub">
|
||||
{{ tenant?.product === 'hamkar' ? 'همکار' : 'خانگی' }} · {{ tenant?.domain }}
|
||||
· هر نسخه میتواند چند سرور جدا داشته باشد
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<div v-if="summary" class="status-banner" :class="toneClass(summary.expiryLevel)">
|
||||
<strong>{{ summary.expiryLabel }}</strong>
|
||||
<span v-if="summary.expiresAt && !summary.isDedicated">
|
||||
انقضا: {{ formatIranDate(summary.expiresAt) }}
|
||||
<template v-if="summary.planLabel"> · {{ summary.planLabel }}</template>
|
||||
</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">
|
||||
ثبت شارژ / تمدید
|
||||
<section class="form-section server-picker">
|
||||
<div class="picker-head">
|
||||
<h3>سرورهای این نسخه</h3>
|
||||
<a-button type="primary" :loading="creating" @click="addServer">
|
||||
<PlusOutlined /> افزودن سرور
|
||||
</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 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>
|
||||
<template v-if="selectedServer">
|
||||
<div v-if="summary" class="status-banner" :class="toneClass(summary.expiryLevel)">
|
||||
<strong>{{ summary.expiryLabel }}</strong>
|
||||
<span v-if="summary.expiresAt && !summary.isDedicated">
|
||||
انقضا: {{ formatIranDate(summary.expiresAt) }}
|
||||
<template v-if="summary.planLabel"> · {{ summary.planLabel }}</template>
|
||||
</span>
|
||||
</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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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 {
|
||||
border-radius: 18px;
|
||||
padding: 14px 16px;
|
||||
@@ -349,6 +548,17 @@ onMounted(loadDetail)
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.actions-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.actions-row > .ant-btn {
|
||||
flex: 1;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.renew-section {
|
||||
margin-top: 28px;
|
||||
padding-top: 22px;
|
||||
@@ -376,4 +586,12 @@ onMounted(loadDetail)
|
||||
color: #64748b;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.hint-box {
|
||||
border-radius: 14px;
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
padding: 14px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user