feat(@vben/web-antd): add finance cost management pages

This commit is contained in:
2026-03-04 15:58:42 +08:00
parent 15d4272d1f
commit d3e32c9e8f
26 changed files with 2819 additions and 0 deletions

View File

@@ -0,0 +1,119 @@
<script setup lang="ts">
import type { EchartsUIType } from '@vben/plugins/echarts';
/**
* 文件职责:成本分析构成环图与图例。
*/
import type { FinanceCostCompositionDto } from '#/api/finance/cost';
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { COST_CATEGORY_COLOR_MAP } from '../composables/cost-page/constants';
import {
formatCurrency,
formatPercent,
} from '../composables/cost-page/helpers';
interface Props {
composition: FinanceCostCompositionDto[];
}
const props = defineProps<Props>();
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
const totalCost = computed(() =>
(props.composition ?? []).reduce(
(sum, item) => sum + Number(item.amount || 0),
0,
),
);
function renderChart() {
const source = props.composition ?? [];
renderEcharts({
tooltip: {
trigger: 'item',
formatter(params: unknown) {
const data = params as {
name?: string;
percent?: number;
value?: number;
};
return `${data.name ?? ''}<br/>${formatCurrency(data.value ?? 0)} (${formatPercent(data.percent ?? 0)})`;
},
},
series: [
{
type: 'pie',
radius: ['52%', '76%'],
center: ['50%', '50%'],
data: source.map((item) => ({
name: item.categoryText,
value: item.amount,
itemStyle: {
color: COST_CATEGORY_COLOR_MAP[item.category] ?? '#2563eb',
},
})),
label: {
show: false,
},
},
],
});
}
watch(
() => props.composition,
async () => {
await nextTick();
renderChart();
},
{ deep: true },
);
onMounted(() => {
renderChart();
});
</script>
<template>
<div class="fc-composition-card">
<div class="fc-section-title">成本构成</div>
<div class="fc-composition-body">
<div class="fc-composition-chart-wrap">
<EchartsUI ref="chartRef" class="fc-composition-chart" />
<div class="fc-composition-center">
<div class="fc-composition-center-value">
{{ formatCurrency(totalCost) }}
</div>
<div class="fc-composition-center-label">总成本</div>
</div>
</div>
<div class="fc-composition-legend">
<div
v-for="item in props.composition"
:key="item.category"
class="fc-composition-legend-item"
>
<span
class="fc-composition-dot"
:style="{ backgroundColor: COST_CATEGORY_COLOR_MAP[item.category] }"
></span>
<span class="fc-composition-name">{{ item.categoryText }}</span>
<span class="fc-composition-amount">{{
formatCurrency(item.amount)
}}</span>
<span class="fc-composition-percent">{{
formatPercent(item.percentage)
}}</span>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,87 @@
<script setup lang="ts">
/**
* 文件职责:成本分析明细表。
*/
import type { TableProps } from 'ant-design-vue';
import type { FinanceCostMonthlyDetailRowDto } from '#/api/finance/cost';
import { h } from 'vue';
import { Table } from 'ant-design-vue';
import {
formatCurrency,
formatPercent,
} from '../composables/cost-page/helpers';
interface Props {
loading: boolean;
rows: FinanceCostMonthlyDetailRowDto[];
}
const props = defineProps<Props>();
const columns: TableProps['columns'] = [
{
title: '月份',
dataIndex: 'month',
width: 110,
},
{
title: '食材',
dataIndex: 'foodAmount',
align: 'right',
customRender: ({ text }) => formatCurrency(Number(text ?? 0)),
},
{
title: '人工',
dataIndex: 'laborAmount',
align: 'right',
customRender: ({ text }) => formatCurrency(Number(text ?? 0)),
},
{
title: '固定',
dataIndex: 'fixedAmount',
align: 'right',
customRender: ({ text }) => formatCurrency(Number(text ?? 0)),
},
{
title: '包装',
dataIndex: 'packagingAmount',
align: 'right',
customRender: ({ text }) => formatCurrency(Number(text ?? 0)),
},
{
title: '总计',
dataIndex: 'totalCost',
align: 'right',
customRender: ({ text }) =>
h(
'span',
{ class: 'fc-total-amount' },
formatCurrency(Number(text ?? 0)),
),
},
{
title: '成本率',
dataIndex: 'costRate',
align: 'right',
customRender: ({ text }) => formatPercent(Number(text ?? 0)),
},
];
</script>
<template>
<div class="fc-table-card">
<div class="fc-section-title">成本明细</div>
<Table
row-key="month"
size="middle"
:columns="columns"
:data-source="props.rows"
:loading="props.loading"
:pagination="false"
/>
</div>
</template>

View File

@@ -0,0 +1,57 @@
<script setup lang="ts">
/**
* 文件职责:成本分析统计卡。
*/
import type { FinanceCostAnalysisStatsDto } from '#/api/finance/cost';
import { computed } from 'vue';
import {
formatCurrency,
formatPercent,
} from '../composables/cost-page/helpers';
interface Props {
stats: FinanceCostAnalysisStatsDto;
}
const props = defineProps<Props>();
const monthOnMonthClass = computed(() => {
if ((props.stats?.monthOnMonthChangeRate ?? 0) > 0) return 'is-up';
if ((props.stats?.monthOnMonthChangeRate ?? 0) < 0) return 'is-down';
return 'is-flat';
});
</script>
<template>
<div class="fc-stats">
<div class="fc-stat-card">
<div class="fc-stat-label">本月总成本</div>
<div class="fc-stat-value">
{{ formatCurrency(props.stats.totalCost) }}
</div>
</div>
<div class="fc-stat-card">
<div class="fc-stat-label">食材成本率</div>
<div class="fc-stat-value">
{{ formatPercent(props.stats.foodCostRate) }}
</div>
</div>
<div class="fc-stat-card">
<div class="fc-stat-label">单均成本</div>
<div class="fc-stat-value">
{{ formatCurrency(props.stats.averageCostPerPaidOrder) }}
</div>
</div>
<div class="fc-stat-card">
<div class="fc-stat-label">环比变化</div>
<div class="fc-stat-value" :class="monthOnMonthClass">
{{ formatPercent(props.stats.monthOnMonthChangeRate) }}
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,114 @@
<script setup lang="ts">
import type { EchartsUIType } from '@vben/plugins/echarts';
/**
* 文件职责:成本分析近 6 月趋势图。
*/
import type { FinanceCostTrendPointDto } from '#/api/finance/cost';
import { nextTick, onMounted, ref, watch } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { formatCurrency } from '../composables/cost-page/helpers';
interface Props {
trend: FinanceCostTrendPointDto[];
}
const props = defineProps<Props>();
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
function renderChart() {
const source = props.trend ?? [];
renderEcharts({
color: ['#2563eb', '#16a34a'],
tooltip: {
trigger: 'axis',
formatter(params: unknown) {
if (!Array.isArray(params)) return '';
const records = params as Array<{
axisValue?: string;
seriesName?: string;
value?: number;
}>;
const month = String(records[0]?.axisValue ?? '');
const cost = Number(
records.find((item) => item.seriesName === '总成本')?.value ?? 0,
);
const revenue = Number(
records.find((item) => item.seriesName === '营业额')?.value ?? 0,
);
return `${month}<br/>总成本:${formatCurrency(cost)}<br/>营业额:${formatCurrency(revenue)}`;
},
},
grid: {
left: '3%',
right: '3%',
bottom: '1%',
containLabel: true,
},
legend: {
data: ['总成本', '营业额'],
top: 0,
},
xAxis: {
type: 'category',
boundaryGap: true,
axisTick: { show: false },
data: source.map((item) => item.month.slice(5)),
},
yAxis: {
type: 'value',
splitLine: {
lineStyle: {
color: '#f1f5f9',
type: 'dashed',
},
},
axisLabel: {
formatter: (value: number) => `${Math.round(value / 1000)}k`,
},
},
series: [
{
name: '总成本',
type: 'bar',
barWidth: 22,
data: source.map((item) => item.totalCost),
itemStyle: {
borderRadius: [6, 6, 0, 0],
},
},
{
name: '营业额',
type: 'line',
smooth: true,
data: source.map((item) => item.revenue),
},
],
});
}
watch(
() => props.trend,
async () => {
await nextTick();
renderChart();
},
{ deep: true },
);
onMounted(() => {
renderChart();
});
</script>
<template>
<div class="fc-chart-card">
<div class="fc-section-title">近6个月成本趋势</div>
<EchartsUI ref="chartRef" class="fc-trend-chart" />
</div>
</template>

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
/**
* 文件职责:成本明细删除确认弹窗。
*/
import { Modal } from 'ant-design-vue';
interface Props {
itemName?: string;
open: boolean;
}
defineProps<Props>();
const emit = defineEmits<{
(event: 'cancel'): void;
(event: 'confirm'): void;
}>();
</script>
<template>
<Modal
:open="open"
title="删除明细"
ok-text="确认删除"
ok-type="danger"
cancel-text="取消"
@cancel="emit('cancel')"
@ok="emit('confirm')"
>
<p class="fc-delete-tip">
确认删除明细项
<strong>{{ itemName || '未命名项' }}</strong>
删除后需重新保存才会生效
</p>
</Modal>
</template>

View File

@@ -0,0 +1,157 @@
<script setup lang="ts">
import type { FinanceCostCategoryViewModel } from '../types';
/**
* 文件职责:成本录入分类卡片(总额 + 明细列表)。
*/
import type { FinanceCostCategoryCode } from '#/api/finance/cost';
import { IconifyIcon } from '@vben/icons';
import { Button, Card, Empty, InputNumber, Tag } from 'ant-design-vue';
import {
formatCurrency,
formatPercent,
} from '../composables/cost-page/helpers';
interface Props {
canManage: boolean;
category: FinanceCostCategoryViewModel;
color?: string;
icon?: string;
}
const props = defineProps<Props>();
const emit = defineEmits<{
(event: 'addItem', category: FinanceCostCategoryCode): void;
(
event: 'deleteItem',
category: FinanceCostCategoryCode,
itemId: string,
): void;
(event: 'editItem', category: FinanceCostCategoryCode, itemId: string): void;
(event: 'toggle', category: FinanceCostCategoryCode): void;
(
event: 'updateTotal',
category: FinanceCostCategoryCode,
value: number,
): void;
}>();
function handleTotalChange(value: unknown) {
emit('updateTotal', props.category.category, Number(value ?? 0));
}
function handleEdit(itemId: string | undefined) {
if (!itemId) return;
emit('editItem', props.category.category, itemId);
}
function handleDelete(itemId: string | undefined) {
if (!itemId) return;
emit('deleteItem', props.category.category, itemId);
}
</script>
<template>
<Card class="fc-entry-card" :bordered="false">
<div class="fc-entry-head">
<div class="fc-entry-icon" :style="{ color: props.color || '#1677ff' }">
<IconifyIcon :icon="props.icon || 'lucide:circle-dollar-sign'" />
</div>
<div class="fc-entry-meta">
<div class="fc-entry-name">
{{ props.category.categoryText }}
<span class="fc-entry-ratio">{{
formatPercent(props.category.percentage)
}}</span>
</div>
</div>
<div class="fc-entry-amount">
<span class="fc-entry-currency">¥</span>
<InputNumber
class="fc-entry-input"
:min="0"
:step="100"
:precision="2"
:value="props.category.totalAmount"
:controls="false"
:disabled="!props.canManage"
@update:value="(value) => handleTotalChange(value)"
/>
</div>
<Button
type="link"
class="fc-entry-toggle"
@click="emit('toggle', props.category.category)"
>
{{ props.category.expanded ? '收起明细' : '展开明细' }}
</Button>
</div>
<div v-if="props.category.expanded" class="fc-entry-detail">
<template v-if="props.category.items.length > 0">
<div
v-for="item in props.category.items"
:key="item.itemId"
class="fc-entry-detail-row"
>
<div class="fc-entry-item-name">{{ item.itemName }}</div>
<div class="fc-entry-item-value">
<template v-if="props.category.category === 'labor'">
<Tag color="blue">{{ item.quantity ?? 0 }} </Tag>
<span class="fc-entry-mul">x</span>
<Tag color="cyan">{{ formatCurrency(item.unitPrice ?? 0) }}</Tag>
<span class="fc-entry-equal">=</span>
</template>
<span class="fc-entry-item-amount">{{
formatCurrency(item.amount)
}}</span>
</div>
<div class="fc-entry-item-actions">
<Button
type="link"
size="small"
:disabled="!props.canManage"
@click="handleEdit(item.itemId)"
>
编辑
</Button>
<Button
type="link"
size="small"
danger
:disabled="!props.canManage"
@click="handleDelete(item.itemId)"
>
删除
</Button>
</div>
</div>
</template>
<div v-else class="fc-entry-empty">
<Empty description="暂无明细" :image-style="{ height: '48px' }" />
</div>
<Button
type="link"
class="fc-entry-add"
:disabled="!props.canManage"
@click="emit('addItem', props.category.category)"
>
<template #icon>
<IconifyIcon icon="lucide:plus" />
</template>
添加明细
</Button>
</div>
</Card>
</template>

View File

@@ -0,0 +1,37 @@
<script setup lang="ts">
/**
* 文件职责:成本录入底部汇总栏。
*/
import { Button } from 'ant-design-vue';
import { formatCurrency } from '../composables/cost-page/helpers';
interface Props {
canManage: boolean;
loading: boolean;
totalCost: number;
}
const props = defineProps<Props>();
const emit = defineEmits<{
(event: 'save'): void;
}>();
</script>
<template>
<div class="fc-summary">
<div class="fc-summary-label">本月总成本</div>
<div class="fc-summary-right">
<div class="fc-summary-value">{{ formatCurrency(props.totalCost) }}</div>
<Button
type="primary"
:loading="props.loading"
:disabled="!props.canManage"
@click="emit('save')"
>
保存
</Button>
</div>
</div>
</template>

View File

@@ -0,0 +1,191 @@
<script setup lang="ts">
/**
* 文件职责:成本明细编辑抽屉(新增/编辑)。
*/
import type {
FinanceCostCategoryCode,
FinanceCostEntryDetailDto,
} from '#/api/finance/cost';
import { computed, reactive, watch } from 'vue';
import { Button, Drawer, Form, Input, InputNumber } from 'ant-design-vue';
import { roundAmount } from '../composables/cost-page/helpers';
interface Props {
category: FinanceCostCategoryCode;
categoryText: string;
mode: 'create' | 'edit';
open: boolean;
sourceItem?: FinanceCostEntryDetailDto;
submitting: boolean;
}
interface EditorFormState {
amount: number;
itemId?: string;
itemName: string;
quantity?: number;
sortOrder: number;
unitPrice?: number;
}
const props = defineProps<Props>();
const emit = defineEmits<{
(event: 'close'): void;
(event: 'submit', payload: FinanceCostEntryDetailDto): void;
}>();
const formState = reactive<EditorFormState>({
itemId: undefined,
itemName: '',
amount: 0,
quantity: 0,
unitPrice: 0,
sortOrder: 1,
});
const isLaborCategory = computed(() => props.category === 'labor');
const drawerTitle = computed(() =>
props.mode === 'create'
? `新增${props.categoryText}明细`
: `编辑${props.categoryText}明细`,
);
const laborComputedAmount = computed(() => {
if (!isLaborCategory.value) return formState.amount;
return roundAmount((formState.quantity ?? 0) * (formState.unitPrice ?? 0));
});
watch(
() => [props.open, props.sourceItem, props.mode] as const,
() => {
if (!props.open) return;
formState.itemId = props.sourceItem?.itemId;
formState.itemName = props.sourceItem?.itemName ?? '';
formState.amount = props.sourceItem?.amount ?? 0;
formState.quantity = props.sourceItem?.quantity ?? 0;
formState.unitPrice = props.sourceItem?.unitPrice ?? 0;
formState.sortOrder = props.sourceItem?.sortOrder ?? 1;
},
{ immediate: true },
);
function handleSubmit() {
if (!formState.itemName.trim()) {
return;
}
formState.amount = isLaborCategory.value
? laborComputedAmount.value
: roundAmount(formState.amount);
emit('submit', {
itemId: formState.itemId,
itemName: formState.itemName.trim(),
amount: Math.max(0, formState.amount),
quantity: isLaborCategory.value
? roundAmount(formState.quantity ?? 0)
: undefined,
unitPrice: isLaborCategory.value
? roundAmount(formState.unitPrice ?? 0)
: undefined,
sortOrder: Math.max(1, Number(formState.sortOrder || 1)),
});
}
</script>
<template>
<Drawer
:open="props.open"
:title="drawerTitle"
width="460"
@close="emit('close')"
>
<Form layout="vertical">
<Form.Item label="明细名称" required>
<Input
:value="formState.itemName"
:maxlength="64"
placeholder="请输入明细名称"
@update:value="(value) => (formState.itemName = String(value ?? ''))"
/>
</Form.Item>
<template v-if="isLaborCategory">
<Form.Item label="人数" required>
<InputNumber
class="fc-full-input"
:min="0"
:precision="2"
:controls="false"
:value="formState.quantity"
@update:value="(value) => (formState.quantity = Number(value ?? 0))"
/>
</Form.Item>
<Form.Item label="月薪" required>
<InputNumber
class="fc-full-input"
:min="0"
:precision="2"
:controls="false"
:value="formState.unitPrice"
@update:value="
(value) => (formState.unitPrice = Number(value ?? 0))
"
/>
</Form.Item>
<Form.Item label="小计">
<InputNumber
class="fc-full-input"
:value="laborComputedAmount"
:precision="2"
:controls="false"
disabled
/>
</Form.Item>
</template>
<Form.Item v-else label="金额" required>
<InputNumber
class="fc-full-input"
:min="0"
:precision="2"
:controls="false"
:value="formState.amount"
@update:value="(value) => (formState.amount = Number(value ?? 0))"
/>
</Form.Item>
<Form.Item label="排序">
<InputNumber
class="fc-full-input"
:min="1"
:precision="0"
:controls="false"
:value="formState.sortOrder"
@update:value="(value) => (formState.sortOrder = Number(value ?? 1))"
/>
</Form.Item>
</Form>
<template #footer>
<div class="fc-drawer-footer">
<Button @click="emit('close')">取消</Button>
<Button
type="primary"
:loading="props.submitting"
:disabled="!formState.itemName.trim()"
@click="handleSubmit"
>
确认
</Button>
</div>
</template>
</Drawer>
</template>

View File

@@ -0,0 +1,107 @@
<script setup lang="ts">
import type { FinanceCostTabKey, OptionItem } from '../types';
/**
* 文件职责成本管理顶部工具条Tab、维度、门店、月份
*/
import type { FinanceCostDimension } from '#/api/finance/cost';
import { IconifyIcon } from '@vben/icons';
import { Button, Input, Segmented, Select } from 'ant-design-vue';
interface Props {
activeTab: FinanceCostTabKey;
dimension: FinanceCostDimension;
dimensionOptions: Array<{ label: string; value: FinanceCostDimension }>;
isStoreLoading: boolean;
month: string;
monthTitle: string;
showStoreSelect: boolean;
storeId: string;
storeOptions: OptionItem[];
tabOptions: Array<{ label: string; value: FinanceCostTabKey }>;
}
const props = defineProps<Props>();
const emit = defineEmits<{
(event: 'nextMonth'): void;
(event: 'prevMonth'): void;
(event: 'update:activeTab', value: FinanceCostTabKey): void;
(event: 'update:dimension', value: FinanceCostDimension): void;
(event: 'update:month', value: string): 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="fc-toolbar">
<Segmented
class="fc-tab-segmented"
:value="props.activeTab"
:options="props.tabOptions"
@update:value="
(value) =>
emit('update:activeTab', (value as FinanceCostTabKey) || 'entry')
"
/>
<div class="fc-toolbar-right">
<Segmented
class="fc-dimension-segmented"
:value="props.dimension"
:options="props.dimensionOptions"
@update:value="
(value) =>
emit(
'update:dimension',
(value as FinanceCostDimension) || 'tenant',
)
"
/>
<Select
v-if="props.showStoreSelect"
class="fc-store-select"
:value="props.storeId"
:options="props.storeOptions"
:loading="props.isStoreLoading"
:disabled="props.storeOptions.length === 0"
placeholder="请选择门店"
@update:value="(value) => handleStoreChange(value)"
/>
<div class="fc-month-picker">
<Button class="fc-month-arrow" @click="emit('prevMonth')">
<template #icon>
<IconifyIcon icon="lucide:chevron-left" />
</template>
</Button>
<div class="fc-month-title">{{ props.monthTitle }}</div>
<Button class="fc-month-arrow" @click="emit('nextMonth')">
<template #icon>
<IconifyIcon icon="lucide:chevron-right" />
</template>
</Button>
<Input
class="fc-month-input"
type="month"
:value="props.month"
@update:value="(value) => emit('update:month', String(value ?? ''))"
/>
</div>
</div>
</div>
</template>