diff --git a/apps/web-antd/src/api/finance/index.ts b/apps/web-antd/src/api/finance/index.ts index c38c9fc..5552e14 100644 --- a/apps/web-antd/src/api/finance/index.ts +++ b/apps/web-antd/src/api/finance/index.ts @@ -3,6 +3,7 @@ */ export * from './cost'; export * from './invoice'; +export * from './overview'; export * from './report'; export * from './settlement'; export * from './transaction'; diff --git a/apps/web-antd/src/api/finance/overview.ts b/apps/web-antd/src/api/finance/overview.ts new file mode 100644 index 0000000..5282af7 --- /dev/null +++ b/apps/web-antd/src/api/finance/overview.ts @@ -0,0 +1,122 @@ +/** + * 文件职责:财务概览 API 契约与请求封装。 + */ +import { requestClient } from '#/api/request'; + +/** 财务概览维度。 */ +export type FinanceOverviewDimension = 'store' | 'tenant'; + +/** 趋势周期值。 */ +export type FinanceOverviewTrendRange = '7' | '30'; + +/** 财务概览查询参数。 */ +export interface FinanceOverviewDashboardQuery { + dimension: FinanceOverviewDimension; + storeId?: string; +} + +/** KPI 卡片数据。 */ +export interface FinanceOverviewKpiCardDto { + amount: number; + changeRate: number; + compareAmount: number; + compareLabel: string; + trend: 'down' | 'flat' | 'up'; +} + +/** 收入趋势点。 */ +export interface FinanceOverviewIncomeTrendPointDto { + amount: number; + date: string; + dateLabel: string; +} + +/** 收入趋势数据。 */ +export interface FinanceOverviewIncomeTrendDto { + last30Days: FinanceOverviewIncomeTrendPointDto[]; + last7Days: FinanceOverviewIncomeTrendPointDto[]; +} + +/** 利润趋势点。 */ +export interface FinanceOverviewProfitTrendPointDto { + costAmount: number; + date: string; + dateLabel: string; + netProfitAmount: number; + revenueAmount: number; +} + +/** 利润趋势数据。 */ +export interface FinanceOverviewProfitTrendDto { + last30Days: FinanceOverviewProfitTrendPointDto[]; + last7Days: FinanceOverviewProfitTrendPointDto[]; +} + +/** 收入构成项。 */ +export interface FinanceOverviewIncomeCompositionItemDto { + amount: number; + channel: 'delivery' | 'dine_in' | 'pickup'; + channelText: string; + percentage: number; +} + +/** 收入构成数据。 */ +export interface FinanceOverviewIncomeCompositionDto { + items: FinanceOverviewIncomeCompositionItemDto[]; + totalAmount: number; +} + +/** 成本构成项。 */ +export interface FinanceOverviewCostCompositionItemDto { + amount: number; + category: 'fixed' | 'food' | 'labor' | 'packaging' | 'platform'; + categoryText: string; + percentage: number; +} + +/** 成本构成数据。 */ +export interface FinanceOverviewCostCompositionDto { + items: FinanceOverviewCostCompositionItemDto[]; + totalAmount: number; +} + +/** TOP 商品项。 */ +export interface FinanceOverviewTopProductItemDto { + percentage: number; + productName: string; + rank: number; + revenueAmount: number; + salesQuantity: number; +} + +/** TOP 商品排行。 */ +export interface FinanceOverviewTopProductDto { + items: FinanceOverviewTopProductItemDto[]; + periodDays: number; +} + +/** 财务概览驾驶舱数据。 */ +export interface FinanceOverviewDashboardDto { + actualReceived: FinanceOverviewKpiCardDto; + costComposition: FinanceOverviewCostCompositionDto; + dimension: FinanceOverviewDimension; + incomeComposition: FinanceOverviewIncomeCompositionDto; + incomeTrend: FinanceOverviewIncomeTrendDto; + netIncome: FinanceOverviewKpiCardDto; + profitTrend: FinanceOverviewProfitTrendDto; + refundAmount: FinanceOverviewKpiCardDto; + storeId?: string; + todayRevenue: FinanceOverviewKpiCardDto; + topProducts: FinanceOverviewTopProductDto; + withdrawableBalance: FinanceOverviewKpiCardDto; +} + +/** 查询财务概览驾驶舱。 */ +export async function getFinanceOverviewDashboardApi( + params: FinanceOverviewDashboardQuery, +) { + return requestClient.get( + '/finance/overview/dashboard', + { params }, + ); +} diff --git a/apps/web-antd/src/api/store-fees/index.ts b/apps/web-antd/src/api/store-fees/index.ts index b33efee..84cea47 100644 --- a/apps/web-antd/src/api/store-fees/index.ts +++ b/apps/web-antd/src/api/store-fees/index.ts @@ -46,6 +46,8 @@ export interface StoreOtherFeesDto { export interface StoreFeesSettingsDto { /** 基础配送费 */ baseDeliveryFee: number; + /** 平台服务费率(%) */ + platformServiceRate: number; /** 固定包装费 */ fixedPackagingFee: number; /** 免配送费门槛,空值表示关闭 */ @@ -70,6 +72,8 @@ export interface StoreFeesSettingsDto { export interface SaveStoreFeesSettingsParams { /** 基础配送费 */ baseDeliveryFee: number; + /** 平台服务费率(%) */ + platformServiceRate: number; /** 固定包装费 */ fixedPackagingFee: number; /** 免配送费门槛,空值表示关闭 */ diff --git a/apps/web-antd/src/views/finance/overview/components/OverviewCompositionCard.vue b/apps/web-antd/src/views/finance/overview/components/OverviewCompositionCard.vue new file mode 100644 index 0000000..2912c6d --- /dev/null +++ b/apps/web-antd/src/views/finance/overview/components/OverviewCompositionCard.vue @@ -0,0 +1,115 @@ + + + diff --git a/apps/web-antd/src/views/finance/overview/components/OverviewIncomeTrendCard.vue b/apps/web-antd/src/views/finance/overview/components/OverviewIncomeTrendCard.vue new file mode 100644 index 0000000..54cc45d --- /dev/null +++ b/apps/web-antd/src/views/finance/overview/components/OverviewIncomeTrendCard.vue @@ -0,0 +1,143 @@ + + + diff --git a/apps/web-antd/src/views/finance/overview/components/OverviewKpiRow.vue b/apps/web-antd/src/views/finance/overview/components/OverviewKpiRow.vue new file mode 100644 index 0000000..ed28b47 --- /dev/null +++ b/apps/web-antd/src/views/finance/overview/components/OverviewKpiRow.vue @@ -0,0 +1,59 @@ + + + diff --git a/apps/web-antd/src/views/finance/overview/components/OverviewProfitTrendCard.vue b/apps/web-antd/src/views/finance/overview/components/OverviewProfitTrendCard.vue new file mode 100644 index 0000000..46149c6 --- /dev/null +++ b/apps/web-antd/src/views/finance/overview/components/OverviewProfitTrendCard.vue @@ -0,0 +1,186 @@ + + + diff --git a/apps/web-antd/src/views/finance/overview/components/OverviewToolbar.vue b/apps/web-antd/src/views/finance/overview/components/OverviewToolbar.vue new file mode 100644 index 0000000..6689cc6 --- /dev/null +++ b/apps/web-antd/src/views/finance/overview/components/OverviewToolbar.vue @@ -0,0 +1,66 @@ + + + diff --git a/apps/web-antd/src/views/finance/overview/components/OverviewTopProductsCard.vue b/apps/web-antd/src/views/finance/overview/components/OverviewTopProductsCard.vue new file mode 100644 index 0000000..e7ff4b6 --- /dev/null +++ b/apps/web-antd/src/views/finance/overview/components/OverviewTopProductsCard.vue @@ -0,0 +1,109 @@ + + + diff --git a/apps/web-antd/src/views/finance/overview/composables/overview-page/constants.ts b/apps/web-antd/src/views/finance/overview/composables/overview-page/constants.ts new file mode 100644 index 0000000..0c71097 --- /dev/null +++ b/apps/web-antd/src/views/finance/overview/composables/overview-page/constants.ts @@ -0,0 +1,153 @@ +import type { FinanceOverviewTrendState } from '../../types'; + +import type { + FinanceOverviewDashboardDto, + FinanceOverviewDimension, + FinanceOverviewKpiCardDto, + FinanceOverviewTrendRange, +} from '#/api/finance/overview'; + +/** + * 文件职责:财务概览页面常量定义。 + */ + +/** 财务概览查看权限。 */ +export const FINANCE_OVERVIEW_VIEW_PERMISSION = 'tenant:finance:overview:view'; + +/** 维度切换选项。 */ +export const OVERVIEW_DIMENSION_OPTIONS: Array<{ + label: string; + value: FinanceOverviewDimension; +}> = [ + { label: '租户汇总', value: 'tenant' }, + { label: '门店视角', value: 'store' }, +]; + +/** KPI 键。 */ +export type FinanceOverviewKpiKey = + | 'actualReceived' + | 'netIncome' + | 'refundAmount' + | 'todayRevenue' + | 'withdrawableBalance'; + +/** KPI 卡片配置信息。 */ +export const OVERVIEW_KPI_CONFIG: Array<{ + icon: string; + key: FinanceOverviewKpiKey; + label: string; + tone: 'blue' | 'green' | 'orange' | 'purple' | 'red'; +}> = [ + { + key: 'todayRevenue', + label: '今日营业额', + icon: 'lucide:coins', + tone: 'blue', + }, + { + key: 'actualReceived', + label: '实收', + icon: 'lucide:badge-check', + tone: 'green', + }, + { + key: 'refundAmount', + label: '退款', + icon: 'lucide:undo-2', + tone: 'red', + }, + { + key: 'netIncome', + label: '净收入', + icon: 'lucide:wallet', + tone: 'purple', + }, + { + key: 'withdrawableBalance', + label: '可提现余额', + icon: 'lucide:landmark', + tone: 'orange', + }, +]; + +/** 收入构成颜色映射。 */ +export const INCOME_COMPOSITION_COLOR_MAP: Record = { + delivery: '#1677ff', + pickup: '#22c55e', + dine_in: '#f59e0b', +}; + +/** 成本构成颜色映射。 */ +export const COST_COMPOSITION_COLOR_MAP: Record = { + food: '#ef4444', + labor: '#f59e0b', + fixed: '#8b5cf6', + packaging: '#06b6d4', + platform: '#94a3b8', +}; + +/** 默认筛选状态。 */ +export const DEFAULT_OVERVIEW_FILTER = { + dimension: 'tenant', + storeId: '', +} as const; + +/** 默认趋势状态。 */ +export const DEFAULT_OVERVIEW_TREND_STATE: FinanceOverviewTrendState = { + incomeRange: '7', + profitRange: '7', +}; + +/** 默认 KPI 卡片。 */ +export const EMPTY_KPI_CARD: FinanceOverviewKpiCardDto = { + amount: 0, + compareAmount: 0, + changeRate: 0, + compareLabel: '较昨日', + trend: 'flat', +}; + +/** 默认财务概览数据。 */ +export function createDefaultFinanceOverviewDashboard(): FinanceOverviewDashboardDto { + return { + dimension: 'tenant', + storeId: undefined, + todayRevenue: { ...EMPTY_KPI_CARD }, + actualReceived: { ...EMPTY_KPI_CARD }, + refundAmount: { ...EMPTY_KPI_CARD }, + netIncome: { ...EMPTY_KPI_CARD }, + withdrawableBalance: { + ...EMPTY_KPI_CARD, + compareLabel: '较上周', + }, + incomeTrend: { + last7Days: [], + last30Days: [], + }, + profitTrend: { + last7Days: [], + last30Days: [], + }, + incomeComposition: { + totalAmount: 0, + items: [], + }, + costComposition: { + totalAmount: 0, + items: [], + }, + topProducts: { + periodDays: 30, + items: [], + }, + }; +} + +/** 趋势范围枚举。 */ +export const TREND_RANGE_OPTIONS: Array<{ + label: string; + value: FinanceOverviewTrendRange; +}> = [ + { label: '近7天', value: '7' }, + { label: '近30天', value: '30' }, +]; diff --git a/apps/web-antd/src/views/finance/overview/composables/overview-page/data-actions.ts b/apps/web-antd/src/views/finance/overview/composables/overview-page/data-actions.ts new file mode 100644 index 0000000..6024429 --- /dev/null +++ b/apps/web-antd/src/views/finance/overview/composables/overview-page/data-actions.ts @@ -0,0 +1,109 @@ +/** + * 文件职责:财务概览页面数据动作。 + */ +import type { Ref } from 'vue'; + +import type { + FinanceOverviewDashboardState, + FinanceOverviewFilterState, +} from '../../types'; + +import type { StoreListItemDto } from '#/api/store'; + +import { message } from 'ant-design-vue'; + +import { getFinanceOverviewDashboardApi } from '#/api/finance/overview'; +import { getStoreListApi } from '#/api/store'; + +import { createDefaultFinanceOverviewDashboard } from './constants'; +import { buildDashboardQuery } from './helpers'; + +interface CreateDataActionsOptions { + dashboard: FinanceOverviewDashboardState; + filters: FinanceOverviewFilterState; + isDashboardLoading: Ref; + isStoreLoading: Ref; + stores: Ref; +} + +/** 创建页面数据动作。 */ +export function createDataActions(options: CreateDataActionsOptions) { + function syncDashboard(source: FinanceOverviewDashboardState) { + options.dashboard.dimension = source.dimension; + options.dashboard.storeId = source.storeId; + options.dashboard.todayRevenue = { ...source.todayRevenue }; + options.dashboard.actualReceived = { ...source.actualReceived }; + options.dashboard.refundAmount = { ...source.refundAmount }; + options.dashboard.netIncome = { ...source.netIncome }; + options.dashboard.withdrawableBalance = { ...source.withdrawableBalance }; + options.dashboard.incomeTrend = { + last7Days: [...source.incomeTrend.last7Days], + last30Days: [...source.incomeTrend.last30Days], + }; + options.dashboard.profitTrend = { + last7Days: [...source.profitTrend.last7Days], + last30Days: [...source.profitTrend.last30Days], + }; + options.dashboard.incomeComposition = { + totalAmount: source.incomeComposition.totalAmount, + items: [...source.incomeComposition.items], + }; + options.dashboard.costComposition = { + totalAmount: source.costComposition.totalAmount, + items: [...source.costComposition.items], + }; + options.dashboard.topProducts = { + periodDays: source.topProducts.periodDays, + items: [...source.topProducts.items], + }; + } + + function clearDashboard() { + syncDashboard(createDefaultFinanceOverviewDashboard()); + } + + async function loadStores() { + options.isStoreLoading.value = true; + try { + const result = await getStoreListApi({ + page: 1, + pageSize: 200, + }); + options.stores.value = result.items ?? []; + } catch (error) { + console.error(error); + options.stores.value = []; + message.error('加载门店列表失败,请稍后重试'); + } finally { + options.isStoreLoading.value = false; + } + } + + async function loadDashboard() { + if (options.filters.dimension === 'store' && !options.filters.storeId) { + clearDashboard(); + return; + } + + options.isDashboardLoading.value = true; + try { + const result = await getFinanceOverviewDashboardApi( + buildDashboardQuery(options.filters), + ); + syncDashboard(result); + } catch (error) { + console.error(error); + clearDashboard(); + message.error('加载财务概览失败,请稍后重试'); + } finally { + options.isDashboardLoading.value = false; + } + } + + return { + clearDashboard, + loadDashboard, + loadStores, + syncDashboard, + }; +} diff --git a/apps/web-antd/src/views/finance/overview/composables/overview-page/helpers.ts b/apps/web-antd/src/views/finance/overview/composables/overview-page/helpers.ts new file mode 100644 index 0000000..42aeda4 --- /dev/null +++ b/apps/web-antd/src/views/finance/overview/composables/overview-page/helpers.ts @@ -0,0 +1,138 @@ +/** + * 文件职责:财务概览页面工具方法。 + */ +import type { + FinanceOverviewCompositionViewItem, + FinanceOverviewFilterState, +} from '../../types'; + +import type { + FinanceOverviewDashboardQuery, + FinanceOverviewKpiCardDto, +} from '#/api/finance/overview'; + +import { + COST_COMPOSITION_COLOR_MAP, + INCOME_COMPOSITION_COLOR_MAP, +} from './constants'; + +const currencyFormatter = new Intl.NumberFormat('zh-CN', { + minimumFractionDigits: 0, + maximumFractionDigits: 2, +}); + +const percentFormatter = new Intl.NumberFormat('zh-CN', { + minimumFractionDigits: 0, + maximumFractionDigits: 2, +}); + +/** 货币格式化。 */ +export function formatCurrency(value: number) { + return `¥${currencyFormatter.format(value)}`; +} + +/** 百分比格式化。 */ +export function formatPercent(value: number) { + return `${percentFormatter.format(value)}%`; +} + +/** 变化率格式化(带正负号)。 */ +export function formatChangeRate(value: number) { + const sign = value > 0 ? '+' : ''; + return `${sign}${percentFormatter.format(value)}%`; +} + +/** 图表纵轴金额格式化。 */ +export function formatAxisAmount(value: number) { + if (Math.abs(value) >= 10_000) { + return `${percentFormatter.format(value / 10_000)}万`; + } + return currencyFormatter.format(value); +} + +/** 生成查询参数。 */ +export function buildDashboardQuery( + filters: FinanceOverviewFilterState, +): FinanceOverviewDashboardQuery { + if (filters.dimension === 'store') { + return { + dimension: filters.dimension, + storeId: filters.storeId, + }; + } + + return { + dimension: filters.dimension, + }; +} + +/** 计算 TOP 占比条宽度。 */ +export function calcTopProductBarWidth( + percentage: number, + maxPercentage: number, +) { + if (maxPercentage <= 0) return 0; + return Math.round((percentage / maxPercentage) * 100); +} + +/** 收入构成转换为视图结构。 */ +export function toIncomeCompositionViewItems( + items: Array<{ + amount: number; + channel: string; + channelText: string; + percentage: number; + }>, +): FinanceOverviewCompositionViewItem[] { + return items.map((item) => ({ + key: item.channel, + name: item.channelText, + amount: item.amount, + percentage: item.percentage, + color: INCOME_COMPOSITION_COLOR_MAP[item.channel] ?? '#1677ff', + })); +} + +/** 成本构成转换为视图结构。 */ +export function toCostCompositionViewItems( + items: Array<{ + amount: number; + category: string; + categoryText: string; + percentage: number; + }>, +): FinanceOverviewCompositionViewItem[] { + return items.map((item) => ({ + key: item.category, + name: item.categoryText, + amount: item.amount, + percentage: item.percentage, + color: COST_COMPOSITION_COLOR_MAP[item.category] ?? '#94a3b8', + })); +} + +/** 获取 KPI 趋势样式标识。 */ +export function resolveKpiTrendClass(card: FinanceOverviewKpiCardDto) { + if (card.trend === 'up') { + return 'is-up'; + } + + if (card.trend === 'down') { + return 'is-down'; + } + + return 'is-flat'; +} + +/** 获取 KPI 趋势图标。 */ +export function resolveKpiTrendIcon(card: FinanceOverviewKpiCardDto) { + if (card.trend === 'up') { + return 'lucide:trending-up'; + } + + if (card.trend === 'down') { + return 'lucide:trending-down'; + } + + return 'lucide:minus'; +} diff --git a/apps/web-antd/src/views/finance/overview/composables/useFinanceOverviewPage.ts b/apps/web-antd/src/views/finance/overview/composables/useFinanceOverviewPage.ts new file mode 100644 index 0000000..4af9098 --- /dev/null +++ b/apps/web-antd/src/views/finance/overview/composables/useFinanceOverviewPage.ts @@ -0,0 +1,249 @@ +/** + * 文件职责:财务概览页面状态编排。 + */ +import type { + FinanceOverviewCompositionViewItem, + FinanceOverviewDashboardState, + FinanceOverviewFilterState, + FinanceOverviewTrendState, + OptionItem, +} from '../types'; + +import type { + FinanceOverviewDimension, + FinanceOverviewTrendRange, +} from '#/api/finance/overview'; +import type { StoreListItemDto } from '#/api/store'; + +import { computed, onActivated, onMounted, reactive, ref, watch } from 'vue'; + +import { useAccessStore } from '@vben/stores'; + +import { + createDefaultFinanceOverviewDashboard, + DEFAULT_OVERVIEW_FILTER, + DEFAULT_OVERVIEW_TREND_STATE, + FINANCE_OVERVIEW_VIEW_PERMISSION, + OVERVIEW_DIMENSION_OPTIONS, +} from './overview-page/constants'; +import { createDataActions } from './overview-page/data-actions'; +import { + toCostCompositionViewItems, + toIncomeCompositionViewItems, +} from './overview-page/helpers'; + +/** 创建财务概览页面组合状态。 */ +export function useFinanceOverviewPage() { + const accessStore = useAccessStore(); + + const stores = ref([]); + const filters = reactive({ + ...DEFAULT_OVERVIEW_FILTER, + }); + const trendState = reactive({ + ...DEFAULT_OVERVIEW_TREND_STATE, + }); + const dashboard = reactive( + createDefaultFinanceOverviewDashboard(), + ); + + const isStoreLoading = ref(false); + const isDashboardLoading = ref(false); + + const accessCodeSet = computed( + () => new Set((accessStore.accessCodes ?? []).map(String)), + ); + + const canView = computed(() => + accessCodeSet.value.has(FINANCE_OVERVIEW_VIEW_PERMISSION), + ); + + const storeOptions = computed(() => + stores.value.map((item) => ({ + label: item.name, + value: item.id, + })), + ); + + const showStoreSelect = computed(() => filters.dimension === 'store'); + const hasStore = computed(() => stores.value.length > 0); + const canQueryCurrentScope = computed( + () => filters.dimension === 'tenant' || Boolean(filters.storeId), + ); + + const incomeTrendPoints = computed(() => + trendState.incomeRange === '7' + ? dashboard.incomeTrend.last7Days + : dashboard.incomeTrend.last30Days, + ); + + const profitTrendPoints = computed(() => + trendState.profitRange === '7' + ? dashboard.profitTrend.last7Days + : dashboard.profitTrend.last30Days, + ); + + const incomeCompositionItems = computed( + () => toIncomeCompositionViewItems(dashboard.incomeComposition.items), + ); + + const costCompositionItems = computed( + () => toCostCompositionViewItems(dashboard.costComposition.items), + ); + + const topProductMaxPercentage = computed(() => { + if (dashboard.topProducts.items.length === 0) { + return 0; + } + return Math.max( + ...dashboard.topProducts.items.map((item) => item.percentage), + ); + }); + + const dataActions = createDataActions({ + stores, + filters, + dashboard, + isStoreLoading, + isDashboardLoading, + }); + + function ensureStoreSelection() { + if (filters.dimension !== 'store') { + return false; + } + + if (filters.storeId) { + return false; + } + + const firstStore = stores.value[0]; + if (!firstStore) { + return false; + } + + filters.storeId = firstStore.id; + return true; + } + + async function loadByCurrentScope() { + if (!canView.value) { + return; + } + + if (filters.dimension === 'store' && !filters.storeId) { + dataActions.clearDashboard(); + return; + } + + await dataActions.loadDashboard(); + } + + async function initPageData() { + if (!canView.value) { + stores.value = []; + filters.storeId = ''; + dataActions.clearDashboard(); + return; + } + + await dataActions.loadStores(); + + if (ensureStoreSelection()) { + return; + } + + await loadByCurrentScope(); + } + + function setDimension(value: FinanceOverviewDimension) { + filters.dimension = value; + } + + function setStoreId(value: string) { + filters.storeId = value; + } + + function setIncomeRange(value: FinanceOverviewTrendRange) { + trendState.incomeRange = value; + } + + function setProfitRange(value: FinanceOverviewTrendRange) { + trendState.profitRange = value; + } + + watch( + () => [filters.dimension, filters.storeId], + async () => { + if (!canView.value) { + return; + } + + if (filters.dimension === 'store' && ensureStoreSelection()) { + return; + } + + await loadByCurrentScope(); + }, + ); + + watch( + () => canView.value, + async (value, oldValue) => { + if (value === oldValue) { + return; + } + + if (!value) { + stores.value = []; + filters.storeId = ''; + dataActions.clearDashboard(); + return; + } + + await initPageData(); + }, + ); + + onMounted(async () => { + await initPageData(); + }); + + onActivated(() => { + if (!canView.value) { + return; + } + + if (stores.value.length === 0) { + void initPageData(); + return; + } + + if (!showStoreSelect.value || filters.storeId) { + void loadByCurrentScope(); + } + }); + + return { + canQueryCurrentScope, + canView, + costCompositionItems, + dashboard, + hasStore, + incomeCompositionItems, + incomeTrendPoints, + isDashboardLoading, + isStoreLoading, + OVERVIEW_DIMENSION_OPTIONS, + profitTrendPoints, + showStoreSelect, + storeOptions, + topProductMaxPercentage, + trendState, + filters, + setDimension, + setStoreId, + setIncomeRange, + setProfitRange, + }; +} diff --git a/apps/web-antd/src/views/finance/overview/index.vue b/apps/web-antd/src/views/finance/overview/index.vue new file mode 100644 index 0000000..394298a --- /dev/null +++ b/apps/web-antd/src/views/finance/overview/index.vue @@ -0,0 +1,108 @@ + + + + + diff --git a/apps/web-antd/src/views/finance/overview/styles/base.less b/apps/web-antd/src/views/finance/overview/styles/base.less new file mode 100644 index 0000000..2e90852 --- /dev/null +++ b/apps/web-antd/src/views/finance/overview/styles/base.less @@ -0,0 +1,54 @@ +/** + * 文件职责:财务概览页面基础样式。 + */ +.page-finance-overview { + .ant-card { + border: 1px solid #f0f0f0; + border-radius: 10px; + } +} + +.fo-page { + display: flex; + flex-direction: column; + gap: 14px; +} + +.fo-empty { + padding: 36px 0; + background: #fff; + border: 1px solid #f0f0f0; + border-radius: 10px; +} + +.fo-page > * { + animation: fo-fade-in 0.38s ease both; +} + +.fo-page > *:nth-child(2) { + animation-delay: 0.04s; +} + +.fo-page > *:nth-child(3) { + animation-delay: 0.08s; +} + +.fo-page > *:nth-child(4) { + animation-delay: 0.12s; +} + +.fo-page > *:nth-child(5) { + animation-delay: 0.16s; +} + +@keyframes fo-fade-in { + from { + opacity: 0; + transform: translateY(6px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/apps/web-antd/src/views/finance/overview/styles/charts.less b/apps/web-antd/src/views/finance/overview/styles/charts.less new file mode 100644 index 0000000..7450a6d --- /dev/null +++ b/apps/web-antd/src/views/finance/overview/styles/charts.less @@ -0,0 +1,125 @@ +/** + * 文件职责:财务概览图表区域样式。 + */ +.fo-trend-chart, +.fo-profit-chart { + height: 260px; +} + +.fo-composition-wrap { + display: flex; + gap: 20px; + align-items: center; +} + +.fo-composition-chart-wrap { + position: relative; + flex-shrink: 0; + width: 220px; + height: 220px; +} + +.fo-composition-chart { + width: 100%; + height: 100%; +} + +.fo-composition-center { + position: absolute; + inset: 71px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + pointer-events: none; +} + +.fo-composition-center-value { + font-size: 14px; + font-weight: 700; + color: rgb(0 0 0 / 88%); +} + +.fo-composition-center-label { + font-size: 11px; + color: rgb(0 0 0 / 45%); +} + +.fo-composition-legend { + display: flex; + flex: 1; + flex-direction: column; + gap: 10px; +} + +.fo-composition-legend-item { + display: grid; + grid-template-columns: 10px 1fr auto auto; + gap: 8px; + align-items: center; + font-size: 13px; +} + +.fo-composition-dot { + width: 10px; + height: 10px; + border-radius: 2px; +} + +.fo-composition-name { + color: rgb(0 0 0 / 88%); +} + +.fo-composition-percent { + min-width: 54px; + color: rgb(0 0 0 / 55%); + text-align: right; +} + +.fo-composition-amount { + min-width: 88px; + font-weight: 600; + color: rgb(0 0 0 / 88%); + text-align: right; +} + +.fo-profit-legend { + display: flex; + gap: 20px; + align-items: center; + margin-top: 10px; +} + +.fo-profit-legend-item { + display: flex; + gap: 6px; + align-items: center; + font-size: 12px; + color: rgb(0 0 0 / 65%); +} + +.fo-profit-legend-line { + display: inline-flex; + width: 20px; + height: 3px; + border-radius: 3px; +} + +.fo-profit-legend-line.is-revenue { + background: #1677ff; +} + +.fo-profit-legend-line.is-cost { + background: repeating-linear-gradient( + 90deg, + #ef4444 0, + #ef4444 6px, + rgb(239 68 68 / 10%) 6px, + rgb(239 68 68 / 10%) 9px + ); +} + +.fo-profit-legend-line.is-net { + background: #22c55e; +} diff --git a/apps/web-antd/src/views/finance/overview/styles/index.less b/apps/web-antd/src/views/finance/overview/styles/index.less new file mode 100644 index 0000000..b02e3da --- /dev/null +++ b/apps/web-antd/src/views/finance/overview/styles/index.less @@ -0,0 +1,9 @@ +/** + * 文件职责:财务概览页面样式聚合入口。 + */ +@import './base.less'; +@import './layout.less'; +@import './kpi.less'; +@import './charts.less'; +@import './table.less'; +@import './responsive.less'; diff --git a/apps/web-antd/src/views/finance/overview/styles/kpi.less b/apps/web-antd/src/views/finance/overview/styles/kpi.less new file mode 100644 index 0000000..26e16fe --- /dev/null +++ b/apps/web-antd/src/views/finance/overview/styles/kpi.less @@ -0,0 +1,97 @@ +/** + * 文件职责:财务概览 KPI 卡片样式。 + */ +.fo-kpi-row { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 12px; +} + +.fo-kpi-card { + display: flex; + flex-direction: column; + gap: 10px; + padding: 16px 18px; + background: #fff; + border: 1px solid #f0f0f0; + border-radius: 10px; + box-shadow: 0 2px 8px rgb(15 23 42 / 5%); + transition: all 0.2s ease; +} + +.fo-kpi-card:hover { + box-shadow: 0 8px 16px rgb(15 23 42 / 10%); + transform: translateY(-1px); +} + +.fo-kpi-top { + display: flex; + align-items: center; + justify-content: space-between; +} + +.fo-kpi-label { + font-size: 13px; + color: rgb(0 0 0 / 60%); +} + +.fo-kpi-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + font-size: 18px; + border-radius: 8px; +} + +.fo-kpi-card.is-blue .fo-kpi-icon { + color: #1677ff; + background: #e6f4ff; +} + +.fo-kpi-card.is-green .fo-kpi-icon { + color: #16a34a; + background: #f0fdf4; +} + +.fo-kpi-card.is-red .fo-kpi-icon { + color: #ef4444; + background: #fef2f2; +} + +.fo-kpi-card.is-purple .fo-kpi-icon { + color: #7c3aed; + background: #f5f3ff; +} + +.fo-kpi-card.is-orange .fo-kpi-icon { + color: #f59e0b; + background: #fffbeb; +} + +.fo-kpi-value { + font-size: 25px; + font-weight: 800; + color: rgb(0 0 0 / 88%); + letter-spacing: -0.2px; +} + +.fo-kpi-change { + display: flex; + gap: 4px; + align-items: center; + font-size: 12px; +} + +.fo-kpi-change.is-up { + color: #16a34a; +} + +.fo-kpi-change.is-down { + color: #ef4444; +} + +.fo-kpi-change.is-flat { + color: rgb(0 0 0 / 45%); +} diff --git a/apps/web-antd/src/views/finance/overview/styles/layout.less b/apps/web-antd/src/views/finance/overview/styles/layout.less new file mode 100644 index 0000000..505fb2b --- /dev/null +++ b/apps/web-antd/src/views/finance/overview/styles/layout.less @@ -0,0 +1,94 @@ +/** + * 文件职责:财务概览页面布局与工具条样式。 + */ +.fo-toolbar { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + justify-content: space-between; + padding: 14px 16px; + background: #fff; + border: 1px solid #f0f0f0; + border-radius: 10px; +} + +.fo-toolbar-title { + font-size: 16px; + font-weight: 700; + color: rgb(0 0 0 / 88%); +} + +.fo-toolbar-right { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + justify-content: flex-end; +} + +.fo-dimension-segmented { + .ant-segmented-item { + min-width: 92px; + text-align: center; + } +} + +.fo-store-select { + width: 230px; +} + +.fo-section-card { + padding: 18px; + background: #fff; + border: 1px solid #f0f0f0; + border-radius: 10px; + box-shadow: 0 2px 8px rgb(15 23 42 / 5%); +} + +.fo-section-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 14px; +} + +.fo-section-title { + padding-left: 10px; + margin-bottom: 12px; + font-size: 15px; + font-weight: 600; + color: rgb(0 0 0 / 88%); + border-left: 3px solid #1677ff; +} + +.fo-section-head .fo-section-title { + margin-bottom: 0; +} + +.fo-pills { + display: flex; + gap: 6px; +} + +.fo-pill { + padding: 4px 14px; + font-size: 12px; + color: rgb(0 0 0 / 65%); + cursor: pointer; + background: #f3f4f6; + border: none; + border-radius: 99px; + transition: all 0.2s ease; +} + +.fo-pill.is-active { + color: #fff; + background: #1677ff; +} + +.fo-two-col { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} diff --git a/apps/web-antd/src/views/finance/overview/styles/responsive.less b/apps/web-antd/src/views/finance/overview/styles/responsive.less new file mode 100644 index 0000000..2ba9922 --- /dev/null +++ b/apps/web-antd/src/views/finance/overview/styles/responsive.less @@ -0,0 +1,66 @@ +/** + * 文件职责:财务概览页面响应式样式。 + */ +.page-finance-overview { + @media (max-width: 1200px) { + .fo-kpi-row { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + } + + @media (max-width: 992px) { + .fo-two-col { + grid-template-columns: 1fr; + } + + .fo-kpi-row { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + + @media (max-width: 768px) { + .fo-toolbar { + align-items: stretch; + } + + .fo-toolbar-right { + justify-content: flex-start; + } + + .fo-store-select { + width: 100%; + } + + .fo-kpi-row { + grid-template-columns: 1fr; + } + + .fo-composition-wrap { + flex-direction: column; + align-items: flex-start; + } + + .fo-composition-chart-wrap { + width: 180px; + height: 180px; + } + + .fo-composition-center { + inset: 56px; + } + + .fo-profit-legend { + flex-wrap: wrap; + gap: 12px; + } + + .fo-top-table { + .ant-table-thead > tr > th:nth-child(3), + .ant-table-thead > tr > th:nth-child(4), + .ant-table-tbody > tr > td:nth-child(3), + .ant-table-tbody > tr > td:nth-child(4) { + display: none; + } + } + } +} diff --git a/apps/web-antd/src/views/finance/overview/styles/table.less b/apps/web-antd/src/views/finance/overview/styles/table.less new file mode 100644 index 0000000..65fe2b0 --- /dev/null +++ b/apps/web-antd/src/views/finance/overview/styles/table.less @@ -0,0 +1,90 @@ +/** + * 文件职责:财务概览 TOP 排行样式。 + */ +.fo-top-card { + padding-bottom: 10px; +} + +.fo-top-table { + .ant-table { + overflow: hidden; + border: 1px solid #f0f0f0; + border-radius: 10px; + } + + .ant-table-thead > tr > th { + font-size: 12px; + font-weight: 600; + color: rgb(0 0 0 / 55%); + background: #fafafa; + } + + .ant-table-tbody > tr > td { + font-size: 13px; + } + + .ant-table-tbody > tr:hover > td { + background: #f8fbff; + } +} + +.fo-rank-num { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + font-size: 11px; + font-weight: 700; + color: #fff; + border-radius: 4px; +} + +.fo-rank-num.is-top1 { + background: linear-gradient(135deg, #ef4444, #f97316); +} + +.fo-rank-num.is-top2 { + background: linear-gradient(135deg, #f59e0b, #fbbf24); +} + +.fo-rank-num.is-top3 { + background: linear-gradient(135deg, #1677ff, #69b1ff); +} + +.fo-rank-num.is-normal { + color: #4b5563; + background: #e5e7eb; +} + +.fo-revenue-text { + font-weight: 600; + color: rgb(0 0 0 / 88%); +} + +.fo-percent-wrap { + display: flex; + gap: 8px; + align-items: center; +} + +.fo-percent-track { + width: 118px; + height: 6px; + overflow: hidden; + background: #e5e7eb; + border-radius: 3px; +} + +.fo-percent-fill { + height: 100%; + background: linear-gradient(90deg, #1677ff, #69b1ff); + border-radius: 3px; +} + +.fo-percent-text { + min-width: 44px; + font-size: 12px; + color: rgb(0 0 0 / 45%); + text-align: right; +} diff --git a/apps/web-antd/src/views/finance/overview/types.ts b/apps/web-antd/src/views/finance/overview/types.ts new file mode 100644 index 0000000..2944121 --- /dev/null +++ b/apps/web-antd/src/views/finance/overview/types.ts @@ -0,0 +1,38 @@ +/** + * 文件职责:财务概览页面本地类型定义。 + */ +import type { + FinanceOverviewDashboardDto, + FinanceOverviewDimension, + FinanceOverviewTrendRange, +} from '#/api/finance/overview'; + +/** 选项项。 */ +export interface OptionItem { + label: string; + value: string; +} + +/** 页面筛选状态。 */ +export interface FinanceOverviewFilterState { + dimension: FinanceOverviewDimension; + storeId: string; +} + +/** 图表构成视图项。 */ +export interface FinanceOverviewCompositionViewItem { + amount: number; + color: string; + key: string; + name: string; + percentage: number; +} + +/** 概览页面数据状态。 */ +export type FinanceOverviewDashboardState = FinanceOverviewDashboardDto; + +/** 趋势切换状态。 */ +export interface FinanceOverviewTrendState { + incomeRange: FinanceOverviewTrendRange; + profitRange: FinanceOverviewTrendRange; +} diff --git a/apps/web-antd/src/views/store/fees/components/FeesDeliveryCard.vue b/apps/web-antd/src/views/store/fees/components/FeesDeliveryCard.vue index 5f9a0f2..9caaf1c 100644 --- a/apps/web-antd/src/views/store/fees/components/FeesDeliveryCard.vue +++ b/apps/web-antd/src/views/store/fees/components/FeesDeliveryCard.vue @@ -12,9 +12,11 @@ interface Props { freeDeliveryThreshold: null | number; isSaving: boolean; minimumOrderAmount: number; + platformServiceRate: number; onSetBaseDeliveryFee: (value: number) => void; onSetFreeDeliveryThreshold: (value: null | number) => void; onSetMinimumOrderAmount: (value: number) => void; + onSetPlatformServiceRate: (value: number) => void; } const props = defineProps(); @@ -85,6 +87,31 @@ function toNumber(value: null | number | string, fallback = 0) {
每笔订单默认收取的配送费
+
+ +
+ + % +
+
按实收金额计算平台服务费成本
+
+
diff --git a/apps/web-antd/src/views/store/fees/composables/fees-page/data-actions.ts b/apps/web-antd/src/views/store/fees/composables/fees-page/data-actions.ts index 71c4a07..ca0ca99 100644 --- a/apps/web-antd/src/views/store/fees/composables/fees-page/data-actions.ts +++ b/apps/web-antd/src/views/store/fees/composables/fees-page/data-actions.ts @@ -62,6 +62,10 @@ export function createDataActions(options: CreateDataActionsOptions) { 0, ); options.form.baseDeliveryFee = normalizeMoney(next.baseDeliveryFee, 0); + options.form.platformServiceRate = normalizeMoney( + next.platformServiceRate, + 0, + ); options.form.freeDeliveryThreshold = next.freeDeliveryThreshold === null ? null @@ -109,6 +113,7 @@ export function createDataActions(options: CreateDataActionsOptions) { return { minimumOrderAmount: normalizeMoney(source?.minimumOrderAmount ?? 0, 0), baseDeliveryFee: normalizeMoney(source?.baseDeliveryFee ?? 0, 0), + platformServiceRate: normalizeMoney(source?.platformServiceRate ?? 0, 0), freeDeliveryThreshold: source?.freeDeliveryThreshold === null || source?.freeDeliveryThreshold === undefined @@ -130,6 +135,7 @@ export function createDataActions(options: CreateDataActionsOptions) { storeId, minimumOrderAmount: options.form.minimumOrderAmount, baseDeliveryFee: options.form.baseDeliveryFee, + platformServiceRate: options.form.platformServiceRate, freeDeliveryThreshold: options.form.freeDeliveryThreshold, packagingFeeMode: options.form.packagingFeeMode, orderPackagingFeeMode: options.form.orderPackagingFeeMode, diff --git a/apps/web-antd/src/views/store/fees/composables/fees-page/helpers.ts b/apps/web-antd/src/views/store/fees/composables/fees-page/helpers.ts index 7eea046..869f914 100644 --- a/apps/web-antd/src/views/store/fees/composables/fees-page/helpers.ts +++ b/apps/web-antd/src/views/store/fees/composables/fees-page/helpers.ts @@ -26,6 +26,7 @@ export function cloneFeesForm(source: StoreFeesFormState): StoreFeesFormState { return { minimumOrderAmount: source.minimumOrderAmount, baseDeliveryFee: source.baseDeliveryFee, + platformServiceRate: source.platformServiceRate, freeDeliveryThreshold: source.freeDeliveryThreshold, packagingFeeMode: source.packagingFeeMode, orderPackagingFeeMode: source.orderPackagingFeeMode, diff --git a/apps/web-antd/src/views/store/fees/composables/useStoreFeesPage.ts b/apps/web-antd/src/views/store/fees/composables/useStoreFeesPage.ts index be2517a..652d0a8 100644 --- a/apps/web-antd/src/views/store/fees/composables/useStoreFeesPage.ts +++ b/apps/web-antd/src/views/store/fees/composables/useStoreFeesPage.ts @@ -36,6 +36,7 @@ import { createPackagingActions } from './fees-page/packaging-actions'; const EMPTY_FEES_SETTINGS: StoreFeesFormState = { minimumOrderAmount: 0, baseDeliveryFee: 0, + platformServiceRate: 0, freeDeliveryThreshold: null, packagingFeeMode: 'order', orderPackagingFeeMode: 'fixed', @@ -207,6 +208,10 @@ export function useStoreFeesPage() { form.baseDeliveryFee = normalizeMoney(value, form.baseDeliveryFee); } + function setPlatformServiceRate(value: number) { + form.platformServiceRate = normalizeMoney(value, form.platformServiceRate); + } + function setFreeDeliveryThreshold(value: null | number) { if (value === null || value === undefined) { form.freeDeliveryThreshold = null; @@ -301,6 +306,7 @@ export function useStoreFeesPage() { const source = snapshot.value; form.minimumOrderAmount = source.minimumOrderAmount; form.baseDeliveryFee = source.baseDeliveryFee; + form.platformServiceRate = source.platformServiceRate; form.freeDeliveryThreshold = source.freeDeliveryThreshold; message.success('已重置起送与配送费'); } @@ -449,6 +455,7 @@ export function useStoreFeesPage() { setFixedPackagingFee, setFreeDeliveryThreshold, setMinimumOrderAmount, + setPlatformServiceRate, setPackagingMode, setRushAmount, setRushEnabled, diff --git a/apps/web-antd/src/views/store/fees/index.vue b/apps/web-antd/src/views/store/fees/index.vue index 32c4640..678a0ca 100644 --- a/apps/web-antd/src/views/store/fees/index.vue +++ b/apps/web-antd/src/views/store/fees/index.vue @@ -59,6 +59,7 @@ const { setFixedPackagingFee, setFreeDeliveryThreshold, setMinimumOrderAmount, + setPlatformServiceRate, setPackagingMode, setRushAmount, setRushEnabled, @@ -111,10 +112,12 @@ function onEditTier(tier: PackagingFeeTierDto) { :can-operate="canOperate" :minimum-order-amount="form.minimumOrderAmount" :base-delivery-fee="form.baseDeliveryFee" + :platform-service-rate="form.platformServiceRate" :free-delivery-threshold="form.freeDeliveryThreshold" :is-saving="isSavingDelivery" :on-set-minimum-order-amount="setMinimumOrderAmount" :on-set-base-delivery-fee="setBaseDeliveryFee" + :on-set-platform-service-rate="setPlatformServiceRate" :on-set-free-delivery-threshold="setFreeDeliveryThreshold" @save="saveDeliverySection" @reset="resetDeliverySection" diff --git a/apps/web-antd/src/views/store/fees/types.ts b/apps/web-antd/src/views/store/fees/types.ts index 346cfbe..e956d67 100644 --- a/apps/web-antd/src/views/store/fees/types.ts +++ b/apps/web-antd/src/views/store/fees/types.ts @@ -21,6 +21,7 @@ export interface PackagingFeeTierFormState { export interface StoreFeesFormState { baseDeliveryFee: number; + platformServiceRate: number; fixedPackagingFee: number; freeDeliveryThreshold: null | number; minimumOrderAmount: number;