feat: 新增费用设置页面并对齐原型交互
This commit is contained in:
95
apps/web-antd/src/api/store-fees/index.ts
Normal file
95
apps/web-antd/src/api/store-fees/index.ts
Normal file
@@ -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<StoreFeesSettingsDto>('/store/fees', {
|
||||||
|
params: { storeId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存门店费用设置 */
|
||||||
|
export async function saveStoreFeesSettingsApi(
|
||||||
|
data: SaveStoreFeesSettingsParams,
|
||||||
|
) {
|
||||||
|
return requestClient.post<StoreFeesSettingsDto>('/store/fees/save', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 复制费用设置到其他门店 */
|
||||||
|
export async function copyStoreFeesSettingsApi(
|
||||||
|
data: CopyStoreFeesSettingsParams,
|
||||||
|
) {
|
||||||
|
return requestClient.post('/store/fees/copy', data);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
// Mock 数据入口,仅在开发环境下使用
|
// Mock 数据入口,仅在开发环境下使用
|
||||||
import './store';
|
import './store';
|
||||||
import './store-dinein';
|
import './store-dinein';
|
||||||
|
import './store-fees';
|
||||||
import './store-hours';
|
import './store-hours';
|
||||||
import './store-pickup';
|
import './store-pickup';
|
||||||
|
|
||||||
|
|||||||
346
apps/web-antd/src/mock/store-fees.ts
Normal file
346
apps/web-antd/src/mock/store-fees.ts
Normal file
@@ -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<string, StoreFeesState>();
|
||||||
|
|
||||||
|
/** 解析 URL 查询参数。 */
|
||||||
|
function parseUrlParams(url: string) {
|
||||||
|
const parsed = new URL(url, 'http://localhost');
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
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<PackagingFeeTierMock>;
|
||||||
|
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<StoreFeesState['otherFees']>;
|
||||||
|
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<StoreFeesState>;
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -46,6 +46,15 @@ const routes: RouteRecordRaw[] = [
|
|||||||
title: '自提设置',
|
title: '自提设置',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'StoreFees',
|
||||||
|
path: '/store/fees',
|
||||||
|
component: () => import('#/views/store/fees/index.vue'),
|
||||||
|
meta: {
|
||||||
|
icon: 'lucide:wallet',
|
||||||
|
title: '费用设置',
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'StoreDineIn',
|
name: 'StoreDineIn',
|
||||||
path: '/store/dine-in',
|
path: '/store/dine-in',
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 文件职责:起送与配送费设置卡片。
|
||||||
|
* 1. 展示起送金额、基础配送费、免配送费门槛字段。
|
||||||
|
* 2. 通过回调上抛字段更新,保存/重置由父级处理。
|
||||||
|
*/
|
||||||
|
import { Button, Card, InputNumber } from 'ant-design-vue';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
baseDeliveryFee: number;
|
||||||
|
freeDeliveryThreshold: null | number;
|
||||||
|
isSaving: boolean;
|
||||||
|
minimumOrderAmount: number;
|
||||||
|
onSetBaseDeliveryFee: (value: number) => void;
|
||||||
|
onSetFreeDeliveryThreshold: (value: null | number) => void;
|
||||||
|
onSetMinimumOrderAmount: (value: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'reset'): void;
|
||||||
|
(event: 'save'): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function toNumber(value: null | number | string, fallback = 0) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : fallback;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Card :bordered="false" class="fees-card">
|
||||||
|
<template #title>
|
||||||
|
<span class="section-title">起送与配送费</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="fees-field-grid">
|
||||||
|
<div class="fees-field">
|
||||||
|
<label>起送金额</label>
|
||||||
|
<div class="fees-input-row">
|
||||||
|
<span class="fees-unit">¥</span>
|
||||||
|
<InputNumber
|
||||||
|
:value="props.minimumOrderAmount"
|
||||||
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
:step="1"
|
||||||
|
:controls="false"
|
||||||
|
class="fees-input"
|
||||||
|
placeholder="如:15.00"
|
||||||
|
@update:value="
|
||||||
|
(value) =>
|
||||||
|
props.onSetMinimumOrderAmount(
|
||||||
|
toNumber(value, props.minimumOrderAmount),
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="fees-hint">低于此金额的订单无法下单</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fees-field">
|
||||||
|
<label>基础配送费</label>
|
||||||
|
<div class="fees-input-row">
|
||||||
|
<span class="fees-unit">¥</span>
|
||||||
|
<InputNumber
|
||||||
|
:value="props.baseDeliveryFee"
|
||||||
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
:step="1"
|
||||||
|
:controls="false"
|
||||||
|
class="fees-input"
|
||||||
|
placeholder="如:3.00"
|
||||||
|
@update:value="
|
||||||
|
(value) =>
|
||||||
|
props.onSetBaseDeliveryFee(
|
||||||
|
toNumber(value, props.baseDeliveryFee),
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="fees-hint">每笔订单默认收取的配送费</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fees-field">
|
||||||
|
<label>免配送费门槛</label>
|
||||||
|
<div class="fees-input-row">
|
||||||
|
<span class="fees-unit">¥</span>
|
||||||
|
<InputNumber
|
||||||
|
:value="props.freeDeliveryThreshold ?? undefined"
|
||||||
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
:step="1"
|
||||||
|
:controls="false"
|
||||||
|
class="fees-input"
|
||||||
|
placeholder="如:30.00"
|
||||||
|
@update:value="
|
||||||
|
(value) =>
|
||||||
|
props.onSetFreeDeliveryThreshold(
|
||||||
|
value === null || value === undefined
|
||||||
|
? null
|
||||||
|
: toNumber(value, props.freeDeliveryThreshold ?? 0),
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="fees-hint">订单满此金额免配送费,留空则不启用</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fees-actions">
|
||||||
|
<Button :disabled="props.isSaving" @click="emit('reset')">重置</Button>
|
||||||
|
<Button type="primary" :loading="props.isSaving" @click="emit('save')">
|
||||||
|
保存设置
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</template>
|
||||||
105
apps/web-antd/src/views/store/fees/components/FeesOtherCard.vue
Normal file
105
apps/web-antd/src/views/store/fees/components/FeesOtherCard.vue
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 文件职责:其他费用设置卡片。
|
||||||
|
* 1. 展示餐具费、加急费开关与金额输入。
|
||||||
|
* 2. 通过回调上抛字段更新,保存/重置由父级处理。
|
||||||
|
*/
|
||||||
|
import { Button, Card, InputNumber, Switch } from 'ant-design-vue';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
cutleryAmount: number;
|
||||||
|
cutleryEnabled: boolean;
|
||||||
|
isSaving: boolean;
|
||||||
|
onSetCutleryAmount: (value: number) => void;
|
||||||
|
onSetCutleryEnabled: (value: boolean) => void;
|
||||||
|
onSetRushAmount: (value: number) => void;
|
||||||
|
onSetRushEnabled: (value: boolean) => void;
|
||||||
|
rushAmount: number;
|
||||||
|
rushEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'reset'): void;
|
||||||
|
(event: 'save'): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function toNumber(value: null | number | string, fallback = 0) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : fallback;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Card :bordered="false" class="fees-card">
|
||||||
|
<template #title>
|
||||||
|
<span class="section-title">其他费用</span>
|
||||||
|
<span class="section-sub-title">可选,按需开启</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="other-fee-row">
|
||||||
|
<Switch
|
||||||
|
:checked="props.cutleryEnabled"
|
||||||
|
@update:checked="(value) => props.onSetCutleryEnabled(Boolean(value))"
|
||||||
|
/>
|
||||||
|
<div class="other-fee-meta">
|
||||||
|
<div class="other-fee-name">餐具费</div>
|
||||||
|
<div class="other-fee-hint">
|
||||||
|
向顾客收取一次性餐具费用,顾客可选择不需要餐具
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="other-fee-input-row">
|
||||||
|
<span class="fees-unit">¥</span>
|
||||||
|
<InputNumber
|
||||||
|
:value="props.cutleryAmount"
|
||||||
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
:step="0.5"
|
||||||
|
:controls="false"
|
||||||
|
:disabled="!props.cutleryEnabled"
|
||||||
|
class="other-fee-input"
|
||||||
|
placeholder="如:1.00"
|
||||||
|
@update:value="
|
||||||
|
(value) =>
|
||||||
|
props.onSetCutleryAmount(toNumber(value, props.cutleryAmount))
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="other-fee-row">
|
||||||
|
<Switch
|
||||||
|
:checked="props.rushEnabled"
|
||||||
|
@update:checked="(value) => props.onSetRushEnabled(Boolean(value))"
|
||||||
|
/>
|
||||||
|
<div class="other-fee-meta">
|
||||||
|
<div class="other-fee-name">加急费</div>
|
||||||
|
<div class="other-fee-hint">顾客选择加急配送时额外收取的费用</div>
|
||||||
|
</div>
|
||||||
|
<div class="other-fee-input-row">
|
||||||
|
<span class="fees-unit">¥</span>
|
||||||
|
<InputNumber
|
||||||
|
:value="props.rushAmount"
|
||||||
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
:step="0.5"
|
||||||
|
:controls="false"
|
||||||
|
:disabled="!props.rushEnabled"
|
||||||
|
class="other-fee-input"
|
||||||
|
placeholder="如:3.00"
|
||||||
|
@update:value="
|
||||||
|
(value) => props.onSetRushAmount(toNumber(value, props.rushAmount))
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fees-actions">
|
||||||
|
<Button :disabled="props.isSaving" @click="emit('reset')">重置</Button>
|
||||||
|
<Button type="primary" :loading="props.isSaving" @click="emit('save')">
|
||||||
|
保存设置
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 文件职责:包装费设置卡片。
|
||||||
|
* 1. 展示按订单/按商品模式切换。
|
||||||
|
* 2. 展示固定包装费、阶梯包装费与操作表格。
|
||||||
|
*/
|
||||||
|
import type { PackagingFeeTierDto } from '#/api/store-fees';
|
||||||
|
|
||||||
|
import { Button, Card, InputNumber, Switch } from 'ant-design-vue';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
fixedPackagingFee: number;
|
||||||
|
formatCurrency: (value: number) => string;
|
||||||
|
formatTierRange: (tier: PackagingFeeTierDto) => string;
|
||||||
|
isSaving: boolean;
|
||||||
|
onSetFixedPackagingFee: (value: number) => void;
|
||||||
|
onSetPackagingMode: (value: 'item' | 'order') => void;
|
||||||
|
onSetTieredEnabled: (value: boolean) => void;
|
||||||
|
packagingMode: 'item' | 'order';
|
||||||
|
tieredEnabled: boolean;
|
||||||
|
tiers: PackagingFeeTierDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'addTier'): void;
|
||||||
|
(event: 'deleteTier', tier: PackagingFeeTierDto): void;
|
||||||
|
(event: 'editTier', tier: PackagingFeeTierDto): void;
|
||||||
|
(event: 'reset'): void;
|
||||||
|
(event: 'save'): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const packagingModes: Array<{ label: string; value: 'item' | 'order' }> = [
|
||||||
|
{ label: '按订单收取', value: 'order' },
|
||||||
|
{ label: '按商品收取', value: 'item' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function toNumber(value: null | number | string, fallback = 0) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : fallback;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Card :bordered="false" class="fees-card">
|
||||||
|
<template #title>
|
||||||
|
<span class="section-title">包装费设置</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="packaging-mode-switch">
|
||||||
|
<button
|
||||||
|
v-for="mode in packagingModes"
|
||||||
|
:key="mode.value"
|
||||||
|
type="button"
|
||||||
|
class="mode-switch-item"
|
||||||
|
:class="{ active: props.packagingMode === mode.value }"
|
||||||
|
@click="props.onSetPackagingMode(mode.value)"
|
||||||
|
>
|
||||||
|
{{ mode.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="props.packagingMode === 'order'">
|
||||||
|
<div class="fees-field-grid">
|
||||||
|
<div class="fees-field">
|
||||||
|
<label>固定包装费</label>
|
||||||
|
<div class="fees-input-row">
|
||||||
|
<span class="fees-unit">¥</span>
|
||||||
|
<InputNumber
|
||||||
|
:value="props.fixedPackagingFee"
|
||||||
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
:step="0.5"
|
||||||
|
:controls="false"
|
||||||
|
class="fees-input"
|
||||||
|
placeholder="如:2.00"
|
||||||
|
@update:value="
|
||||||
|
(value) =>
|
||||||
|
props.onSetFixedPackagingFee(
|
||||||
|
toNumber(value, props.fixedPackagingFee),
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="fees-hint">每笔订单统一收取的包装费</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="packaging-tier-block">
|
||||||
|
<div class="packaging-tier-toggle-row">
|
||||||
|
<Switch
|
||||||
|
:checked="props.tieredEnabled"
|
||||||
|
@update:checked="
|
||||||
|
(value) => props.onSetTieredEnabled(Boolean(value))
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<span class="packaging-tier-toggle-label">启用阶梯包装费</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="props.tieredEnabled">
|
||||||
|
<div class="packaging-tier-note">
|
||||||
|
启用后将按订单金额区间收取不同包装费,覆盖上方固定包装费
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="packaging-tier-table-wrap">
|
||||||
|
<table class="packaging-tier-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>订单金额区间</th>
|
||||||
|
<th>包装费</th>
|
||||||
|
<th style="width: 112px">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="tier in props.tiers" :key="tier.id">
|
||||||
|
<td>{{ props.formatTierRange(tier) }}</td>
|
||||||
|
<td>{{ props.formatCurrency(tier.fee) }}</td>
|
||||||
|
<td>
|
||||||
|
<a class="fees-table-link" @click="emit('editTier', tier)">
|
||||||
|
编辑
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
class="fees-table-link danger"
|
||||||
|
@click="emit('deleteTier', tier)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="packaging-tier-add-row">
|
||||||
|
<Button @click="emit('addTier')">+ 添加阶梯</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="packaging-item-mode-tip">
|
||||||
|
<div class="tip-title">按商品收取包装费</div>
|
||||||
|
<div class="tip-desc">
|
||||||
|
每个商品单独设置包装费,请在商品管理中配置,系统将自动汇总订单内所有商品包装费。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="fees-actions">
|
||||||
|
<Button :disabled="props.isSaving" @click="emit('reset')">重置</Button>
|
||||||
|
<Button type="primary" :loading="props.isSaving" @click="emit('save')">
|
||||||
|
保存设置
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</template>
|
||||||
118
apps/web-antd/src/views/store/fees/components/FeesTierDrawer.vue
Normal file
118
apps/web-antd/src/views/store/fees/components/FeesTierDrawer.vue
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 文件职责:阶梯包装费编辑抽屉。
|
||||||
|
* 1. 展示阶梯区间与费用输入项。
|
||||||
|
* 2. 通过回调更新父级表单并触发提交。
|
||||||
|
*/
|
||||||
|
import type { PackagingFeeTierFormState } from '#/views/store/fees/types';
|
||||||
|
|
||||||
|
import { Button, Drawer, InputNumber } from 'ant-design-vue';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
form: PackagingFeeTierFormState;
|
||||||
|
isSaving: boolean;
|
||||||
|
onSetFee: (value: number) => void;
|
||||||
|
onSetMaxAmount: (value: null | number) => void;
|
||||||
|
onSetMinAmount: (value: number) => void;
|
||||||
|
open: boolean;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'submit'): void;
|
||||||
|
(event: 'update:open', value: boolean): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function toNumber(value: null | number | string, fallback = 0) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : fallback;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Drawer
|
||||||
|
class="fees-tier-drawer-wrap"
|
||||||
|
:open="props.open"
|
||||||
|
:title="props.title"
|
||||||
|
:width="460"
|
||||||
|
:mask-closable="true"
|
||||||
|
@update:open="(value) => emit('update:open', value)"
|
||||||
|
>
|
||||||
|
<div class="drawer-form-block">
|
||||||
|
<label class="drawer-form-label required">订单金额区间</label>
|
||||||
|
<div class="drawer-range-row">
|
||||||
|
<InputNumber
|
||||||
|
:value="props.form.minAmount"
|
||||||
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
:step="1"
|
||||||
|
:controls="false"
|
||||||
|
class="drawer-input"
|
||||||
|
placeholder="起始金额"
|
||||||
|
@update:value="
|
||||||
|
(value) =>
|
||||||
|
props.onSetMinAmount(toNumber(value, props.form.minAmount))
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<span class="drawer-range-separator">~</span>
|
||||||
|
<InputNumber
|
||||||
|
:value="props.form.maxAmount ?? undefined"
|
||||||
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
:step="1"
|
||||||
|
:controls="false"
|
||||||
|
class="drawer-input"
|
||||||
|
placeholder="结束金额(留空表示无上限)"
|
||||||
|
@update:value="
|
||||||
|
(value) =>
|
||||||
|
props.onSetMaxAmount(
|
||||||
|
value === null || value === undefined
|
||||||
|
? null
|
||||||
|
: toNumber(
|
||||||
|
value,
|
||||||
|
props.form.maxAmount ?? props.form.minAmount,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<span>元</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="drawer-form-block">
|
||||||
|
<label class="drawer-form-label required">包装费</label>
|
||||||
|
<div class="drawer-input-with-unit">
|
||||||
|
<InputNumber
|
||||||
|
:value="props.form.fee"
|
||||||
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
:step="0.5"
|
||||||
|
:controls="false"
|
||||||
|
class="drawer-input"
|
||||||
|
placeholder="如:2.00"
|
||||||
|
@update:value="
|
||||||
|
(value) => props.onSetFee(toNumber(value, props.form.fee))
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<span>元</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="drawer-footer">
|
||||||
|
<Button :disabled="props.isSaving" @click="emit('update:open', false)">
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
:loading="props.isSaving"
|
||||||
|
@click="emit('submit')"
|
||||||
|
>
|
||||||
|
确认
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Drawer>
|
||||||
|
</template>
|
||||||
@@ -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<StoreFeesSettingsDto, 'storeId'> = {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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<StoreListItemDto[]>;
|
||||||
|
copyTargetStoreIds: Ref<string[]>;
|
||||||
|
isCopyModalOpen: Ref<boolean>;
|
||||||
|
isCopySubmitting: Ref<boolean>;
|
||||||
|
selectedStoreId: Ref<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<boolean>;
|
||||||
|
isStoreLoading: Ref<boolean>;
|
||||||
|
selectedStoreId: Ref<string>;
|
||||||
|
snapshot: Ref<null | StoreFeesSettingsSnapshot>;
|
||||||
|
stores: Ref<StoreListItemDto[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<StoreFeesSettingsDto> | 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 '';
|
||||||
|
}
|
||||||
@@ -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<boolean>;
|
||||||
|
tierDrawerMode: Ref<FeesTierDrawerMode>;
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<StoreListItemDto[]>([]);
|
||||||
|
const selectedStoreId = ref('');
|
||||||
|
const form = reactive<StoreFeesFormState>(
|
||||||
|
cloneFeesForm(DEFAULT_FEES_SETTINGS),
|
||||||
|
);
|
||||||
|
const snapshot = ref<null | StoreFeesSettingsSnapshot>(null);
|
||||||
|
|
||||||
|
// 3. 复制弹窗状态。
|
||||||
|
const isCopyModalOpen = ref(false);
|
||||||
|
const copyTargetStoreIds = ref<string[]>([]);
|
||||||
|
|
||||||
|
// 4. 阶梯抽屉状态。
|
||||||
|
const isTierDrawerOpen = ref(false);
|
||||||
|
const tierDrawerMode = ref<FeesTierDrawerMode>('create');
|
||||||
|
const tierForm = reactive<PackagingFeeTierFormState>({
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
178
apps/web-antd/src/views/store/fees/index.vue
Normal file
178
apps/web-antd/src/views/store/fees/index.vue
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 文件职责:费用设置页面主视图。
|
||||||
|
* 1. 组合起送/配送费、包装费、其他费用卡片。
|
||||||
|
* 2. 承接门店维度切换、阶梯抽屉与复制弹窗。
|
||||||
|
*/
|
||||||
|
import type { PackagingFeeTierDto } from '#/api/store-fees';
|
||||||
|
|
||||||
|
import { Page } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import { Card, Empty, Spin } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import CopyToStoresModal from '../components/CopyToStoresModal.vue';
|
||||||
|
import StoreScopeToolbar from '../components/StoreScopeToolbar.vue';
|
||||||
|
import FeesDeliveryCard from './components/FeesDeliveryCard.vue';
|
||||||
|
import FeesOtherCard from './components/FeesOtherCard.vue';
|
||||||
|
import FeesPackagingCard from './components/FeesPackagingCard.vue';
|
||||||
|
import FeesTierDrawer from './components/FeesTierDrawer.vue';
|
||||||
|
import { useStoreFeesPage } from './composables/useStoreFeesPage';
|
||||||
|
|
||||||
|
const {
|
||||||
|
copyCandidates,
|
||||||
|
copyTargetStoreIds,
|
||||||
|
form,
|
||||||
|
formatCurrency,
|
||||||
|
formatTierRange,
|
||||||
|
handleCopyCheckAll,
|
||||||
|
handleCopySubmit,
|
||||||
|
handleSubmitTier,
|
||||||
|
isCopyAllChecked,
|
||||||
|
isCopyIndeterminate,
|
||||||
|
isCopyModalOpen,
|
||||||
|
isCopySubmitting,
|
||||||
|
isPageLoading,
|
||||||
|
isSavingDelivery,
|
||||||
|
isSavingOther,
|
||||||
|
isSavingPackaging,
|
||||||
|
isStoreLoading,
|
||||||
|
isTierDrawerOpen,
|
||||||
|
onDeleteTier,
|
||||||
|
openCopyModal,
|
||||||
|
openTierDrawer,
|
||||||
|
resetDeliverySection,
|
||||||
|
resetOtherSection,
|
||||||
|
resetPackagingSection,
|
||||||
|
saveDeliverySection,
|
||||||
|
saveOtherSection,
|
||||||
|
savePackagingSection,
|
||||||
|
selectedStoreId,
|
||||||
|
selectedStoreName,
|
||||||
|
setBaseDeliveryFee,
|
||||||
|
setCopyModalOpen,
|
||||||
|
setCutleryAmount,
|
||||||
|
setCutleryEnabled,
|
||||||
|
setFixedPackagingFee,
|
||||||
|
setFreeDeliveryThreshold,
|
||||||
|
setMinimumOrderAmount,
|
||||||
|
setPackagingMode,
|
||||||
|
setRushAmount,
|
||||||
|
setRushEnabled,
|
||||||
|
setSelectedStoreId,
|
||||||
|
setTierDrawerOpen,
|
||||||
|
setTierFee,
|
||||||
|
setTierMaxAmount,
|
||||||
|
setTierMinAmount,
|
||||||
|
setTieredEnabled,
|
||||||
|
storeOptions,
|
||||||
|
tierDrawerTitle,
|
||||||
|
tierForm,
|
||||||
|
toggleCopyStore,
|
||||||
|
} = useStoreFeesPage();
|
||||||
|
|
||||||
|
function onEditTier(tier: PackagingFeeTierDto) {
|
||||||
|
openTierDrawer('edit', tier);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Page title="费用设置" content-class="space-y-4 page-store-fees">
|
||||||
|
<StoreScopeToolbar
|
||||||
|
:selected-store-id="selectedStoreId"
|
||||||
|
:store-options="storeOptions"
|
||||||
|
:is-store-loading="isStoreLoading"
|
||||||
|
:copy-disabled="!selectedStoreId || copyCandidates.length === 0"
|
||||||
|
copy-button-text="复制费用设置到其他门店"
|
||||||
|
@update:selected-store-id="setSelectedStoreId"
|
||||||
|
@copy="openCopyModal"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<template v-if="storeOptions.length === 0">
|
||||||
|
<Card :bordered="false">
|
||||||
|
<Empty description="暂无门店,请先创建门店" />
|
||||||
|
</Card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<Spin :spinning="isPageLoading">
|
||||||
|
<FeesDeliveryCard
|
||||||
|
:minimum-order-amount="form.minimumOrderAmount"
|
||||||
|
:base-delivery-fee="form.baseDeliveryFee"
|
||||||
|
:free-delivery-threshold="form.freeDeliveryThreshold"
|
||||||
|
:is-saving="isSavingDelivery"
|
||||||
|
:on-set-minimum-order-amount="setMinimumOrderAmount"
|
||||||
|
:on-set-base-delivery-fee="setBaseDeliveryFee"
|
||||||
|
:on-set-free-delivery-threshold="setFreeDeliveryThreshold"
|
||||||
|
@save="saveDeliverySection"
|
||||||
|
@reset="resetDeliverySection"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FeesPackagingCard
|
||||||
|
:packaging-mode="form.packagingFeeMode"
|
||||||
|
:tiered-enabled="form.orderPackagingFeeMode === 'tiered'"
|
||||||
|
:fixed-packaging-fee="form.fixedPackagingFee"
|
||||||
|
:tiers="form.packagingFeeTiers"
|
||||||
|
:is-saving="isSavingPackaging"
|
||||||
|
:format-currency="formatCurrency"
|
||||||
|
:format-tier-range="formatTierRange"
|
||||||
|
:on-set-packaging-mode="setPackagingMode"
|
||||||
|
:on-set-tiered-enabled="setTieredEnabled"
|
||||||
|
:on-set-fixed-packaging-fee="setFixedPackagingFee"
|
||||||
|
@add-tier="openTierDrawer('create')"
|
||||||
|
@edit-tier="onEditTier"
|
||||||
|
@delete-tier="onDeleteTier"
|
||||||
|
@save="savePackagingSection"
|
||||||
|
@reset="resetPackagingSection"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FeesOtherCard
|
||||||
|
:cutlery-enabled="form.otherFees.cutlery.enabled"
|
||||||
|
:cutlery-amount="form.otherFees.cutlery.amount"
|
||||||
|
:rush-enabled="form.otherFees.rush.enabled"
|
||||||
|
:rush-amount="form.otherFees.rush.amount"
|
||||||
|
:is-saving="isSavingOther"
|
||||||
|
:on-set-cutlery-enabled="setCutleryEnabled"
|
||||||
|
:on-set-cutlery-amount="setCutleryAmount"
|
||||||
|
:on-set-rush-enabled="setRushEnabled"
|
||||||
|
:on-set-rush-amount="setRushAmount"
|
||||||
|
@save="saveOtherSection"
|
||||||
|
@reset="resetOtherSection"
|
||||||
|
/>
|
||||||
|
</Spin>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<FeesTierDrawer
|
||||||
|
:open="isTierDrawerOpen"
|
||||||
|
:title="tierDrawerTitle"
|
||||||
|
:form="tierForm"
|
||||||
|
:is-saving="isSavingPackaging"
|
||||||
|
:on-set-min-amount="setTierMinAmount"
|
||||||
|
:on-set-max-amount="setTierMaxAmount"
|
||||||
|
:on-set-fee="setTierFee"
|
||||||
|
@update:open="setTierDrawerOpen"
|
||||||
|
@submit="handleSubmitTier"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CopyToStoresModal
|
||||||
|
:open="isCopyModalOpen"
|
||||||
|
:copy-candidates="copyCandidates"
|
||||||
|
:target-store-ids="copyTargetStoreIds"
|
||||||
|
:is-all-checked="isCopyAllChecked"
|
||||||
|
:is-indeterminate="isCopyIndeterminate"
|
||||||
|
:is-submitting="isCopySubmitting"
|
||||||
|
:selected-store-name="selectedStoreName"
|
||||||
|
title="复制费用设置到其他门店"
|
||||||
|
confirm-text="确认复制"
|
||||||
|
@update:open="setCopyModalOpen"
|
||||||
|
@check-all="handleCopyCheckAll"
|
||||||
|
@submit="handleCopySubmit"
|
||||||
|
@toggle-store="
|
||||||
|
({ storeId, checked }) => toggleCopyStore(storeId, checked)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</Page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less">
|
||||||
|
@import './styles/index.less';
|
||||||
|
</style>
|
||||||
41
apps/web-antd/src/views/store/fees/styles/base.less
Normal file
41
apps/web-antd/src/views/store/fees/styles/base.less
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
43
apps/web-antd/src/views/store/fees/styles/delivery.less
Normal file
43
apps/web-antd/src/views/store/fees/styles/delivery.less
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
55
apps/web-antd/src/views/store/fees/styles/drawer.less
Normal file
55
apps/web-antd/src/views/store/fees/styles/drawer.less
Normal file
@@ -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;
|
||||||
|
}
|
||||||
7
apps/web-antd/src/views/store/fees/styles/index.less
Normal file
7
apps/web-antd/src/views/store/fees/styles/index.less
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/* 文件职责:费用设置页面样式聚合入口(仅负责分片导入)。 */
|
||||||
|
@import './base.less';
|
||||||
|
@import './delivery.less';
|
||||||
|
@import './packaging.less';
|
||||||
|
@import './other.less';
|
||||||
|
@import './drawer.less';
|
||||||
|
@import './responsive.less';
|
||||||
41
apps/web-antd/src/views/store/fees/styles/other.less
Normal file
41
apps/web-antd/src/views/store/fees/styles/other.less
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
123
apps/web-antd/src/views/store/fees/styles/packaging.less
Normal file
123
apps/web-antd/src/views/store/fees/styles/packaging.less
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
47
apps/web-antd/src/views/store/fees/styles/responsive.less
Normal file
47
apps/web-antd/src/views/store/fees/styles/responsive.less
Normal file
@@ -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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
33
apps/web-antd/src/views/store/fees/types.ts
Normal file
33
apps/web-antd/src/views/store/fees/types.ts
Normal file
@@ -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;
|
||||||
Reference in New Issue
Block a user