feat: add report charts and drilldowns

This commit is contained in:
2026-01-15 10:27:03 +08:00
parent 235a216b0c
commit d66b9f2dfa
4 changed files with 368 additions and 49 deletions

View File

@@ -14,7 +14,15 @@ const props = defineProps({
<template>
<div :class="props.containerClass">
<div class="flex flex-wrap items-center justify-between gap-6">
<div v-for="(item, idx) in props.items" :key="item.key ?? idx" class="flex items-center gap-4 flex-1 min-w-[220px]">
<component
:is="item.onClick ? 'button' : 'div'"
v-for="(item, idx) in props.items"
:key="item.key ?? idx"
class="flex items-center gap-4 flex-1 min-w-[220px] text-left"
:class="item.onClick ? 'cursor-pointer transition-colors hover:bg-surface-100 dark:hover:bg-surface-800 rounded-lg p-2 -m-2 border-0 bg-transparent' : ''"
:type="item.onClick ? 'button' : undefined"
@click="item.onClick && item.onClick()"
>
<div class="w-12 h-12 rounded-full flex items-center justify-center bg-surface-100 dark:bg-surface-800">
<i class="pi text-primary text-xl" :class="item.icon" />
</div>
@@ -23,7 +31,7 @@ const props = defineProps({
<span class="text-surface-900 dark:text-surface-0 text-xl font-semibold" :class="item.valueClass">{{ item.value }}</span>
</div>
<div v-if="idx !== props.items.length - 1" class="hidden xl:block w-px self-stretch bg-surface-200 dark:bg-surface-700 ml-auto"></div>
</div>
</component>
</div>
</div>
</template>

View File

@@ -3,9 +3,11 @@ import SearchField from '@/components/SearchField.vue';
import SearchPanel from '@/components/SearchPanel.vue';
import { ContentService } from '@/service/ContentService';
import { useToast } from 'primevue/usetoast';
import { computed, onMounted, ref } from 'vue';
import { computed, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
const toast = useToast();
const route = useRoute();
const loading = ref(false);
const contents = ref([]);
@@ -47,6 +49,24 @@ const visibilityOptions = [
{ label: 'private', value: 'private' }
];
function getQueryValue(value) {
if (Array.isArray(value)) return value[0];
return value ?? null;
}
function parseNumber(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return null;
return parsed;
}
function parseDate(value) {
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
return date;
}
function formatDate(value) {
if (!value) return '-';
if (String(value).startsWith('0001-01-01')) return '-';
@@ -55,6 +75,56 @@ function formatDate(value) {
return date.toLocaleString();
}
function resetFilters() {
contentID.value = null;
tenantID.value = null;
tenantCode.value = '';
tenantName.value = '';
ownerUserID.value = null;
ownerUsername.value = '';
keyword.value = '';
status.value = '';
visibility.value = '';
publishedAtFrom.value = null;
publishedAtTo.value = null;
createdAtFrom.value = null;
createdAtTo.value = null;
priceAmountMin.value = null;
priceAmountMax.value = null;
sortField.value = 'id';
sortOrder.value = -1;
}
function applyRouteQuery(query) {
resetFilters();
const idValue = getQueryValue(query?.id);
const tenantValue = getQueryValue(query?.tenant_id);
const userValue = getQueryValue(query?.user_id);
if (idValue) contentID.value = parseNumber(idValue);
if (tenantValue) tenantID.value = parseNumber(tenantValue);
if (userValue) ownerUserID.value = parseNumber(userValue);
const statusValue = getQueryValue(query?.status);
const visibilityValue = getQueryValue(query?.visibility);
const keywordValue = getQueryValue(query?.keyword);
if (statusValue !== null) status.value = String(statusValue);
if (visibilityValue !== null) visibility.value = String(visibilityValue);
if (keywordValue !== null) keyword.value = String(keywordValue);
const publishedFromValue = getQueryValue(query?.published_at_from);
const publishedToValue = getQueryValue(query?.published_at_to);
const createdFromValue = getQueryValue(query?.created_at_from);
const createdToValue = getQueryValue(query?.created_at_to);
if (publishedFromValue) publishedAtFrom.value = parseDate(publishedFromValue);
if (publishedToValue) publishedAtTo.value = parseDate(publishedToValue);
if (createdFromValue) createdAtFrom.value = parseDate(createdFromValue);
if (createdToValue) createdAtTo.value = parseDate(createdToValue);
}
function formatCny(amountInCents) {
const amount = Number(amountInCents) / 100;
if (!Number.isFinite(amount)) return '-';
@@ -128,23 +198,7 @@ function onSearch() {
}
function onReset() {
contentID.value = null;
tenantID.value = null;
tenantCode.value = '';
tenantName.value = '';
ownerUserID.value = null;
ownerUsername.value = '';
keyword.value = '';
status.value = '';
visibility.value = '';
publishedAtFrom.value = null;
publishedAtTo.value = null;
createdAtFrom.value = null;
createdAtTo.value = null;
priceAmountMin.value = null;
priceAmountMax.value = null;
sortField.value = 'id';
sortOrder.value = -1;
resetFilters();
page.value = 1;
rows.value = 10;
loadContents();
@@ -192,9 +246,16 @@ async function confirmUnpublish() {
}
}
onMounted(() => {
loadContents();
});
watch(
() => route.query,
(query) => {
applyRouteQuery(query);
page.value = 1;
rows.value = 10;
loadContents();
},
{ immediate: true }
);
</script>
<template>

View File

@@ -3,9 +3,11 @@ import SearchField from '@/components/SearchField.vue';
import SearchPanel from '@/components/SearchPanel.vue';
import { OrderService } from '@/service/OrderService';
import { useToast } from 'primevue/usetoast';
import { ref } from 'vue';
import { ref, watch } from 'vue';
import { useRoute } from 'vue-router';
const toast = useToast();
const route = useRoute();
const orders = ref([]);
const loading = ref(false);
@@ -54,6 +56,24 @@ const typeOptions = [
{ label: 'content_purchase', value: 'content_purchase' }
];
function getQueryValue(value) {
if (Array.isArray(value)) return value[0];
return value ?? null;
}
function parseNumber(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return null;
return parsed;
}
function parseDate(value) {
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
return date;
}
function formatDate(value) {
if (!value) return '-';
if (String(value).startsWith('0001-01-01')) return '-';
@@ -62,6 +82,56 @@ function formatDate(value) {
return date.toLocaleString();
}
function resetFilters() {
orderID.value = null;
tenantID.value = null;
tenantCode.value = '';
tenantName.value = '';
buyerUserID.value = null;
buyerUsername.value = '';
contentID.value = null;
contentTitle.value = '';
status.value = '';
type.value = '';
createdAtFrom.value = null;
createdAtTo.value = null;
paidAtFrom.value = null;
paidAtTo.value = null;
amountPaidMin.value = null;
amountPaidMax.value = null;
sortField.value = 'id';
sortOrder.value = -1;
}
function applyRouteQuery(query) {
resetFilters();
const idValue = getQueryValue(query?.id);
const tenantValue = getQueryValue(query?.tenant_id);
const userValue = getQueryValue(query?.user_id);
const contentValue = getQueryValue(query?.content_id);
if (idValue) orderID.value = parseNumber(idValue);
if (tenantValue) tenantID.value = parseNumber(tenantValue);
if (userValue) buyerUserID.value = parseNumber(userValue);
if (contentValue) contentID.value = parseNumber(contentValue);
const statusValue = getQueryValue(query?.status);
const typeValue = getQueryValue(query?.type);
if (statusValue !== null) status.value = String(statusValue);
if (typeValue !== null) type.value = String(typeValue);
const createdFromValue = getQueryValue(query?.created_at_from);
const createdToValue = getQueryValue(query?.created_at_to);
const paidFromValue = getQueryValue(query?.paid_at_from);
const paidToValue = getQueryValue(query?.paid_at_to);
if (createdFromValue) createdAtFrom.value = parseDate(createdFromValue);
if (createdToValue) createdAtTo.value = parseDate(createdToValue);
if (paidFromValue) paidAtFrom.value = parseDate(paidFromValue);
if (paidToValue) paidAtTo.value = parseDate(paidToValue);
}
function formatCny(amountInCents) {
const amount = Number(amountInCents) / 100;
if (!Number.isFinite(amount)) return '-';
@@ -147,24 +217,7 @@ function onSearch() {
}
function onReset() {
orderID.value = null;
tenantID.value = null;
tenantCode.value = '';
tenantName.value = '';
buyerUserID.value = null;
buyerUsername.value = '';
contentID.value = null;
contentTitle.value = '';
status.value = '';
type.value = '';
createdAtFrom.value = null;
createdAtTo.value = null;
paidAtFrom.value = null;
paidAtTo.value = null;
amountPaidMin.value = null;
amountPaidMax.value = null;
sortField.value = 'id';
sortOrder.value = -1;
resetFilters();
page.value = 1;
rows.value = 10;
loadOrders();
@@ -182,7 +235,16 @@ function onSort(event) {
loadOrders();
}
loadOrders();
watch(
() => route.query,
(query) => {
applyRouteQuery(query);
page.value = 1;
rows.value = 10;
loadOrders();
},
{ immediate: true }
);
</script>
<template>

View File

@@ -2,11 +2,15 @@
import SearchField from '@/components/SearchField.vue';
import SearchPanel from '@/components/SearchPanel.vue';
import StatisticsStrip from '@/components/StatisticsStrip.vue';
import { useLayout } from '@/layout/composables/layout';
import { ReportService } from '@/service/ReportService';
import { useToast } from 'primevue/usetoast';
import { computed, ref } from 'vue';
import { computed, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
const toast = useToast();
const router = useRouter();
const { getPrimary, getSurface, isDarkTheme } = useLayout();
const overview = ref(null);
const loading = ref(false);
@@ -16,6 +20,11 @@ const startAt = ref(null);
const endAt = ref(null);
const granularity = ref('day');
const orderChartData = ref(null);
const amountChartData = ref(null);
const orderChartOptions = ref(null);
const amountChartOptions = ref(null);
const granularityOptions = [{ label: '按天', value: 'day' }];
function formatPercent(value) {
@@ -30,16 +39,165 @@ function formatCnyFromYuan(amount) {
return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' }).format(value);
}
function formatDateParam(value) {
if (!value) return undefined;
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return undefined;
return date.toISOString();
}
function buildOrdersQuery({ status, usePaidAt } = {}) {
const query = {};
if (tenantID.value) query.tenant_id = tenantID.value;
if (status) query.status = status;
const start = formatDateParam(startAt.value);
const end = formatDateParam(endAt.value);
if (usePaidAt) {
if (start) query.paid_at_from = start;
if (end) query.paid_at_to = end;
}
return query;
}
function goToOrders(status, usePaidAt) {
router.push({ name: 'superadmin-orders', query: buildOrdersQuery({ status, usePaidAt }) });
}
function goToContents() {
const query = {};
if (tenantID.value) query.tenant_id = tenantID.value;
router.push({ name: 'superadmin-contents', query });
}
function buildOrderChartData(items) {
const documentStyle = getComputedStyle(document.documentElement);
const labels = items.map((item) => item.date);
const paidOrders = items.map((item) => Number(item.paid_orders ?? 0));
const refundOrders = items.map((item) => Number(item.refund_orders ?? 0));
return {
labels,
datasets: [
{
label: '已支付订单',
data: paidOrders,
fill: true,
backgroundColor: documentStyle.getPropertyValue('--p-primary-200'),
borderColor: documentStyle.getPropertyValue('--p-primary-500'),
tension: 0.35
},
{
label: '退款订单',
data: refundOrders,
fill: true,
backgroundColor: documentStyle.getPropertyValue('--p-orange-200'),
borderColor: documentStyle.getPropertyValue('--p-orange-500'),
tension: 0.35
}
]
};
}
function buildAmountChartData(items) {
const documentStyle = getComputedStyle(document.documentElement);
const labels = items.map((item) => item.date);
const paidAmounts = items.map((item) => Number(item.paid_amount ?? 0));
const refundAmounts = items.map((item) => Number(item.refund_amount ?? 0));
return {
labels,
datasets: [
{
label: '已支付金额',
data: paidAmounts,
fill: true,
backgroundColor: documentStyle.getPropertyValue('--p-emerald-200'),
borderColor: documentStyle.getPropertyValue('--p-emerald-500'),
tension: 0.35
},
{
label: '退款金额',
data: refundAmounts,
fill: true,
backgroundColor: documentStyle.getPropertyValue('--p-rose-200'),
borderColor: documentStyle.getPropertyValue('--p-rose-500'),
tension: 0.35
}
]
};
}
function buildChartOptions({ amountMode } = {}) {
const documentStyle = getComputedStyle(document.documentElement);
const textColor = documentStyle.getPropertyValue('--text-color');
const textMutedColor = documentStyle.getPropertyValue('--text-color-secondary');
const borderColor = documentStyle.getPropertyValue('--surface-border');
const options = {
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
}
}
}
};
if (amountMode) {
options.plugins.tooltip = {
callbacks: {
label(context) {
const label = context.dataset?.label ? `${context.dataset.label}: ` : '';
return `${label}${formatCnyFromYuan(context.parsed?.y ?? 0)}`;
}
}
};
}
return options;
}
function updateCharts() {
const items = overview.value?.items ?? [];
orderChartData.value = buildOrderChartData(items);
amountChartData.value = buildAmountChartData(items);
orderChartOptions.value = buildChartOptions({ amountMode: false });
amountChartOptions.value = buildChartOptions({ amountMode: true });
}
const summaryItems = computed(() => {
const summary = overview.value?.summary;
if (!summary) return [];
return [
{ key: 'views', label: '累计曝光:', value: summary.total_views ?? 0, icon: 'pi-eye' },
{ key: 'paid-orders', label: '已支付订单:', value: summary.paid_orders ?? 0, icon: 'pi-shopping-cart' },
{ key: 'paid-amount', label: '已支付金额:', value: formatCnyFromYuan(summary.paid_amount), icon: 'pi-wallet' },
{ key: 'refund-orders', label: '退款订单:', value: summary.refund_orders ?? 0, icon: 'pi-undo' },
{ key: 'refund-amount', label: '退款金额:', value: formatCnyFromYuan(summary.refund_amount), icon: 'pi-replay' },
{ key: 'views', label: '累计曝光:', value: summary.total_views ?? 0, icon: 'pi-eye', onClick: goToContents },
{ key: 'paid-orders', label: '已支付订单:', value: summary.paid_orders ?? 0, icon: 'pi-shopping-cart', onClick: () => goToOrders('paid', true) },
{ key: 'paid-amount', label: '已支付金额:', value: formatCnyFromYuan(summary.paid_amount), icon: 'pi-wallet', onClick: () => goToOrders('paid', true) },
{ key: 'refund-orders', label: '退款订单:', value: summary.refund_orders ?? 0, icon: 'pi-undo', onClick: () => goToOrders('refunded', false) },
{ key: 'refund-amount', label: '退款金额:', value: formatCnyFromYuan(summary.refund_amount), icon: 'pi-replay', onClick: () => goToOrders('refunded', false) },
{ key: 'conversion', label: '转化率:', value: formatPercent(summary.conversion_rate), icon: 'pi-percentage' }
];
});
@@ -53,6 +211,7 @@ async function loadOverview() {
end_at: endAt.value || undefined,
granularity: granularity.value
});
updateCharts();
} catch (error) {
toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载报表数据', life: 4000 });
} finally {
@@ -97,6 +256,10 @@ async function exportReport() {
}
loadOverview();
watch([getPrimary, getSurface, isDarkTheme], () => {
updateCharts();
});
</script>
<template>
@@ -125,6 +288,31 @@ loadOverview();
<StatisticsStrip v-if="summaryItems.length" :items="summaryItems" containerClass="card mb-4" />
<div class="card mb-4">
<div class="flex items-center justify-between mb-4">
<div class="flex flex-col">
<h4 class="m-0">趋势概览</h4>
<span class="text-muted-color"> {{ granularity === 'day' ? '天' : granularity }} 汇总</span>
</div>
</div>
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6">
<div class="flex flex-col gap-3">
<div class="flex items-center justify-between">
<span class="font-medium">订单趋势</span>
<span class="text-muted-color text-sm">已支付 vs 退款</span>
</div>
<Chart type="line" :data="orderChartData" :options="orderChartOptions" class="h-72" />
</div>
<div class="flex flex-col gap-3">
<div class="flex items-center justify-between">
<span class="font-medium">金额趋势</span>
<span class="text-muted-color text-sm">单位</span>
</div>
<Chart type="line" :data="amountChartData" :options="amountChartOptions" class="h-72" />
</div>
</div>
</div>
<div class="card">
<div class="flex items-center justify-between mb-4">
<h4 class="m-0">趋势明细</h4>