feat: add report charts and drilldowns
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user