feat: add tenant health panel
This commit is contained in:
@@ -41,6 +41,40 @@ export const TenantService = {
|
|||||||
items: normalizeItems(data?.items)
|
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 } = {}) {
|
async createTenant({ code, name, admin_user_id, duration } = {}) {
|
||||||
return requestJson('/super/v1/tenants', {
|
return requestJson('/super/v1/tenants', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -27,6 +27,15 @@ const createdAtTo = ref(null);
|
|||||||
const sortField = ref('id');
|
const sortField = ref('id');
|
||||||
const sortOrder = ref(-1);
|
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 tenantUsersDialogVisible = ref(false);
|
||||||
const tenantUsersLoading = ref(false);
|
const tenantUsersLoading = ref(false);
|
||||||
const tenantUsersTenant = ref(null);
|
const tenantUsersTenant = ref(null);
|
||||||
@@ -63,6 +72,12 @@ function formatCny(amountInCents) {
|
|||||||
return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' }).format(amount);
|
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) {
|
function getStatusSeverity(status) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'verified':
|
case 'verified':
|
||||||
@@ -95,12 +110,34 @@ function getExpiryDaysInfo(expiredAt) {
|
|||||||
return { daysLeft: deltaDays, tooltipText, textClass };
|
return { daysLeft: deltaDays, tooltipText, textClass };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadTenants() {
|
function getHealthSeverity(level) {
|
||||||
loading.value = true;
|
switch (level) {
|
||||||
try {
|
case 'healthy':
|
||||||
const result = await TenantService.listTenants({
|
return 'success';
|
||||||
page: page.value,
|
case 'warning':
|
||||||
limit: rows.value,
|
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,
|
id: tenantID.value || undefined,
|
||||||
user_id: ownerUserID.value || undefined,
|
user_id: ownerUserID.value || undefined,
|
||||||
name: nameKeyword.value,
|
name: nameKeyword.value,
|
||||||
@@ -109,7 +146,17 @@ async function loadTenants() {
|
|||||||
expired_at_from: expiredAtFrom.value || undefined,
|
expired_at_from: expiredAtFrom.value || undefined,
|
||||||
expired_at_to: expiredAtTo.value || undefined,
|
expired_at_to: expiredAtTo.value || undefined,
|
||||||
created_at_from: createdAtFrom.value || undefined,
|
created_at_from: createdAtFrom.value || undefined,
|
||||||
created_at_to: createdAtTo.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,
|
||||||
|
...buildTenantFilters(),
|
||||||
sortField: sortField.value,
|
sortField: sortField.value,
|
||||||
sortOrder: sortOrder.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() {
|
function onSearch() {
|
||||||
page.value = 1;
|
page.value = 1;
|
||||||
loadTenants();
|
loadTenants();
|
||||||
@@ -380,8 +477,11 @@ onMounted(() => {
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h4 class="m-0">租户列表</h4>
|
<h4 class="m-0">租户列表</h4>
|
||||||
|
<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" />
|
<Button label="创建租户" icon="pi pi-plus" @click="openCreateDialog" />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<SearchPanel :loading="loading" @search="onSearch" @reset="onReset">
|
<SearchPanel :loading="loading" @search="onSearch" @reset="onReset">
|
||||||
<SearchField label="TenantID">
|
<SearchField label="TenantID">
|
||||||
@@ -506,6 +606,97 @@ onMounted(() => {
|
|||||||
</DataTable>
|
</DataTable>
|
||||||
</div>
|
</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' }">
|
<Dialog v-model:visible="renewDialogVisible" :modal="true" :style="{ width: '420px' }">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
|||||||
Reference in New Issue
Block a user