feat(@vben/web-antd): add finance overview cockpit and fee rate
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user