feat: add tenant finance report overview

This commit is contained in:
2026-01-16 12:37:45 +08:00
parent 609ca7b980
commit 7ead7fc11c
3 changed files with 320 additions and 10 deletions

View File

@@ -5,7 +5,7 @@
## 1) 总体结论 ## 1) 总体结论
- **已落地**:登录、租户/用户/订单/内容基础管理、内容审核(含批量)、平台概览(内容趋势/退款率/漏斗)、提现审核、钱包流水与异常排查、报表概览与导出、用户钱包/通知/优惠券/实名/充值记录、互动(收藏/点赞/关注)与内容消费明细视图、创作者申请/成员审核/邀请、优惠券创建/编辑/发放/冻结/发放记录/异常核查、资产治理(列表/用量/清理)、通知中心(列表/群发/模板)、审计日志与系统配置、评论治理。 - **已落地**:登录、租户/用户/订单/内容基础管理、内容审核(含批量)、平台概览(内容趋势/退款率/漏斗)、提现审核、钱包流水与异常排查、报表概览与导出、用户钱包/通知/优惠券/实名/充值记录、互动(收藏/点赞/关注)与内容消费明细视图、创作者申请/成员审核/邀请、优惠券创建/编辑/发放/冻结/发放记录/异常核查、资产治理(列表/用量/清理)、通知中心(列表/群发/模板)、审计日志与系统配置、评论治理。
- **部分落地**租户详情(缺财务/报表聚合)、内容治理(缺批量处置扩展)、创作者治理(缺提现审核联动与结算账户审批流)。 - **部分落地**:内容治理(缺批量处置扩展)、创作者治理(缺提现审核联动与结算账户审批流)。
- **未落地**:暂无。 - **未落地**:暂无。
## 2) 按页面完成度(对照 2.x ## 2) 按页面完成度(对照 2.x
@@ -26,9 +26,9 @@
- 备注:成员加入审核/邀请由创作者页统一处理。 - 备注:成员加入审核/邀请由创作者页统一处理。
### 2.4 租户详情 `/superadmin/tenants/:tenantID` ### 2.4 租户详情 `/superadmin/tenants/:tenantID`
- 状态:**部分完成** - 状态:**完成**
- 已有:租户信息、状态/续期、成员列表、内容与订单查询。 - 已有:租户信息、状态/续期、成员列表、内容与订单查询、租户级财务/报表聚合入口
- 缺口:租户级财务与报表聚合入口(成员审核/邀请由超管入口完成) - 缺口:无显著功能缺口
### 2.5 用户管理 `/superadmin/users` ### 2.5 用户管理 `/superadmin/users`
- 状态:**已完成** - 状态:**已完成**
@@ -92,6 +92,6 @@
## 4) 建议的下一步(按优先级) ## 4) 建议的下一步(按优先级)
1. **租户详情聚合**:补齐租户财务与报表聚合入口 1. **订单运营补强**:问题订单标记、支付对账辅助能力
2. **订单运营补强**:问题订单标记、支付对账辅助能力 2. **内容批量处置扩展**:补齐内容/举报的批量下架、封禁与归档处理
3. **内容批量处置扩展**:补齐内容/举报的批量下架、封禁与归档处理 3. **创作者治理补强**:结算账户审批流、提现审核联动流程

View File

@@ -6,9 +6,10 @@ import { useLayout } from '@/layout/composables/layout';
import { ReportService } from '@/service/ReportService'; import { ReportService } from '@/service/ReportService';
import { useToast } from 'primevue/usetoast'; import { useToast } from 'primevue/usetoast';
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
const toast = useToast(); const toast = useToast();
const route = useRoute();
const router = useRouter(); const router = useRouter();
const { getPrimary, getSurface, isDarkTheme } = useLayout(); const { getPrimary, getSurface, isDarkTheme } = useLayout();
@@ -33,6 +34,24 @@ const interactionChartOptions = ref(null);
const granularityOptions = [{ label: '按天', value: 'day' }]; const granularityOptions = [{ label: '按天', value: 'day' }];
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 formatPercent(value) { function formatPercent(value) {
const rate = Number(value); const rate = Number(value);
if (!Number.isFinite(rate)) return '-'; if (!Number.isFinite(rate)) return '-';
@@ -446,7 +465,24 @@ async function exportReport() {
} }
} }
watch(
() => route.query,
(query) => {
const tenantValue = parseNumber(getQueryValue(query?.tenant_id));
tenantID.value = tenantValue || null;
const startValue = getQueryValue(query?.start_at);
const endValue = getQueryValue(query?.end_at);
startAt.value = parseDate(startValue);
endAt.value = parseDate(endValue);
const granularityValue = getQueryValue(query?.granularity);
if (granularityValue) granularity.value = String(granularityValue);
loadOverview(); loadOverview();
},
{ immediate: true }
);
watch([getPrimary, getSurface, isDarkTheme], () => { watch([getPrimary, getSurface, isDarkTheme], () => {
updateCharts(); updateCharts();

View File

@@ -1,15 +1,19 @@
<script setup> <script setup>
import SearchField from '@/components/SearchField.vue'; import SearchField from '@/components/SearchField.vue';
import SearchPanel from '@/components/SearchPanel.vue'; import SearchPanel from '@/components/SearchPanel.vue';
import StatisticsStrip from '@/components/StatisticsStrip.vue';
import { ContentService } from '@/service/ContentService'; import { ContentService } from '@/service/ContentService';
import { FinanceService } from '@/service/FinanceService';
import { OrderService } from '@/service/OrderService'; import { OrderService } from '@/service/OrderService';
import { ReportService } from '@/service/ReportService';
import { TenantService } from '@/service/TenantService'; import { TenantService } from '@/service/TenantService';
import { useToast } from 'primevue/usetoast'; import { useToast } from 'primevue/usetoast';
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
const toast = useToast(); const toast = useToast();
const route = useRoute(); const route = useRoute();
const router = useRouter();
const tenantID = computed(() => Number(route.params.tenantID)); const tenantID = computed(() => Number(route.params.tenantID));
@@ -30,6 +34,17 @@ function formatCny(amountInCents) {
return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' }).format(amount); return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' }).format(amount);
} }
function formatCnyFromYuan(amountInYuan) {
const amount = Number(amountInYuan);
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)}%`;
}
function getStatusSeverity(status) { function getStatusSeverity(status) {
switch (status) { switch (status) {
case 'verified': case 'verified':
@@ -86,6 +101,61 @@ function getContentVisibilitySeverity(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 goToReports() {
const query = {};
if (tenantID.value) query.tenant_id = tenantID.value;
const start = formatDateParam(reportStartAt.value);
const end = formatDateParam(reportEndAt.value);
if (start) query.start_at = start;
if (end) query.end_at = end;
router.push({ name: 'superadmin-reports', query });
}
function goToFinance() {
const query = {};
if (tenantID.value) query.tenant_id = tenantID.value;
router.push({ name: 'superadmin-finance', query });
}
function goToOrders(status, usePaidAt) {
const query = {};
if (tenantID.value) query.tenant_id = tenantID.value;
if (status) query.status = status;
const start = formatDateParam(reportStartAt.value);
const end = formatDateParam(reportEndAt.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;
}
router.push({ name: 'superadmin-orders', query });
}
function goToWithdrawals(status, usePaidAt) {
const query = {};
if (tenantID.value) query.tenant_id = tenantID.value;
if (status) query.status = status;
const start = formatDateParam(reportStartAt.value);
const end = formatDateParam(reportEndAt.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;
}
router.push({ name: 'superadmin-finance', query });
}
async function loadTenant() { async function loadTenant() {
const id = tenantID.value; const id = tenantID.value;
if (!id || Number.isNaN(id)) return; if (!id || Number.isNaN(id)) return;
@@ -375,6 +445,21 @@ const orderStatusOptions = [
{ label: 'failed', value: 'failed' } { label: 'failed', value: 'failed' }
]; ];
const reportLoading = ref(false);
const reportOverview = ref(null);
const reportStartAt = ref(null);
const reportEndAt = ref(null);
const reportRangeDays = ref(7);
const reportRangeOptions = [
{ label: '近7天', value: 7 },
{ label: '近30天', value: 30 },
{ label: '近90天', value: 90 }
];
const ledgerLoading = ref(false);
const ledgers = ref([]);
const ledgerRows = ref(6);
async function loadOrders() { async function loadOrders() {
const id = tenantID.value; const id = tenantID.value;
if (!id) return; if (!id) return;
@@ -438,6 +523,95 @@ function onOrdersSort(event) {
loadOrders(); loadOrders();
} }
function ensureReportRange() {
if (reportStartAt.value || reportEndAt.value) return;
const end = new Date();
const start = new Date(end);
start.setDate(end.getDate() - Math.max(reportRangeDays.value, 1) + 1);
reportStartAt.value = start;
reportEndAt.value = end;
}
async function loadReportOverview() {
const id = tenantID.value;
if (!id) return;
ensureReportRange();
reportLoading.value = true;
try {
reportOverview.value = await ReportService.getOverview({
tenant_id: id,
start_at: reportStartAt.value || undefined,
end_at: reportEndAt.value || undefined,
granularity: 'day'
});
} catch (error) {
toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载租户报表概览', life: 4000 });
} finally {
reportLoading.value = false;
}
}
function applyReportRange(days) {
reportRangeDays.value = days;
reportStartAt.value = null;
reportEndAt.value = null;
loadReportOverview();
}
function resetReportRange() {
reportRangeDays.value = 7;
reportStartAt.value = null;
reportEndAt.value = null;
loadReportOverview();
}
const reportSummaryItems = computed(() => {
const summary = reportOverview.value?.summary;
if (!summary) return [];
return [
{ 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' }
];
});
const withdrawSummaryItems = computed(() => {
const summary = reportOverview.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-apply-amount', label: '申请金额:', value: formatCnyFromYuan(summary.withdrawal_apply_amount), icon: 'pi-wallet', onClick: () => goToWithdrawals('created', false) },
{ key: 'withdraw-paid', label: '提现完成:', value: summary.withdrawal_paid_orders ?? 0, icon: 'pi-check-circle', onClick: () => goToWithdrawals('paid', true) },
{ key: 'withdraw-paid-amount', label: '完成金额:', value: formatCnyFromYuan(summary.withdrawal_paid_amount), icon: 'pi-check', onClick: () => goToWithdrawals('paid', true) },
{ key: 'withdraw-failed', label: '提现失败:', value: summary.withdrawal_failed_orders ?? 0, icon: 'pi-times-circle', onClick: () => goToWithdrawals('failed', false) },
{ key: 'withdraw-failed-amount', label: '失败金额:', value: formatCnyFromYuan(summary.withdrawal_failed_amount), icon: 'pi-exclamation-circle', onClick: () => goToWithdrawals('failed', false) }
];
});
async function loadLedgers() {
const id = tenantID.value;
if (!id) return;
ledgerLoading.value = true;
try {
const result = await FinanceService.listLedgers({
page: 1,
limit: ledgerRows.value,
tenant_id: id,
sortField: 'created_at',
sortOrder: -1
});
ledgers.value = result.items;
} catch (error) {
toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载租户资金流水', life: 4000 });
} finally {
ledgerLoading.value = false;
}
}
watch( watch(
() => tenantID.value, () => tenantID.value,
() => { () => {
@@ -447,14 +621,30 @@ watch(
contentsRows.value = 10; contentsRows.value = 10;
ordersPage.value = 1; ordersPage.value = 1;
ordersRows.value = 10; ordersRows.value = 10;
reportStartAt.value = null;
reportEndAt.value = null;
loadTenant(); loadTenant();
loadTenantUsers(); loadTenantUsers();
loadContents(); loadContents();
loadOrders(); loadOrders();
if (tabValue.value === 'finance') {
loadReportOverview();
loadLedgers();
}
}, },
{ immediate: true } { immediate: true }
); );
watch(
() => tabValue.value,
(value) => {
if (value === 'finance') {
loadReportOverview();
loadLedgers();
}
}
);
onMounted(() => { onMounted(() => {
ensureStatusOptionsLoaded().catch(() => {}); ensureStatusOptionsLoaded().catch(() => {});
}); });
@@ -526,6 +716,7 @@ onMounted(() => {
<Tab value="users">成员</Tab> <Tab value="users">成员</Tab>
<Tab value="contents">内容</Tab> <Tab value="contents">内容</Tab>
<Tab value="orders">订单</Tab> <Tab value="orders">订单</Tab>
<Tab value="finance">财务报表</Tab>
</TabList> </TabList>
<TabPanels> <TabPanels>
<TabPanel value="users"> <TabPanel value="users">
@@ -830,6 +1021,89 @@ onMounted(() => {
</DataTable> </DataTable>
</div> </div>
</TabPanel> </TabPanel>
<TabPanel value="finance">
<div class="flex flex-col gap-4">
<div class="flex items-center justify-between">
<div class="flex flex-col">
<span class="font-medium">财务与报表</span>
<span class="text-muted-color">租户级汇总与资金流水</span>
</div>
<div class="flex items-center gap-2">
<Button label="查看报表" icon="pi pi-chart-line" severity="secondary" @click="goToReports" />
<Button label="查看财务" icon="pi pi-wallet" severity="secondary" @click="goToFinance" />
<Button
label="刷新"
icon="pi pi-refresh"
severity="secondary"
@click="
() => {
loadReportOverview();
loadLedgers();
}
"
:disabled="reportLoading || ledgerLoading"
/>
</div>
</div>
<SearchPanel :loading="reportLoading" @search="loadReportOverview" @reset="resetReportRange">
<SearchField label="快速区间">
<Select v-model="reportRangeDays" :options="reportRangeOptions" optionLabel="label" optionValue="value" placeholder="选择区间" class="w-full" @update:modelValue="applyReportRange" />
</SearchField>
<SearchField label="开始时间">
<DatePicker v-model="reportStartAt" showIcon showButtonBar placeholder="开始时间" class="w-full" />
</SearchField>
<SearchField label="结束时间">
<DatePicker v-model="reportEndAt" showIcon showButtonBar placeholder="结束时间" class="w-full" />
</SearchField>
</SearchPanel>
<StatisticsStrip v-if="reportSummaryItems.length" :items="reportSummaryItems" containerClass="card" />
<StatisticsStrip v-if="withdrawSummaryItems.length" :items="withdrawSummaryItems" containerClass="card" />
<div class="card">
<div class="flex items-center justify-between mb-4">
<div class="flex flex-col">
<span class="font-medium">最近资金流水</span>
<span class="text-muted-color">展示最近 {{ ledgerRows }} </span>
</div>
<Button label="刷新" icon="pi pi-refresh" severity="secondary" @click="loadLedgers" :disabled="ledgerLoading" />
</div>
<DataTable :value="ledgers" :loading="ledgerLoading" scrollable scrollHeight="420px" responsiveLayout="scroll">
<Column field="created_at" header="时间" style="min-width: 14rem">
<template #body="{ data }">
{{ formatDate(data?.created_at) }}
</template>
</Column>
<Column field="type" header="类型" style="min-width: 12rem">
<template #body="{ data }">
{{ data?.type_description || data?.type || '-' }}
</template>
</Column>
<Column field="amount" header="金额" style="min-width: 10rem">
<template #body="{ data }">
{{ formatCny(data?.amount) }}
</template>
</Column>
<Column header="余额变化" style="min-width: 14rem">
<template #body="{ data }">
<div class="flex flex-col">
<span>{{ formatCny(data?.balance_before) }} {{ formatCny(data?.balance_after) }}</span>
<span class="text-xs text-muted-color">冻结 {{ formatCny(data?.frozen_before) }} {{ formatCny(data?.frozen_after) }}</span>
</div>
</template>
</Column>
<Column field="remark" header="备注" style="min-width: 18rem">
<template #body="{ data }">
<span class="text-sm">{{ data?.remark || '-' }}</span>
</template>
</Column>
<Column field="order_id" header="订单ID" style="min-width: 10rem" />
</DataTable>
</div>
</div>
</TabPanel>
</TabPanels> </TabPanels>
</Tabs> </Tabs>
</div> </div>