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>
|
||||||
|
|||||||
+240
-22
@@ -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,11 +283,39 @@ 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">
|
||||||
|
<section class="form-section server-picker">
|
||||||
|
<div class="picker-head">
|
||||||
|
<h3>سرورهای این نسخه</h3>
|
||||||
|
<a-button type="primary" :loading="creating" @click="addServer">
|
||||||
|
<PlusOutlined /> افزودن سرور
|
||||||
|
</a-button>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<template v-if="selectedServer">
|
||||||
<div v-if="summary" class="status-banner" :class="toneClass(summary.expiryLevel)">
|
<div v-if="summary" class="status-banner" :class="toneClass(summary.expiryLevel)">
|
||||||
<strong>{{ summary.expiryLabel }}</strong>
|
<strong>{{ summary.expiryLabel }}</strong>
|
||||||
<span v-if="summary.expiresAt && !summary.isDedicated">
|
<span v-if="summary.expiresAt && !summary.isDedicated">
|
||||||
@@ -197,7 +339,11 @@ onMounted(loadDetail)
|
|||||||
<h3>مشخصات</h3>
|
<h3>مشخصات</h3>
|
||||||
<a-form layout="vertical">
|
<a-form layout="vertical">
|
||||||
<a-form-item label="نام سرور">
|
<a-form-item label="نام سرور">
|
||||||
<a-input v-model:value="form.serverName" size="large" placeholder="مثلاً Hetzner CX31 / ایرانسرور" />
|
<a-input
|
||||||
|
v-model:value="form.serverName"
|
||||||
|
size="large"
|
||||||
|
placeholder="مثلاً Hetzner CX31 / ایرانسرور / DB"
|
||||||
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="مشخصات">
|
<a-form-item label="مشخصات">
|
||||||
<a-textarea
|
<a-textarea
|
||||||
@@ -255,10 +401,13 @@ onMounted(loadDetail)
|
|||||||
</a-form>
|
</a-form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="sticky-actions settings-save">
|
<div class="sticky-actions settings-save actions-row">
|
||||||
<a-button type="primary" size="large" block :loading="saving" @click="saveAccount">
|
<a-button type="primary" size="large" :loading="saving" @click="saveAccount">
|
||||||
ذخیره تنظیمات
|
ذخیره تنظیمات
|
||||||
</a-button>
|
</a-button>
|
||||||
|
<a-button danger size="large" :loading="deleting" @click="confirmDelete">
|
||||||
|
<DeleteOutlined /> حذف سرور
|
||||||
|
</a-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section v-if="!form.isDedicated" class="form-section renew-section">
|
<section v-if="!form.isDedicated" class="form-section renew-section">
|
||||||
@@ -319,11 +468,61 @@ onMounted(loadDetail)
|
|||||||
</div>
|
</div>
|
||||||
<div v-else class="hint-box">هنوز شارژی ثبت نشده است.</div>
|
<div v-else class="hint-box">هنوز شارژی ثبت نشده است.</div>
|
||||||
</section>
|
</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