feat: deepen report metrics

This commit is contained in:
2026-01-15 17:50:37 +08:00
parent ba1d120c84
commit 914df9edf2
10 changed files with 1163 additions and 52 deletions

View File

@@ -3,9 +3,11 @@ import SearchField from '@/components/SearchField.vue';
import SearchPanel from '@/components/SearchPanel.vue';
import { FinanceService } from '@/service/FinanceService';
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 withdrawals = ref([]);
const loading = ref(false);
@@ -37,6 +39,24 @@ const statusOptions = [
{ label: 'failed', value: 'failed' }
];
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;
}
const approveDialogVisible = ref(false);
const approveLoading = ref(false);
const approveOrder = ref(null);
@@ -109,7 +129,7 @@ function onSearch() {
loadWithdrawals();
}
function onReset() {
function resetFilters() {
orderID.value = null;
tenantID.value = null;
tenantCode.value = '';
@@ -127,6 +147,44 @@ function onReset() {
sortOrder.value = -1;
page.value = 1;
rows.value = 10;
}
function applyRouteQuery(query) {
resetFilters();
const idValue = getQueryValue(query?.id);
const tenantValue = getQueryValue(query?.tenant_id);
const userValue = getQueryValue(query?.user_id);
if (idValue) orderID.value = parseNumber(idValue);
if (tenantValue) tenantID.value = parseNumber(tenantValue);
if (userValue) userID.value = parseNumber(userValue);
const statusValue = getQueryValue(query?.status);
const tenantCodeValue = getQueryValue(query?.tenant_code);
const tenantNameValue = getQueryValue(query?.tenant_name);
const usernameValue = getQueryValue(query?.username);
if (statusValue !== null) status.value = String(statusValue);
if (tenantCodeValue !== null) tenantCode.value = String(tenantCodeValue);
if (tenantNameValue !== null) tenantName.value = String(tenantNameValue);
if (usernameValue !== null) username.value = String(usernameValue);
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);
const amountMinValue = getQueryValue(query?.amount_paid_min);
const amountMaxValue = getQueryValue(query?.amount_paid_max);
if (amountMinValue) amountPaidMin.value = parseNumber(amountMinValue);
if (amountMaxValue) amountPaidMax.value = parseNumber(amountMaxValue);
}
function onReset() {
resetFilters();
loadWithdrawals();
}
@@ -185,7 +243,14 @@ async function confirmReject() {
}
}
loadWithdrawals();
watch(
() => route.query,
(query) => {
applyRouteQuery(query);
loadWithdrawals();
},
{ immediate: true }
);
</script>
<template>

View File

@@ -24,6 +24,12 @@ const orderChartData = ref(null);
const amountChartData = ref(null);
const orderChartOptions = ref(null);
const amountChartOptions = ref(null);
const withdrawOrderChartData = ref(null);
const withdrawAmountChartData = ref(null);
const withdrawOrderChartOptions = ref(null);
const withdrawAmountChartOptions = ref(null);
const interactionChartData = ref(null);
const interactionChartOptions = ref(null);
const granularityOptions = [{ label: '按天', value: 'day' }];
@@ -63,9 +69,40 @@ function goToOrders(status, usePaidAt) {
router.push({ name: 'superadmin-orders', query: buildOrdersQuery({ status, usePaidAt }) });
}
function goToContents() {
function buildWithdrawalsQuery({ 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;
} else {
if (start) query.created_at_from = start;
if (end) query.created_at_to = end;
}
return query;
}
function goToWithdrawals(status, usePaidAt) {
router.push({ name: 'superadmin-finance', query: buildWithdrawalsQuery({ status, usePaidAt }) });
}
function buildContentsQuery({ useCreatedAt } = {}) {
const query = {};
if (tenantID.value) query.tenant_id = tenantID.value;
if (useCreatedAt) {
const start = formatDateParam(startAt.value);
const end = formatDateParam(endAt.value);
if (start) query.created_at_from = start;
if (end) query.created_at_to = end;
}
return query;
}
function goToContents(extraQuery = {}) {
const query = { ...buildContentsQuery(), ...extraQuery };
router.push({ name: 'superadmin-contents', query });
}
@@ -127,6 +164,129 @@ function buildAmountChartData(items) {
};
}
function buildWithdrawOrderChartData(items) {
const documentStyle = getComputedStyle(document.documentElement);
const labels = items.map((item) => item.date);
const applyOrders = items.map((item) => Number(item.withdrawal_apply_orders ?? 0));
const paidOrders = items.map((item) => Number(item.withdrawal_paid_orders ?? 0));
const failedOrders = items.map((item) => Number(item.withdrawal_failed_orders ?? 0));
return {
labels,
datasets: [
{
label: '提现申请',
data: applyOrders,
fill: true,
backgroundColor: documentStyle.getPropertyValue('--p-primary-200'),
borderColor: documentStyle.getPropertyValue('--p-primary-500'),
tension: 0.35
},
{
label: '提现完成',
data: paidOrders,
fill: true,
backgroundColor: documentStyle.getPropertyValue('--p-emerald-200'),
borderColor: documentStyle.getPropertyValue('--p-emerald-500'),
tension: 0.35
},
{
label: '提现失败',
data: failedOrders,
fill: true,
backgroundColor: documentStyle.getPropertyValue('--p-rose-200'),
borderColor: documentStyle.getPropertyValue('--p-rose-500'),
tension: 0.35
}
]
};
}
function buildWithdrawAmountChartData(items) {
const documentStyle = getComputedStyle(document.documentElement);
const labels = items.map((item) => item.date);
const applyAmounts = items.map((item) => Number(item.withdrawal_apply_amount ?? 0));
const paidAmounts = items.map((item) => Number(item.withdrawal_paid_amount ?? 0));
const failedAmounts = items.map((item) => Number(item.withdrawal_failed_amount ?? 0));
return {
labels,
datasets: [
{
label: '申请金额',
data: applyAmounts,
fill: true,
backgroundColor: documentStyle.getPropertyValue('--p-primary-200'),
borderColor: documentStyle.getPropertyValue('--p-primary-500'),
tension: 0.35
},
{
label: '完成金额',
data: paidAmounts,
fill: true,
backgroundColor: documentStyle.getPropertyValue('--p-emerald-200'),
borderColor: documentStyle.getPropertyValue('--p-emerald-500'),
tension: 0.35
},
{
label: '失败金额',
data: failedAmounts,
fill: true,
backgroundColor: documentStyle.getPropertyValue('--p-rose-200'),
borderColor: documentStyle.getPropertyValue('--p-rose-500'),
tension: 0.35
}
]
};
}
function buildInteractionChartData(items) {
const documentStyle = getComputedStyle(document.documentElement);
const labels = items.map((item) => item.date);
const contentCreated = items.map((item) => Number(item.content_created ?? 0));
const likeActions = items.map((item) => Number(item.like_actions ?? 0));
const favoriteActions = items.map((item) => Number(item.favorite_actions ?? 0));
const commentCount = items.map((item) => Number(item.comment_count ?? 0));
return {
labels,
datasets: [
{
label: '新增内容',
data: contentCreated,
fill: true,
backgroundColor: documentStyle.getPropertyValue('--p-primary-200'),
borderColor: documentStyle.getPropertyValue('--p-primary-500'),
tension: 0.35
},
{
label: '新增点赞',
data: likeActions,
fill: true,
backgroundColor: documentStyle.getPropertyValue('--p-emerald-200'),
borderColor: documentStyle.getPropertyValue('--p-emerald-500'),
tension: 0.35
},
{
label: '新增收藏',
data: favoriteActions,
fill: true,
backgroundColor: documentStyle.getPropertyValue('--p-orange-200'),
borderColor: documentStyle.getPropertyValue('--p-orange-500'),
tension: 0.35
},
{
label: '新增评论',
data: commentCount,
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');
@@ -186,6 +346,12 @@ function updateCharts() {
amountChartData.value = buildAmountChartData(items);
orderChartOptions.value = buildChartOptions({ amountMode: false });
amountChartOptions.value = buildChartOptions({ amountMode: true });
withdrawOrderChartData.value = buildWithdrawOrderChartData(items);
withdrawAmountChartData.value = buildWithdrawAmountChartData(items);
withdrawOrderChartOptions.value = buildChartOptions({ amountMode: false });
withdrawAmountChartOptions.value = buildChartOptions({ amountMode: true });
interactionChartData.value = buildInteractionChartData(items);
interactionChartOptions.value = buildChartOptions({ amountMode: false });
}
const summaryItems = computed(() => {
@@ -202,6 +368,31 @@ const summaryItems = computed(() => {
];
});
const withdrawSummaryItems = computed(() => {
const summary = overview.value?.summary;
if (!summary) return [];
return [
{ key: 'withdraw-apply', label: '提现申请:', value: summary.withdrawal_apply_orders ?? 0, icon: 'pi-inbox', onClick: () => goToWithdrawals('created', false) },
{ key: 'withdraw-paid', label: '提现完成:', value: summary.withdrawal_paid_orders ?? 0, icon: 'pi-check-circle', onClick: () => goToWithdrawals('paid', true) },
{ key: 'withdraw-failed', label: '提现失败:', value: summary.withdrawal_failed_orders ?? 0, icon: 'pi-times-circle', onClick: () => goToWithdrawals('failed', false) }
];
});
const contentSummaryItems = computed(() => {
const summary = overview.value?.summary;
if (!summary) return [];
const rangeQuery = buildContentsQuery({ useCreatedAt: true });
return [
{ key: 'content-count', label: '内容总量:', value: summary.content_count ?? 0, icon: 'pi-book', onClick: () => goToContents() },
{ key: 'content-created', label: '新增内容:', value: summary.content_created ?? 0, icon: 'pi-plus', onClick: () => goToContents(rangeQuery) },
{ key: 'like-actions', label: '新增点赞:', value: summary.like_actions ?? 0, icon: 'pi-thumbs-up', onClick: () => goToContents(rangeQuery) },
{ key: 'favorite-actions', label: '新增收藏:', value: summary.favorite_actions ?? 0, icon: 'pi-star', onClick: () => goToContents(rangeQuery) },
{ key: 'comment-count', label: '新增评论:', value: summary.comment_count ?? 0, icon: 'pi-comments', onClick: () => goToContents(rangeQuery) }
];
});
async function loadOverview() {
loading.value = true;
try {
@@ -287,6 +478,8 @@ watch([getPrimary, getSurface, isDarkTheme], () => {
</div>
<StatisticsStrip v-if="summaryItems.length" :items="summaryItems" containerClass="card mb-4" />
<StatisticsStrip v-if="withdrawSummaryItems.length" :items="withdrawSummaryItems" containerClass="card mb-4" />
<StatisticsStrip v-if="contentSummaryItems.length" :items="contentSummaryItems" containerClass="card mb-4" />
<div class="card mb-4">
<div class="flex items-center justify-between mb-4">
@@ -313,6 +506,41 @@ watch([getPrimary, getSurface, isDarkTheme], () => {
</div>
</div>
<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">申请 / 完成 / 失败</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 完成 vs 失败</span>
</div>
<Chart type="line" :data="withdrawOrderChartData" :options="withdrawOrderChartOptions" 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="withdrawAmountChartData" :options="withdrawAmountChartOptions" class="h-72" />
</div>
</div>
</div>
<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">新增内容 / 点赞 / 收藏 / 评论</span>
</div>
</div>
<Chart type="line" :data="interactionChartData" :options="interactionChartOptions" class="h-72" />
</div>
<div class="card">
<div class="flex items-center justify-between mb-4">
<h4 class="m-0">趋势明细</h4>
@@ -331,6 +559,28 @@ watch([getPrimary, getSurface, isDarkTheme], () => {
{{ formatCnyFromYuan(data.refund_amount) }}
</template>
</Column>
<Column field="withdrawal_apply_orders" header="提现申请" style="min-width: 10rem" />
<Column field="withdrawal_apply_amount" header="申请金额" style="min-width: 12rem">
<template #body="{ data }">
{{ formatCnyFromYuan(data.withdrawal_apply_amount) }}
</template>
</Column>
<Column field="withdrawal_paid_orders" header="提现完成" style="min-width: 10rem" />
<Column field="withdrawal_paid_amount" header="完成金额" style="min-width: 12rem">
<template #body="{ data }">
{{ formatCnyFromYuan(data.withdrawal_paid_amount) }}
</template>
</Column>
<Column field="withdrawal_failed_orders" header="提现失败" style="min-width: 10rem" />
<Column field="withdrawal_failed_amount" header="失败金额" style="min-width: 12rem">
<template #body="{ data }">
{{ formatCnyFromYuan(data.withdrawal_failed_amount) }}
</template>
</Column>
<Column field="content_created" header="新增内容" style="min-width: 10rem" />
<Column field="like_actions" header="新增点赞" style="min-width: 10rem" />
<Column field="favorite_actions" header="新增收藏" style="min-width: 10rem" />
<Column field="comment_count" header="新增评论" style="min-width: 10rem" />
</DataTable>
</div>
</div>