feat(@vben/web-antd): add finance overview cockpit and fee rate

This commit is contained in:
2026-03-05 10:48:43 +08:00
parent 1e3f1be961
commit 13212d7ff5
28 changed files with 2180 additions and 0 deletions

View File

@@ -0,0 +1,115 @@
<script setup lang="ts">
import type { EchartsUIType } from '@vben/plugins/echarts';
/**
* 文件职责:财务概览构成环图卡片。
*/
import type { FinanceOverviewCompositionViewItem } from '../types';
import { nextTick, onMounted, ref, watch } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import {
formatCurrency,
formatPercent,
} from '../composables/overview-page/helpers';
interface Props {
items: FinanceOverviewCompositionViewItem[];
title: string;
totalAmount: number;
totalLabel: string;
}
const props = defineProps<Props>();
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
function renderChart() {
renderEcharts({
tooltip: {
trigger: 'item',
formatter(params: unknown) {
const record = params as {
name?: string;
percent?: number;
value?: number;
};
return `${record.name ?? ''}<br/>${formatCurrency(Number(record.value ?? 0))} (${formatPercent(Number(record.percent ?? 0))})`;
},
},
series: [
{
type: 'pie',
radius: ['55%', '76%'],
center: ['50%', '50%'],
avoidLabelOverlap: true,
label: {
show: false,
},
data: props.items.map((item) => ({
name: item.name,
value: item.amount,
itemStyle: {
color: item.color,
},
})),
},
],
});
}
watch(
() => props.items,
async () => {
await nextTick();
renderChart();
},
{ deep: true },
);
onMounted(() => {
renderChart();
});
</script>
<template>
<div class="fo-section-card">
<div class="fo-section-title">{{ props.title }}</div>
<div class="fo-composition-wrap">
<div class="fo-composition-chart-wrap">
<EchartsUI ref="chartRef" class="fo-composition-chart" />
<div class="fo-composition-center">
<div class="fo-composition-center-value">
{{ formatCurrency(props.totalAmount) }}
</div>
<div class="fo-composition-center-label">{{ props.totalLabel }}</div>
</div>
</div>
<div class="fo-composition-legend">
<div
v-for="item in props.items"
:key="item.key"
class="fo-composition-legend-item"
>
<span
class="fo-composition-dot"
:style="{ backgroundColor: item.color }"
></span>
<span class="fo-composition-name">{{ item.name }}</span>
<span class="fo-composition-percent">{{
formatPercent(item.percentage)
}}</span>
<span class="fo-composition-amount">{{
formatCurrency(item.amount)
}}</span>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,143 @@
<script setup lang="ts">
import type { EchartsUIType } from '@vben/plugins/echarts';
/**
* 文件职责:财务概览收入趋势图卡片。
*/
import type {
FinanceOverviewIncomeTrendPointDto,
FinanceOverviewTrendRange,
} from '#/api/finance/overview';
import { nextTick, onMounted, ref, watch } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import {
formatAxisAmount,
formatCurrency,
} from '../composables/overview-page/helpers';
interface Props {
points: FinanceOverviewIncomeTrendPointDto[];
range: FinanceOverviewTrendRange;
}
const props = defineProps<Props>();
const emit = defineEmits<{
(event: 'update:range', value: FinanceOverviewTrendRange): void;
}>();
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
function renderChart() {
renderEcharts({
tooltip: {
trigger: 'axis',
formatter(params: unknown) {
if (!Array.isArray(params) || params.length === 0) return '';
const records = params as Array<{
axisValue?: string;
value?: number;
}>;
const record = records[0] ?? {};
return `${record.axisValue ?? ''}<br/>实收:${formatCurrency(Number(record.value ?? 0))}`;
},
},
grid: {
left: '1%',
right: '2%',
bottom: '2%',
top: '8%',
containLabel: true,
},
xAxis: {
type: 'category',
boundaryGap: false,
axisTick: { show: false },
axisLine: {
lineStyle: {
color: '#e5e7eb',
},
},
data: props.points.map((item) => item.dateLabel),
},
yAxis: {
type: 'value',
axisLabel: {
formatter: (value: number) => formatAxisAmount(value),
},
splitLine: {
lineStyle: {
color: '#f1f5f9',
type: 'dashed',
},
},
},
series: [
{
name: '实收',
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 6,
itemStyle: {
color: '#1677ff',
},
areaStyle: {
color: 'rgba(22,119,255,0.15)',
},
lineStyle: {
color: '#1677ff',
width: 2.5,
},
data: props.points.map((item) => item.amount),
},
],
});
}
watch(
() => props.points,
async () => {
await nextTick();
renderChart();
},
{ deep: true },
);
onMounted(() => {
renderChart();
});
</script>
<template>
<div class="fo-section-card">
<div class="fo-section-head">
<div class="fo-section-title">收入趋势</div>
<div class="fo-pills">
<button
class="fo-pill"
:class="{ 'is-active': props.range === '7' }"
type="button"
@click="emit('update:range', '7')"
>
近7天
</button>
<button
class="fo-pill"
:class="{ 'is-active': props.range === '30' }"
type="button"
@click="emit('update:range', '30')"
>
近30天
</button>
</div>
</div>
<EchartsUI ref="chartRef" class="fo-trend-chart" />
</div>
</template>

View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
/**
* 文件职责:财务概览 KPI 指标卡。
*/
import type { FinanceOverviewKpiKey } from '../composables/overview-page/constants';
import type { FinanceOverviewDashboardDto } from '#/api/finance/overview';
import { computed } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { OVERVIEW_KPI_CONFIG } from '../composables/overview-page/constants';
import {
formatChangeRate,
formatCurrency,
resolveKpiTrendClass,
resolveKpiTrendIcon,
} from '../composables/overview-page/helpers';
interface Props {
dashboard: FinanceOverviewDashboardDto;
}
const props = defineProps<Props>();
const kpiCards = computed(() =>
OVERVIEW_KPI_CONFIG.map((item) => ({
...item,
value: props.dashboard[item.key as FinanceOverviewKpiKey],
})),
);
</script>
<template>
<div class="fo-kpi-row">
<div
v-for="card in kpiCards"
:key="card.key"
class="fo-kpi-card"
:class="`is-${card.tone}`"
>
<div class="fo-kpi-top">
<span class="fo-kpi-label">{{ card.label }}</span>
<span class="fo-kpi-icon">
<IconifyIcon :icon="card.icon" />
</span>
</div>
<div class="fo-kpi-value">{{ formatCurrency(card.value.amount) }}</div>
<div class="fo-kpi-change" :class="resolveKpiTrendClass(card.value)">
<IconifyIcon :icon="resolveKpiTrendIcon(card.value)" />
<span>{{ formatChangeRate(card.value.changeRate) }}</span>
<span>{{ card.value.compareLabel }}</span>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,186 @@
<script setup lang="ts">
import type { EchartsUIType } from '@vben/plugins/echarts';
/**
* 文件职责:财务概览利润走势图卡片。
*/
import type {
FinanceOverviewProfitTrendPointDto,
FinanceOverviewTrendRange,
} from '#/api/finance/overview';
import { nextTick, onMounted, ref, watch } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import {
formatAxisAmount,
formatCurrency,
} from '../composables/overview-page/helpers';
interface Props {
points: FinanceOverviewProfitTrendPointDto[];
range: FinanceOverviewTrendRange;
}
const props = defineProps<Props>();
const emit = defineEmits<{
(event: 'update:range', value: FinanceOverviewTrendRange): void;
}>();
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
function renderChart() {
renderEcharts({
tooltip: {
trigger: 'axis',
formatter(params: unknown) {
if (!Array.isArray(params) || params.length === 0) return '';
const records = params as Array<{
axisValue?: string;
seriesName?: string;
value?: number;
}>;
const date = records[0]?.axisValue ?? '';
const revenue = Number(
records.find((item) => item.seriesName === '营收')?.value ?? 0,
);
const cost = Number(
records.find((item) => item.seriesName === '成本')?.value ?? 0,
);
const netProfit = Number(
records.find((item) => item.seriesName === '净利润')?.value ?? 0,
);
return `${date}<br/>营收:${formatCurrency(revenue)}<br/>成本:${formatCurrency(cost)}<br/>净利润:${formatCurrency(netProfit)}`;
},
},
grid: {
left: '1%',
right: '2%',
bottom: '2%',
top: '8%',
containLabel: true,
},
xAxis: {
type: 'category',
boundaryGap: false,
axisTick: { show: false },
axisLine: {
lineStyle: {
color: '#e5e7eb',
},
},
data: props.points.map((item) => item.dateLabel),
},
yAxis: {
type: 'value',
axisLabel: {
formatter: (value: number) => formatAxisAmount(value),
},
splitLine: {
lineStyle: {
color: '#f1f5f9',
type: 'dashed',
},
},
},
series: [
{
name: '营收',
type: 'line',
smooth: true,
symbol: 'none',
lineStyle: {
color: '#1677ff',
width: 2.5,
},
data: props.points.map((item) => item.revenueAmount),
},
{
name: '成本',
type: 'line',
smooth: true,
symbol: 'none',
lineStyle: {
color: '#ef4444',
width: 2,
type: 'dashed',
},
data: props.points.map((item) => item.costAmount),
},
{
name: '净利润',
type: 'line',
smooth: true,
symbol: 'none',
lineStyle: {
color: '#22c55e',
width: 2.5,
},
data: props.points.map((item) => item.netProfitAmount),
},
],
});
}
watch(
() => props.points,
async () => {
await nextTick();
renderChart();
},
{ deep: true },
);
onMounted(() => {
renderChart();
});
</script>
<template>
<div class="fo-section-card">
<div class="fo-section-head">
<div class="fo-section-title">利润走势</div>
<div class="fo-pills">
<button
class="fo-pill"
:class="{ 'is-active': props.range === '7' }"
type="button"
@click="emit('update:range', '7')"
>
近7天
</button>
<button
class="fo-pill"
:class="{ 'is-active': props.range === '30' }"
type="button"
@click="emit('update:range', '30')"
>
近30天
</button>
</div>
</div>
<EchartsUI ref="chartRef" class="fo-profit-chart" />
<div class="fo-profit-legend">
<div class="fo-profit-legend-item">
<span class="fo-profit-legend-line is-revenue"></span>
营收
</div>
<div class="fo-profit-legend-item">
<span class="fo-profit-legend-line is-cost"></span>
成本
</div>
<div class="fo-profit-legend-item">
<span class="fo-profit-legend-line is-net"></span>
净利润
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
/**
* 文件职责:财务概览顶部维度工具条。
*/
import type { OptionItem } from '../types';
import type { FinanceOverviewDimension } from '#/api/finance/overview';
import { Segmented, Select } from 'ant-design-vue';
interface Props {
dimension: FinanceOverviewDimension;
dimensionOptions: Array<{ label: string; value: FinanceOverviewDimension }>;
isStoreLoading: boolean;
showStoreSelect: boolean;
storeId: string;
storeOptions: OptionItem[];
}
const props = defineProps<Props>();
const emit = defineEmits<{
(event: 'update:dimension', value: FinanceOverviewDimension): void;
(event: 'update:storeId', value: string): void;
}>();
function handleStoreChange(value: unknown) {
if (typeof value === 'number' || typeof value === 'string') {
emit('update:storeId', String(value));
return;
}
emit('update:storeId', '');
}
</script>
<template>
<div class="fo-toolbar">
<div class="fo-toolbar-title">财务概览驾驶舱</div>
<div class="fo-toolbar-right">
<Segmented
class="fo-dimension-segmented"
:value="props.dimension"
:options="props.dimensionOptions"
@update:value="
(value) =>
emit(
'update:dimension',
(value as FinanceOverviewDimension) || 'tenant',
)
"
/>
<Select
v-if="props.showStoreSelect"
class="fo-store-select"
:value="props.storeId"
:options="props.storeOptions"
:loading="props.isStoreLoading"
:disabled="props.storeOptions.length === 0"
placeholder="请选择门店"
@update:value="(value) => handleStoreChange(value)"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,109 @@
<script setup lang="ts">
/**
* 文件职责:财务概览 TOP10 商品排行表。
*/
import type { FinanceOverviewTopProductItemDto } from '#/api/finance/overview';
import { computed } from 'vue';
import { Table } from 'ant-design-vue';
import {
calcTopProductBarWidth,
formatCurrency,
formatPercent,
} from '../composables/overview-page/helpers';
interface Props {
items: FinanceOverviewTopProductItemDto[];
maxPercentage: number;
periodDays: number;
}
const props = defineProps<Props>();
const columns = [
{
title: '排名',
key: 'rank',
width: 86,
},
{
title: '商品名称',
dataIndex: 'productName',
key: 'productName',
},
{
title: '销量',
dataIndex: 'salesQuantity',
key: 'salesQuantity',
width: 100,
},
{
title: '营收',
key: 'revenueAmount',
width: 120,
},
{
title: '占比',
key: 'percentage',
width: 190,
},
];
const dataSource = computed(() => props.items);
function resolveRankClass(rank: number) {
if (rank === 1) return 'is-top1';
if (rank === 2) return 'is-top2';
if (rank === 3) return 'is-top3';
return 'is-normal';
}
</script>
<template>
<div class="fo-section-card fo-top-card">
<div class="fo-section-title">
TOP10 商品营收排行{{ props.periodDays }}
</div>
<Table
class="fo-top-table"
:columns="columns"
:data-source="dataSource"
:pagination="false"
:row-key="(record) => `${record.rank}-${record.productName}`"
size="middle"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'rank'">
<span class="fo-rank-num" :class="resolveRankClass(record.rank)">
{{ record.rank }}
</span>
</template>
<template v-else-if="column.key === 'revenueAmount'">
<span class="fo-revenue-text">{{
formatCurrency(record.revenueAmount)
}}</span>
</template>
<template v-else-if="column.key === 'percentage'">
<div class="fo-percent-wrap">
<div class="fo-percent-track">
<div
class="fo-percent-fill"
:style="{
width: `${calcTopProductBarWidth(record.percentage, props.maxPercentage)}%`,
}"
></div>
</div>
<span class="fo-percent-text">{{
formatPercent(record.percentage)
}}</span>
</div>
</template>
</template>
</Table>
</div>
</template>