fix: 报表接口对接

This commit is contained in:
ZhuRui
2026-08-05 17:56:05 +08:00
parent bc7e9169db
commit b496fb60e2
4 changed files with 79 additions and 156 deletions
+23 -56
View File
@@ -1,34 +1,29 @@
import { defineComponent, ref, nextTick } from 'vue';
import { Select, Table } from 'ant-design-vue';
import { DatePicker, Table } from 'ant-design-vue';
import * as echarts from 'echarts';
import { useReportsModel } from './model/useReportsModel';
import { useEffect, useContainerSize } from '@/hooks';
import styles from './index.module.less';
const { RangePicker } = DatePicker;
/**
* 账务报表
*
* 页面结构:
* 1. 顶部标题 + 时间范围选择
* 2. 三张统计卡(总收入 / 总提现 / 平台余额)
* 3. 月度收支趋势(echarts 柱状 + 折线组合图)
* 4. 月度收支明细(表格)
*/
export default defineComponent({
name: 'FinanceReports',
setup() {
const {
range,
handleRangeChange,
dateRange,
handleDateRangeChange,
summary,
chartData,
detailColumns,
detailData,
formatMoney,
RANGE_OPTIONS,
} = useReportsModel();
// ===== 组合图(柱状 + 折线,echarts =====
// ===== echarts 组合图 =====
const chartRef = ref<HTMLElement>();
let chartInstance: any = null;
@@ -109,11 +104,7 @@ export default defineComponent({
symbol: 'circle',
symbolSize: 8,
lineStyle: { width: 3, color: '#52c41a' },
itemStyle: {
color: '#52c41a',
borderWidth: 2,
borderColor: '#fff',
},
itemStyle: { color: '#52c41a', borderWidth: 2, borderColor: '#fff' },
areaStyle: {
color: {
type: 'linear',
@@ -136,17 +127,13 @@ export default defineComponent({
const renderChart = () => {
if (!chartRef.value) return;
if (!chartInstance) {
chartInstance = echarts.init(chartRef.value);
}
if (!chartInstance) chartInstance = echarts.init(chartRef.value);
chartInstance.setOption(getChartOption());
};
const handleResize = () => {
chartInstance?.resize();
};
const handleResize = () => chartInstance?.resize();
// ECharts 挂载/销毁 + 窗口 resize 监听
// ECharts 挂载/销毁
useEffect(() => {
const el = chartRef.value;
if (!el) return;
@@ -170,24 +157,9 @@ export default defineComponent({
});
return () => {
/** 根据增长率构建箭头 + 颜色 */
const changeDesc = (rateStr: string | undefined, fallbackRate: number | null) => {
if (rateStr === undefined && fallbackRate !== null) rateStr = String(fallbackRate);
if (rateStr == null || rateStr === '' || isNaN(parseFloat(rateStr)))
return { desc: '= 总收入 - 提现金额', descColor: 'rgba(0, 0, 0, 0.55)' };
const rate = parseFloat(rateStr);
const arrow = rate > 0 ? '↑' : rate < 0 ? '↓' : '';
const sign = rate > 0 ? '+' : '';
const absRate = Math.abs(rate).toFixed(2);
return {
desc: rate === 0 ? `→ 与上月持平` : `${arrow} 较上月 ${sign}${absRate}%`,
descColor: rate > 0 ? '#52c41a' : rate < 0 ? '#ff4d4f' : 'rgba(0, 0, 0, 0.55)',
};
};
const s = summary.value;
/** 顶部 3 张统计卡配置 */
/** 顶部 3 张统计卡 */
const statCards = [
{
key: 'totalIncome',
@@ -195,7 +167,6 @@ export default defineComponent({
value: s.totalIncome,
bgColor: '#f6ffed',
accentColor: '#52c41a',
...changeDesc(s.totalIncomeTendency, null),
},
{
key: 'totalWithdraw',
@@ -203,7 +174,6 @@ export default defineComponent({
value: s.totalWithdraw,
bgColor: '#fffbe6',
accentColor: '#faad14',
...changeDesc(s.totalWithdrawTendency, null),
},
{
key: 'totalBalance',
@@ -211,13 +181,12 @@ export default defineComponent({
value: s.totalBalance,
bgColor: '#e6f4ff',
accentColor: '#1677ff',
...changeDesc(undefined, null),
},
];
return (
<div class={styles.reportsMain}>
{/* ===== 顶部标题 + 时间范围 ===== */}
{/* ===== 顶部标题 + 时间段选择 ===== */}
<div class={styles.reportHeader}>
<div class={styles.headerLeft}>
<div class={styles.reportTitle}></div>
@@ -225,11 +194,12 @@ export default defineComponent({
退
</div>
</div>
<Select
value={range.value}
onUpdate:value={handleRangeChange}
options={[...RANGE_OPTIONS]}
style={{ width: '120px' }}
<RangePicker
picker="month"
format="YYYY-MM"
value={dateRange.value}
onUpdate:value={handleDateRangeChange}
style={{ width: '240px' }}
/>
</div>
@@ -241,18 +211,18 @@ export default defineComponent({
<div class={styles.statValue} style={{ color: item.accentColor }}>
¥{formatMoney(item.value)}
</div>
<div class={styles.statDesc} style={{ color: item.descColor }}>
{item.desc}
<div class={styles.statDesc} style="visibility: hidden;">
-
</div>
</div>
))}
</div>
{/* ===== 组合图卡echarts ===== */}
{/* ===== 组合图卡 ===== */}
<div class={styles.chartCard}>
<div class={styles.cardTitle}>
<span class={styles.cardTitleIcon}>📊</span>
{chartData.value.length}
{chartData.value.length}
</div>
<div
ref={(el: any) => {
@@ -273,10 +243,7 @@ export default defineComponent({
size="middle"
bordered={false}
rowKey="key"
scroll={{
y: detailTableHeight.value,
x: 'max-content',
}}
scroll={{ y: detailTableHeight.value, x: 'max-content' }}
/>
</div>
</div>
@@ -1,35 +1,7 @@
import { computed, ref } from 'vue';
import type { SelectValue } from 'ant-design-vue/es/select';
import { useRequest } from '@/hooks/useRequest';
import { useEffect } from '@/hooks/useEffect';
import { getFinanceReport, type FinanceReportVO } from './services';
import dayjs from 'dayjs';
export type RangeValue = (typeof RANGE_OPTIONS)[number]['value'];
// ============================================================
// 常量
// ============================================================
/** 时间范围选项 */
export const RANGE_OPTIONS = [
{ value: 'month' as const, label: '本月' },
{ value: '3m' as const, label: '近 3 个月' },
{ value: '6m' as const, label: '近 6 个月' },
{ value: 'year' as const, label: '本年' },
];
/** 范围对应的月数 */
const RANGE_MONTHS: Record<RangeValue, number> = {
month: 1,
'3m': 3,
'6m': 6,
year: 12,
};
function isRangeValue(val: SelectValue): val is RangeValue {
return typeof val === 'string' && RANGE_OPTIONS.some((item) => item.value === val);
}
import dayjs, { type Dayjs } from 'dayjs';
// ============================================================
// 工具
@@ -59,34 +31,26 @@ function toNumber(value: string): number {
* 账务报表数据模型
*/
export function useReportsModel() {
// ===== 时间范围 =====
const range = ref<RangeValue>('month');
// ===== 时间范围(年月区间选择器) =====
const dateRange = ref<[Dayjs, Dayjs]>([dayjs().startOf('month'), dayjs().startOf('month')]);
// ===== 构建 API 参数(根据 range 推算查询起始月份) =====
// ===== 构建 API 参数 =====
const buildQueryParams = () => {
const months = RANGE_MONTHS[range.value];
const date = dayjs()
.subtract(months - 1, 'month')
.format('YYYY-MM');
return { date };
const [start, end] = dateRange.value;
return {
beginDate: start.format('YYYY-MM'),
endDate: end.format('YYYY-MM'),
};
};
// ===== 请求(range 变化时自动重新请求) =====
const {
data,
loading,
run: fetchReport,
} = useRequest<FinanceReportVO>(() => getFinanceReport(buildQueryParams()), {
refreshDeps: [range],
// ===== 请求(dateRange 变化时自动重新请求) =====
const { data, loading } = useRequest<FinanceReportVO>(
() => getFinanceReport(buildQueryParams()),
{
refreshDeps: [dateRange],
formatResult: (res) => (res.code == 200 ? res.data : null),
});
// ===== 根据范围过滤后的月列表 =====
const filteredMonthList = computed(() => {
const list = data.value?.monthList || [];
const count = RANGE_MONTHS[range.value];
return list.slice(-count);
});
},
);
// ===== 汇总 =====
const summary = computed(() => ({
@@ -98,15 +62,16 @@ export function useReportsModel() {
}));
// ===== 图表数据 =====
const chartData = computed(() =>
filteredMonthList.value.map((item) => ({
const chartData = computed(() => {
const list = data.value?.monthList || [];
return list.map((item) => ({
month: dayjs(item.month).format('MM月'),
order: toNumber(item.orderAmount),
refund: toNumber(item.refundAmount),
netIncome: toNumber(item.netIncome),
withdraw: toNumber(item.withdrawAmount),
})),
);
}));
});
// ===== 明细表列 =====
const detailColumns = [
@@ -153,25 +118,24 @@ export function useReportsModel() {
];
// ===== 明细表数据 =====
const detailData = computed(() =>
filteredMonthList.value.map((item) => ({
const detailData = computed(() => {
const list = data.value?.monthList || [];
return list.map((item) => ({
key: item.month,
month: item.month,
order: toNumber(item.orderAmount),
refund: toNumber(item.refundAmount),
netIncome: toNumber(item.netIncome),
withdraw: toNumber(item.withdrawAmount),
})),
);
}));
});
// ===== 切换时间范围 =====
const handleRangeChange = (val: SelectValue) => {
if (!isRangeValue(val)) return;
range.value = val;
// ===== 时间范围变更 =====
const handleDateRangeChange = (val: any) => {
if (!val || val.length < 2) return;
dateRange.value = [dayjs(val[0]), dayjs(val[1])];
};
// ===== 范围变化时刷新图表(通过 page 中的 useEffect 监听 range =====
/** 千分位格式金额(页面渲染用) */
function formatMoney(val: string | number): string {
if (typeof val === 'string') return formatMoneyStr(val);
@@ -182,14 +146,13 @@ export function useReportsModel() {
}
return {
range,
dateRange,
loading,
handleRangeChange,
handleDateRangeChange,
summary,
chartData,
detailColumns,
detailData,
formatMoney,
RANGE_OPTIONS: RANGE_OPTIONS as any,
};
}
@@ -1,4 +1,5 @@
import { defineComponent, reactive, ref, watch } from 'vue';
import { defineComponent, reactive, ref } from 'vue';
import { useEffect } from '@/hooks';
import { Modal, Table, Button, Select, Form, Space, DatePicker, Pagination } from 'ant-design-vue';
import dayjs from 'dayjs';
import {
@@ -92,11 +93,9 @@ export default defineComponent({
handleSearch();
};
// ===== 弹窗打开 → 首次加载 =====
watch(
() => props.visible,
(val) => {
if (val) {
// ===== 弹窗打开 → 重置并加载 =====
useEffect(() => {
if (props.visible) {
filterForm.timeRange = null;
filterForm.type = '';
filterForm.status = '';
@@ -105,8 +104,7 @@ export default defineComponent({
dataSource.value = [];
handleSearch();
}
},
);
}, [() => props.visible]);
// ===== 表格列(dataIndex 对齐 API =====
const columns = [
@@ -1,4 +1,5 @@
import { defineComponent, ref, reactive, watch } from 'vue';
import { defineComponent, ref, reactive } from 'vue';
import { useEffect } from '@/hooks';
import { Modal, Form, Input, Tree, Button, Spin, message } from 'ant-design-vue';
import type { TreeProps } from 'ant-design-vue';
import { getPermissionTree, type PermissionTreeNode } from '../model/services';
@@ -77,19 +78,13 @@ export default defineComponent({
await loadPermissionTree(record.roleId);
};
// 监听 visible 变化,弹窗打开时重新初始化
// immediate: true 是因为父组件用 v-if 控制显隐,
// 组件挂载时 visible 就已经是 true,不会触发 watch 变更回调
watch(
() => props.visible,
async (visible) => {
if (visible) {
await initFormFromRecord(props.record);
// 弹窗打开时重新初始化useEffect 内置 immediate 行为)
useEffect(() => {
if (props.visible) {
initFormFromRecord(props.record);
setTimeout(() => formRef.value?.clearValidate(), 0);
}
},
{ immediate: true },
);
}, [() => props.visible]);
/** Tree 勾选回调(受控) */
const handleCheck: TreeProps['onCheck'] = (checkedKeys, info) => {