feat: add tenant health panel
This commit is contained in:
@@ -41,6 +41,40 @@ export const TenantService = {
|
||||
items: normalizeItems(data?.items)
|
||||
};
|
||||
},
|
||||
async listTenantHealth({ page, limit, id, user_id, name, code, status, expired_at_from, expired_at_to, created_at_from, created_at_to, sortField, sortOrder } = {}) {
|
||||
const iso = (d) => {
|
||||
if (!d) return undefined;
|
||||
const date = d instanceof Date ? d : new Date(d);
|
||||
if (Number.isNaN(date.getTime())) return undefined;
|
||||
return date.toISOString();
|
||||
};
|
||||
|
||||
const query = {
|
||||
page,
|
||||
limit,
|
||||
id,
|
||||
user_id,
|
||||
name,
|
||||
code,
|
||||
status,
|
||||
expired_at_from: iso(expired_at_from),
|
||||
expired_at_to: iso(expired_at_to),
|
||||
created_at_from: iso(created_at_from),
|
||||
created_at_to: iso(created_at_to)
|
||||
};
|
||||
if (sortField && sortOrder) {
|
||||
if (sortOrder === 1) query.asc = sortField;
|
||||
if (sortOrder === -1) query.desc = sortField;
|
||||
}
|
||||
|
||||
const data = await requestJson('/super/v1/tenants/health', { query });
|
||||
return {
|
||||
page: data?.page ?? page ?? 1,
|
||||
limit: data?.limit ?? limit ?? 10,
|
||||
total: data?.total ?? 0,
|
||||
items: normalizeItems(data?.items)
|
||||
};
|
||||
},
|
||||
async createTenant({ code, name, admin_user_id, duration } = {}) {
|
||||
return requestJson('/super/v1/tenants', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -27,6 +27,15 @@ const createdAtTo = ref(null);
|
||||
const sortField = ref('id');
|
||||
const sortOrder = ref(-1);
|
||||
|
||||
const healthDialogVisible = ref(false);
|
||||
const healthLoading = ref(false);
|
||||
const healthItems = ref([]);
|
||||
const healthTotal = ref(0);
|
||||
const healthPage = ref(1);
|
||||
const healthRows = ref(10);
|
||||
const healthSortField = ref('tenant_id');
|
||||
const healthSortOrder = ref(-1);
|
||||
|
||||
const tenantUsersDialogVisible = ref(false);
|
||||
const tenantUsersLoading = ref(false);
|
||||
const tenantUsersTenant = ref(null);
|
||||
@@ -63,6 +72,12 @@ function formatCny(amountInCents) {
|
||||
return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' }).format(amount);
|
||||
}
|
||||
|
||||
function formatPercent(value) {
|
||||
const rate = Number(value);
|
||||
if (!Number.isFinite(rate)) return '-';
|
||||
return `${(rate * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function getStatusSeverity(status) {
|
||||
switch (status) {
|
||||
case 'verified':
|
||||
@@ -95,21 +110,53 @@ function getExpiryDaysInfo(expiredAt) {
|
||||
return { daysLeft: deltaDays, tooltipText, textClass };
|
||||
}
|
||||
|
||||
function getHealthSeverity(level) {
|
||||
switch (level) {
|
||||
case 'healthy':
|
||||
return 'success';
|
||||
case 'warning':
|
||||
return 'warn';
|
||||
case 'risk':
|
||||
return 'danger';
|
||||
default:
|
||||
return 'secondary';
|
||||
}
|
||||
}
|
||||
|
||||
function formatHealthLevel(level) {
|
||||
switch (level) {
|
||||
case 'healthy':
|
||||
return '健康';
|
||||
case 'warning':
|
||||
return '预警';
|
||||
case 'risk':
|
||||
return '风险';
|
||||
default:
|
||||
return level || '-';
|
||||
}
|
||||
}
|
||||
|
||||
function buildTenantFilters() {
|
||||
return {
|
||||
id: tenantID.value || undefined,
|
||||
user_id: ownerUserID.value || undefined,
|
||||
name: nameKeyword.value,
|
||||
code: codeKeyword.value,
|
||||
status: status.value,
|
||||
expired_at_from: expiredAtFrom.value || undefined,
|
||||
expired_at_to: expiredAtTo.value || undefined,
|
||||
created_at_from: createdAtFrom.value || undefined,
|
||||
created_at_to: createdAtTo.value || undefined
|
||||
};
|
||||
}
|
||||
|
||||
async function loadTenants() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await TenantService.listTenants({
|
||||
page: page.value,
|
||||
limit: rows.value,
|
||||
id: tenantID.value || undefined,
|
||||
user_id: ownerUserID.value || undefined,
|
||||
name: nameKeyword.value,
|
||||
code: codeKeyword.value,
|
||||
status: status.value,
|
||||
expired_at_from: expiredAtFrom.value || undefined,
|
||||
expired_at_to: expiredAtTo.value || undefined,
|
||||
created_at_from: createdAtFrom.value || undefined,
|
||||
created_at_to: createdAtTo.value || undefined,
|
||||
...buildTenantFilters(),
|
||||
sortField: sortField.value,
|
||||
sortOrder: sortOrder.value
|
||||
});
|
||||
@@ -127,6 +174,56 @@ async function loadTenants() {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHealthSortField(field) {
|
||||
if (field === 'tenant_id') return 'id';
|
||||
return field;
|
||||
}
|
||||
|
||||
async function loadTenantHealth() {
|
||||
healthLoading.value = true;
|
||||
try {
|
||||
const result = await TenantService.listTenantHealth({
|
||||
page: healthPage.value,
|
||||
limit: healthRows.value,
|
||||
...buildTenantFilters(),
|
||||
sortField: normalizeHealthSortField(healthSortField.value),
|
||||
sortOrder: healthSortOrder.value
|
||||
});
|
||||
healthItems.value = result.items;
|
||||
healthTotal.value = result.total;
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: '加载失败',
|
||||
detail: error?.message || '无法加载租户健康概览',
|
||||
life: 4000
|
||||
});
|
||||
} finally {
|
||||
healthLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openHealthDialog() {
|
||||
healthDialogVisible.value = true;
|
||||
healthPage.value = 1;
|
||||
healthRows.value = 10;
|
||||
healthSortField.value = 'tenant_id';
|
||||
healthSortOrder.value = -1;
|
||||
loadTenantHealth();
|
||||
}
|
||||
|
||||
function onHealthPage(event) {
|
||||
healthPage.value = (event.page ?? 0) + 1;
|
||||
healthRows.value = event.rows ?? healthRows.value;
|
||||
loadTenantHealth();
|
||||
}
|
||||
|
||||
function onHealthSort(event) {
|
||||
healthSortField.value = event.sortField ?? healthSortField.value;
|
||||
healthSortOrder.value = event.sortOrder ?? healthSortOrder.value;
|
||||
loadTenantHealth();
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
page.value = 1;
|
||||
loadTenants();
|
||||
@@ -380,7 +477,10 @@ onMounted(() => {
|
||||
<div class="card">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h4 class="m-0">租户列表</h4>
|
||||
<Button label="创建租户" icon="pi pi-plus" @click="openCreateDialog" />
|
||||
<div class="flex items-center gap-2">
|
||||
<Button label="健康概览" icon="pi pi-heart" severity="secondary" @click="openHealthDialog" />
|
||||
<Button label="创建租户" icon="pi pi-plus" @click="openCreateDialog" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchPanel :loading="loading" @search="onSearch" @reset="onReset">
|
||||
@@ -506,6 +606,97 @@ onMounted(() => {
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<Dialog v-model:visible="healthDialogVisible" :modal="true" :style="{ width: '1200px' }">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">租户健康概览</span>
|
||||
<Tag value="使用当前筛选条件" severity="secondary" />
|
||||
</div>
|
||||
<Button label="刷新" icon="pi pi-refresh" text :loading="healthLoading" @click="loadTenantHealth" />
|
||||
</div>
|
||||
</template>
|
||||
<Message severity="info" icon="pi pi-info-circle" class="mb-3">健康指标基于当前筛选条件聚合,退款率=退款订单/已支付订单。</Message>
|
||||
<DataTable
|
||||
:value="healthItems"
|
||||
dataKey="tenant_id"
|
||||
:loading="healthLoading"
|
||||
lazy
|
||||
:paginator="true"
|
||||
:rows="healthRows"
|
||||
:totalRecords="healthTotal"
|
||||
:first="(healthPage - 1) * healthRows"
|
||||
:rowsPerPageOptions="[10, 20, 50, 100]"
|
||||
sortMode="single"
|
||||
:sortField="healthSortField"
|
||||
:sortOrder="healthSortOrder"
|
||||
@page="onHealthPage"
|
||||
@sort="onHealthSort"
|
||||
currentPageReportTemplate="显示第 {first} - {last} 条,共 {totalRecords} 条"
|
||||
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
|
||||
scrollable
|
||||
scrollHeight="420px"
|
||||
responsiveLayout="scroll"
|
||||
>
|
||||
<Column field="tenant_id" header="TenantID" sortable style="min-width: 7rem" />
|
||||
<Column field="name" header="名称" sortable style="min-width: 16rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex flex-col">
|
||||
<Button :label="data.name || '-'" icon="pi pi-external-link" text size="small" class="p-0" as="router-link" :to="`/superadmin/tenants/${data.tenant_id}`" />
|
||||
<span class="text-xs text-muted-color">Code: {{ data.code || '-' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="status" header="状态" sortable style="min-width: 10rem">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.status_description || data.status || '-'" :severity="getStatusSeverity(data.status)" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="health_level" header="健康等级" style="min-width: 9rem">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="formatHealthLevel(data.health_level)" :severity="getHealthSeverity(data.health_level)" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="refund_rate" header="退款率" style="min-width: 8rem">
|
||||
<template #body="{ data }">
|
||||
{{ formatPercent(data.refund_rate) }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="异常提示" style="min-width: 18rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Tag v-for="alert in data.alerts || []" :key="alert" :value="alert" severity="warning" />
|
||||
<span v-if="!data.alerts || data.alerts.length === 0" class="text-muted-color">-</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="member_count" header="成员数" style="min-width: 8rem" />
|
||||
<Column header="内容(已发布/总)" style="min-width: 12rem">
|
||||
<template #body="{ data }">{{ data.published_content_count ?? 0 }} / {{ data.content_count ?? 0 }}</template>
|
||||
</Column>
|
||||
<Column field="paid_orders" header="成交单" style="min-width: 8rem" />
|
||||
<Column field="paid_amount" header="成交金额" style="min-width: 10rem">
|
||||
<template #body="{ data }">
|
||||
{{ formatCny(data.paid_amount) }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="refund_orders" header="退款单" style="min-width: 8rem" />
|
||||
<Column field="refund_amount" header="退款金额" style="min-width: 10rem">
|
||||
<template #body="{ data }">
|
||||
{{ formatCny(data.refund_amount) }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="last_paid_at" header="最近成交" style="min-width: 14rem">
|
||||
<template #body="{ data }">
|
||||
{{ formatDate(data.last_paid_at) }}
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
<template #footer>
|
||||
<Button label="关闭" icon="pi pi-times" text @click="healthDialogVisible = false" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:visible="renewDialogVisible" :modal="true" :style="{ width: '420px' }">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
Reference in New Issue
Block a user