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
|
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,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
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user