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
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 -3
View File
@@ -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
View File
@@ -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>