feat: enhance superadmin dashboard overview
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user