feat: add tenant health panel

This commit is contained in:
2026-01-14 19:52:37 +08:00
parent 2b644f8e3d
commit bb4c5b39d2
2 changed files with 235 additions and 10 deletions

View File

@@ -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',

View File

@@ -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">