From aebd0c285b83162e1efa4ffb43e4a1493fff8729 Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Mon, 16 Feb 2026 16:39:27 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E8=B4=B9=E7=94=A8?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E9=A1=B5=E9=9D=A2=E5=B9=B6=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=E5=8E=9F=E5=9E=8B=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web-antd/src/api/store-fees/index.ts | 95 +++++ apps/web-antd/src/mock/index.ts | 1 + apps/web-antd/src/mock/store-fees.ts | 346 ++++++++++++++++++ .../src/router/routes/modules/store.ts | 9 + .../fees/components/FeesDeliveryCard.vue | 118 ++++++ .../store/fees/components/FeesOtherCard.vue | 105 ++++++ .../fees/components/FeesPackagingCard.vue | 158 ++++++++ .../store/fees/components/FeesTierDrawer.vue | 118 ++++++ .../fees/composables/fees-page/constants.ts | 61 +++ .../composables/fees-page/copy-actions.ts | 77 ++++ .../composables/fees-page/data-actions.ts | 233 ++++++++++++ .../fees/composables/fees-page/helpers.ts | 126 +++++++ .../fees-page/packaging-actions.ts | 208 +++++++++++ .../fees/composables/useStoreFeesPage.ts | 345 +++++++++++++++++ apps/web-antd/src/views/store/fees/index.vue | 178 +++++++++ .../src/views/store/fees/styles/base.less | 41 +++ .../src/views/store/fees/styles/delivery.less | 43 +++ .../src/views/store/fees/styles/drawer.less | 55 +++ .../src/views/store/fees/styles/index.less | 7 + .../src/views/store/fees/styles/other.less | 41 +++ .../views/store/fees/styles/packaging.less | 123 +++++++ .../views/store/fees/styles/responsive.less | 47 +++ apps/web-antd/src/views/store/fees/types.ts | 33 ++ 23 files changed, 2568 insertions(+) create mode 100644 apps/web-antd/src/api/store-fees/index.ts create mode 100644 apps/web-antd/src/mock/store-fees.ts create mode 100644 apps/web-antd/src/views/store/fees/components/FeesDeliveryCard.vue create mode 100644 apps/web-antd/src/views/store/fees/components/FeesOtherCard.vue create mode 100644 apps/web-antd/src/views/store/fees/components/FeesPackagingCard.vue create mode 100644 apps/web-antd/src/views/store/fees/components/FeesTierDrawer.vue create mode 100644 apps/web-antd/src/views/store/fees/composables/fees-page/constants.ts create mode 100644 apps/web-antd/src/views/store/fees/composables/fees-page/copy-actions.ts create mode 100644 apps/web-antd/src/views/store/fees/composables/fees-page/data-actions.ts create mode 100644 apps/web-antd/src/views/store/fees/composables/fees-page/helpers.ts create mode 100644 apps/web-antd/src/views/store/fees/composables/fees-page/packaging-actions.ts create mode 100644 apps/web-antd/src/views/store/fees/composables/useStoreFeesPage.ts create mode 100644 apps/web-antd/src/views/store/fees/index.vue create mode 100644 apps/web-antd/src/views/store/fees/styles/base.less create mode 100644 apps/web-antd/src/views/store/fees/styles/delivery.less create mode 100644 apps/web-antd/src/views/store/fees/styles/drawer.less create mode 100644 apps/web-antd/src/views/store/fees/styles/index.less create mode 100644 apps/web-antd/src/views/store/fees/styles/other.less create mode 100644 apps/web-antd/src/views/store/fees/styles/packaging.less create mode 100644 apps/web-antd/src/views/store/fees/styles/responsive.less create mode 100644 apps/web-antd/src/views/store/fees/types.ts diff --git a/apps/web-antd/src/api/store-fees/index.ts b/apps/web-antd/src/api/store-fees/index.ts new file mode 100644 index 0000000..b6f9df1 --- /dev/null +++ b/apps/web-antd/src/api/store-fees/index.ts @@ -0,0 +1,95 @@ +/** + * 文件职责:费用设置模块 API 与 DTO 定义。 + * 1. 维护起送/配送费、包装费、其他费用类型。 + * 2. 提供查询、保存与复制费用设置接口。 + */ +import { requestClient } from '#/api/request'; + +/** 包装费模式 */ +export type PackagingFeeMode = 'item' | 'order'; + +/** 按订单包装费模式 */ +export type OrderPackagingFeeMode = 'fixed' | 'tiered'; + +/** 其他费用类型 */ +export type AdditionalFeeType = 'cutlery' | 'rush'; + +/** 阶梯包装费条目 */ +export interface PackagingFeeTierDto { + /** 单档费用 */ + fee: number; + /** 主键 */ + id: string; + /** 起始订单金额(含) */ + minAmount: number; + /** 结束订单金额(空值表示无上限) */ + maxAmount: null | number; + /** 排序号 */ + sort: number; +} + +/** 单项其他费用 */ +export interface AdditionalFeeItemDto { + /** 费用金额 */ + amount: number; + /** 是否启用 */ + enabled: boolean; +} + +/** 其他费用聚合 */ +export interface StoreOtherFeesDto { + cutlery: AdditionalFeeItemDto; + rush: AdditionalFeeItemDto; +} + +/** 门店费用设置聚合 */ +export interface StoreFeesSettingsDto { + /** 基础配送费 */ + baseDeliveryFee: number; + /** 固定包装费 */ + fixedPackagingFee: number; + /** 免配送费门槛,空值表示关闭 */ + freeDeliveryThreshold: null | number; + /** 起送金额 */ + minimumOrderAmount: number; + /** 其他费用 */ + otherFees: StoreOtherFeesDto; + /** 按订单包装费模式 */ + orderPackagingFeeMode: OrderPackagingFeeMode; + /** 包装费模式 */ + packagingFeeMode: PackagingFeeMode; + /** 包装费阶梯 */ + packagingFeeTiers: PackagingFeeTierDto[]; + /** 门店 ID */ + storeId: string; +} + +/** 保存费用设置参数 */ +export type SaveStoreFeesSettingsParams = StoreFeesSettingsDto; + +/** 复制费用设置参数 */ +export interface CopyStoreFeesSettingsParams { + sourceStoreId: string; + targetStoreIds: string[]; +} + +/** 获取门店费用设置 */ +export async function getStoreFeesSettingsApi(storeId: string) { + return requestClient.get('/store/fees', { + params: { storeId }, + }); +} + +/** 保存门店费用设置 */ +export async function saveStoreFeesSettingsApi( + data: SaveStoreFeesSettingsParams, +) { + return requestClient.post('/store/fees/save', data); +} + +/** 复制费用设置到其他门店 */ +export async function copyStoreFeesSettingsApi( + data: CopyStoreFeesSettingsParams, +) { + return requestClient.post('/store/fees/copy', data); +} diff --git a/apps/web-antd/src/mock/index.ts b/apps/web-antd/src/mock/index.ts index c795695..a1bfff0 100644 --- a/apps/web-antd/src/mock/index.ts +++ b/apps/web-antd/src/mock/index.ts @@ -1,6 +1,7 @@ // Mock 数据入口,仅在开发环境下使用 import './store'; import './store-dinein'; +import './store-fees'; import './store-hours'; import './store-pickup'; diff --git a/apps/web-antd/src/mock/store-fees.ts b/apps/web-antd/src/mock/store-fees.ts new file mode 100644 index 0000000..468a720 --- /dev/null +++ b/apps/web-antd/src/mock/store-fees.ts @@ -0,0 +1,346 @@ +import Mock from 'mockjs'; + +/** 文件职责:费用设置页面 Mock 接口。 */ +interface MockRequestOptions { + body: null | string; + type: string; + url: string; +} + +type PackagingFeeMode = 'item' | 'order'; +type OrderPackagingFeeMode = 'fixed' | 'tiered'; + +interface PackagingFeeTierMock { + fee: number; + id: string; + maxAmount: null | number; + minAmount: number; + sort: number; +} + +interface AdditionalFeeItemMock { + amount: number; + enabled: boolean; +} + +interface StoreFeesState { + baseDeliveryFee: number; + fixedPackagingFee: number; + freeDeliveryThreshold: null | number; + minimumOrderAmount: number; + orderPackagingFeeMode: OrderPackagingFeeMode; + otherFees: { + cutlery: AdditionalFeeItemMock; + rush: AdditionalFeeItemMock; + }; + packagingFeeMode: PackagingFeeMode; + packagingFeeTiers: PackagingFeeTierMock[]; +} + +const storeFeesMap = new Map(); + +/** 解析 URL 查询参数。 */ +function parseUrlParams(url: string) { + const parsed = new URL(url, 'http://localhost'); + const params: Record = {}; + parsed.searchParams.forEach((value, key) => { + params[key] = value; + }); + return params; +} + +/** 解析请求体 JSON。 */ +function parseBody(options: MockRequestOptions) { + if (!options.body) return {}; + try { + return JSON.parse(options.body); + } catch (error) { + console.error('[mock-store-fees] parseBody error:', error); + return {}; + } +} + +/** 保留两位小数并裁剪为非负数。 */ +function normalizeMoney(value: unknown, fallback = 0) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return fallback; + return Math.round(Math.max(0, parsed) * 100) / 100; +} + +/** 归一化包装费模式。 */ +function normalizePackagingFeeMode(value: unknown, fallback: PackagingFeeMode) { + return value === 'item' || value === 'order' ? value : fallback; +} + +/** 归一化按订单包装费模式。 */ +function normalizeOrderPackagingFeeMode( + value: unknown, + fallback: OrderPackagingFeeMode, +) { + return value === 'fixed' || value === 'tiered' ? value : fallback; +} + +/** 深拷贝阶梯列表。 */ +function cloneTiers(source: PackagingFeeTierMock[]) { + return source.map((item) => ({ ...item })); +} + +/** 深拷贝状态对象。 */ +function cloneStoreState(source: StoreFeesState): StoreFeesState { + return { + minimumOrderAmount: source.minimumOrderAmount, + baseDeliveryFee: source.baseDeliveryFee, + freeDeliveryThreshold: source.freeDeliveryThreshold, + packagingFeeMode: source.packagingFeeMode, + orderPackagingFeeMode: source.orderPackagingFeeMode, + fixedPackagingFee: source.fixedPackagingFee, + packagingFeeTiers: cloneTiers(source.packagingFeeTiers), + otherFees: { + cutlery: { ...source.otherFees.cutlery }, + rush: { ...source.otherFees.rush }, + }, + }; +} + +/** 排序并归一化阶梯列表。 */ +function normalizeTiers( + source: unknown, + fallback: PackagingFeeTierMock[], +): PackagingFeeTierMock[] { + if (!Array.isArray(source) || source.length === 0) { + return cloneTiers(fallback); + } + + const raw = source + .map((item, index) => { + const record = item as Partial; + const minAmount = normalizeMoney(record.minAmount, 0); + let maxAmount: null | number = null; + if ( + record.maxAmount !== null && + record.maxAmount !== undefined && + String(record.maxAmount) !== '' + ) { + maxAmount = normalizeMoney(record.maxAmount, minAmount); + } + return { + id: + typeof record.id === 'string' && record.id.trim() + ? record.id + : `fee-tier-${Date.now()}-${index}`, + minAmount, + maxAmount, + fee: normalizeMoney(record.fee, 0), + sort: Math.max(1, Number(record.sort) || index + 1), + }; + }) + .toSorted((a, b) => { + if (a.minAmount !== b.minAmount) return a.minAmount - b.minAmount; + if (a.maxAmount === null) return 1; + if (b.maxAmount === null) return -1; + return a.maxAmount - b.maxAmount; + }) + .slice(0, 10); + + let hasUnbounded = false; + return raw.map((item, index) => { + let maxAmount = item.maxAmount; + if (hasUnbounded) { + maxAmount = item.minAmount + 0.01; + } + if (maxAmount !== null && maxAmount <= item.minAmount) { + maxAmount = item.minAmount + 0.01; + } + if (maxAmount === null) hasUnbounded = true; + return { + ...item, + maxAmount: + index === raw.length - 1 + ? maxAmount + : (maxAmount ?? item.minAmount + 1), + sort: index + 1, + }; + }); +} + +/** 归一化其他费用。 */ +function normalizeOtherFees( + source: unknown, + fallback: StoreFeesState['otherFees'], +) { + const record = (source || {}) as Partial; + return { + cutlery: { + enabled: Boolean(record.cutlery?.enabled), + amount: normalizeMoney(record.cutlery?.amount, fallback.cutlery.amount), + }, + rush: { + enabled: Boolean(record.rush?.enabled), + amount: normalizeMoney(record.rush?.amount, fallback.rush.amount), + }, + }; +} + +/** 归一化提交数据。 */ +function normalizeStoreState(source: unknown, fallback: StoreFeesState) { + const record = (source || {}) as Partial; + const packagingFeeMode = normalizePackagingFeeMode( + record.packagingFeeMode, + fallback.packagingFeeMode, + ); + + const orderPackagingFeeMode = + packagingFeeMode === 'order' + ? normalizeOrderPackagingFeeMode( + record.orderPackagingFeeMode, + fallback.orderPackagingFeeMode, + ) + : 'fixed'; + + return { + minimumOrderAmount: normalizeMoney( + record.minimumOrderAmount, + fallback.minimumOrderAmount, + ), + baseDeliveryFee: normalizeMoney( + record.baseDeliveryFee, + fallback.baseDeliveryFee, + ), + freeDeliveryThreshold: + record.freeDeliveryThreshold === null || + record.freeDeliveryThreshold === undefined || + String(record.freeDeliveryThreshold) === '' + ? null + : normalizeMoney( + record.freeDeliveryThreshold, + fallback.freeDeliveryThreshold ?? 0, + ), + packagingFeeMode, + orderPackagingFeeMode, + fixedPackagingFee: normalizeMoney( + record.fixedPackagingFee, + fallback.fixedPackagingFee, + ), + packagingFeeTiers: normalizeTiers( + record.packagingFeeTiers, + fallback.packagingFeeTiers, + ), + otherFees: normalizeOtherFees(record.otherFees, fallback.otherFees), + } satisfies StoreFeesState; +} + +/** 创建默认状态。 */ +function createDefaultState(): StoreFeesState { + return { + minimumOrderAmount: 15, + baseDeliveryFee: 3, + freeDeliveryThreshold: 30, + packagingFeeMode: 'order', + orderPackagingFeeMode: 'tiered', + fixedPackagingFee: 2, + packagingFeeTiers: [ + { + id: `fee-tier-${Date.now()}-1`, + minAmount: 0, + maxAmount: 30, + fee: 2, + sort: 1, + }, + { + id: `fee-tier-${Date.now()}-2`, + minAmount: 30, + maxAmount: 60, + fee: 3, + sort: 2, + }, + { + id: `fee-tier-${Date.now()}-3`, + minAmount: 60, + maxAmount: null, + fee: 5, + sort: 3, + }, + ], + otherFees: { + cutlery: { + enabled: false, + amount: 1, + }, + rush: { + enabled: false, + amount: 3, + }, + }, + }; +} + +/** 确保门店状态存在。 */ +function ensureStoreState(storeId = '') { + const key = storeId || 'default'; + let state = storeFeesMap.get(key); + if (!state) { + state = createDefaultState(); + storeFeesMap.set(key, state); + } + return state; +} + +Mock.mock(/\/store\/fees(?:\?|$)/, 'get', (options: MockRequestOptions) => { + const params = parseUrlParams(options.url); + const storeId = String(params.storeId || ''); + const state = ensureStoreState(storeId); + + return { + code: 200, + data: { + storeId, + ...cloneStoreState(state), + }, + }; +}); + +Mock.mock(/\/store\/fees\/save/, 'post', (options: MockRequestOptions) => { + const body = parseBody(options); + const storeId = String((body as { storeId?: unknown }).storeId || ''); + const fallback = ensureStoreState(storeId); + const next = normalizeStoreState(body, fallback); + storeFeesMap.set(storeId || 'default', next); + + return { + code: 200, + data: { + storeId, + ...cloneStoreState(next), + }, + }; +}); + +Mock.mock(/\/store\/fees\/copy/, 'post', (options: MockRequestOptions) => { + const body = parseBody(options) as { + sourceStoreId?: string; + targetStoreIds?: string[]; + }; + const sourceStoreId = String(body.sourceStoreId || ''); + const targetStoreIds = Array.isArray(body.targetStoreIds) + ? body.targetStoreIds.map(String).filter(Boolean) + : []; + + if (!sourceStoreId || targetStoreIds.length === 0) { + return { + code: 400, + message: '参数错误', + }; + } + + const source = ensureStoreState(sourceStoreId); + targetStoreIds.forEach((storeId) => { + storeFeesMap.set(storeId, cloneStoreState(source)); + }); + + return { + code: 200, + data: { + copiedCount: targetStoreIds.length, + }, + }; +}); diff --git a/apps/web-antd/src/router/routes/modules/store.ts b/apps/web-antd/src/router/routes/modules/store.ts index b0bac92..73deef6 100644 --- a/apps/web-antd/src/router/routes/modules/store.ts +++ b/apps/web-antd/src/router/routes/modules/store.ts @@ -46,6 +46,15 @@ const routes: RouteRecordRaw[] = [ title: '自提设置', }, }, + { + name: 'StoreFees', + path: '/store/fees', + component: () => import('#/views/store/fees/index.vue'), + meta: { + icon: 'lucide:wallet', + title: '费用设置', + }, + }, { name: 'StoreDineIn', path: '/store/dine-in', diff --git a/apps/web-antd/src/views/store/fees/components/FeesDeliveryCard.vue b/apps/web-antd/src/views/store/fees/components/FeesDeliveryCard.vue new file mode 100644 index 0000000..2a1c3c9 --- /dev/null +++ b/apps/web-antd/src/views/store/fees/components/FeesDeliveryCard.vue @@ -0,0 +1,118 @@ + + + diff --git a/apps/web-antd/src/views/store/fees/components/FeesOtherCard.vue b/apps/web-antd/src/views/store/fees/components/FeesOtherCard.vue new file mode 100644 index 0000000..2f549c0 --- /dev/null +++ b/apps/web-antd/src/views/store/fees/components/FeesOtherCard.vue @@ -0,0 +1,105 @@ + + + diff --git a/apps/web-antd/src/views/store/fees/components/FeesPackagingCard.vue b/apps/web-antd/src/views/store/fees/components/FeesPackagingCard.vue new file mode 100644 index 0000000..c153685 --- /dev/null +++ b/apps/web-antd/src/views/store/fees/components/FeesPackagingCard.vue @@ -0,0 +1,158 @@ + + + diff --git a/apps/web-antd/src/views/store/fees/components/FeesTierDrawer.vue b/apps/web-antd/src/views/store/fees/components/FeesTierDrawer.vue new file mode 100644 index 0000000..e7beee4 --- /dev/null +++ b/apps/web-antd/src/views/store/fees/components/FeesTierDrawer.vue @@ -0,0 +1,118 @@ + + + diff --git a/apps/web-antd/src/views/store/fees/composables/fees-page/constants.ts b/apps/web-antd/src/views/store/fees/composables/fees-page/constants.ts new file mode 100644 index 0000000..251744c --- /dev/null +++ b/apps/web-antd/src/views/store/fees/composables/fees-page/constants.ts @@ -0,0 +1,61 @@ +import type { + PackagingFeeMode, + PackagingFeeTierDto, + StoreFeesSettingsDto, +} from '#/api/store-fees'; + +/** 文件职责:费用设置页面常量定义。 */ + +export const MAX_PACKAGING_TIER_COUNT = 10; + +export const PACKAGING_MODE_OPTIONS: Array<{ + label: string; + value: PackagingFeeMode; +}> = [ + { label: '按订单收取', value: 'order' }, + { label: '按商品收取', value: 'item' }, +]; + +export const DEFAULT_PACKAGING_TIERS: PackagingFeeTierDto[] = [ + { + id: 'packaging-tier-1', + minAmount: 0, + maxAmount: 30, + fee: 2, + sort: 1, + }, + { + id: 'packaging-tier-2', + minAmount: 30, + maxAmount: 60, + fee: 3, + sort: 2, + }, + { + id: 'packaging-tier-3', + minAmount: 60, + maxAmount: null, + fee: 5, + sort: 3, + }, +]; + +export const DEFAULT_FEES_SETTINGS: Omit = { + minimumOrderAmount: 15, + baseDeliveryFee: 3, + freeDeliveryThreshold: 30, + packagingFeeMode: 'order', + orderPackagingFeeMode: 'tiered', + fixedPackagingFee: 2, + packagingFeeTiers: DEFAULT_PACKAGING_TIERS, + otherFees: { + cutlery: { + enabled: false, + amount: 1, + }, + rush: { + enabled: false, + amount: 3, + }, + }, +}; diff --git a/apps/web-antd/src/views/store/fees/composables/fees-page/copy-actions.ts b/apps/web-antd/src/views/store/fees/composables/fees-page/copy-actions.ts new file mode 100644 index 0000000..eed27af --- /dev/null +++ b/apps/web-antd/src/views/store/fees/composables/fees-page/copy-actions.ts @@ -0,0 +1,77 @@ +import type { ComputedRef, Ref } from 'vue'; + +/** + * 文件职责:费用设置复制动作。 + * 1. 管理复制弹窗状态与目标门店勾选。 + * 2. 提交复制请求并反馈结果。 + */ +import type { StoreListItemDto } from '#/api/store'; + +import { message } from 'ant-design-vue'; + +import { copyStoreFeesSettingsApi } from '#/api/store-fees'; + +interface CreateCopyActionsOptions { + copyCandidates: ComputedRef; + copyTargetStoreIds: Ref; + isCopyModalOpen: Ref; + isCopySubmitting: Ref; + selectedStoreId: Ref; +} + +export function createCopyActions(options: CreateCopyActionsOptions) { + /** 打开弹窗前清空目标勾选。 */ + function openCopyModal() { + if (!options.selectedStoreId.value) return; + options.copyTargetStoreIds.value = []; + options.isCopyModalOpen.value = true; + } + + /** 切换单个目标门店状态。 */ + function toggleCopyStore(storeId: string, checked: boolean) { + options.copyTargetStoreIds.value = checked + ? [...new Set([storeId, ...options.copyTargetStoreIds.value])] + : options.copyTargetStoreIds.value.filter((id) => id !== storeId); + } + + /** 全选/取消全选。 */ + function handleCopyCheckAll(checked: boolean) { + options.copyTargetStoreIds.value = checked + ? options.copyCandidates.value.map((store) => store.id) + : []; + } + + /** 提交复制请求。 */ + async function handleCopySubmit() { + if (!options.selectedStoreId.value) return; + if (options.copyTargetStoreIds.value.length === 0) { + message.error('请至少选择一个目标门店'); + return; + } + + options.isCopySubmitting.value = true; + try { + await copyStoreFeesSettingsApi({ + sourceStoreId: options.selectedStoreId.value, + targetStoreIds: options.copyTargetStoreIds.value, + }); + message.success( + `已复制到 ${options.copyTargetStoreIds.value.length} 家门店`, + ); + options.isCopyModalOpen.value = false; + options.copyTargetStoreIds.value = []; + } catch (error) { + console.error(error); + message.error('复制失败,请稍后重试'); + } finally { + options.isCopySubmitting.value = false; + } + } + + return { + handleCopyCheckAll, + handleCopySubmit, + openCopyModal, + toggleCopyStore, + }; +} 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 new file mode 100644 index 0000000..9fb7f1a --- /dev/null +++ b/apps/web-antd/src/views/store/fees/composables/fees-page/data-actions.ts @@ -0,0 +1,233 @@ +import type { Ref } from 'vue'; + +import type { StoreListItemDto } from '#/api/store'; +/** + * 文件职责:费用设置数据动作。 + * 1. 加载门店列表与门店费用配置。 + * 2. 保存费用配置并维护快照。 + */ +import type { StoreFeesSettingsDto } from '#/api/store-fees'; +import type { + StoreFeesFormState, + StoreFeesSettingsSnapshot, +} from '#/views/store/fees/types'; + +import { message } from 'ant-design-vue'; + +import { getStoreListApi } from '#/api/store'; +import { + getStoreFeesSettingsApi, + saveStoreFeesSettingsApi, +} from '#/api/store-fees'; + +import { DEFAULT_FEES_SETTINGS, DEFAULT_PACKAGING_TIERS } from './constants'; +import { + cloneOtherFees, + cloneTiers, + createSettingsSnapshot, + normalizeMoney, + sortTiers, +} from './helpers'; + +interface CreateDataActionsOptions { + form: StoreFeesFormState; + isPageLoading: Ref; + isStoreLoading: Ref; + selectedStoreId: Ref; + snapshot: Ref; + stores: Ref; +} + +export function createDataActions(options: CreateDataActionsOptions) { + /** 同步页面表单,保持 reactive 引用不变。 */ + function syncForm(next: StoreFeesFormState) { + options.form.minimumOrderAmount = normalizeMoney( + next.minimumOrderAmount, + 0, + ); + options.form.baseDeliveryFee = normalizeMoney(next.baseDeliveryFee, 0); + options.form.freeDeliveryThreshold = + next.freeDeliveryThreshold === null + ? null + : normalizeMoney(next.freeDeliveryThreshold, 0); + options.form.packagingFeeMode = next.packagingFeeMode; + options.form.orderPackagingFeeMode = next.orderPackagingFeeMode; + options.form.fixedPackagingFee = normalizeMoney(next.fixedPackagingFee, 0); + options.form.packagingFeeTiers = sortTiers( + cloneTiers(next.packagingFeeTiers), + ); + options.form.otherFees = cloneOtherFees(next.otherFees); + } + + /** 构建当前表单快照。 */ + function buildCurrentSnapshot() { + return createSettingsSnapshot(options.form); + } + + /** 应用默认配置。 */ + function applyDefaultSettings() { + syncForm({ + ...DEFAULT_FEES_SETTINGS, + packagingFeeTiers: cloneTiers(DEFAULT_PACKAGING_TIERS), + otherFees: cloneOtherFees(DEFAULT_FEES_SETTINGS.otherFees), + }); + } + + /** 将接口返回值转为页面表单态。 */ + function normalizeSettings( + source: null | Partial | undefined, + ): StoreFeesFormState { + return { + minimumOrderAmount: normalizeMoney( + source?.minimumOrderAmount ?? DEFAULT_FEES_SETTINGS.minimumOrderAmount, + DEFAULT_FEES_SETTINGS.minimumOrderAmount, + ), + baseDeliveryFee: normalizeMoney( + source?.baseDeliveryFee ?? DEFAULT_FEES_SETTINGS.baseDeliveryFee, + DEFAULT_FEES_SETTINGS.baseDeliveryFee, + ), + freeDeliveryThreshold: + source?.freeDeliveryThreshold === null || + source?.freeDeliveryThreshold === undefined + ? null + : normalizeMoney( + source.freeDeliveryThreshold, + DEFAULT_FEES_SETTINGS.freeDeliveryThreshold ?? 0, + ), + packagingFeeMode: + source?.packagingFeeMode === 'item' || + source?.packagingFeeMode === 'order' + ? source.packagingFeeMode + : DEFAULT_FEES_SETTINGS.packagingFeeMode, + orderPackagingFeeMode: + source?.orderPackagingFeeMode === 'fixed' || + source?.orderPackagingFeeMode === 'tiered' + ? source.orderPackagingFeeMode + : DEFAULT_FEES_SETTINGS.orderPackagingFeeMode, + fixedPackagingFee: normalizeMoney( + source?.fixedPackagingFee ?? DEFAULT_FEES_SETTINGS.fixedPackagingFee, + DEFAULT_FEES_SETTINGS.fixedPackagingFee, + ), + packagingFeeTiers: sortTiers( + cloneTiers( + source?.packagingFeeTiers?.length + ? source.packagingFeeTiers + : DEFAULT_PACKAGING_TIERS, + ), + ), + otherFees: cloneOtherFees( + source?.otherFees ?? DEFAULT_FEES_SETTINGS.otherFees, + ), + }; + } + + /** 按当前门店构建保存参数。 */ + function buildSavePayload(storeId: string): StoreFeesSettingsDto { + return { + storeId, + minimumOrderAmount: options.form.minimumOrderAmount, + baseDeliveryFee: options.form.baseDeliveryFee, + freeDeliveryThreshold: options.form.freeDeliveryThreshold, + packagingFeeMode: options.form.packagingFeeMode, + orderPackagingFeeMode: options.form.orderPackagingFeeMode, + fixedPackagingFee: options.form.fixedPackagingFee, + packagingFeeTiers: cloneTiers(options.form.packagingFeeTiers), + otherFees: cloneOtherFees(options.form.otherFees), + }; + } + + /** 加载指定门店费用配置。 */ + async function loadStoreSettings(storeId: string) { + options.isPageLoading.value = true; + try { + const currentStoreId = storeId; + const result = await getStoreFeesSettingsApi(storeId); + if (options.selectedStoreId.value !== currentStoreId) return; + + syncForm(normalizeSettings(result)); + options.snapshot.value = buildCurrentSnapshot(); + } catch (error) { + console.error(error); + applyDefaultSettings(); + options.snapshot.value = buildCurrentSnapshot(); + } finally { + options.isPageLoading.value = false; + } + } + + /** 加载门店列表并处理默认选中。 */ + async function loadStores() { + options.isStoreLoading.value = true; + try { + const result = await getStoreListApi({ + keyword: undefined, + businessStatus: undefined, + auditStatus: undefined, + serviceType: undefined, + page: 1, + pageSize: 200, + }); + options.stores.value = result.items ?? []; + + if (options.stores.value.length === 0) { + options.selectedStoreId.value = ''; + options.snapshot.value = null; + applyDefaultSettings(); + return; + } + + const hasSelected = options.stores.value.some( + (item) => item.id === options.selectedStoreId.value, + ); + if (!hasSelected) { + const firstStore = options.stores.value[0]; + if (firstStore) options.selectedStoreId.value = firstStore.id; + return; + } + if (options.selectedStoreId.value) { + await loadStoreSettings(options.selectedStoreId.value); + } + } catch (error) { + console.error(error); + options.stores.value = []; + options.selectedStoreId.value = ''; + options.snapshot.value = null; + applyDefaultSettings(); + } finally { + options.isStoreLoading.value = false; + } + } + + /** 保存当前配置(按调用方文案提示)。 */ + async function saveCurrentSettings(successText: string) { + if (!options.selectedStoreId.value) return false; + try { + const payload = buildSavePayload(options.selectedStoreId.value); + const result = await saveStoreFeesSettingsApi(payload); + syncForm(normalizeSettings(result ?? payload)); + options.snapshot.value = buildCurrentSnapshot(); + message.success(successText); + return true; + } catch (error) { + console.error(error); + message.error('保存失败,请稍后重试'); + return false; + } + } + + /** 重置到最近一次快照。 */ + function resetFromSnapshot() { + if (!options.snapshot.value) { + applyDefaultSettings(); + return; + } + syncForm(options.snapshot.value); + } + + return { + loadStoreSettings, + loadStores, + resetFromSnapshot, + saveCurrentSettings, + }; +} 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 new file mode 100644 index 0000000..7eea046 --- /dev/null +++ b/apps/web-antd/src/views/store/fees/composables/fees-page/helpers.ts @@ -0,0 +1,126 @@ +import type { PackagingFeeTierDto, StoreOtherFeesDto } from '#/api/store-fees'; +import type { + StoreFeesFormState, + StoreFeesSettingsSnapshot, +} from '#/views/store/fees/types'; + +import { MAX_PACKAGING_TIER_COUNT } from './constants'; + +/** 文件职责:费用设置页面工具方法。 */ + +/** 深拷贝包装费阶梯。 */ +export function cloneTiers(source: PackagingFeeTierDto[]) { + return source.map((item) => ({ ...item })); +} + +/** 深拷贝其他费用配置。 */ +export function cloneOtherFees(source: StoreOtherFeesDto): StoreOtherFeesDto { + return { + cutlery: { ...source.cutlery }, + rush: { ...source.rush }, + }; +} + +/** 深拷贝页面表单配置。 */ +export function cloneFeesForm(source: StoreFeesFormState): StoreFeesFormState { + return { + minimumOrderAmount: source.minimumOrderAmount, + baseDeliveryFee: source.baseDeliveryFee, + freeDeliveryThreshold: source.freeDeliveryThreshold, + packagingFeeMode: source.packagingFeeMode, + orderPackagingFeeMode: source.orderPackagingFeeMode, + fixedPackagingFee: source.fixedPackagingFee, + packagingFeeTiers: cloneTiers(source.packagingFeeTiers), + otherFees: cloneOtherFees(source.otherFees), + }; +} + +/** 生成页面快照。 */ +export function createSettingsSnapshot( + source: StoreFeesFormState, +): StoreFeesSettingsSnapshot { + return cloneFeesForm(source); +} + +/** 生成包装费阶梯 ID。 */ +export function createTierId() { + return `packaging-tier-${Date.now()}-${Math.floor(Math.random() * 1000)}`; +} + +/** 归一化金额。 */ +export function normalizeMoney(value: number, fallback = 0) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return fallback; + const safeValue = Math.max(0, parsed); + return Math.round(safeValue * 100) / 100; +} + +/** 阶梯排序(按起始金额升序,无上限档位置于末尾)。 */ +export function sortTiers(source: PackagingFeeTierDto[]) { + return cloneTiers(source).toSorted((a, b) => { + if (a.minAmount !== b.minAmount) return a.minAmount - b.minAmount; + if (a.maxAmount === null) return 1; + if (b.maxAmount === null) return -1; + return a.maxAmount - b.maxAmount; + }); +} + +/** 输出金额格式。 */ +export function formatCurrency(value: number) { + return `¥${normalizeMoney(value).toFixed(2)}`; +} + +/** 输出阶梯区间文本。 */ +export function formatTierRange(tier: PackagingFeeTierDto) { + if (tier.maxAmount === null) { + return `${normalizeMoney(tier.minAmount).toFixed(2)} 元以上`; + } + return `${normalizeMoney(tier.minAmount).toFixed(2)} ~ ${normalizeMoney( + tier.maxAmount, + ).toFixed(2)} 元`; +} + +/** 校验阶梯列表连续性与合法性。 */ +export function validateTierList(tiers: PackagingFeeTierDto[]) { + if (tiers.length === 0) return '请至少配置一档阶梯包装费'; + if (tiers.length > MAX_PACKAGING_TIER_COUNT) { + return `阶梯包装费最多支持 ${MAX_PACKAGING_TIER_COUNT} 档`; + } + + const sorted = sortTiers(tiers).map((item, index) => ({ + ...item, + minAmount: normalizeMoney(item.minAmount, 0), + maxAmount: + item.maxAmount === null + ? null + : normalizeMoney(item.maxAmount, item.minAmount), + fee: normalizeMoney(item.fee, 0), + sort: index + 1, + })); + + for (const [index, tier] of sorted.entries()) { + const label = `第${index + 1}档`; + if (tier.maxAmount !== null && tier.maxAmount <= tier.minAmount) { + return `${label}结束金额必须大于起始金额`; + } + if (tier.fee < 0) { + return `${label}包装费不能小于 0`; + } + if (index < sorted.length - 1 && tier.maxAmount === null) { + return `${label}为无上限档位时,必须放在最后一档`; + } + if (index > 0) { + const previous = sorted[index - 1]; + if (previous) { + if (previous.maxAmount === null) { + return `第${index}档已是无上限,后续不能继续配置`; + } + if (tier.minAmount < previous.maxAmount) { + return `${label}与前一档区间重叠,请调整金额区间`; + } + } + } + } + + return ''; +} diff --git a/apps/web-antd/src/views/store/fees/composables/fees-page/packaging-actions.ts b/apps/web-antd/src/views/store/fees/composables/fees-page/packaging-actions.ts new file mode 100644 index 0000000..4a93210 --- /dev/null +++ b/apps/web-antd/src/views/store/fees/composables/fees-page/packaging-actions.ts @@ -0,0 +1,208 @@ +import type { Ref } from 'vue'; + +import type { PackagingFeeTierDto } from '#/api/store-fees'; +import type { + FeesTierDrawerMode, + PackagingFeeTierFormState, + StoreFeesFormState, +} from '#/views/store/fees/types'; + +import { message } from 'ant-design-vue'; + +import { DEFAULT_PACKAGING_TIERS } from './constants'; +import { + cloneTiers, + createTierId, + normalizeMoney, + sortTiers, + validateTierList, +} from './helpers'; + +interface CreatePackagingActionsOptions { + form: StoreFeesFormState; + isTierDrawerOpen: Ref; + tierDrawerMode: Ref; + tierForm: PackagingFeeTierFormState; +} + +export function createPackagingActions(options: CreatePackagingActionsOptions) { + /** 打开阶梯抽屉。 */ + function openTierDrawer( + mode: FeesTierDrawerMode, + tier?: PackagingFeeTierDto, + ) { + options.tierDrawerMode.value = mode; + if (mode === 'edit' && tier) { + options.tierForm.id = tier.id; + options.tierForm.minAmount = tier.minAmount; + options.tierForm.maxAmount = tier.maxAmount; + options.tierForm.fee = tier.fee; + options.isTierDrawerOpen.value = true; + return; + } + + const sorted = sortTiers(options.form.packagingFeeTiers); + const lastTier = sorted[sorted.length - 1]; + const defaultMin = + lastTier?.maxAmount === null + ? normalizeMoney(lastTier.minAmount + 10, 0) + : normalizeMoney(lastTier?.maxAmount ?? 0, 0); + + options.tierForm.id = ''; + options.tierForm.minAmount = defaultMin; + options.tierForm.maxAmount = null; + options.tierForm.fee = normalizeMoney(lastTier?.fee ?? 2, 2); + options.isTierDrawerOpen.value = true; + } + + /** 更新抽屉开关。 */ + function setTierDrawerOpen(value: boolean) { + options.isTierDrawerOpen.value = value; + } + + /** 更新阶梯起始金额。 */ + function setTierMinAmount(value: number) { + options.tierForm.minAmount = normalizeMoney( + value, + options.tierForm.minAmount, + ); + } + + /** 更新阶梯结束金额(空值表示无上限)。 */ + function setTierMaxAmount(value: null | number) { + if (value === null || value === undefined) { + options.tierForm.maxAmount = null; + return; + } + options.tierForm.maxAmount = normalizeMoney( + value, + options.tierForm.minAmount, + ); + } + + /** 更新阶梯包装费。 */ + function setTierFee(value: number) { + options.tierForm.fee = normalizeMoney(value, options.tierForm.fee); + } + + /** 切换包装费模式。 */ + function setPackagingFeeMode(value: StoreFeesFormState['packagingFeeMode']) { + options.form.packagingFeeMode = value; + if (value === 'item') { + options.form.orderPackagingFeeMode = 'fixed'; + } + } + + /** 切换是否启用阶梯包装费。 */ + function toggleTiered(checked: boolean) { + options.form.orderPackagingFeeMode = checked ? 'tiered' : 'fixed'; + if (checked && options.form.packagingFeeTiers.length === 0) { + options.form.packagingFeeTiers = cloneTiers(DEFAULT_PACKAGING_TIERS); + } + } + + /** 删除阶梯。 */ + function handleDeleteTier(tier: PackagingFeeTierDto) { + options.form.packagingFeeTiers = options.form.packagingFeeTiers.filter( + (item) => item.id !== tier.id, + ); + message.success('阶梯已删除'); + } + + /** 校验抽屉表单。 */ + function validateTierForm() { + if (options.tierForm.minAmount < 0) { + message.error('起始金额不能小于 0'); + return false; + } + if ( + options.tierForm.maxAmount !== null && + options.tierForm.maxAmount <= options.tierForm.minAmount + ) { + message.error('结束金额必须大于起始金额'); + return false; + } + if (options.tierForm.fee < 0) { + message.error('包装费不能小于 0'); + return false; + } + return true; + } + + /** 提交阶梯表单(仅更新本地表单态)。 */ + function handleSubmitTier() { + if (!validateTierForm()) return false; + + const tier: PackagingFeeTierDto = { + id: options.tierForm.id || createTierId(), + minAmount: normalizeMoney(options.tierForm.minAmount, 0), + maxAmount: + options.tierForm.maxAmount === null + ? null + : normalizeMoney( + options.tierForm.maxAmount, + options.tierForm.minAmount, + ), + fee: normalizeMoney(options.tierForm.fee, 0), + sort: 0, + }; + + const nextList = cloneTiers(options.form.packagingFeeTiers); + if (options.tierDrawerMode.value === 'edit') { + const index = nextList.findIndex((item) => item.id === tier.id); + if (index === -1) nextList.push(tier); + else nextList[index] = tier; + } else { + nextList.push(tier); + } + + const sorted = sortTiers(nextList).map((item, index) => ({ + ...item, + sort: index + 1, + })); + const validationError = validateTierList(sorted); + if (validationError) { + message.error(validationError); + return false; + } + + options.form.packagingFeeTiers = sorted; + options.isTierDrawerOpen.value = false; + message.success( + options.tierDrawerMode.value === 'edit' ? '阶梯已更新' : '阶梯已添加', + ); + return true; + } + + /** 校验当前包装费配置(保存前调用)。 */ + function validateCurrentPackaging() { + if (options.form.packagingFeeMode === 'item') return true; + + if (options.form.fixedPackagingFee < 0) { + message.error('固定包装费不能小于 0'); + return false; + } + + if (options.form.orderPackagingFeeMode !== 'tiered') return true; + + const validationError = validateTierList(options.form.packagingFeeTiers); + if (validationError) { + message.error(validationError); + return false; + } + return true; + } + + return { + handleDeleteTier, + handleSubmitTier, + openTierDrawer, + setPackagingFeeMode, + setTierDrawerOpen, + setTierFee, + setTierMaxAmount, + setTierMinAmount, + toggleTiered, + validateCurrentPackaging, + }; +} diff --git a/apps/web-antd/src/views/store/fees/composables/useStoreFeesPage.ts b/apps/web-antd/src/views/store/fees/composables/useStoreFeesPage.ts new file mode 100644 index 0000000..4ddc1f9 --- /dev/null +++ b/apps/web-antd/src/views/store/fees/composables/useStoreFeesPage.ts @@ -0,0 +1,345 @@ +import type { StoreListItemDto } from '#/api/store'; +/** + * 文件职责:费用设置页面主编排。 + * 1. 维护页面级状态(门店、费用配置、抽屉、复制弹窗)。 + * 2. 组合数据加载、复制、包装费阶梯动作。 + * 3. 对外暴露视图层可直接消费的状态与方法。 + */ +import type { PackagingFeeMode, PackagingFeeTierDto } from '#/api/store-fees'; +import type { + FeesTierDrawerMode, + PackagingFeeTierFormState, + StoreFeesFormState, + StoreFeesSettingsSnapshot, +} from '#/views/store/fees/types'; + +import { computed, onMounted, reactive, ref, watch } from 'vue'; + +import { message } from 'ant-design-vue'; + +import { + DEFAULT_FEES_SETTINGS, + PACKAGING_MODE_OPTIONS, +} from './fees-page/constants'; +import { createCopyActions } from './fees-page/copy-actions'; +import { createDataActions } from './fees-page/data-actions'; +import { + cloneFeesForm, + cloneOtherFees, + cloneTiers, + formatCurrency, + formatTierRange, + normalizeMoney, +} from './fees-page/helpers'; +import { createPackagingActions } from './fees-page/packaging-actions'; + +export function useStoreFeesPage() { + // 1. 页面 loading / submitting 状态。 + const isStoreLoading = ref(false); + const isPageLoading = ref(false); + const isSavingDelivery = ref(false); + const isSavingPackaging = ref(false); + const isSavingOther = ref(false); + const isCopySubmitting = ref(false); + + // 2. 页面核心业务数据。 + const stores = ref([]); + const selectedStoreId = ref(''); + const form = reactive( + cloneFeesForm(DEFAULT_FEES_SETTINGS), + ); + const snapshot = ref(null); + + // 3. 复制弹窗状态。 + const isCopyModalOpen = ref(false); + const copyTargetStoreIds = ref([]); + + // 4. 阶梯抽屉状态。 + const isTierDrawerOpen = ref(false); + const tierDrawerMode = ref('create'); + const tierForm = reactive({ + id: '', + minAmount: 0, + maxAmount: null, + fee: 2, + }); + + // 5. 页面衍生视图数据。 + const storeOptions = computed(() => + stores.value.map((store) => ({ label: store.name, value: store.id })), + ); + + const selectedStoreName = computed( + () => + stores.value.find((store) => store.id === selectedStoreId.value)?.name ?? + '', + ); + + const copyCandidates = computed(() => + stores.value.filter((store) => store.id !== selectedStoreId.value), + ); + + const isCopyAllChecked = computed( + () => + copyCandidates.value.length > 0 && + copyTargetStoreIds.value.length === copyCandidates.value.length, + ); + + const isCopyIndeterminate = computed( + () => + copyTargetStoreIds.value.length > 0 && + copyTargetStoreIds.value.length < copyCandidates.value.length, + ); + + const tierDrawerTitle = computed(() => + tierDrawerMode.value === 'edit' ? '编辑阶梯包装费' : '添加阶梯包装费', + ); + + const isOrderMode = computed(() => form.packagingFeeMode === 'order'); + + // 6. 动作装配。 + const { + loadStoreSettings, + loadStores, + resetFromSnapshot, + saveCurrentSettings, + } = createDataActions({ + form, + isPageLoading, + isStoreLoading, + selectedStoreId, + snapshot, + stores, + }); + + const { + handleCopyCheckAll, + handleCopySubmit, + openCopyModal, + toggleCopyStore, + } = createCopyActions({ + copyCandidates, + copyTargetStoreIds, + isCopyModalOpen, + isCopySubmitting, + selectedStoreId, + }); + + const { + handleDeleteTier, + handleSubmitTier, + openTierDrawer, + setPackagingFeeMode, + setTierDrawerOpen, + setTierFee, + setTierMaxAmount, + setTierMinAmount, + toggleTiered, + validateCurrentPackaging, + } = createPackagingActions({ + form, + isTierDrawerOpen, + tierDrawerMode, + tierForm, + }); + + // 7. 字段更新方法。 + function setSelectedStoreId(value: string) { + selectedStoreId.value = value; + } + + function setMinimumOrderAmount(value: number) { + form.minimumOrderAmount = normalizeMoney(value, form.minimumOrderAmount); + } + + function setBaseDeliveryFee(value: number) { + form.baseDeliveryFee = normalizeMoney(value, form.baseDeliveryFee); + } + + function setFreeDeliveryThreshold(value: null | number) { + if (value === null || value === undefined) { + form.freeDeliveryThreshold = null; + return; + } + form.freeDeliveryThreshold = normalizeMoney(value, 0); + } + + function setFixedPackagingFee(value: number) { + form.fixedPackagingFee = normalizeMoney(value, form.fixedPackagingFee); + } + + function setPackagingMode(value: PackagingFeeMode) { + setPackagingFeeMode(value); + } + + function setCutleryEnabled(value: boolean) { + form.otherFees.cutlery.enabled = Boolean(value); + } + + function setCutleryAmount(value: number) { + form.otherFees.cutlery.amount = normalizeMoney( + value, + form.otherFees.cutlery.amount, + ); + } + + function setRushEnabled(value: boolean) { + form.otherFees.rush.enabled = Boolean(value); + } + + function setRushAmount(value: number) { + form.otherFees.rush.amount = normalizeMoney( + value, + form.otherFees.rush.amount, + ); + } + + /** 重置“起送与配送费”分区。 */ + function resetDeliverySection() { + const source = snapshot.value ?? cloneFeesForm(DEFAULT_FEES_SETTINGS); + form.minimumOrderAmount = source.minimumOrderAmount; + form.baseDeliveryFee = source.baseDeliveryFee; + form.freeDeliveryThreshold = source.freeDeliveryThreshold; + message.success('已重置起送与配送费'); + } + + /** 重置“包装费设置”分区。 */ + function resetPackagingSection() { + const source = snapshot.value ?? cloneFeesForm(DEFAULT_FEES_SETTINGS); + form.packagingFeeMode = source.packagingFeeMode; + form.orderPackagingFeeMode = source.orderPackagingFeeMode; + form.fixedPackagingFee = source.fixedPackagingFee; + form.packagingFeeTiers = cloneTiers(source.packagingFeeTiers); + message.success('已重置包装费设置'); + } + + /** 重置“其他费用”分区。 */ + function resetOtherSection() { + const source = snapshot.value ?? cloneFeesForm(DEFAULT_FEES_SETTINGS); + form.otherFees = cloneOtherFees(source.otherFees); + message.success('已重置其他费用'); + } + + /** 保存“起送与配送费”分区。 */ + async function saveDeliverySection() { + if (!selectedStoreId.value) return; + isSavingDelivery.value = true; + try { + await saveCurrentSettings('起送与配送费已保存'); + } finally { + isSavingDelivery.value = false; + } + } + + /** 保存“包装费设置”分区。 */ + async function savePackagingSection() { + if (!selectedStoreId.value) return; + if (!validateCurrentPackaging()) return; + isSavingPackaging.value = true; + try { + await saveCurrentSettings('包装费设置已保存'); + } finally { + isSavingPackaging.value = false; + } + } + + /** 保存“其他费用”分区。 */ + async function saveOtherSection() { + if (!selectedStoreId.value) return; + isSavingOther.value = true; + try { + await saveCurrentSettings('其他费用已保存'); + } finally { + isSavingOther.value = false; + } + } + + /** 处理阶梯删除并保持包装模式合法。 */ + function onDeleteTier(tier: PackagingFeeTierDto) { + handleDeleteTier(tier); + if ( + form.packagingFeeMode === 'order' && + form.orderPackagingFeeMode === 'tiered' && + form.packagingFeeTiers.length === 0 + ) { + message.warning('阶梯模式下至少需要一档,请添加后再保存'); + } + } + + /** 设置是否启用阶梯包装费。 */ + function setTieredEnabled(value: boolean) { + toggleTiered(value); + } + + /** 切换门店时同步拉取配置。 */ + watch(selectedStoreId, async (storeId) => { + if (!storeId) { + Object.assign(form, cloneFeesForm(DEFAULT_FEES_SETTINGS)); + snapshot.value = null; + isTierDrawerOpen.value = false; + return; + } + await loadStoreSettings(storeId); + }); + + // 8. 页面首屏初始化。 + onMounted(loadStores); + + return { + PACKAGING_MODE_OPTIONS, + copyCandidates, + copyTargetStoreIds, + form, + formatCurrency, + formatTierRange, + handleCopyCheckAll, + handleCopySubmit, + handleSubmitTier, + isCopyAllChecked, + isCopyIndeterminate, + isCopyModalOpen, + isCopySubmitting, + isOrderMode, + isPageLoading, + isSavingDelivery, + isSavingOther, + isSavingPackaging, + isStoreLoading, + isTierDrawerOpen, + onDeleteTier, + openCopyModal, + openTierDrawer, + resetDeliverySection, + resetFromSnapshot, + resetOtherSection, + resetPackagingSection, + saveDeliverySection, + saveOtherSection, + savePackagingSection, + selectedStoreId, + selectedStoreName, + setBaseDeliveryFee, + setCopyModalOpen: (value: boolean) => { + isCopyModalOpen.value = value; + }, + setCutleryAmount, + setCutleryEnabled, + setFixedPackagingFee, + setFreeDeliveryThreshold, + setMinimumOrderAmount, + setPackagingMode, + setRushAmount, + setRushEnabled, + setSelectedStoreId, + setTierDrawerOpen, + setTierFee, + setTierMaxAmount, + setTierMinAmount, + setTieredEnabled, + storeOptions, + tierDrawerMode, + tierDrawerTitle, + tierForm, + toggleCopyStore, + }; +} diff --git a/apps/web-antd/src/views/store/fees/index.vue b/apps/web-antd/src/views/store/fees/index.vue new file mode 100644 index 0000000..0d885ca --- /dev/null +++ b/apps/web-antd/src/views/store/fees/index.vue @@ -0,0 +1,178 @@ + + + + + diff --git a/apps/web-antd/src/views/store/fees/styles/base.less b/apps/web-antd/src/views/store/fees/styles/base.less new file mode 100644 index 0000000..9399011 --- /dev/null +++ b/apps/web-antd/src/views/store/fees/styles/base.less @@ -0,0 +1,41 @@ +/* 文件职责:费用设置页面基础骨架样式。 */ +.page-store-fees { + max-width: 980px; + + .fees-card { + margin-bottom: 16px; + overflow: hidden; + border-radius: 12px; + box-shadow: 0 1px 3px rgb(15 23 42 / 8%); + + .ant-card-head { + min-height: 52px; + padding: 0 18px; + background: #f8f9fb; + border-bottom: 1px solid #f3f4f6; + } + + .ant-card-head-title { + display: flex; + gap: 8px; + align-items: center; + padding: 12px 0; + } + + .ant-card-body { + padding: 16px 18px; + } + } + + .section-title { + font-size: 14px; + font-weight: 600; + color: #1a1a2e; + } + + .section-sub-title { + font-size: 11px; + font-weight: 400; + color: #9ca3af; + } +} diff --git a/apps/web-antd/src/views/store/fees/styles/delivery.less b/apps/web-antd/src/views/store/fees/styles/delivery.less new file mode 100644 index 0000000..85f7238 --- /dev/null +++ b/apps/web-antd/src/views/store/fees/styles/delivery.less @@ -0,0 +1,43 @@ +/* 文件职责:起送与配送费卡片样式。 */ +.page-store-fees { + .fees-field-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px 24px; + } + + .fees-field label { + display: block; + margin-bottom: 6px; + font-size: 12px; + color: #4b5563; + } + + .fees-input-row { + display: flex; + gap: 6px; + align-items: center; + } + + .fees-input { + width: 160px; + } + + .fees-unit { + font-size: 13px; + color: #4b5563; + } + + .fees-hint { + margin-top: 4px; + font-size: 11px; + color: #9ca3af; + } + + .fees-actions { + display: flex; + gap: 8px; + justify-content: flex-end; + margin-top: 20px; + } +} diff --git a/apps/web-antd/src/views/store/fees/styles/drawer.less b/apps/web-antd/src/views/store/fees/styles/drawer.less new file mode 100644 index 0000000..1365910 --- /dev/null +++ b/apps/web-antd/src/views/store/fees/styles/drawer.less @@ -0,0 +1,55 @@ +/* 文件职责:阶梯包装费抽屉样式。 */ +.fees-tier-drawer-wrap { + .ant-drawer-body { + padding: 16px 20px 90px; + } + + .ant-drawer-footer { + padding: 12px 20px; + border-top: 1px solid #f0f0f0; + } +} + +.drawer-form-block { + margin-bottom: 14px; +} + +.drawer-form-label { + display: block; + margin-bottom: 8px; + font-size: 13px; + font-weight: 500; + color: #1f2937; +} + +.drawer-form-label.required::before { + margin-right: 4px; + color: #ef4444; + content: '*'; +} + +.drawer-range-row { + display: flex; + gap: 8px; + align-items: center; +} + +.drawer-range-separator { + color: #9ca3af; +} + +.drawer-input-with-unit { + display: flex; + gap: 6px; + align-items: center; +} + +.drawer-input { + width: 150px; +} + +.drawer-footer { + display: flex; + gap: 10px; + justify-content: flex-end; +} diff --git a/apps/web-antd/src/views/store/fees/styles/index.less b/apps/web-antd/src/views/store/fees/styles/index.less new file mode 100644 index 0000000..0f0f687 --- /dev/null +++ b/apps/web-antd/src/views/store/fees/styles/index.less @@ -0,0 +1,7 @@ +/* 文件职责:费用设置页面样式聚合入口(仅负责分片导入)。 */ +@import './base.less'; +@import './delivery.less'; +@import './packaging.less'; +@import './other.less'; +@import './drawer.less'; +@import './responsive.less'; diff --git a/apps/web-antd/src/views/store/fees/styles/other.less b/apps/web-antd/src/views/store/fees/styles/other.less new file mode 100644 index 0000000..ac1c81f --- /dev/null +++ b/apps/web-antd/src/views/store/fees/styles/other.less @@ -0,0 +1,41 @@ +/* 文件职责:其他费用卡片样式。 */ +.page-store-fees { + .other-fee-row { + display: flex; + gap: 12px; + align-items: flex-start; + padding: 14px 0; + border-bottom: 1px solid #f3f4f6; + } + + .other-fee-row:last-of-type { + border-bottom: none; + } + + .other-fee-meta { + flex: 1; + min-width: 0; + } + + .other-fee-name { + margin-bottom: 2px; + font-size: 13px; + font-weight: 500; + color: #1f2937; + } + + .other-fee-hint { + font-size: 11px; + color: #9ca3af; + } + + .other-fee-input-row { + display: flex; + gap: 6px; + align-items: center; + } + + .other-fee-input { + width: 140px; + } +} diff --git a/apps/web-antd/src/views/store/fees/styles/packaging.less b/apps/web-antd/src/views/store/fees/styles/packaging.less new file mode 100644 index 0000000..b56c6ed --- /dev/null +++ b/apps/web-antd/src/views/store/fees/styles/packaging.less @@ -0,0 +1,123 @@ +/* 文件职责:包装费卡片样式。 */ +.page-store-fees { + .packaging-mode-switch { + display: inline-flex; + gap: 2px; + padding: 3px; + margin-bottom: 16px; + background: #f8f9fb; + border-radius: 8px; + } + + .mode-switch-item { + padding: 6px 18px; + font-size: 13px; + color: #4b5563; + cursor: pointer; + background: transparent; + border: none; + border-radius: 6px; + transition: all 0.2s ease; + } + + .mode-switch-item.active { + font-weight: 600; + color: #1677ff; + background: #fff; + box-shadow: 0 1px 3px rgb(15 23 42 / 10%); + } + + .packaging-tier-block { + padding-top: 16px; + margin-top: 18px; + border-top: 1px solid #f3f4f6; + } + + .packaging-tier-toggle-row { + display: flex; + gap: 10px; + align-items: center; + margin-bottom: 12px; + } + + .packaging-tier-toggle-label { + font-size: 13px; + font-weight: 500; + color: #1f2937; + } + + .packaging-tier-note { + padding: 8px 10px; + margin-bottom: 10px; + font-size: 12px; + color: #6b7280; + background: #f9fafb; + border-radius: 8px; + } + + .packaging-tier-table-wrap { + overflow: hidden; + border: 1px solid #edf0f5; + border-radius: 10px; + } + + .packaging-tier-table { + width: 100%; + border-collapse: collapse; + } + + .packaging-tier-table th { + padding: 10px 12px; + font-size: 12px; + font-weight: 600; + color: #6b7280; + text-align: left; + background: #f8f9fb; + border-bottom: 1px solid #edf0f5; + } + + .packaging-tier-table td { + padding: 10px 12px; + font-size: 13px; + color: #1f2937; + border-bottom: 1px solid #f3f4f6; + } + + .packaging-tier-table tr:last-child td { + border-bottom: none; + } + + .fees-table-link { + margin-right: 8px; + font-size: 12px; + color: #1677ff; + cursor: pointer; + } + + .fees-table-link.danger { + color: #ef4444; + } + + .packaging-tier-add-row { + margin-top: 10px; + } + + .packaging-item-mode-tip { + padding: 18px; + background: #fafafa; + border: 1px dashed #e5e7eb; + border-radius: 10px; + } + + .packaging-item-mode-tip .tip-title { + margin-bottom: 6px; + font-size: 13px; + font-weight: 600; + color: #374151; + } + + .packaging-item-mode-tip .tip-desc { + font-size: 12px; + color: #6b7280; + } +} diff --git a/apps/web-antd/src/views/store/fees/styles/responsive.less b/apps/web-antd/src/views/store/fees/styles/responsive.less new file mode 100644 index 0000000..7427d58 --- /dev/null +++ b/apps/web-antd/src/views/store/fees/styles/responsive.less @@ -0,0 +1,47 @@ +/* 文件职责:费用设置页面响应式规则。 */ +.page-store-fees { + @media (max-width: 768px) { + .fees-field-grid { + grid-template-columns: 1fr; + gap: 14px; + } + + .fees-input { + width: 100%; + } + + .other-fee-row { + flex-wrap: wrap; + } + + .other-fee-input-row { + width: 100%; + margin-left: 34px; + } + + .other-fee-input { + width: 100%; + } + + .packaging-mode-switch { + display: flex; + width: 100%; + } + + .mode-switch-item { + flex: 1; + text-align: center; + } + } +} + +@media (max-width: 640px) { + .drawer-range-row { + flex-wrap: wrap; + row-gap: 8px; + } + + .drawer-input { + width: 100%; + } +} diff --git a/apps/web-antd/src/views/store/fees/types.ts b/apps/web-antd/src/views/store/fees/types.ts new file mode 100644 index 0000000..346cfbe --- /dev/null +++ b/apps/web-antd/src/views/store/fees/types.ts @@ -0,0 +1,33 @@ +/** + * 文件职责:费用设置页面类型定义。 + * 1. 声明页面表单态与快照类型。 + * 2. 声明阶梯抽屉模式与表单类型。 + */ +import type { + OrderPackagingFeeMode, + PackagingFeeMode, + PackagingFeeTierDto, + StoreOtherFeesDto, +} from '#/api/store-fees'; + +export type FeesTierDrawerMode = 'create' | 'edit'; + +export interface PackagingFeeTierFormState { + fee: number; + id: string; + maxAmount: null | number; + minAmount: number; +} + +export interface StoreFeesFormState { + baseDeliveryFee: number; + fixedPackagingFee: number; + freeDeliveryThreshold: null | number; + minimumOrderAmount: number; + orderPackagingFeeMode: OrderPackagingFeeMode; + otherFees: StoreOtherFeesDto; + packagingFeeMode: PackagingFeeMode; + packagingFeeTiers: PackagingFeeTierDto[]; +} + +export type StoreFeesSettingsSnapshot = StoreFeesFormState;