feat: add tenant finance report overview
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
## 1) 总体结论
|
||||
|
||||
- **已落地**:登录、租户/用户/订单/内容基础管理、内容审核(含批量)、平台概览(内容趋势/退款率/漏斗)、提现审核、钱包流水与异常排查、报表概览与导出、用户钱包/通知/优惠券/实名/充值记录、互动(收藏/点赞/关注)与内容消费明细视图、创作者申请/成员审核/邀请、优惠券创建/编辑/发放/冻结/发放记录/异常核查、资产治理(列表/用量/清理)、通知中心(列表/群发/模板)、审计日志与系统配置、评论治理。
|
||||
- **部分落地**:租户详情(缺财务/报表聚合)、内容治理(缺批量处置扩展)、创作者治理(缺提现审核联动与结算账户审批流)。
|
||||
- **部分落地**:内容治理(缺批量处置扩展)、创作者治理(缺提现审核联动与结算账户审批流)。
|
||||
- **未落地**:暂无。
|
||||
|
||||
## 2) 按页面完成度(对照 2.x)
|
||||
@@ -26,9 +26,9 @@
|
||||
- 备注:成员加入审核/邀请由创作者页统一处理。
|
||||
|
||||
### 2.4 租户详情 `/superadmin/tenants/:tenantID`
|
||||
- 状态:**部分完成**
|
||||
- 已有:租户信息、状态/续期、成员列表、内容与订单查询。
|
||||
- 缺口:租户级财务与报表聚合入口(成员审核/邀请由超管入口完成)。
|
||||
- 状态:**已完成**
|
||||
- 已有:租户信息、状态/续期、成员列表、内容与订单查询、租户级财务/报表聚合入口。
|
||||
- 缺口:无显著功能缺口。
|
||||
|
||||
### 2.5 用户管理 `/superadmin/users`
|
||||
- 状态:**已完成**
|
||||
@@ -92,6 +92,6 @@
|
||||
|
||||
## 4) 建议的下一步(按优先级)
|
||||
|
||||
1. **租户详情聚合**:补齐租户财务与报表聚合入口。
|
||||
2. **订单运营补强**:问题订单标记、支付对账辅助能力。
|
||||
3. **内容批量处置扩展**:补齐内容/举报的批量下架、封禁与归档处理。
|
||||
1. **订单运营补强**:问题订单标记、支付对账辅助能力。
|
||||
2. **内容批量处置扩展**:补齐内容/举报的批量下架、封禁与归档处理。
|
||||
3. **创作者治理补强**:结算账户审批流、提现审核联动流程。
|
||||
|
||||
@@ -6,9 +6,10 @@ import { useLayout } from '@/layout/composables/layout';
|
||||
import { ReportService } from '@/service/ReportService';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
const toast = useToast();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { getPrimary, getSurface, isDarkTheme } = useLayout();
|
||||
|
||||
@@ -33,6 +34,24 @@ const interactionChartOptions = ref(null);
|
||||
|
||||
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) {
|
||||
const rate = Number(value);
|
||||
if (!Number.isFinite(rate)) return '-';
|
||||
@@ -446,7 +465,24 @@ async function exportReport() {
|
||||
}
|
||||
}
|
||||
|
||||
loadOverview();
|
||||
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();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch([getPrimary, getSurface, isDarkTheme], () => {
|
||||
updateCharts();
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
<script setup>
|
||||
import SearchField from '@/components/SearchField.vue';
|
||||
import SearchPanel from '@/components/SearchPanel.vue';
|
||||
import StatisticsStrip from '@/components/StatisticsStrip.vue';
|
||||
import { ContentService } from '@/service/ContentService';
|
||||
import { FinanceService } from '@/service/FinanceService';
|
||||
import { OrderService } from '@/service/OrderService';
|
||||
import { ReportService } from '@/service/ReportService';
|
||||
import { TenantService } from '@/service/TenantService';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
const toast = useToast();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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) {
|
||||
switch (status) {
|
||||
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() {
|
||||
const id = tenantID.value;
|
||||
if (!id || Number.isNaN(id)) return;
|
||||
@@ -375,6 +445,21 @@ const orderStatusOptions = [
|
||||
{ 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() {
|
||||
const id = tenantID.value;
|
||||
if (!id) return;
|
||||
@@ -438,6 +523,95 @@ function onOrdersSort(event) {
|
||||
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(
|
||||
() => tenantID.value,
|
||||
() => {
|
||||
@@ -447,14 +621,30 @@ watch(
|
||||
contentsRows.value = 10;
|
||||
ordersPage.value = 1;
|
||||
ordersRows.value = 10;
|
||||
reportStartAt.value = null;
|
||||
reportEndAt.value = null;
|
||||
loadTenant();
|
||||
loadTenantUsers();
|
||||
loadContents();
|
||||
loadOrders();
|
||||
if (tabValue.value === 'finance') {
|
||||
loadReportOverview();
|
||||
loadLedgers();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => tabValue.value,
|
||||
(value) => {
|
||||
if (value === 'finance') {
|
||||
loadReportOverview();
|
||||
loadLedgers();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
ensureStatusOptionsLoaded().catch(() => {});
|
||||
});
|
||||
@@ -526,6 +716,7 @@ onMounted(() => {
|
||||
<Tab value="users">成员</Tab>
|
||||
<Tab value="contents">内容</Tab>
|
||||
<Tab value="orders">订单</Tab>
|
||||
<Tab value="finance">财务报表</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel value="users">
|
||||
@@ -830,6 +1021,89 @@ onMounted(() => {
|
||||
</DataTable>
|
||||
</div>
|
||||
</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>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user