diff --git a/apps/web-antd/src/api/member/index.ts b/apps/web-antd/src/api/member/index.ts index 74ea27d..467e6e8 100644 --- a/apps/web-antd/src/api/member/index.ts +++ b/apps/web-antd/src/api/member/index.ts @@ -283,4 +283,5 @@ export async function getMemberCouponPickerApi(params: { } export * from './message-reach'; +export * from './points-mall'; export * from './stored-card'; diff --git a/apps/web-antd/src/api/member/points-mall.ts b/apps/web-antd/src/api/member/points-mall.ts new file mode 100644 index 0000000..9ca6cd2 --- /dev/null +++ b/apps/web-antd/src/api/member/points-mall.ts @@ -0,0 +1,372 @@ +/** + * 文件职责:会员中心积分商城 API 契约定义。 + */ +import { requestClient } from '#/api/request'; + +/** 兑换类型。 */ +export type MemberPointMallRedeemType = 'coupon' | 'physical' | 'product'; + +/** 兑换方式。 */ +export type MemberPointMallExchangeType = 'mixed' | 'points'; + +/** 商品状态。 */ +export type MemberPointMallProductStatus = 'disabled' | 'enabled'; + +/** 积分有效期模式。 */ +export type MemberPointMallExpiryMode = 'permanent' | 'yearly_clear'; + +/** 通知渠道。 */ +export type MemberPointMallNotifyChannel = 'in_app' | 'sms'; + +/** 实物领取方式。 */ +export type MemberPointMallPickupMethod = 'delivery' | 'store_pickup'; + +/** 兑换记录状态。 */ +export type MemberPointMallRecordStatus = + | 'canceled' + | 'completed' + | 'issued' + | 'pending_pickup'; + +/** 核销方式。 */ +export type MemberPointMallVerifyMethod = 'manual' | 'scan'; + +/** 规则详情查询。 */ +export interface MemberPointMallRuleDetailQuery { + storeId: string; +} + +/** 规则模型。 */ +export interface MemberPointMallRuleDto { + consumeAmountPerStep: number; + consumeRewardPointsPerStep: number; + expiryMode: MemberPointMallExpiryMode; + isConsumeRewardEnabled: boolean; + isRegisterRewardEnabled: boolean; + isReviewRewardEnabled: boolean; + isSigninRewardEnabled: boolean; + registerRewardPoints: number; + reviewRewardPoints: number; + signinRewardPoints: number; + storeId: string; +} + +/** 规则统计。 */ +export interface MemberPointMallRuleStatsDto { + pointMembers: number; + redeemRate: number; + redeemedPoints: number; + totalIssuedPoints: number; +} + +/** 规则详情结果。 */ +export interface MemberPointMallRuleDetailResultDto { + rule: MemberPointMallRuleDto; + stats: MemberPointMallRuleStatsDto; +} + +/** 保存规则。 */ +export type SaveMemberPointMallRulePayload = MemberPointMallRuleDto; + +/** 商品列表查询。 */ +export interface MemberPointMallProductListQuery { + keyword?: string; + status?: MemberPointMallProductStatus; + storeId: string; +} + +/** 商品详情查询。 */ +export interface MemberPointMallProductDetailQuery { + pointMallProductId: string; + storeId: string; +} + +/** 商品模型。 */ +export interface MemberPointMallProductDto { + cashAmount: number; + couponTemplateId?: string; + description?: string; + exchangeType: MemberPointMallExchangeType; + imageUrl?: string; + name: string; + notifyChannels: MemberPointMallNotifyChannel[]; + perMemberLimit?: number; + physicalName?: string; + pickupMethod?: MemberPointMallPickupMethod; + pointMallProductId: string; + productId?: string; + redeemType: MemberPointMallRedeemType; + redeemTypeText: string; + redeemedCount: number; + requiredPoints: number; + status: MemberPointMallProductStatus; + statusText: string; + stockAvailable: number; + stockTotal: number; + storeId: string; + updatedAt: string; +} + +/** 商品列表结果。 */ +export interface MemberPointMallProductListResultDto { + items: MemberPointMallProductDto[]; +} + +/** 保存商品。 */ +export interface SaveMemberPointMallProductPayload { + cashAmount: number; + couponTemplateId?: string; + description?: string; + exchangeType: MemberPointMallExchangeType; + imageUrl?: string; + name: string; + notifyChannels: MemberPointMallNotifyChannel[]; + perMemberLimit?: number; + physicalName?: string; + pickupMethod?: MemberPointMallPickupMethod; + pointMallProductId?: string; + productId?: string; + redeemType: MemberPointMallRedeemType; + requiredPoints: number; + status: MemberPointMallProductStatus; + stockTotal: number; + storeId: string; +} + +/** 修改商品状态。 */ +export interface ChangeMemberPointMallProductStatusPayload { + pointMallProductId: string; + status: MemberPointMallProductStatus; + storeId: string; +} + +/** 删除商品。 */ +export interface DeleteMemberPointMallProductPayload { + pointMallProductId: string; + storeId: string; +} + +/** 记录列表查询。 */ +export interface MemberPointMallRecordListQuery { + endDate?: string; + keyword?: string; + page: number; + pageSize: number; + redeemType?: MemberPointMallRedeemType; + startDate?: string; + status?: MemberPointMallRecordStatus; + storeId: string; +} + +/** 记录详情查询。 */ +export interface MemberPointMallRecordDetailQuery { + recordId: string; + storeId: string; +} + +/** 记录模型。 */ +export interface MemberPointMallRecordDto { + cashAmount: number; + exchangeType: MemberPointMallExchangeType; + issuedAt?: string; + memberId: string; + memberMobileMasked: string; + memberName: string; + pointMallProductId: string; + productName: string; + recordId: string; + recordNo: string; + redeemedAt: string; + redeemType: MemberPointMallRedeemType; + redeemTypeText: string; + status: MemberPointMallRecordStatus; + statusText: string; + usedPoints: number; + verifiedAt?: string; +} + +/** 记录详情。 */ +export interface MemberPointMallRecordDetailDto extends MemberPointMallRecordDto { + verifyMethod?: MemberPointMallVerifyMethod; + verifyMethodText?: string; + verifyRemark?: string; + verifiedBy?: string; +} + +/** 记录统计。 */ +export interface MemberPointMallRecordStatsDto { + currentMonthUsedPoints: number; + pendingPhysicalCount: number; + todayRedeemCount: number; +} + +/** 记录分页结果。 */ +export interface MemberPointMallRecordListResultDto { + items: MemberPointMallRecordDto[]; + page: number; + pageSize: number; + stats: MemberPointMallRecordStatsDto; + totalCount: number; +} + +/** 导出记录查询。 */ +export interface ExportMemberPointMallRecordQuery { + endDate?: string; + keyword?: string; + redeemType?: MemberPointMallRedeemType; + startDate?: string; + status?: MemberPointMallRecordStatus; + storeId: string; +} + +/** 导出结果。 */ +export interface MemberPointMallRecordExportDto { + fileContentBase64: string; + fileName: string; + totalCount: number; +} + +/** 写入记录请求。 */ +export interface WriteMemberPointMallRecordPayload { + memberId: string; + pointMallProductId: string; + redeemedAt?: string; + storeId: string; +} + +/** 核销记录请求。 */ +export interface VerifyMemberPointMallRecordPayload { + recordId: string; + storeId: string; + verifyMethod: MemberPointMallVerifyMethod; + verifyRemark?: string; +} + +/** 查询规则详情。 */ +export async function getMemberPointMallRuleDetailApi( + params: MemberPointMallRuleDetailQuery, +) { + return requestClient.get( + '/member/points-mall/rule/detail', + { + params, + }, + ); +} + +/** 保存规则。 */ +export async function saveMemberPointMallRuleApi( + payload: SaveMemberPointMallRulePayload, +) { + return requestClient.post( + '/member/points-mall/rule/save', + payload, + ); +} + +/** 查询商品列表。 */ +export async function getMemberPointMallProductListApi( + params: MemberPointMallProductListQuery, +) { + return requestClient.get( + '/member/points-mall/product/list', + { + params, + }, + ); +} + +/** 查询商品详情。 */ +export async function getMemberPointMallProductDetailApi( + params: MemberPointMallProductDetailQuery, +) { + return requestClient.get( + '/member/points-mall/product/detail', + { + params, + }, + ); +} + +/** 保存商品。 */ +export async function saveMemberPointMallProductApi( + payload: SaveMemberPointMallProductPayload, +) { + return requestClient.post( + '/member/points-mall/product/save', + payload, + ); +} + +/** 修改商品状态。 */ +export async function changeMemberPointMallProductStatusApi( + payload: ChangeMemberPointMallProductStatusPayload, +) { + return requestClient.post( + '/member/points-mall/product/status', + payload, + ); +} + +/** 删除商品。 */ +export async function deleteMemberPointMallProductApi( + payload: DeleteMemberPointMallProductPayload, +) { + return requestClient.post('/member/points-mall/product/delete', payload); +} + +/** 查询兑换记录。 */ +export async function getMemberPointMallRecordListApi( + params: MemberPointMallRecordListQuery, +) { + return requestClient.get( + '/member/points-mall/record/list', + { + params, + }, + ); +} + +/** 查询兑换记录详情。 */ +export async function getMemberPointMallRecordDetailApi( + params: MemberPointMallRecordDetailQuery, +) { + return requestClient.get( + '/member/points-mall/record/detail', + { + params, + }, + ); +} + +/** 导出兑换记录。 */ +export async function exportMemberPointMallRecordApi( + params: ExportMemberPointMallRecordQuery, +) { + return requestClient.get( + '/member/points-mall/record/export', + { + params, + }, + ); +} + +/** 写入兑换记录。 */ +export async function writeMemberPointMallRecordApi( + payload: WriteMemberPointMallRecordPayload, +) { + return requestClient.post( + '/member/points-mall/record/write', + payload, + ); +} + +/** 核销兑换记录。 */ +export async function verifyMemberPointMallRecordApi( + payload: VerifyMemberPointMallRecordPayload, +) { + return requestClient.post( + '/member/points-mall/record/verify', + payload, + ); +} diff --git a/apps/web-antd/src/views/member/points-mall/components/PointsMallProductCard.vue b/apps/web-antd/src/views/member/points-mall/components/PointsMallProductCard.vue new file mode 100644 index 0000000..347f77a --- /dev/null +++ b/apps/web-antd/src/views/member/points-mall/components/PointsMallProductCard.vue @@ -0,0 +1,109 @@ + + + diff --git a/apps/web-antd/src/views/member/points-mall/components/PointsMallProductEditorDrawer.vue b/apps/web-antd/src/views/member/points-mall/components/PointsMallProductEditorDrawer.vue new file mode 100644 index 0000000..5709a72 --- /dev/null +++ b/apps/web-antd/src/views/member/points-mall/components/PointsMallProductEditorDrawer.vue @@ -0,0 +1,381 @@ + + + diff --git a/apps/web-antd/src/views/member/points-mall/components/PointsMallProductPickerModal.vue b/apps/web-antd/src/views/member/points-mall/components/PointsMallProductPickerModal.vue new file mode 100644 index 0000000..fec2e1e --- /dev/null +++ b/apps/web-antd/src/views/member/points-mall/components/PointsMallProductPickerModal.vue @@ -0,0 +1,89 @@ + + + diff --git a/apps/web-antd/src/views/member/points-mall/components/PointsMallProductToolbar.vue b/apps/web-antd/src/views/member/points-mall/components/PointsMallProductToolbar.vue new file mode 100644 index 0000000..d053ed2 --- /dev/null +++ b/apps/web-antd/src/views/member/points-mall/components/PointsMallProductToolbar.vue @@ -0,0 +1,63 @@ + + + diff --git a/apps/web-antd/src/views/member/points-mall/components/PointsMallRecordDetailDrawer.vue b/apps/web-antd/src/views/member/points-mall/components/PointsMallRecordDetailDrawer.vue new file mode 100644 index 0000000..7aac5c1 --- /dev/null +++ b/apps/web-antd/src/views/member/points-mall/components/PointsMallRecordDetailDrawer.vue @@ -0,0 +1,105 @@ + + + diff --git a/apps/web-antd/src/views/member/points-mall/components/PointsMallRecordStatsCards.vue b/apps/web-antd/src/views/member/points-mall/components/PointsMallRecordStatsCards.vue new file mode 100644 index 0000000..23b97d9 --- /dev/null +++ b/apps/web-antd/src/views/member/points-mall/components/PointsMallRecordStatsCards.vue @@ -0,0 +1,32 @@ + + + diff --git a/apps/web-antd/src/views/member/points-mall/components/PointsMallRecordTable.vue b/apps/web-antd/src/views/member/points-mall/components/PointsMallRecordTable.vue new file mode 100644 index 0000000..dd164f3 --- /dev/null +++ b/apps/web-antd/src/views/member/points-mall/components/PointsMallRecordTable.vue @@ -0,0 +1,176 @@ + + + diff --git a/apps/web-antd/src/views/member/points-mall/components/PointsMallRecordToolbar.vue b/apps/web-antd/src/views/member/points-mall/components/PointsMallRecordToolbar.vue new file mode 100644 index 0000000..7427f07 --- /dev/null +++ b/apps/web-antd/src/views/member/points-mall/components/PointsMallRecordToolbar.vue @@ -0,0 +1,99 @@ + + + diff --git a/apps/web-antd/src/views/member/points-mall/components/PointsMallRulePanel.vue b/apps/web-antd/src/views/member/points-mall/components/PointsMallRulePanel.vue new file mode 100644 index 0000000..3ed3164 --- /dev/null +++ b/apps/web-antd/src/views/member/points-mall/components/PointsMallRulePanel.vue @@ -0,0 +1,179 @@ + + + diff --git a/apps/web-antd/src/views/member/points-mall/components/PointsMallRuleStatsCards.vue b/apps/web-antd/src/views/member/points-mall/components/PointsMallRuleStatsCards.vue new file mode 100644 index 0000000..c53e4e8 --- /dev/null +++ b/apps/web-antd/src/views/member/points-mall/components/PointsMallRuleStatsCards.vue @@ -0,0 +1,38 @@ + + + diff --git a/apps/web-antd/src/views/member/points-mall/components/PointsMallVerifyDrawer.vue b/apps/web-antd/src/views/member/points-mall/components/PointsMallVerifyDrawer.vue new file mode 100644 index 0000000..0902b2c --- /dev/null +++ b/apps/web-antd/src/views/member/points-mall/components/PointsMallVerifyDrawer.vue @@ -0,0 +1,123 @@ + + + diff --git a/apps/web-antd/src/views/member/points-mall/composables/points-mall-page/constants.ts b/apps/web-antd/src/views/member/points-mall/composables/points-mall-page/constants.ts new file mode 100644 index 0000000..2eb807b --- /dev/null +++ b/apps/web-antd/src/views/member/points-mall/composables/points-mall-page/constants.ts @@ -0,0 +1,147 @@ +import type { + MemberPointMallExchangeType, + MemberPointMallNotifyChannel, + MemberPointMallPickupMethod, + MemberPointMallProductStatus, + MemberPointMallRecordStatus, + MemberPointMallRedeemType, + MemberPointMallVerifyMethod, +} from '#/api/member/points-mall'; +import type { PointMallTabKey } from '#/views/member/points-mall/types'; + +/** 积分商城查看权限。 */ +export const MEMBER_POINTS_MALL_VIEW_PERMISSION = + 'tenant:member:points-mall:view'; + +/** 积分商城管理权限。 */ +export const MEMBER_POINTS_MALL_MANAGE_PERMISSION = + 'tenant:member:points-mall:manage'; + +/** 页面 Tab 选项。 */ +export const POINTS_MALL_TAB_OPTIONS: Array<{ + label: string; + value: PointMallTabKey; +}> = [ + { label: '积分规则', value: 'rules' }, + { label: '兑换商品', value: 'products' }, + { label: '兑换记录', value: 'records' }, +]; + +/** 商品状态筛选。 */ +export const POINTS_MALL_PRODUCT_STATUS_FILTER_OPTIONS: Array<{ + label: string; + value: '' | MemberPointMallProductStatus; +}> = [ + { label: '全部状态', value: '' }, + { label: '上架', value: 'enabled' }, + { label: '下架', value: 'disabled' }, +]; + +/** 记录类型筛选。 */ +export const POINTS_MALL_RECORD_REDEEM_FILTER_OPTIONS: Array<{ + label: string; + value: '' | MemberPointMallRedeemType; +}> = [ + { label: '全部类型', value: '' }, + { label: '兑换商品', value: 'product' }, + { label: '兑换优惠券', value: 'coupon' }, + { label: '兑换实物', value: 'physical' }, +]; + +/** 记录状态筛选。 */ +export const POINTS_MALL_RECORD_STATUS_FILTER_OPTIONS: Array<{ + label: string; + value: '' | MemberPointMallRecordStatus; +}> = [ + { label: '全部状态', value: '' }, + { label: '待领取', value: 'pending_pickup' }, + { label: '已发放', value: 'issued' }, + { label: '已完成', value: 'completed' }, + { label: '已取消', value: 'canceled' }, +]; + +/** 兑换类型选项。 */ +export const POINTS_MALL_REDEEM_TYPE_OPTIONS: Array<{ + label: string; + value: MemberPointMallRedeemType; +}> = [ + { label: '兑换商品', value: 'product' }, + { label: '兑换优惠券', value: 'coupon' }, + { label: '兑换实物', value: 'physical' }, +]; + +/** 兑换类型提示。 */ +export const POINTS_MALL_REDEEM_TYPE_HINT_MAP: Record< + MemberPointMallRedeemType, + string +> = { + product: '顾客兑换后系统自动发放商品兑换券,下单时该商品免费', + coupon: '顾客兑换后优惠券直接发到券包', + physical: '实物商品需到店自提,不走线上履约', +}; + +/** 领取方式选项。 */ +export const POINTS_MALL_PICKUP_METHOD_OPTIONS: Array<{ + label: string; + value: MemberPointMallPickupMethod; +}> = [ + { label: '到店自提', value: 'store_pickup' }, + { label: '快递配送', value: 'delivery' }, +]; + +/** 兑换方式选项。 */ +export const POINTS_MALL_EXCHANGE_TYPE_OPTIONS: Array<{ + label: string; + value: MemberPointMallExchangeType; +}> = [ + { label: '纯积分', value: 'points' }, + { label: '积分 + 现金', value: 'mixed' }, +]; + +/** 通知渠道选项。 */ +export const POINTS_MALL_NOTIFY_CHANNEL_OPTIONS: Array<{ + label: string; + value: MemberPointMallNotifyChannel; +}> = [ + { label: '站内消息', value: 'in_app' }, + { label: '短信通知', value: 'sms' }, +]; + +/** 核销方式选项。 */ +export const POINTS_MALL_VERIFY_METHOD_OPTIONS: Array<{ + label: string; + value: MemberPointMallVerifyMethod; +}> = [ + { label: '扫码核销', value: 'scan' }, + { label: '手动核销', value: 'manual' }, +]; + +/** 商品状态文案。 */ +export const POINTS_MALL_PRODUCT_STATUS_TEXT_MAP: Record< + MemberPointMallProductStatus, + string +> = { + enabled: '上架', + disabled: '下架', +}; + +/** 类型 Tag 样式。 */ +export const POINTS_MALL_REDEEM_TYPE_CLASS_MAP: Record< + MemberPointMallRedeemType, + 'is-blue' | 'is-green' | 'is-orange' +> = { + product: 'is-blue', + coupon: 'is-green', + physical: 'is-orange', +}; + +/** 记录状态样式。 */ +export const POINTS_MALL_RECORD_STATUS_CLASS_MAP: Record< + MemberPointMallRecordStatus, + 'is-gray' | 'is-green' | 'is-orange' | 'is-red' +> = { + pending_pickup: 'is-orange', + issued: 'is-green', + completed: 'is-green', + canceled: 'is-gray', +}; diff --git a/apps/web-antd/src/views/member/points-mall/composables/points-mall-page/data-actions.ts b/apps/web-antd/src/views/member/points-mall/composables/points-mall-page/data-actions.ts new file mode 100644 index 0000000..c1f3553 --- /dev/null +++ b/apps/web-antd/src/views/member/points-mall/composables/points-mall-page/data-actions.ts @@ -0,0 +1,207 @@ +import type { Ref } from 'vue'; + +import type { StoreListItemDto } from '#/api/store'; +import type { + PointMallProductCardViewModel, + PointMallProductFilterForm, + PointMallRecordFilterForm, + PointMallRecordPager, + PointMallRecordStatsViewModel, + PointMallRuleForm, + PointMallRuleStatsViewModel, +} from '#/views/member/points-mall/types'; + +import { message } from 'ant-design-vue'; + +import { + getMemberPointMallProductListApi, + getMemberPointMallRecordListApi, + getMemberPointMallRuleDetailApi, +} from '#/api/member/points-mall'; +import { getStoreListApi } from '#/api/store'; + +import { mapRecordFilterToQuery, mapRuleToForm } from './helpers'; + +interface CreateDataActionsOptions { + isProductLoading: Ref; + isRecordLoading: Ref; + isRuleLoading: Ref; + isStoreLoading: Ref; + productFilterForm: PointMallProductFilterForm; + productRows: Ref; + recordFilterForm: PointMallRecordFilterForm; + recordPager: Ref; + recordStats: Ref; + ruleForm: PointMallRuleForm; + ruleStats: Ref; + selectedStoreId: Ref; + stores: Ref; +} + +export function createDataActions(options: CreateDataActionsOptions) { + function resetRuleData() { + options.ruleStats.value = { + totalIssuedPoints: 0, + redeemedPoints: 0, + pointMembers: 0, + redeemRate: 0, + }; + } + + function resetProductData() { + options.productRows.value = []; + } + + function resetRecordData() { + options.recordPager.value = { + ...options.recordPager.value, + items: [], + totalCount: 0, + }; + options.recordStats.value = { + todayRedeemCount: 0, + pendingPhysicalCount: 0, + currentMonthUsedPoints: 0, + }; + } + + async function loadStores() { + options.isStoreLoading.value = true; + try { + const result = await getStoreListApi({ + page: 1, + pageSize: 200, + }); + + options.stores.value = result.items ?? []; + + if (options.stores.value.length === 0) { + options.selectedStoreId.value = ''; + resetRuleData(); + resetProductData(); + resetRecordData(); + return; + } + + if (!options.selectedStoreId.value) { + options.selectedStoreId.value = options.stores.value[0]?.id ?? ''; + return; + } + + const exists = options.stores.value.some( + (item) => item.id === options.selectedStoreId.value, + ); + if (!exists) { + options.selectedStoreId.value = options.stores.value[0]?.id ?? ''; + } + } catch (error) { + console.error(error); + message.error('加载门店失败'); + } finally { + options.isStoreLoading.value = false; + } + } + + async function loadRuleDetail() { + if (!options.selectedStoreId.value) { + resetRuleData(); + return; + } + + options.isRuleLoading.value = true; + try { + const result = await getMemberPointMallRuleDetailApi({ + storeId: options.selectedStoreId.value, + }); + + const form = mapRuleToForm({ + ...result.rule, + storeId: options.selectedStoreId.value, + }); + options.ruleForm.isConsumeRewardEnabled = form.isConsumeRewardEnabled; + options.ruleForm.consumeAmountPerStep = form.consumeAmountPerStep; + options.ruleForm.consumeRewardPointsPerStep = + form.consumeRewardPointsPerStep; + options.ruleForm.isReviewRewardEnabled = form.isReviewRewardEnabled; + options.ruleForm.reviewRewardPoints = form.reviewRewardPoints; + options.ruleForm.isRegisterRewardEnabled = form.isRegisterRewardEnabled; + options.ruleForm.registerRewardPoints = form.registerRewardPoints; + options.ruleForm.isSigninRewardEnabled = form.isSigninRewardEnabled; + options.ruleForm.signinRewardPoints = form.signinRewardPoints; + options.ruleForm.expiryMode = form.expiryMode; + options.ruleStats.value = result.stats ?? options.ruleStats.value; + } catch (error) { + console.error(error); + resetRuleData(); + message.error('加载积分规则失败'); + } finally { + options.isRuleLoading.value = false; + } + } + + async function loadProductList() { + if (!options.selectedStoreId.value) { + resetProductData(); + return; + } + + options.isProductLoading.value = true; + try { + const result = await getMemberPointMallProductListApi({ + storeId: options.selectedStoreId.value, + status: options.productFilterForm.status || undefined, + keyword: options.productFilterForm.keyword.trim() || undefined, + }); + options.productRows.value = result.items ?? []; + } catch (error) { + console.error(error); + resetProductData(); + message.error('加载兑换商品失败'); + } finally { + options.isProductLoading.value = false; + } + } + + async function loadRecordList() { + if (!options.selectedStoreId.value) { + resetRecordData(); + return; + } + + options.isRecordLoading.value = true; + try { + const query = mapRecordFilterToQuery(options.recordFilterForm); + const result = await getMemberPointMallRecordListApi({ + storeId: options.selectedStoreId.value, + page: options.recordPager.value.page, + pageSize: options.recordPager.value.pageSize, + ...query, + }); + + options.recordPager.value = { + items: result.items ?? [], + page: result.page, + pageSize: result.pageSize, + totalCount: result.totalCount, + }; + options.recordStats.value = result.stats ?? { + todayRedeemCount: 0, + pendingPhysicalCount: 0, + currentMonthUsedPoints: 0, + }; + } catch (error) { + console.error(error); + resetRecordData(); + message.error('加载兑换记录失败'); + } finally { + options.isRecordLoading.value = false; + } + } + + return { + loadProductList, + loadRecordList, + loadRuleDetail, + loadStores, + }; +} diff --git a/apps/web-antd/src/views/member/points-mall/composables/points-mall-page/drawer-actions.ts b/apps/web-antd/src/views/member/points-mall/composables/points-mall-page/drawer-actions.ts new file mode 100644 index 0000000..6283b68 --- /dev/null +++ b/apps/web-antd/src/views/member/points-mall/composables/points-mall-page/drawer-actions.ts @@ -0,0 +1,356 @@ +import type { Ref } from 'vue'; + +import type { + PointMallProductCardViewModel, + PointMallProductEditorForm, +} from '#/views/member/points-mall/types'; + +import { message } from 'ant-design-vue'; + +import { getMemberCouponPickerApi } from '#/api/member'; +import { + getMemberPointMallProductDetailApi, + saveMemberPointMallProductApi, +} from '#/api/member/points-mall'; + +import { + mapProductEditorFormToSavePayload, + mapProductToEditorForm, + resetProductEditorForm, +} from './helpers'; + +interface CouponOption { + label: string; + value: string; +} + +interface CreateDrawerActionsOptions { + canManage: Ref; + couponOptions: Ref; + drawerMode: Ref<'create' | 'edit'>; + form: PointMallProductEditorForm; + isDrawerLoading: Ref; + isDrawerOpen: Ref; + isDrawerSubmitting: Ref; + loadProductList: () => Promise; + openPicker: (currentProductId?: string) => Promise; + selectedStoreId: Ref; +} + +export function createDrawerActions(options: CreateDrawerActionsOptions) { + async function loadCouponOptions(keyword?: string) { + if (!options.selectedStoreId.value) { + options.couponOptions.value = []; + return; + } + + try { + const rows = await getMemberCouponPickerApi({ + storeId: options.selectedStoreId.value, + keyword: keyword?.trim() || undefined, + }); + options.couponOptions.value = rows.map((item) => ({ + label: item.displayText || item.name, + value: item.couponTemplateId, + })); + } catch (error) { + console.error(error); + options.couponOptions.value = []; + message.error('加载优惠券失败'); + } + } + + async function openCreateDrawer() { + if (!options.selectedStoreId.value) { + message.warning('请先选择门店'); + return; + } + + options.drawerMode.value = 'create'; + resetProductEditorForm(options.form); + options.isDrawerLoading.value = false; + options.isDrawerOpen.value = true; + await loadCouponOptions(); + } + + async function openEditDrawer(item: PointMallProductCardViewModel) { + if (!options.selectedStoreId.value) { + message.warning('请先选择门店'); + return; + } + + options.drawerMode.value = 'edit'; + options.isDrawerLoading.value = true; + options.isDrawerOpen.value = true; + + try { + const detail = await getMemberPointMallProductDetailApi({ + storeId: options.selectedStoreId.value, + pointMallProductId: item.pointMallProductId, + }); + const mapped = mapProductToEditorForm(detail); + + options.form.pointMallProductId = mapped.pointMallProductId; + options.form.redeemType = mapped.redeemType; + options.form.productId = mapped.productId; + options.form.productName = mapped.productName; + options.form.couponTemplateId = mapped.couponTemplateId; + options.form.couponTemplateName = mapped.couponTemplateName; + options.form.physicalName = mapped.physicalName; + options.form.pickupMethod = mapped.pickupMethod; + options.form.name = mapped.name; + options.form.imageUrl = mapped.imageUrl; + options.form.exchangeType = mapped.exchangeType; + options.form.requiredPoints = mapped.requiredPoints; + options.form.cashAmount = mapped.cashAmount; + options.form.stockTotal = mapped.stockTotal; + options.form.perMemberLimit = mapped.perMemberLimit; + options.form.description = mapped.description; + options.form.notifyChannels = [...mapped.notifyChannels]; + options.form.status = mapped.status; + + if (detail.redeemType === 'coupon') { + await loadCouponOptions(); + const selected = options.couponOptions.value.find( + (row) => row.value === detail.couponTemplateId, + ); + options.form.couponTemplateName = selected?.label ?? ''; + } + } catch (error) { + console.error(error); + message.error('加载兑换商品详情失败'); + options.isDrawerOpen.value = false; + } finally { + options.isDrawerLoading.value = false; + } + } + + function setDrawerOpen(value: boolean) { + options.isDrawerOpen.value = value; + if (!value) { + options.isDrawerLoading.value = false; + options.isDrawerSubmitting.value = false; + } + } + + function setFormName(value: string) { + options.form.name = value; + } + + function setFormImageUrl(value: string) { + options.form.imageUrl = value; + } + + function setFormRedeemType(value: PointMallProductEditorForm['redeemType']) { + options.form.redeemType = value; + + if (value !== 'product') { + options.form.productId = ''; + options.form.productName = ''; + } + if (value !== 'coupon') { + options.form.couponTemplateId = ''; + options.form.couponTemplateName = ''; + } + if (value !== 'physical') { + options.form.physicalName = ''; + options.form.pickupMethod = 'store_pickup'; + } + + if (value === 'physical') { + options.form.notifyChannels = ['in_app', 'sms']; + return; + } + + if (!options.form.notifyChannels.includes('in_app')) { + options.form.notifyChannels = ['in_app']; + } + } + + function setFormProduct(value: { id: string; name: string }) { + options.form.productId = value.id; + options.form.productName = value.name; + } + + function setFormCouponTemplateId(value: string) { + options.form.couponTemplateId = value; + const selected = options.couponOptions.value.find( + (item) => item.value === value, + ); + options.form.couponTemplateName = selected?.label ?? ''; + } + + function setFormPhysicalName(value: string) { + options.form.physicalName = value; + } + + function setFormPickupMethod( + value: PointMallProductEditorForm['pickupMethod'], + ) { + options.form.pickupMethod = value; + } + + function setFormDescription(value: string) { + options.form.description = value; + } + + function setFormExchangeType( + value: PointMallProductEditorForm['exchangeType'], + ) { + options.form.exchangeType = value; + if (value === 'points') { + options.form.cashAmount = null; + } + } + + function setFormRequiredPoints(value: null | number) { + options.form.requiredPoints = value; + } + + function setFormCashAmount(value: null | number) { + options.form.cashAmount = value; + } + + function setFormStockTotal(value: null | number) { + options.form.stockTotal = value; + } + + function setFormPerMemberLimit(value: null | number) { + options.form.perMemberLimit = value; + } + + function setFormStatus(value: PointMallProductEditorForm['status']) { + options.form.status = value; + } + + function toggleNotifyChannel(value: 'in_app' | 'sms') { + if (options.form.notifyChannels.includes(value)) { + if (options.form.notifyChannels.length === 1) { + return; + } + options.form.notifyChannels = options.form.notifyChannels.filter( + (item) => item !== value, + ); + return; + } + + options.form.notifyChannels = [...options.form.notifyChannels, value]; + } + + async function openProductPicker() { + await options.openPicker(options.form.productId || undefined); + } + + function validateForm() { + if (!options.form.name.trim()) { + message.warning('请填写展示名称'); + return false; + } + + if (!options.form.requiredPoints || options.form.requiredPoints <= 0) { + message.warning('所需积分必须大于 0'); + return false; + } + + if (options.form.stockTotal === null || options.form.stockTotal < 0) { + message.warning('库存数量不能小于 0'); + return false; + } + + if (options.form.redeemType === 'product' && !options.form.productId) { + message.warning('请选择关联商品'); + return false; + } + + if ( + options.form.redeemType === 'coupon' && + !options.form.couponTemplateId.trim() + ) { + message.warning('请选择关联优惠券'); + return false; + } + + if ( + options.form.redeemType === 'physical' && + !options.form.physicalName.trim() + ) { + message.warning('请填写实物名称'); + return false; + } + + if ( + options.form.exchangeType === 'mixed' && + (!options.form.cashAmount || options.form.cashAmount <= 0) + ) { + message.warning('积分+现金模式下,现金部分必须大于 0'); + return false; + } + + if (options.form.notifyChannels.length === 0) { + message.warning('请至少选择一种通知方式'); + return false; + } + + return true; + } + + async function submitDrawer() { + if (!options.canManage.value) { + return; + } + + if (!options.selectedStoreId.value) { + message.warning('请先选择门店'); + return; + } + + if (!validateForm()) { + return; + } + + options.isDrawerSubmitting.value = true; + try { + await saveMemberPointMallProductApi( + mapProductEditorFormToSavePayload( + options.form, + options.selectedStoreId.value, + ), + ); + + message.success( + options.drawerMode.value === 'create' ? '添加成功' : '保存成功', + ); + options.isDrawerOpen.value = false; + await options.loadProductList(); + } catch (error) { + console.error(error); + message.error('保存失败'); + } finally { + options.isDrawerSubmitting.value = false; + } + } + + return { + loadCouponOptions, + openCreateDrawer, + openEditDrawer, + openProductPicker, + setDrawerOpen, + setFormCashAmount, + setFormCouponTemplateId, + setFormDescription, + setFormExchangeType, + setFormImageUrl, + setFormName, + setFormPerMemberLimit, + setFormPhysicalName, + setFormPickupMethod, + setFormProduct, + setFormRedeemType, + setFormRequiredPoints, + setFormStatus, + setFormStockTotal, + submitDrawer, + toggleNotifyChannel, + }; +} diff --git a/apps/web-antd/src/views/member/points-mall/composables/points-mall-page/helpers.ts b/apps/web-antd/src/views/member/points-mall/composables/points-mall-page/helpers.ts new file mode 100644 index 0000000..e539800 --- /dev/null +++ b/apps/web-antd/src/views/member/points-mall/composables/points-mall-page/helpers.ts @@ -0,0 +1,234 @@ +import type { + ExportMemberPointMallRecordQuery, + MemberPointMallProductDto, + MemberPointMallRecordListQuery, + SaveMemberPointMallProductPayload, + SaveMemberPointMallRulePayload, +} from '#/api/member/points-mall'; +import type { + PointMallProductEditorForm, + PointMallRecordFilterForm, + PointMallRuleForm, +} from '#/views/member/points-mall/types'; + +import { + createDefaultPointMallProductEditorForm, + createDefaultPointMallRuleForm, +} from '#/views/member/points-mall/types'; + +/** 数量格式化。 */ +export function formatInteger(value: null | number | undefined) { + const num = Number(value ?? 0); + if (!Number.isFinite(num)) { + return '0'; + } + return Math.round(num).toLocaleString('zh-CN'); +} + +/** 积分格式化。 */ +export function formatPoints(value: null | number | undefined) { + return formatInteger(value); +} + +/** 金额格式化。 */ +export function formatCurrency(value: null | number | undefined) { + const amount = Number(value ?? 0); + if (Number.isNaN(amount)) { + return '¥0.00'; + } + return `¥${amount.toFixed(2)}`; +} + +/** 判断字符串是否为空。 */ +export function isBlank(value: string | undefined) { + return !value || value.trim().length === 0; +} + +/** 规则 DTO -> 表单。 */ +export function mapRuleToForm( + rule: SaveMemberPointMallRulePayload, +): PointMallRuleForm { + return { + isConsumeRewardEnabled: Boolean(rule.isConsumeRewardEnabled), + consumeAmountPerStep: Number(rule.consumeAmountPerStep), + consumeRewardPointsPerStep: Number(rule.consumeRewardPointsPerStep), + isReviewRewardEnabled: Boolean(rule.isReviewRewardEnabled), + reviewRewardPoints: Number(rule.reviewRewardPoints), + isRegisterRewardEnabled: Boolean(rule.isRegisterRewardEnabled), + registerRewardPoints: Number(rule.registerRewardPoints), + isSigninRewardEnabled: Boolean(rule.isSigninRewardEnabled), + signinRewardPoints: Number(rule.signinRewardPoints), + expiryMode: rule.expiryMode, + }; +} + +/** 规则表单 -> 保存 DTO。 */ +export function mapRuleFormToSavePayload( + form: PointMallRuleForm, + storeId: string, +): SaveMemberPointMallRulePayload { + return { + storeId, + isConsumeRewardEnabled: form.isConsumeRewardEnabled, + consumeAmountPerStep: Number(form.consumeAmountPerStep ?? 0), + consumeRewardPointsPerStep: Number(form.consumeRewardPointsPerStep ?? 0), + isReviewRewardEnabled: form.isReviewRewardEnabled, + reviewRewardPoints: Number(form.reviewRewardPoints ?? 0), + isRegisterRewardEnabled: form.isRegisterRewardEnabled, + registerRewardPoints: Number(form.registerRewardPoints ?? 0), + isSigninRewardEnabled: form.isSigninRewardEnabled, + signinRewardPoints: Number(form.signinRewardPoints ?? 0), + expiryMode: form.expiryMode, + }; +} + +/** 重置规则表单。 */ +export function resetRuleForm(form: PointMallRuleForm) { + const defaults = createDefaultPointMallRuleForm(); + form.isConsumeRewardEnabled = defaults.isConsumeRewardEnabled; + form.consumeAmountPerStep = defaults.consumeAmountPerStep; + form.consumeRewardPointsPerStep = defaults.consumeRewardPointsPerStep; + form.isReviewRewardEnabled = defaults.isReviewRewardEnabled; + form.reviewRewardPoints = defaults.reviewRewardPoints; + form.isRegisterRewardEnabled = defaults.isRegisterRewardEnabled; + form.registerRewardPoints = defaults.registerRewardPoints; + form.isSigninRewardEnabled = defaults.isSigninRewardEnabled; + form.signinRewardPoints = defaults.signinRewardPoints; + form.expiryMode = defaults.expiryMode; +} + +/** 商品 DTO -> 编辑表单。 */ +export function mapProductToEditorForm( + source: MemberPointMallProductDto, +): PointMallProductEditorForm { + return { + pointMallProductId: source.pointMallProductId, + redeemType: source.redeemType, + productId: source.productId ?? '', + productName: '', + couponTemplateId: source.couponTemplateId ?? '', + couponTemplateName: '', + physicalName: source.physicalName ?? '', + pickupMethod: source.pickupMethod ?? 'store_pickup', + name: source.name, + imageUrl: source.imageUrl ?? '', + exchangeType: source.exchangeType, + requiredPoints: source.requiredPoints, + cashAmount: source.exchangeType === 'mixed' ? source.cashAmount : null, + stockTotal: source.stockTotal, + perMemberLimit: source.perMemberLimit ?? null, + description: source.description ?? '', + notifyChannels: [...source.notifyChannels], + status: source.status, + }; +} + +/** 商品表单 -> 保存 DTO。 */ +export function mapProductEditorFormToSavePayload( + form: PointMallProductEditorForm, + storeId: string, +): SaveMemberPointMallProductPayload { + return { + storeId, + pointMallProductId: form.pointMallProductId || undefined, + name: form.name.trim(), + imageUrl: form.imageUrl.trim() || undefined, + redeemType: form.redeemType, + productId: form.productId || undefined, + couponTemplateId: form.couponTemplateId || undefined, + physicalName: form.physicalName.trim() || undefined, + pickupMethod: + form.redeemType === 'physical' ? form.pickupMethod : undefined, + description: form.description.trim() || undefined, + exchangeType: form.exchangeType, + requiredPoints: Number(form.requiredPoints ?? 0), + cashAmount: Number(form.cashAmount ?? 0), + stockTotal: Number(form.stockTotal ?? 0), + perMemberLimit: + form.perMemberLimit && form.perMemberLimit > 0 + ? Number(form.perMemberLimit) + : undefined, + notifyChannels: [...form.notifyChannels], + status: form.status, + }; +} + +/** 重置商品编辑表单。 */ +export function resetProductEditorForm(form: PointMallProductEditorForm) { + const defaults = createDefaultPointMallProductEditorForm(); + form.pointMallProductId = defaults.pointMallProductId; + form.redeemType = defaults.redeemType; + form.productId = defaults.productId; + form.productName = defaults.productName; + form.couponTemplateId = defaults.couponTemplateId; + form.couponTemplateName = defaults.couponTemplateName; + form.physicalName = defaults.physicalName; + form.pickupMethod = defaults.pickupMethod; + form.name = defaults.name; + form.imageUrl = defaults.imageUrl; + form.exchangeType = defaults.exchangeType; + form.requiredPoints = defaults.requiredPoints; + form.cashAmount = defaults.cashAmount; + form.stockTotal = defaults.stockTotal; + form.perMemberLimit = defaults.perMemberLimit; + form.description = defaults.description; + form.notifyChannels = [...defaults.notifyChannels]; + form.status = defaults.status; +} + +/** 记录筛选项映射。 */ +export function mapRecordFilterToQuery( + filterForm: PointMallRecordFilterForm, +): Pick< + ExportMemberPointMallRecordQuery & MemberPointMallRecordListQuery, + 'endDate' | 'keyword' | 'redeemType' | 'startDate' | 'status' +> { + const query: Pick< + ExportMemberPointMallRecordQuery & MemberPointMallRecordListQuery, + 'endDate' | 'keyword' | 'redeemType' | 'startDate' | 'status' + > = {}; + + if (filterForm.redeemType) { + query.redeemType = filterForm.redeemType; + } + + if (filterForm.status) { + query.status = filterForm.status; + } + + const keyword = filterForm.keyword.trim(); + if (keyword) { + query.keyword = keyword; + } + + if (filterForm.dateRange && filterForm.dateRange.length === 2) { + query.startDate = filterForm.dateRange[0].format('YYYY-MM-DD'); + query.endDate = filterForm.dateRange[1].format('YYYY-MM-DD'); + } + + return query; +} + +/** base64 下载。 */ +export function downloadBase64File( + fileName: string, + fileContentBase64: string, +) { + const binary = atob(fileContentBase64); + const length = binary.length; + const bytes = new Uint8Array(length); + + for (let index = 0; index < length; index++) { + bytes[index] = binary.codePointAt(index) ?? 0; + } + + const blob = new Blob([bytes], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = fileName; + document.body.append(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); +} diff --git a/apps/web-antd/src/views/member/points-mall/composables/points-mall-page/picker-actions.ts b/apps/web-antd/src/views/member/points-mall/composables/points-mall-page/picker-actions.ts new file mode 100644 index 0000000..ef81765 --- /dev/null +++ b/apps/web-antd/src/views/member/points-mall/composables/points-mall-page/picker-actions.ts @@ -0,0 +1,83 @@ +import type { Ref } from 'vue'; + +import type { ProductPickerItemDto } from '#/api/product'; + +import { message } from 'ant-design-vue'; + +import { searchProductPickerApi } from '#/api/product'; + +interface CreatePickerActionsOptions { + isPickerLoading: Ref; + isPickerOpen: Ref; + onPicked: (item: ProductPickerItemDto | undefined) => void; + pickerKeyword: Ref; + pickerRows: Ref; + pickerSelectedId: Ref; + selectedStoreId: Ref; +} + +export function createPickerActions(options: CreatePickerActionsOptions) { + async function loadPickerProducts() { + if (!options.selectedStoreId.value) { + options.pickerRows.value = []; + return; + } + + options.isPickerLoading.value = true; + try { + options.pickerRows.value = await searchProductPickerApi({ + storeId: options.selectedStoreId.value, + keyword: options.pickerKeyword.value.trim() || undefined, + limit: 500, + }); + } catch (error) { + console.error(error); + options.pickerRows.value = []; + message.error('加载商品失败'); + } finally { + options.isPickerLoading.value = false; + } + } + + async function openPicker(currentProductId?: string) { + options.pickerKeyword.value = ''; + options.pickerSelectedId.value = currentProductId ?? ''; + options.pickerRows.value = []; + options.isPickerOpen.value = true; + await loadPickerProducts(); + } + + function setPickerOpen(value: boolean) { + options.isPickerOpen.value = value; + } + + function setPickerKeyword(value: string) { + options.pickerKeyword.value = value; + } + + function setPickerSelectedId(value: string) { + options.pickerSelectedId.value = value; + } + + function submitPicker() { + if (!options.pickerSelectedId.value) { + message.warning('请选择商品'); + return; + } + + const selected = options.pickerRows.value.find( + (item) => item.id === options.pickerSelectedId.value, + ); + options.onPicked(selected); + options.isPickerOpen.value = false; + } + + return { + loadPickerProducts, + openPicker, + setPickerKeyword, + setPickerOpen, + setPickerSelectedId, + submitPicker, + }; +} diff --git a/apps/web-antd/src/views/member/points-mall/composables/points-mall-page/product-actions.ts b/apps/web-antd/src/views/member/points-mall/composables/points-mall-page/product-actions.ts new file mode 100644 index 0000000..d1c998b --- /dev/null +++ b/apps/web-antd/src/views/member/points-mall/composables/points-mall-page/product-actions.ts @@ -0,0 +1,80 @@ +import type { Ref } from 'vue'; + +import type { PointMallProductCardViewModel } from '#/views/member/points-mall/types'; + +import { message, Modal } from 'ant-design-vue'; + +import { + changeMemberPointMallProductStatusApi, + deleteMemberPointMallProductApi, +} from '#/api/member/points-mall'; + +interface CreateProductActionsOptions { + canManage: Ref; + loadProductList: () => Promise; + selectedStoreId: Ref; +} + +export function createProductActions(options: CreateProductActionsOptions) { + async function toggleStatus(item: PointMallProductCardViewModel) { + if (!options.canManage.value) { + return; + } + + if (!options.selectedStoreId.value) { + message.warning('请先选择门店'); + return; + } + + const targetStatus = item.status === 'enabled' ? 'disabled' : 'enabled'; + try { + await changeMemberPointMallProductStatusApi({ + storeId: options.selectedStoreId.value, + pointMallProductId: item.pointMallProductId, + status: targetStatus, + }); + message.success(targetStatus === 'enabled' ? '商品已上架' : '商品已下架'); + await options.loadProductList(); + } catch (error) { + console.error(error); + message.error('状态更新失败'); + } + } + + function removeProduct(item: PointMallProductCardViewModel) { + if (!options.canManage.value) { + return; + } + + if (!options.selectedStoreId.value) { + message.warning('请先选择门店'); + return; + } + + Modal.confirm({ + title: '删除兑换商品', + content: `确认删除「${item.name}」吗?删除后不可恢复。`, + okText: '删除', + okType: 'danger', + cancelText: '取消', + async onOk() { + try { + await deleteMemberPointMallProductApi({ + storeId: options.selectedStoreId.value, + pointMallProductId: item.pointMallProductId, + }); + message.success('删除成功'); + await options.loadProductList(); + } catch (error) { + console.error(error); + message.error('删除失败'); + } + }, + }); + } + + return { + removeProduct, + toggleStatus, + }; +} diff --git a/apps/web-antd/src/views/member/points-mall/composables/points-mall-page/record-actions.ts b/apps/web-antd/src/views/member/points-mall/composables/points-mall-page/record-actions.ts new file mode 100644 index 0000000..2315188 --- /dev/null +++ b/apps/web-antd/src/views/member/points-mall/composables/points-mall-page/record-actions.ts @@ -0,0 +1,158 @@ +import type { Ref } from 'vue'; + +import type { + PointMallRecordDetailViewModel, + PointMallVerifyForm, +} from '#/views/member/points-mall/types'; + +import { message } from 'ant-design-vue'; + +import { + getMemberPointMallRecordDetailApi, + verifyMemberPointMallRecordApi, +} from '#/api/member/points-mall'; + +interface CreateRecordActionsOptions { + canManage: Ref; + detailRecord: Ref; + isDetailLoading: Ref; + isDetailOpen: Ref; + isVerifyLoading: Ref; + isVerifyOpen: Ref; + isVerifySubmitting: Ref; + loadRecordList: () => Promise; + selectedRecordId: Ref; + selectedStoreId: Ref; + verifyForm: PointMallVerifyForm; +} + +export function createRecordActions(options: CreateRecordActionsOptions) { + async function loadRecordDetail(recordId: string) { + if (!options.selectedStoreId.value || !recordId) { + options.detailRecord.value = null; + return; + } + + const detail = await getMemberPointMallRecordDetailApi({ + storeId: options.selectedStoreId.value, + recordId, + }); + options.detailRecord.value = detail; + } + + async function openDetailDrawer(recordId: string) { + if (!options.selectedStoreId.value) { + message.warning('请先选择门店'); + return; + } + + options.selectedRecordId.value = recordId; + options.isDetailLoading.value = true; + options.isDetailOpen.value = true; + + try { + await loadRecordDetail(recordId); + } catch (error) { + console.error(error); + options.isDetailOpen.value = false; + message.error('加载记录详情失败'); + } finally { + options.isDetailLoading.value = false; + } + } + + async function openVerifyDrawer(recordId: string) { + if (!options.canManage.value) { + return; + } + + if (!options.selectedStoreId.value) { + message.warning('请先选择门店'); + return; + } + + options.selectedRecordId.value = recordId; + options.isVerifyLoading.value = true; + options.isVerifyOpen.value = true; + options.verifyForm.verifyMethod = 'manual'; + options.verifyForm.verifyRemark = ''; + + try { + await loadRecordDetail(recordId); + } catch (error) { + console.error(error); + options.isVerifyOpen.value = false; + message.error('加载核销信息失败'); + } finally { + options.isVerifyLoading.value = false; + } + } + + function setDetailOpen(value: boolean) { + options.isDetailOpen.value = value; + if (!value) { + options.isDetailLoading.value = false; + } + } + + function setVerifyOpen(value: boolean) { + options.isVerifyOpen.value = value; + if (!value) { + options.isVerifyLoading.value = false; + options.isVerifySubmitting.value = false; + } + } + + function setVerifyMethod(value: PointMallVerifyForm['verifyMethod']) { + options.verifyForm.verifyMethod = value; + } + + function setVerifyRemark(value: string) { + options.verifyForm.verifyRemark = value; + } + + async function submitVerify() { + if (!options.canManage.value) { + return; + } + + if (!options.selectedStoreId.value) { + message.warning('请先选择门店'); + return; + } + + if (!options.selectedRecordId.value) { + message.warning('请选择需要核销的记录'); + return; + } + + options.isVerifySubmitting.value = true; + try { + await verifyMemberPointMallRecordApi({ + storeId: options.selectedStoreId.value, + recordId: options.selectedRecordId.value, + verifyMethod: options.verifyForm.verifyMethod, + verifyRemark: options.verifyForm.verifyRemark.trim() || undefined, + }); + message.success('核销成功'); + options.isVerifyOpen.value = false; + await options.loadRecordList(); + await loadRecordDetail(options.selectedRecordId.value); + } catch (error) { + console.error(error); + message.error('核销失败'); + } finally { + options.isVerifySubmitting.value = false; + } + } + + return { + openDetailDrawer, + openVerifyDrawer, + setDetailOpen, + setVerifyMethod, + setVerifyOpen, + setVerifyRemark, + submitVerify, + }; +} diff --git a/apps/web-antd/src/views/member/points-mall/composables/useMemberPointsMallPage.ts b/apps/web-antd/src/views/member/points-mall/composables/useMemberPointsMallPage.ts new file mode 100644 index 0000000..ad6513c --- /dev/null +++ b/apps/web-antd/src/views/member/points-mall/composables/useMemberPointsMallPage.ts @@ -0,0 +1,587 @@ +import type { Dayjs } from 'dayjs'; + +import type { ProductPickerItemDto } from '#/api/product'; +import type { StoreListItemDto } from '#/api/store'; +import type { + PointMallProductCardViewModel, + PointMallRecordDetailViewModel, + PointMallTabKey, +} from '#/views/member/points-mall/types'; + +import { computed, onMounted, reactive, ref, watch } from 'vue'; + +import { useAccessStore } from '@vben/stores'; + +import { message } from 'ant-design-vue'; + +import { + exportMemberPointMallRecordApi, + saveMemberPointMallRuleApi, +} from '#/api/member/points-mall'; + +import { + createDefaultPointMallProductEditorForm, + createDefaultPointMallProductFilterForm, + createDefaultPointMallRecordFilterForm, + createDefaultPointMallRecordPager, + createDefaultPointMallRecordStats, + createDefaultPointMallRuleForm, + createDefaultPointMallRuleStats, + createDefaultPointMallVerifyForm, +} from '../types'; +import { + MEMBER_POINTS_MALL_MANAGE_PERMISSION, + MEMBER_POINTS_MALL_VIEW_PERMISSION, + POINTS_MALL_TAB_OPTIONS, +} from './points-mall-page/constants'; +import { createDataActions } from './points-mall-page/data-actions'; +import { createDrawerActions } from './points-mall-page/drawer-actions'; +import { + downloadBase64File, + mapRecordFilterToQuery, + mapRuleFormToSavePayload, + resetRuleForm, +} from './points-mall-page/helpers'; +import { createPickerActions } from './points-mall-page/picker-actions'; +import { createProductActions } from './points-mall-page/product-actions'; +import { createRecordActions } from './points-mall-page/record-actions'; + +export function useMemberPointsMallPage() { + const accessStore = useAccessStore(); + + const stores = ref([]); + const selectedStoreId = ref(''); + const isStoreLoading = ref(false); + + const activeTab = ref('rules'); + + const ruleForm = reactive(createDefaultPointMallRuleForm()); + const ruleStats = ref(createDefaultPointMallRuleStats()); + const isRuleLoading = ref(false); + const isRuleSubmitting = ref(false); + + const productFilterForm = reactive(createDefaultPointMallProductFilterForm()); + const productRows = ref([]); + const isProductLoading = ref(false); + + const drawerMode = ref<'create' | 'edit'>('create'); + const productForm = reactive(createDefaultPointMallProductEditorForm()); + const isDrawerOpen = ref(false); + const isDrawerLoading = ref(false); + const isDrawerSubmitting = ref(false); + const couponOptions = ref>([]); + + const isPickerOpen = ref(false); + const isPickerLoading = ref(false); + const pickerKeyword = ref(''); + const pickerRows = ref([]); + const pickerSelectedId = ref(''); + + const recordFilterForm = reactive(createDefaultPointMallRecordFilterForm()); + const recordPager = ref(createDefaultPointMallRecordPager()); + const recordStats = ref(createDefaultPointMallRecordStats()); + const isRecordLoading = ref(false); + const isExporting = ref(false); + + const selectedRecordId = ref(''); + const detailRecord = ref(null); + const isDetailOpen = ref(false); + const isDetailLoading = ref(false); + const verifyForm = reactive(createDefaultPointMallVerifyForm()); + const isVerifyOpen = ref(false); + const isVerifyLoading = ref(false); + const isVerifySubmitting = ref(false); + + const accessCodeSet = computed( + () => new Set((accessStore.accessCodes ?? []).map(String)), + ); + const canManage = computed(() => + accessCodeSet.value.has(MEMBER_POINTS_MALL_MANAGE_PERMISSION), + ); + const canView = computed( + () => + canManage.value || + accessCodeSet.value.has(MEMBER_POINTS_MALL_VIEW_PERMISSION), + ); + + const hasStore = computed(() => stores.value.length > 0); + const storeOptions = computed(() => + stores.value.map((item) => ({ + label: item.name, + value: item.id, + })), + ); + + const drawerTitle = computed(() => + drawerMode.value === 'create' ? '添加兑换商品' : '编辑兑换商品', + ); + const drawerSubmitText = computed(() => '保存'); + + const { loadStores, loadRuleDetail, loadProductList, loadRecordList } = + createDataActions({ + stores, + selectedStoreId, + isStoreLoading, + ruleForm, + ruleStats, + isRuleLoading, + productFilterForm, + productRows, + isProductLoading, + recordFilterForm, + recordPager, + recordStats, + isRecordLoading, + }); + + const { + loadPickerProducts, + openPicker, + setPickerKeyword, + setPickerOpen, + setPickerSelectedId, + submitPicker, + } = createPickerActions({ + isPickerOpen, + isPickerLoading, + pickerKeyword, + pickerRows, + pickerSelectedId, + selectedStoreId, + onPicked(item) { + if (!item) { + return; + } + productForm.productId = item.id; + productForm.productName = item.name; + if (!productForm.name.trim()) { + productForm.name = item.name; + } + }, + }); + + const { + loadCouponOptions, + openCreateDrawer, + openEditDrawer, + openProductPicker, + setDrawerOpen, + setFormCashAmount, + setFormCouponTemplateId, + setFormDescription, + setFormExchangeType, + setFormImageUrl, + setFormName, + setFormPerMemberLimit, + setFormPhysicalName, + setFormPickupMethod, + setFormRedeemType, + setFormRequiredPoints, + setFormStatus, + setFormStockTotal, + submitDrawer, + toggleNotifyChannel, + } = createDrawerActions({ + canManage, + drawerMode, + form: productForm, + isDrawerLoading, + isDrawerOpen, + isDrawerSubmitting, + selectedStoreId, + couponOptions, + loadProductList, + openPicker, + }); + + const { removeProduct, toggleStatus } = createProductActions({ + canManage, + selectedStoreId, + loadProductList, + }); + + const { + openDetailDrawer, + openVerifyDrawer, + setDetailOpen, + setVerifyMethod, + setVerifyOpen, + setVerifyRemark, + submitVerify, + } = createRecordActions({ + canManage, + selectedStoreId, + selectedRecordId, + detailRecord, + isDetailLoading, + isDetailOpen, + verifyForm, + isVerifyLoading, + isVerifyOpen, + isVerifySubmitting, + loadRecordList, + }); + + function setSelectedStoreId(value: string) { + selectedStoreId.value = value; + } + + function setActiveTab(value: PointMallTabKey) { + activeTab.value = value; + } + + function setRuleConsumeRewardEnabled(value: boolean) { + ruleForm.isConsumeRewardEnabled = value; + } + + function setRuleConsumeAmountPerStep(value: null | number) { + ruleForm.consumeAmountPerStep = value; + } + + function setRuleConsumeRewardPointsPerStep(value: null | number) { + ruleForm.consumeRewardPointsPerStep = value; + } + + function setRuleReviewRewardEnabled(value: boolean) { + ruleForm.isReviewRewardEnabled = value; + } + + function setRuleReviewRewardPoints(value: null | number) { + ruleForm.reviewRewardPoints = value; + } + + function setRuleRegisterRewardEnabled(value: boolean) { + ruleForm.isRegisterRewardEnabled = value; + } + + function setRuleRegisterRewardPoints(value: null | number) { + ruleForm.registerRewardPoints = value; + } + + function setRuleSigninRewardEnabled(value: boolean) { + ruleForm.isSigninRewardEnabled = value; + } + + function setRuleSigninRewardPoints(value: null | number) { + ruleForm.signinRewardPoints = value; + } + + function setRuleExpiryMode(value: 'permanent' | 'yearly_clear') { + ruleForm.expiryMode = value; + } + + function validateRuleForm() { + if ( + ruleForm.isConsumeRewardEnabled && + (!ruleForm.consumeAmountPerStep || ruleForm.consumeAmountPerStep <= 0) + ) { + message.warning('消费获取规则中“每消费金额”必须大于 0'); + return false; + } + + if ( + ruleForm.isConsumeRewardEnabled && + (!ruleForm.consumeRewardPointsPerStep || + ruleForm.consumeRewardPointsPerStep <= 0) + ) { + message.warning('消费获取规则中“每步积分”必须大于 0'); + return false; + } + + if ( + ruleForm.isReviewRewardEnabled && + (!ruleForm.reviewRewardPoints || ruleForm.reviewRewardPoints <= 0) + ) { + message.warning('评价奖励积分必须大于 0'); + return false; + } + + if ( + ruleForm.isRegisterRewardEnabled && + (!ruleForm.registerRewardPoints || ruleForm.registerRewardPoints <= 0) + ) { + message.warning('注册奖励积分必须大于 0'); + return false; + } + + if ( + ruleForm.isSigninRewardEnabled && + (!ruleForm.signinRewardPoints || ruleForm.signinRewardPoints <= 0) + ) { + message.warning('签到奖励积分必须大于 0'); + return false; + } + + return true; + } + + async function saveRule() { + if (!canManage.value) { + return; + } + + if (!selectedStoreId.value) { + message.warning('请先选择门店'); + return; + } + + if (!validateRuleForm()) { + return; + } + + isRuleSubmitting.value = true; + try { + await saveMemberPointMallRuleApi( + mapRuleFormToSavePayload(ruleForm, selectedStoreId.value), + ); + message.success('规则已保存'); + await loadRuleDetail(); + } catch (error) { + console.error(error); + message.error('保存规则失败'); + } finally { + isRuleSubmitting.value = false; + } + } + + function setProductKeyword(value: string) { + productFilterForm.keyword = value; + } + + function setProductStatusFilter(value: '' | 'disabled' | 'enabled') { + productFilterForm.status = value; + } + + async function applyProductFilters() { + await loadProductList(); + } + + async function resetProductFilters() { + productFilterForm.status = ''; + productFilterForm.keyword = ''; + await loadProductList(); + } + + function setRecordRedeemType(value: '' | 'coupon' | 'physical' | 'product') { + recordFilterForm.redeemType = value; + } + + function setRecordStatus( + value: '' | 'canceled' | 'completed' | 'issued' | 'pending_pickup', + ) { + recordFilterForm.status = value; + } + + function setRecordKeyword(value: string) { + recordFilterForm.keyword = value; + } + + function setRecordDateRange(value: [Dayjs, Dayjs] | null) { + recordFilterForm.dateRange = value ?? null; + } + + async function applyRecordFilters() { + recordPager.value = { + ...recordPager.value, + page: 1, + }; + await loadRecordList(); + } + + async function resetRecordFilters() { + recordFilterForm.redeemType = ''; + recordFilterForm.status = ''; + recordFilterForm.keyword = ''; + recordFilterForm.dateRange = null; + recordPager.value = { + ...recordPager.value, + page: 1, + }; + await loadRecordList(); + } + + async function handleRecordPageChange(page: number, pageSize: number) { + recordPager.value = { + ...recordPager.value, + page, + pageSize, + }; + await loadRecordList(); + } + + async function exportRecords() { + if (!selectedStoreId.value) { + message.warning('请先选择门店'); + return; + } + + isExporting.value = true; + try { + const query = mapRecordFilterToQuery(recordFilterForm); + const result = await exportMemberPointMallRecordApi({ + storeId: selectedStoreId.value, + ...query, + }); + downloadBase64File(result.fileName, result.fileContentBase64); + message.success(`导出成功,共 ${result.totalCount} 条`); + } catch (error) { + console.error(error); + message.error('导出失败'); + } finally { + isExporting.value = false; + } + } + + function openEditProduct(item: PointMallProductCardViewModel) { + void openEditDrawer(item); + } + + function openCreateProduct() { + void openCreateDrawer(); + } + + function openProductPickerDrawer() { + void openProductPicker(); + } + + function onPickedProductSelected(id: string) { + setPickerSelectedId(id); + } + + function submitProductPicker() { + submitPicker(); + } + + function searchPickerProducts() { + void loadPickerProducts(); + } + + function searchCouponOptions(keyword: string) { + void loadCouponOptions(keyword); + } + + watch(selectedStoreId, async () => { + recordPager.value = { + ...recordPager.value, + page: 1, + pageSize: 10, + }; + await Promise.all([loadRuleDetail(), loadProductList(), loadRecordList()]); + await loadCouponOptions(); + }); + + onMounted(async () => { + await loadStores(); + if (selectedStoreId.value) { + await Promise.all([ + loadRuleDetail(), + loadProductList(), + loadRecordList(), + ]); + await loadCouponOptions(); + } else { + resetRuleForm(ruleForm); + } + }); + + return { + activeTab, + applyProductFilters, + applyRecordFilters, + canManage, + canView, + couponOptions, + detailRecord, + drawerSubmitText, + drawerTitle, + exportRecords, + hasStore, + handleRecordPageChange, + isDetailLoading, + isDetailOpen, + isDrawerLoading, + isDrawerOpen, + isDrawerSubmitting, + isExporting, + isPickerLoading, + isPickerOpen, + isProductLoading, + isRecordLoading, + isRuleLoading, + isRuleSubmitting, + isStoreLoading, + isVerifyLoading, + isVerifyOpen, + isVerifySubmitting, + openCreateProduct, + openDetailDrawer, + openEditProduct, + openProductPickerDrawer, + openVerifyDrawer, + pickerKeyword, + pickerRows, + pickerSelectedId, + productFilterForm, + productForm, + productRows, + recordFilterForm, + recordPager, + recordStats, + removeProduct, + resetProductFilters, + resetRecordFilters, + ruleForm, + ruleStats, + saveRule, + selectedStoreId, + setActiveTab, + setDetailOpen, + setDrawerOpen, + setFormCashAmount, + setFormCouponTemplateId, + setFormDescription, + setFormExchangeType, + setFormImageUrl, + setFormName, + setFormPerMemberLimit, + setFormPhysicalName, + setFormPickupMethod, + setFormRedeemType, + setFormRequiredPoints, + setFormStatus, + setFormStockTotal, + setPickedProductId: onPickedProductSelected, + setPickerKeyword, + setPickerOpen, + setProductKeyword, + setProductStatusFilter, + setRecordDateRange, + setRecordKeyword, + setRecordRedeemType, + setRecordStatus, + setRuleConsumeAmountPerStep, + setRuleConsumeRewardEnabled, + setRuleConsumeRewardPointsPerStep, + setRuleExpiryMode, + setRuleRegisterRewardEnabled, + setRuleRegisterRewardPoints, + setRuleReviewRewardEnabled, + setRuleReviewRewardPoints, + setRuleSigninRewardEnabled, + setRuleSigninRewardPoints, + setSelectedStoreId, + searchCouponOptions, + searchPickerProducts, + setVerifyMethod, + setVerifyOpen, + setVerifyRemark, + storeOptions, + submitDrawer, + submitProductPicker, + submitVerify, + tabOptions: POINTS_MALL_TAB_OPTIONS, + toggleNotifyChannel, + toggleStatus, + verifyForm, + }; +} diff --git a/apps/web-antd/src/views/member/points-mall/index.vue b/apps/web-antd/src/views/member/points-mall/index.vue new file mode 100644 index 0000000..d71ab1f --- /dev/null +++ b/apps/web-antd/src/views/member/points-mall/index.vue @@ -0,0 +1,309 @@ + + + + + diff --git a/apps/web-antd/src/views/member/points-mall/styles/base.less b/apps/web-antd/src/views/member/points-mall/styles/base.less new file mode 100644 index 0000000..e0581c8 --- /dev/null +++ b/apps/web-antd/src/views/member/points-mall/styles/base.less @@ -0,0 +1,140 @@ +.page-member-points-mall { + --mpm-transition: 220ms cubic-bezier(0.4, 0, 0.2, 1); + --mpm-shadow-sm: 0 1px 2px rgb(0 0 0 / 4%); + --mpm-shadow-md: 0 8px 22px rgb(0 0 0 / 10%), 0 2px 6px rgb(0 0 0 / 8%); + --mpm-border: #e7eaf0; + --mpm-text: #1f2937; + --mpm-subtext: #6b7280; + --mpm-muted: #9ca3af; + --mpm-bg: #fff; + + .g-action { + padding: 0; + font-size: 13px; + color: #1677ff; + cursor: pointer; + background: none; + border: none; + } + + .g-action + .g-action { + margin-left: 12px; + } + + .g-action-danger { + color: #ef4444; + } + + .g-btn { + display: inline-flex; + gap: 6px; + align-items: center; + justify-content: center; + height: 34px; + padding: 0 14px; + font-size: 13px; + color: #374151; + cursor: pointer; + background: #fff; + border: 1px solid #d1d5db; + border-radius: 8px; + transition: all var(--mpm-transition); + } + + .g-btn:hover { + color: #1677ff; + border-color: #1677ff; + } + + .g-btn-primary { + color: #fff; + background: #1677ff; + border-color: #1677ff; + } + + .g-btn-primary:hover { + color: #fff; + background: #4096ff; + border-color: #4096ff; + } + + .g-btn-sm { + height: 30px; + padding: 0 12px; + } + + .mpm-pill { + height: 34px; + padding: 0 14px; + font-size: 13px; + color: #4b5563; + cursor: pointer; + background: #fff; + border: 1px solid #d1d5db; + border-radius: 8px; + transition: all var(--mpm-transition); + } + + .mpm-pill.checked { + color: #1677ff; + background: #e6f4ff; + border-color: #1677ff; + } + + .mpm-pill:disabled { + cursor: not-allowed; + opacity: 0.6; + } + + .mpm-pill-row { + display: flex; + flex-wrap: wrap; + gap: 10px; + } + + .mpm-form-hint { + margin-top: 6px; + font-size: 12px; + color: var(--mpm-muted); + } + + .mpm-inline-fields { + display: inline-flex; + gap: 8px; + align-items: center; + } + + .mpm-inline-fields .ant-input-number { + width: 140px; + } + + .mpm-type-tag { + display: inline-flex; + align-items: center; + height: 22px; + padding: 0 8px; + font-size: 12px; + border-radius: 6px; + } + + .mpm-type-tag.is-blue { + color: #1677ff; + background: #e6f4ff; + } + + .mpm-type-tag.is-green { + color: #16a34a; + background: #dcfce7; + } + + .mpm-type-tag.is-orange { + color: #d97706; + background: #fef3c7; + } + + .mpm-drawer-footer { + display: flex; + gap: 8px; + justify-content: flex-end; + } +} diff --git a/apps/web-antd/src/views/member/points-mall/styles/drawer.less b/apps/web-antd/src/views/member/points-mall/styles/drawer.less new file mode 100644 index 0000000..1ae8705 --- /dev/null +++ b/apps/web-antd/src/views/member/points-mall/styles/drawer.less @@ -0,0 +1,13 @@ +.page-member-points-mall { + .mpm-editor-drawer, + .mpm-verify-drawer, + .mpm-detail-drawer { + .ant-drawer-body { + padding: 16px 20px; + } + + .ant-drawer-footer { + padding: 10px 16px; + } + } +} diff --git a/apps/web-antd/src/views/member/points-mall/styles/index.less b/apps/web-antd/src/views/member/points-mall/styles/index.less new file mode 100644 index 0000000..e58217c --- /dev/null +++ b/apps/web-antd/src/views/member/points-mall/styles/index.less @@ -0,0 +1,8 @@ +@import './base.less'; +@import './layout.less'; +@import './rules.less'; +@import './products.less'; +@import './records.less'; +@import './drawer.less'; +@import './modal.less'; +@import './responsive.less'; diff --git a/apps/web-antd/src/views/member/points-mall/styles/layout.less b/apps/web-antd/src/views/member/points-mall/styles/layout.less new file mode 100644 index 0000000..95ef5c9 --- /dev/null +++ b/apps/web-antd/src/views/member/points-mall/styles/layout.less @@ -0,0 +1,154 @@ +.page-member-points-mall { + .mpm-page { + display: flex; + flex-direction: column; + gap: 16px; + } + + .mpm-tab-panel { + display: flex; + flex-direction: column; + gap: 14px; + } + + .mpm-toolbar { + display: flex; + gap: 10px; + align-items: center; + padding: 12px 14px; + background: var(--mpm-bg); + border: 1px solid var(--mpm-border); + border-radius: 10px; + box-shadow: var(--mpm-shadow-sm); + } + + .mpm-toolbar-top { + gap: 12px; + } + + .mpm-store-select { + width: 220px; + } + + .mpm-filter-select { + width: 140px; + } + + .mpm-search-input { + width: 220px; + } + + .mpm-spacer { + flex: 1; + } + + .mpm-readonly-tip { + margin-left: auto; + font-size: 12px; + color: #a1a1aa; + } + + .mpm-segments { + display: inline-flex; + overflow: hidden; + background: #f5f5f5; + border: 1px solid #e7e7e7; + border-radius: 9px; + } + + .mpm-segment-item { + min-width: 108px; + height: 34px; + padding: 0 16px; + font-size: 13px; + color: #4b5563; + cursor: pointer; + background: transparent; + border: 0; + transition: all var(--mpm-transition); + } + + .mpm-segment-item.active { + font-weight: 600; + color: #1677ff; + background: #fff; + box-shadow: 0 1px 4px rgb(0 0 0 / 8%); + } + + .mpm-stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + } + + .mpm-record-stats { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .mpm-stat-card { + display: flex; + flex-direction: column; + justify-content: center; + min-height: 98px; + padding: 16px; + background: #fff; + border: 1px solid var(--mpm-border); + border-radius: 10px; + box-shadow: var(--mpm-shadow-sm); + transition: box-shadow var(--mpm-transition); + } + + .mpm-stat-card:hover { + box-shadow: var(--mpm-shadow-md); + } + + .mpm-stat-label { + font-size: 13px; + color: var(--mpm-muted); + } + + .mpm-stat-value { + margin-top: 8px; + font-size: 24px; + font-weight: 700; + line-height: 1.1; + color: var(--mpm-text); + } + + .mpm-stat-value.is-primary { + color: #1677ff; + } + + .mpm-stat-value.is-green { + color: #16a34a; + } + + .mpm-stat-value.is-orange { + color: #d97706; + } + + .mpm-card { + border: 1px solid var(--mpm-border); + box-shadow: var(--mpm-shadow-sm); + } + + .mpm-section-title { + padding-left: 10px; + margin-bottom: 12px; + font-size: 15px; + font-weight: 600; + color: var(--mpm-text); + border-left: 3px solid #1677ff; + } + + .mpm-empty { + padding: 28px 14px; + font-size: 13px; + color: #9ca3af; + text-align: center; + background: #fff; + border: 1px solid var(--mpm-border); + border-radius: 10px; + box-shadow: var(--mpm-shadow-sm); + } +} diff --git a/apps/web-antd/src/views/member/points-mall/styles/modal.less b/apps/web-antd/src/views/member/points-mall/styles/modal.less new file mode 100644 index 0000000..0782eeb --- /dev/null +++ b/apps/web-antd/src/views/member/points-mall/styles/modal.less @@ -0,0 +1,77 @@ +.page-member-points-mall { + .mpm-picker-toolbar { + display: flex; + gap: 10px; + align-items: center; + margin-bottom: 12px; + } + + .mpm-picker-empty { + padding: 32px 12px; + border: 1px dashed #e5e7eb; + border-radius: 10px; + } + + .mpm-picker-table-wrap { + max-height: 420px; + overflow: auto; + border: 1px solid #e5e7eb; + border-radius: 10px; + } + + .mpm-picker-table { + width: 100%; + min-width: 680px; + border-collapse: collapse; + } + + .mpm-picker-table th, + .mpm-picker-table td { + padding: 10px 12px; + font-size: 13px; + color: #334155; + text-align: left; + white-space: nowrap; + border-bottom: 1px solid #f1f5f9; + } + + .mpm-picker-table th { + position: sticky; + top: 0; + z-index: 1; + font-size: 12px; + color: #64748b; + background: #f8fafc; + } + + .mpm-picker-table tr { + cursor: pointer; + } + + .mpm-picker-table tr.checked { + background: #eff6ff; + } + + .mpm-picker-table .name { + font-size: 13px; + color: #111827; + } + + .mpm-picker-table .spu { + margin-top: 2px; + font-size: 12px; + color: #9ca3af; + } + + .mpm-picker-table .col-radio { + width: 52px; + } + + .mpm-picker-table .col-price { + width: 120px; + } + + .mpm-picker-table .col-status { + width: 120px; + } +} diff --git a/apps/web-antd/src/views/member/points-mall/styles/products.less b/apps/web-antd/src/views/member/points-mall/styles/products.less new file mode 100644 index 0000000..7ef2957 --- /dev/null +++ b/apps/web-antd/src/views/member/points-mall/styles/products.less @@ -0,0 +1,129 @@ +.page-member-points-mall { + .mpm-product-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; + } + + .mpm-product-card { + overflow: hidden; + background: #fff; + border: 1px solid var(--mpm-border); + border-radius: 10px; + box-shadow: var(--mpm-shadow-sm); + transition: all var(--mpm-transition); + } + + .mpm-product-card:hover { + box-shadow: var(--mpm-shadow-md); + transform: translateY(-1px); + } + + .mpm-product-card.is-disabled { + opacity: 0.58; + } + + .mpm-product-image { + display: flex; + align-items: center; + justify-content: center; + height: 140px; + color: #9ca3af; + background: #f3f4f6; + } + + .mpm-product-image .iconify { + width: 32px; + height: 32px; + } + + .mpm-product-image img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .mpm-product-body { + padding: 14px 16px; + } + + .mpm-product-name { + display: flex; + gap: 6px; + align-items: center; + font-size: 14px; + font-weight: 600; + color: var(--mpm-text); + } + + .mpm-product-price { + margin-top: 10px; + font-size: 20px; + font-weight: 700; + line-height: 1.1; + color: #1677ff; + } + + .mpm-product-price .cash { + margin-left: 4px; + font-size: 13px; + font-weight: 500; + color: var(--mpm-subtext); + } + + .mpm-product-meta { + display: flex; + gap: 12px; + margin-top: 8px; + font-size: 12px; + color: var(--mpm-muted); + } + + .mpm-product-footer { + display: flex; + align-items: center; + padding-top: 10px; + margin-top: 10px; + border-top: 1px solid #f1f5f9; + } + + .mpm-product-footer .ant-tag { + margin-left: auto; + } + + .mpm-image-field { + display: flex; + gap: 12px; + align-items: center; + } + + .mpm-image-preview { + width: 80px; + height: 80px; + object-fit: cover; + border: 1px solid #e5e7eb; + border-radius: 8px; + } + + .mpm-selected-product { + padding: 10px 12px; + margin-bottom: 8px; + background: #f8fafc; + border: 1px solid #e5e7eb; + border-radius: 8px; + } + + .mpm-selected-product .name { + display: block; + font-size: 13px; + font-weight: 600; + color: var(--mpm-text); + } + + .mpm-selected-product .id { + display: block; + margin-top: 2px; + font-size: 12px; + color: var(--mpm-muted); + } +} diff --git a/apps/web-antd/src/views/member/points-mall/styles/records.less b/apps/web-antd/src/views/member/points-mall/styles/records.less new file mode 100644 index 0000000..a05f33b --- /dev/null +++ b/apps/web-antd/src/views/member/points-mall/styles/records.less @@ -0,0 +1,179 @@ +.page-member-points-mall { + .mpm-table-panel { + padding: 10px 12px; + background: #fff; + border: 1px solid var(--mpm-border); + border-radius: 12px; + box-shadow: var(--mpm-shadow-sm); + } + + .mpm-record-table-wrap { + display: flex; + flex-direction: column; + gap: 12px; + } + + .mpm-record-no { + font-family: ui-monospace, sfmono-regular, menlo, monospace; + font-size: 12px; + color: #334155; + } + + .mpm-record-member { + display: flex; + flex-direction: column; + gap: 2px; + } + + .mpm-record-member .name { + font-size: 13px; + font-weight: 500; + color: var(--mpm-text); + } + + .mpm-record-member .phone { + font-size: 12px; + color: var(--mpm-muted); + } + + .mpm-record-points { + font-weight: 600; + color: #111827; + } + + .mpm-record-status { + position: relative; + display: inline-flex; + gap: 6px; + align-items: center; + font-size: 12px; + color: #4b5563; + } + + .mpm-record-status::before { + width: 6px; + height: 6px; + content: ''; + background: #9ca3af; + border-radius: 999px; + } + + .mpm-record-status.is-green::before { + background: #16a34a; + } + + .mpm-record-status.is-orange::before { + background: #d97706; + } + + .mpm-record-status.is-gray::before { + background: #9ca3af; + } + + .mpm-record-status.is-red::before { + background: #ef4444; + } + + .mpm-pagination { + display: flex; + justify-content: flex-end; + } + + .mpm-verify-content { + display: flex; + flex-direction: column; + gap: 16px; + } + + .mpm-verify-card { + padding: 14px; + background: #f8fafc; + border: 1px solid #e5e7eb; + border-radius: 10px; + } + + .mpm-verify-product .name { + margin-bottom: 6px; + font-size: 15px; + font-weight: 600; + color: var(--mpm-text); + } + + .mpm-verify-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + margin-top: 12px; + } + + .mpm-verify-grid .item { + display: flex; + flex-direction: column; + gap: 2px; + } + + .mpm-verify-grid .label { + font-size: 12px; + color: var(--mpm-muted); + } + + .mpm-verify-grid .value { + font-size: 13px; + color: var(--mpm-text); + } + + .mpm-verify-grid .value.mono { + font-family: ui-monospace, sfmono-regular, menlo, monospace; + } + + .mpm-verify-grid .value.is-primary { + font-weight: 600; + color: #1677ff; + } + + .mpm-detail-content { + display: flex; + flex-direction: column; + gap: 14px; + } + + .mpm-detail-top .name { + margin-bottom: 6px; + font-size: 15px; + font-weight: 600; + color: var(--mpm-text); + } + + .mpm-detail-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 14px; + } + + .mpm-detail-grid .item { + display: flex; + flex-direction: column; + gap: 2px; + } + + .mpm-detail-grid .label { + font-size: 12px; + color: var(--mpm-muted); + } + + .mpm-detail-grid .value { + font-size: 13px; + color: var(--mpm-text); + } + + .mpm-detail-grid .value.mono { + font-family: ui-monospace, sfmono-regular, menlo, monospace; + } + + .mpm-empty-tip { + padding: 20px 0; + font-size: 13px; + color: var(--mpm-muted); + text-align: center; + } +} diff --git a/apps/web-antd/src/views/member/points-mall/styles/responsive.less b/apps/web-antd/src/views/member/points-mall/styles/responsive.less new file mode 100644 index 0000000..ea7de98 --- /dev/null +++ b/apps/web-antd/src/views/member/points-mall/styles/responsive.less @@ -0,0 +1,42 @@ +.page-member-points-mall { + @media (max-width: 1400px) { + .mpm-product-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + + @media (max-width: 1024px) { + .mpm-toolbar { + flex-wrap: wrap; + } + + .mpm-store-select, + .mpm-search-input { + width: 100%; + } + + .mpm-stats, + .mpm-record-stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .mpm-product-grid { + grid-template-columns: repeat(1, minmax(0, 1fr)); + } + } + + @media (max-width: 768px) { + .mpm-rule-row { + flex-wrap: wrap; + } + + .mpm-rule-label { + min-width: auto; + } + + .mpm-verify-grid, + .mpm-detail-grid { + grid-template-columns: repeat(1, minmax(0, 1fr)); + } + } +} diff --git a/apps/web-antd/src/views/member/points-mall/styles/rules.less b/apps/web-antd/src/views/member/points-mall/styles/rules.less new file mode 100644 index 0000000..20b0d4b --- /dev/null +++ b/apps/web-antd/src/views/member/points-mall/styles/rules.less @@ -0,0 +1,40 @@ +.page-member-points-mall { + .mpm-rule-panel { + display: flex; + flex-direction: column; + gap: 14px; + } + + .mpm-rule-row { + display: flex; + gap: 10px; + align-items: center; + padding: 14px 0; + border-bottom: 1px solid #f1f5f9; + } + + .mpm-rule-row:last-child { + border-bottom: none; + } + + .mpm-rule-label { + min-width: 96px; + font-size: 13px; + font-weight: 500; + color: var(--mpm-text); + } + + .mpm-rule-text { + font-size: 13px; + color: var(--mpm-subtext); + } + + .mpm-rule-input { + width: 86px; + } + + .mpm-save-bar { + display: flex; + justify-content: flex-end; + } +} diff --git a/apps/web-antd/src/views/member/points-mall/types.ts b/apps/web-antd/src/views/member/points-mall/types.ts new file mode 100644 index 0000000..b4f4565 --- /dev/null +++ b/apps/web-antd/src/views/member/points-mall/types.ts @@ -0,0 +1,191 @@ +import type { Dayjs } from 'dayjs'; + +import type { + MemberPointMallExchangeType, + MemberPointMallExpiryMode, + MemberPointMallNotifyChannel, + MemberPointMallPickupMethod, + MemberPointMallProductDto, + MemberPointMallProductStatus, + MemberPointMallRecordDetailDto, + MemberPointMallRecordDto, + MemberPointMallRecordStatsDto, + MemberPointMallRecordStatus, + MemberPointMallRedeemType, + MemberPointMallRuleStatsDto, + MemberPointMallVerifyMethod, +} from '#/api/member/points-mall'; + +/** 页面主 Tab。 */ +export type PointMallTabKey = 'products' | 'records' | 'rules'; + +/** 规则编辑表单。 */ +export interface PointMallRuleForm { + consumeAmountPerStep: null | number; + consumeRewardPointsPerStep: null | number; + expiryMode: MemberPointMallExpiryMode; + isConsumeRewardEnabled: boolean; + isRegisterRewardEnabled: boolean; + isReviewRewardEnabled: boolean; + isSigninRewardEnabled: boolean; + registerRewardPoints: null | number; + reviewRewardPoints: null | number; + signinRewardPoints: null | number; +} + +/** 商品筛选表单。 */ +export interface PointMallProductFilterForm { + keyword: string; + status: '' | MemberPointMallProductStatus; +} + +/** 商品编辑表单。 */ +export interface PointMallProductEditorForm { + cashAmount: null | number; + couponTemplateId: string; + couponTemplateName: string; + description: string; + exchangeType: MemberPointMallExchangeType; + imageUrl: string; + name: string; + notifyChannels: MemberPointMallNotifyChannel[]; + perMemberLimit: null | number; + physicalName: string; + pickupMethod: MemberPointMallPickupMethod; + pointMallProductId: string; + productId: string; + productName: string; + redeemType: MemberPointMallRedeemType; + requiredPoints: null | number; + status: MemberPointMallProductStatus; + stockTotal: null | number; +} + +/** 记录筛选表单。 */ +export interface PointMallRecordFilterForm { + dateRange: [Dayjs, Dayjs] | null; + keyword: string; + redeemType: '' | MemberPointMallRedeemType; + status: '' | MemberPointMallRecordStatus; +} + +/** 记录分页状态。 */ +export interface PointMallRecordPager { + items: MemberPointMallRecordDto[]; + page: number; + pageSize: number; + totalCount: number; +} + +/** 核销表单。 */ +export interface PointMallVerifyForm { + verifyMethod: MemberPointMallVerifyMethod; + verifyRemark: string; +} + +/** 商品卡片模型。 */ +export type PointMallProductCardViewModel = MemberPointMallProductDto; + +/** 规则统计模型。 */ +export type PointMallRuleStatsViewModel = MemberPointMallRuleStatsDto; + +/** 记录统计模型。 */ +export type PointMallRecordStatsViewModel = MemberPointMallRecordStatsDto; + +/** 记录详情模型。 */ +export type PointMallRecordDetailViewModel = MemberPointMallRecordDetailDto; + +/** 创建默认规则表单。 */ +export function createDefaultPointMallRuleForm(): PointMallRuleForm { + return { + isConsumeRewardEnabled: true, + consumeAmountPerStep: 1, + consumeRewardPointsPerStep: 1, + isReviewRewardEnabled: true, + reviewRewardPoints: 10, + isRegisterRewardEnabled: true, + registerRewardPoints: 100, + isSigninRewardEnabled: false, + signinRewardPoints: 5, + expiryMode: 'yearly_clear', + }; +} + +/** 创建默认商品筛选表单。 */ +export function createDefaultPointMallProductFilterForm(): PointMallProductFilterForm { + return { + status: '', + keyword: '', + }; +} + +/** 创建默认商品编辑表单。 */ +export function createDefaultPointMallProductEditorForm(): PointMallProductEditorForm { + return { + pointMallProductId: '', + redeemType: 'product', + productId: '', + productName: '', + couponTemplateId: '', + couponTemplateName: '', + physicalName: '', + pickupMethod: 'store_pickup', + name: '', + imageUrl: '', + exchangeType: 'points', + requiredPoints: null, + cashAmount: null, + stockTotal: null, + perMemberLimit: null, + description: '', + notifyChannels: ['in_app'], + status: 'enabled', + }; +} + +/** 创建默认记录筛选表单。 */ +export function createDefaultPointMallRecordFilterForm(): PointMallRecordFilterForm { + return { + redeemType: '', + status: '', + keyword: '', + dateRange: null, + }; +} + +/** 创建默认记录分页。 */ +export function createDefaultPointMallRecordPager(): PointMallRecordPager { + return { + items: [], + page: 1, + pageSize: 10, + totalCount: 0, + }; +} + +/** 创建默认规则统计。 */ +export function createDefaultPointMallRuleStats(): PointMallRuleStatsViewModel { + return { + totalIssuedPoints: 0, + redeemedPoints: 0, + pointMembers: 0, + redeemRate: 0, + }; +} + +/** 创建默认记录统计。 */ +export function createDefaultPointMallRecordStats(): PointMallRecordStatsViewModel { + return { + todayRedeemCount: 0, + pendingPhysicalCount: 0, + currentMonthUsedPoints: 0, + }; +} + +/** 创建默认核销表单。 */ +export function createDefaultPointMallVerifyForm(): PointMallVerifyForm { + return { + verifyMethod: 'manual', + verifyRemark: '', + }; +} diff --git a/apps/web-antd/src/views/member/stored-card/composables/stored-card-page/helpers.ts b/apps/web-antd/src/views/member/stored-card/composables/stored-card-page/helpers.ts index 3cbfaaa..44178d4 100644 --- a/apps/web-antd/src/views/member/stored-card/composables/stored-card-page/helpers.ts +++ b/apps/web-antd/src/views/member/stored-card/composables/stored-card-page/helpers.ts @@ -3,11 +3,12 @@ import type { SaveMemberStoredCardPlanPayload, } from '#/api/member/stored-card'; import type { - createDefaultStoredCardPlanEditorForm, StoredCardPlanEditorForm, - type StoredCardRecordFilterForm, + StoredCardRecordFilterForm, } from '#/views/member/stored-card/types'; +import { createDefaultStoredCardPlanEditorForm } from '#/views/member/stored-card/types'; + /** 金额格式化。 */ export function formatCurrency(value: null | number | undefined) { const amount = Number(value ?? 0);