feat: enhance superadmin dashboard overview

This commit is contained in:
2026-01-15 14:16:20 +08:00
parent a8453e0c6c
commit c683fa5cf3
10 changed files with 449 additions and 10 deletions

View File

@@ -130,5 +130,21 @@ export const ContentService = {
reason
}
});
},
async getContentStatistics({ tenant_id, start_at, end_at, granularity } = {}) {
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),
granularity
};
return requestJson('/super/v1/contents/statistics', { query });
}
};

View File

@@ -1,12 +1,16 @@
<script setup>
import StatisticsStrip from '@/components/StatisticsStrip.vue';
import { useLayout } from '@/layout/composables/layout';
import { ContentService } from '@/service/ContentService';
import { OrderService } from '@/service/OrderService';
import { ReportService } from '@/service/ReportService';
import { TenantService } from '@/service/TenantService';
import { UserService } from '@/service/UserService';
import { useToast } from 'primevue/usetoast';
import { computed, onMounted, ref } from 'vue';
import { computed, onMounted, ref, watch } from 'vue';
const toast = useToast();
const { getPrimary, getSurface, isDarkTheme } = useLayout();
const tenantTotal = ref(null);
const tenantLoading = ref(false);
@@ -17,12 +21,25 @@ const statisticsLoading = ref(false);
const orderStats = ref(null);
const orderStatsLoading = ref(false);
const contentStats = ref(null);
const contentStatsLoading = ref(false);
const contentTrendData = ref(null);
const contentTrendOptions = ref(null);
const reportOverview = ref(null);
const reportLoading = ref(false);
function formatCny(amountInCents) {
const amount = Number(amountInCents) / 100;
if (!Number.isFinite(amount)) return '-';
return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' }).format(amount);
}
function formatPercent(rate) {
if (!Number.isFinite(rate)) return '-';
return `${(rate * 100).toFixed(2)}%`;
}
const statisticsItems = computed(() => {
const total = (statistics.value || []).reduce((sum, row) => sum + (Number(row?.count) || 0), 0);
const statusIcon = (status) => {
@@ -89,6 +106,103 @@ const orderItems = computed(() => {
];
});
const orderFunnelItems = computed(() => {
const byStatus = new Map((orderStats.value?.by_status || []).map((row) => [row?.status, row]));
const getCount = (status) => byStatus.get(status)?.count ?? 0;
return [
{ key: 'orders-created', label: '创建:', value: orderStatsLoading.value ? '-' : getCount('created'), icon: 'pi-file' },
{ key: 'orders-paid-funnel', label: '已支付:', value: orderStatsLoading.value ? '-' : getCount('paid'), icon: 'pi-check-circle' },
{ key: 'orders-refunding-funnel', label: '退款中:', value: orderStatsLoading.value ? '-' : getCount('refunding'), icon: 'pi-spin pi-spinner' },
{ key: 'orders-refunded-funnel', label: '已退款:', value: orderStatsLoading.value ? '-' : getCount('refunded'), icon: 'pi-undo' }
];
});
const contentItems = computed(() => {
const trend = contentStats.value?.trend || [];
const recentCreated = trend.reduce((sum, item) => sum + (Number(item?.created_count) || 0), 0);
return [
{ key: 'contents-total', label: '内容总量:', value: contentStatsLoading.value ? '-' : (contentStats.value?.total_count ?? 0), icon: 'pi-book' },
{ key: 'contents-recent', label: '近7日新增', value: contentStatsLoading.value ? '-' : recentCreated, icon: 'pi-chart-line' }
];
});
const refundRateText = computed(() => {
if (reportLoading.value) return '-';
const summary = reportOverview.value?.summary;
const paidOrders = Number(summary?.paid_orders ?? 0);
const refundOrders = Number(summary?.refund_orders ?? 0);
const denominator = paidOrders + refundOrders;
if (!denominator) return '0%';
return formatPercent(refundOrders / denominator);
});
function buildContentChartData(items) {
const documentStyle = getComputedStyle(document.documentElement);
const labels = items.map((item) => item?.date ?? '-');
const counts = items.map((item) => Number(item?.created_count ?? 0));
return {
labels,
datasets: [
{
label: '新增内容',
data: counts,
fill: true,
backgroundColor: documentStyle.getPropertyValue('--p-primary-200'),
borderColor: documentStyle.getPropertyValue('--p-primary-500'),
tension: 0.35
}
]
};
}
function buildChartOptions() {
const documentStyle = getComputedStyle(document.documentElement);
const textColor = documentStyle.getPropertyValue('--text-color');
const textMutedColor = documentStyle.getPropertyValue('--text-color-secondary');
const borderColor = documentStyle.getPropertyValue('--surface-border');
return {
maintainAspectRatio: false,
aspectRatio: 2.2,
plugins: {
legend: {
labels: {
color: textColor
}
}
},
scales: {
x: {
ticks: {
color: textMutedColor
},
grid: {
color: 'transparent',
borderColor: 'transparent'
}
},
y: {
ticks: {
color: textMutedColor
},
grid: {
color: borderColor,
borderColor: 'transparent',
drawTicks: false
}
}
}
};
}
function updateContentChart() {
const items = contentStats.value?.trend ?? [];
contentTrendData.value = buildContentChartData(items);
contentTrendOptions.value = buildChartOptions();
}
async function loadTenantTotal() {
tenantLoading.value = true;
try {
@@ -123,10 +237,39 @@ async function loadOrderStatistics() {
}
}
async function loadContentStatistics() {
contentStatsLoading.value = true;
try {
contentStats.value = await ContentService.getContentStatistics({ granularity: 'day' });
updateContentChart();
} catch (error) {
toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载内容统计信息', life: 4000 });
} finally {
contentStatsLoading.value = false;
}
}
async function loadReportOverview() {
reportLoading.value = true;
try {
reportOverview.value = await ReportService.getOverview({ granularity: 'day' });
} catch (error) {
toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载报表指标', life: 4000 });
} finally {
reportLoading.value = false;
}
}
onMounted(() => {
loadTenantTotal();
loadStatistics();
loadOrderStatistics();
loadContentStatistics();
loadReportOverview();
});
watch([getPrimary, getSurface, isDarkTheme], () => {
updateContentChart();
});
</script>
@@ -136,6 +279,32 @@ onMounted(() => {
<StatisticsStrip :items="orderItems" containerClass="card mb-4" />
<StatisticsStrip :items="statisticsItems" containerClass="card mb-4" />
<div class="grid">
<div class="col-12 xl:col-7">
<div class="card">
<div class="flex items-center justify-between mb-4">
<div>
<h4 class="m-0">内容趋势</h4>
<div class="text-sm text-muted-color">近7日</div>
</div>
</div>
<StatisticsStrip :items="contentItems" containerClass="p-0 mb-4" />
<Chart v-if="contentTrendData" type="line" :data="contentTrendData" :options="contentTrendOptions" class="h-72" />
</div>
</div>
<div class="col-12 xl:col-5">
<div class="card">
<div class="flex items-center justify-between mb-4">
<div>
<h4 class="m-0">订单漏斗</h4>
<div class="text-sm text-muted-color">近7日退款率{{ refundRateText }}</div>
</div>
</div>
<StatisticsStrip :items="orderFunnelItems" containerClass="p-0" />
</div>
</div>
</div>
<div class="card">
<div class="flex items-center justify-between mb-4">
<h4 class="m-0">快捷入口</h4>