feat: add superadmin health center
This commit is contained in:
@@ -32,12 +32,13 @@
|
||||
- `/superadmin/reports`:报表与导出(待补超管接口)
|
||||
- `/superadmin/assets`:资产与上传(待补超管接口)
|
||||
- `/superadmin/notifications`:通知与消息(待补超管接口)
|
||||
- `/superadmin/health`:健康与告警中心
|
||||
|
||||
## 1.1 迭代路线(按接口可用性)
|
||||
|
||||
- **P0(已有接口可落地)**:登录、概览、租户管理/详情、用户管理/详情、订单列表/详情/退款、内容治理。
|
||||
- **P1(需补超管接口)**:创作者审核、优惠券、财务/提现、报表导出。
|
||||
- **P2(扩展增强)**:资产/上传治理、通知中心、审计日志。
|
||||
- **P2(扩展增强)**:资产/上传治理、通知中心、审计日志、健康与告警。
|
||||
|
||||
## 2. 页面规格(页面 → 功能 → API)
|
||||
|
||||
@@ -188,6 +189,13 @@
|
||||
- `GET /super/v1/notifications/templates`
|
||||
- `POST /super/v1/notifications/templates`
|
||||
|
||||
### 2.16 健康与告警 `/superadmin/health`
|
||||
|
||||
- 平台健康总览、失败率/超时统计、风险租户告警列表。
|
||||
- API:
|
||||
- `GET /super/v1/health/overview`
|
||||
- `GET /super/v1/tenants/health`
|
||||
|
||||
## 3. 枚举与数据结构(UI 需要)
|
||||
|
||||
- 租户状态:`consts.TenantStatus`(`pending_verify` / `verified` / `banned`),推荐用 `GET /super/v1/tenants/statuses` 驱动展示 label
|
||||
|
||||
@@ -12,6 +12,7 @@ const model = ref([
|
||||
label: 'Super Admin',
|
||||
items: [
|
||||
{ label: 'Tenants', icon: 'pi pi-fw pi-building', to: '/superadmin/tenants' },
|
||||
{ label: 'Health Center', icon: 'pi pi-fw pi-heart', to: '/superadmin/health' },
|
||||
{ label: 'Users', icon: 'pi pi-fw pi-users', to: '/superadmin/users' },
|
||||
{ label: 'Orders', icon: 'pi pi-fw pi-shopping-cart', to: '/superadmin/orders' },
|
||||
{ label: 'Contents', icon: 'pi pi-fw pi-file', to: '/superadmin/contents' },
|
||||
|
||||
@@ -119,6 +119,11 @@ const router = createRouter({
|
||||
name: 'superadmin-tenants',
|
||||
component: () => import('@/views/superadmin/Tenants.vue')
|
||||
},
|
||||
{
|
||||
path: '/superadmin/health',
|
||||
name: 'superadmin-health',
|
||||
component: () => import('@/views/superadmin/HealthOverview.vue')
|
||||
},
|
||||
{
|
||||
path: '/superadmin/tenants/:tenantID',
|
||||
name: 'superadmin-tenant-detail',
|
||||
|
||||
21
frontend/superadmin/src/service/HealthService.js
Normal file
21
frontend/superadmin/src/service/HealthService.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import { requestJson } from './apiClient';
|
||||
|
||||
export const HealthService = {
|
||||
async getOverview({ tenant_id, start_at, end_at, upload_stuck_hours } = {}) {
|
||||
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 = {
|
||||
tenant_id,
|
||||
start_at: iso(start_at),
|
||||
end_at: iso(end_at),
|
||||
upload_stuck_hours
|
||||
};
|
||||
|
||||
return requestJson('/super/v1/health/overview', { query });
|
||||
}
|
||||
};
|
||||
239
frontend/superadmin/src/views/superadmin/HealthOverview.vue
Normal file
239
frontend/superadmin/src/views/superadmin/HealthOverview.vue
Normal file
@@ -0,0 +1,239 @@
|
||||
<script setup>
|
||||
import SearchField from '@/components/SearchField.vue';
|
||||
import SearchPanel from '@/components/SearchPanel.vue';
|
||||
import StatisticsStrip from '@/components/StatisticsStrip.vue';
|
||||
import { HealthService } from '@/service/HealthService';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const overview = ref(null);
|
||||
const loading = ref(false);
|
||||
|
||||
const tenantID = ref(null);
|
||||
const startAt = ref(null);
|
||||
const endAt = ref(null);
|
||||
const uploadStuckHours = ref(24);
|
||||
|
||||
const defaultRangeDays = 7;
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return '-';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return String(value);
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
function formatPercent(value) {
|
||||
const rate = Number(value);
|
||||
if (!Number.isFinite(rate)) return '-';
|
||||
return `${(rate * 100).toFixed(2)}%`;
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
const value = Number(bytes);
|
||||
if (!Number.isFinite(value)) return '-';
|
||||
if (value < 1024) return `${value} B`;
|
||||
const units = ['KB', 'MB', 'GB', 'TB'];
|
||||
let size = value;
|
||||
let idx = -1;
|
||||
while (size >= 1024 && idx < units.length - 1) {
|
||||
size /= 1024;
|
||||
idx += 1;
|
||||
}
|
||||
return `${size.toFixed(1)} ${units[idx]}`;
|
||||
}
|
||||
|
||||
function getAlertSeverity(level) {
|
||||
switch (level) {
|
||||
case 'risk':
|
||||
return 'danger';
|
||||
case 'warning':
|
||||
return 'warn';
|
||||
default:
|
||||
return 'secondary';
|
||||
}
|
||||
}
|
||||
|
||||
function formatAlertLevel(level) {
|
||||
switch (level) {
|
||||
case 'risk':
|
||||
return '风险';
|
||||
case 'warning':
|
||||
return '预警';
|
||||
default:
|
||||
return level || '-';
|
||||
}
|
||||
}
|
||||
|
||||
function formatAlertKind(kind) {
|
||||
switch (kind) {
|
||||
case 'order_error_rate':
|
||||
return '订单失败率';
|
||||
case 'upload_error_rate':
|
||||
return '上传失败率';
|
||||
case 'upload_stuck':
|
||||
return '上传超时';
|
||||
case 'tenant_health':
|
||||
return '租户健康';
|
||||
default:
|
||||
return kind || '-';
|
||||
}
|
||||
}
|
||||
|
||||
const rangeText = computed(() => {
|
||||
if (!overview.value) return '';
|
||||
return `统计区间:${formatDate(overview.value.start_at)} ~ ${formatDate(overview.value.end_at)}`;
|
||||
});
|
||||
|
||||
const coreItems = computed(() => {
|
||||
const data = overview.value || {};
|
||||
const warningCount = Number(data.tenant_warning_count ?? 0);
|
||||
const riskCount = Number(data.tenant_risk_count ?? 0);
|
||||
const orderFailedRate = Number(data.order_failed_rate ?? 0);
|
||||
const failedRateClass = orderFailedRate >= 0.2 ? 'text-red-500' : orderFailedRate >= 0.1 ? 'text-orange-500' : '';
|
||||
|
||||
return [
|
||||
{ key: 'tenants-total', label: '租户总数:', value: loading.value ? '-' : (data.tenant_total ?? 0), icon: 'pi-building' },
|
||||
{ key: 'tenants-warning', label: '预警租户:', value: loading.value ? '-' : warningCount, icon: 'pi-exclamation-triangle', valueClass: warningCount ? 'text-orange-500' : '' },
|
||||
{ key: 'tenants-risk', label: '风险租户:', value: loading.value ? '-' : riskCount, icon: 'pi-times-circle', valueClass: riskCount ? 'text-red-500' : '' },
|
||||
{ key: 'orders-total', label: '订单总数:', value: loading.value ? '-' : (data.order_total ?? 0), icon: 'pi-shopping-cart' },
|
||||
{ key: 'orders-failed', label: '失败订单:', value: loading.value ? '-' : (data.order_failed ?? 0), icon: 'pi-ban', valueClass: data.order_failed ? 'text-red-500' : '' },
|
||||
{ key: 'orders-failed-rate', label: '失败率:', value: loading.value ? '-' : formatPercent(data.order_failed_rate), icon: 'pi-percentage', valueClass: failedRateClass }
|
||||
];
|
||||
});
|
||||
|
||||
const uploadItems = computed(() => {
|
||||
const data = overview.value || {};
|
||||
const uploadFailedRate = Number(data.upload_failed_rate ?? 0);
|
||||
const failedRateClass = uploadFailedRate >= 0.2 ? 'text-red-500' : uploadFailedRate >= 0.1 ? 'text-orange-500' : '';
|
||||
|
||||
return [
|
||||
{ key: 'uploads-total', label: '上传总量:', value: loading.value ? '-' : (data.upload_total ?? 0), icon: 'pi-upload' },
|
||||
{ key: 'uploads-failed', label: '失败上传:', value: loading.value ? '-' : (data.upload_failed ?? 0), icon: 'pi-times', valueClass: data.upload_failed ? 'text-red-500' : '' },
|
||||
{ key: 'uploads-failed-rate', label: '上传失败率:', value: loading.value ? '-' : formatPercent(data.upload_failed_rate), icon: 'pi-percentage', valueClass: failedRateClass },
|
||||
{ key: 'uploads-stuck-processing', label: '处理超时:', value: loading.value ? '-' : (data.upload_processing_stuck ?? 0), icon: 'pi-clock', valueClass: data.upload_processing_stuck ? 'text-orange-500' : '' },
|
||||
{ key: 'uploads-stuck-uploaded', label: '入库滞留:', value: loading.value ? '-' : (data.upload_uploaded_stuck ?? 0), icon: 'pi-exclamation-circle', valueClass: data.upload_uploaded_stuck ? 'text-orange-500' : '' },
|
||||
{ key: 'storage-total', label: '资产总量:', value: loading.value ? '-' : (data.storage_total_count ?? 0), icon: 'pi-images' },
|
||||
{ key: 'storage-size', label: '资产大小:', value: loading.value ? '-' : formatBytes(data.storage_total_size ?? 0), icon: 'pi-database' }
|
||||
];
|
||||
});
|
||||
|
||||
const alertItems = computed(() => overview.value?.alerts || []);
|
||||
|
||||
async function loadOverview() {
|
||||
loading.value = true;
|
||||
try {
|
||||
overview.value = await HealthService.getOverview({
|
||||
tenant_id: tenantID.value || undefined,
|
||||
start_at: startAt.value || undefined,
|
||||
end_at: endAt.value || undefined,
|
||||
upload_stuck_hours: uploadStuckHours.value || undefined
|
||||
});
|
||||
} catch (error) {
|
||||
toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载健康概览', life: 4000 });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function applyDefaultRange() {
|
||||
const end = new Date();
|
||||
const start = new Date(end);
|
||||
start.setDate(start.getDate() - defaultRangeDays);
|
||||
startAt.value = start;
|
||||
endAt.value = end;
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
loadOverview();
|
||||
}
|
||||
|
||||
function onReset() {
|
||||
tenantID.value = null;
|
||||
uploadStuckHours.value = 24;
|
||||
applyDefaultRange();
|
||||
loadOverview();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
applyDefaultRange();
|
||||
loadOverview();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="card mb-4">
|
||||
<div class="flex items-start justify-between gap-3 mb-4">
|
||||
<div>
|
||||
<h4 class="m-0">平台健康与告警</h4>
|
||||
<p class="text-sm text-muted-color mt-2">{{ rangeText }}</p>
|
||||
</div>
|
||||
<Button label="刷新" icon="pi pi-refresh" severity="secondary" :loading="loading" @click="loadOverview" />
|
||||
</div>
|
||||
|
||||
<SearchPanel :loading="loading" @search="onSearch" @reset="onReset">
|
||||
<SearchField label="TenantID">
|
||||
<InputNumber v-model="tenantID" :min="1" placeholder="精确匹配" class="w-full" />
|
||||
</SearchField>
|
||||
<SearchField label="统计开始">
|
||||
<DatePicker v-model="startAt" showIcon showButtonBar placeholder="开始时间" class="w-full" />
|
||||
</SearchField>
|
||||
<SearchField label="统计结束">
|
||||
<DatePicker v-model="endAt" showIcon showButtonBar placeholder="结束时间" class="w-full" />
|
||||
</SearchField>
|
||||
<SearchField label="上传超时(小时)">
|
||||
<InputNumber v-model="uploadStuckHours" :min="1" :max="168" placeholder="例如 24" class="w-full" />
|
||||
</SearchField>
|
||||
</SearchPanel>
|
||||
</div>
|
||||
|
||||
<StatisticsStrip :items="coreItems" containerClass="card mb-4" />
|
||||
<StatisticsStrip :items="uploadItems" containerClass="card mb-4" />
|
||||
|
||||
<div class="card">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h5 class="m-0">健康告警</h5>
|
||||
<Tag :value="`共 ${alertItems.length} 条`" severity="secondary" />
|
||||
</div>
|
||||
|
||||
<DataTable :value="alertItems" :loading="loading">
|
||||
<template #empty>
|
||||
<span class="text-muted-color">暂无告警</span>
|
||||
</template>
|
||||
<Column header="级别" style="min-width: 8rem">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="formatAlertLevel(data.level)" :severity="getAlertSeverity(data.level)" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="类型" style="min-width: 10rem">
|
||||
<template #body="{ data }">
|
||||
{{ formatAlertKind(data.kind) }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="租户" style="min-width: 14rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium">{{ data.tenant_name || '-' }}</span>
|
||||
<span class="text-sm text-muted-color">{{ data.tenant_code || (data.tenant_id ? `ID:${data.tenant_id}` : '-') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="title" header="标题" style="min-width: 14rem" />
|
||||
<Column header="详情" style="min-width: 18rem">
|
||||
<template #body="{ data }">
|
||||
<span class="block max-w-[360px] truncate" :title="data.detail">{{ data.detail || '-' }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="count" header="数量" style="min-width: 8rem" />
|
||||
<Column field="updated_at" header="更新时间" style="min-width: 14rem">
|
||||
<template #body="{ data }">
|
||||
{{ formatDate(data.updated_at) }}
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user