From 5e1910781bdd5e90c5ff972edbc8e4c37d1ce19b Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Wed, 4 Mar 2026 09:15:16 +0800 Subject: [PATCH] feat: implement member stored card page and drawers --- apps/web-antd/src/api/member/index.ts | 2 + apps/web-antd/src/api/member/stored-card.ts | 205 ++++++++++++++ .../components/StoredCardPlanCard.vue | 67 +++++ .../components/StoredCardPlanEditorDrawer.vue | 121 ++++++++ .../components/StoredCardRecordTable.vue | 148 ++++++++++ .../components/StoredCardRecordToolbar.vue | 53 ++++ .../components/StoredCardStatsCards.vue | 39 +++ .../composables/stored-card-page/constants.ts | 58 ++++ .../stored-card-page/data-actions.ts | 151 ++++++++++ .../stored-card-page/drawer-actions.ts | 125 +++++++++ .../composables/stored-card-page/helpers.ts | 127 +++++++++ .../stored-card-page/plan-actions.ts | 81 ++++++ .../composables/useMemberStoredCardPage.ts | 261 ++++++++++++++++++ .../src/views/member/stored-card/index.vue | 164 +++++++++++ .../views/member/stored-card/styles/base.less | 30 ++ .../member/stored-card/styles/drawer.less | 52 ++++ .../member/stored-card/styles/index.less | 6 + .../member/stored-card/styles/layout.less | 25 ++ .../views/member/stored-card/styles/plan.less | 139 ++++++++++ .../member/stored-card/styles/record.less | 82 ++++++ .../member/stored-card/styles/responsive.less | 34 +++ .../src/views/member/stored-card/types.ts | 79 ++++++ 22 files changed, 2049 insertions(+) create mode 100644 apps/web-antd/src/api/member/stored-card.ts create mode 100644 apps/web-antd/src/views/member/stored-card/components/StoredCardPlanCard.vue create mode 100644 apps/web-antd/src/views/member/stored-card/components/StoredCardPlanEditorDrawer.vue create mode 100644 apps/web-antd/src/views/member/stored-card/components/StoredCardRecordTable.vue create mode 100644 apps/web-antd/src/views/member/stored-card/components/StoredCardRecordToolbar.vue create mode 100644 apps/web-antd/src/views/member/stored-card/components/StoredCardStatsCards.vue create mode 100644 apps/web-antd/src/views/member/stored-card/composables/stored-card-page/constants.ts create mode 100644 apps/web-antd/src/views/member/stored-card/composables/stored-card-page/data-actions.ts create mode 100644 apps/web-antd/src/views/member/stored-card/composables/stored-card-page/drawer-actions.ts create mode 100644 apps/web-antd/src/views/member/stored-card/composables/stored-card-page/helpers.ts create mode 100644 apps/web-antd/src/views/member/stored-card/composables/stored-card-page/plan-actions.ts create mode 100644 apps/web-antd/src/views/member/stored-card/composables/useMemberStoredCardPage.ts create mode 100644 apps/web-antd/src/views/member/stored-card/index.vue create mode 100644 apps/web-antd/src/views/member/stored-card/styles/base.less create mode 100644 apps/web-antd/src/views/member/stored-card/styles/drawer.less create mode 100644 apps/web-antd/src/views/member/stored-card/styles/index.less create mode 100644 apps/web-antd/src/views/member/stored-card/styles/layout.less create mode 100644 apps/web-antd/src/views/member/stored-card/styles/plan.less create mode 100644 apps/web-antd/src/views/member/stored-card/styles/record.less create mode 100644 apps/web-antd/src/views/member/stored-card/styles/responsive.less create mode 100644 apps/web-antd/src/views/member/stored-card/types.ts diff --git a/apps/web-antd/src/api/member/index.ts b/apps/web-antd/src/api/member/index.ts index 0ce403c..c8798aa 100644 --- a/apps/web-antd/src/api/member/index.ts +++ b/apps/web-antd/src/api/member/index.ts @@ -281,3 +281,5 @@ export async function getMemberCouponPickerApi(params: { }, ); } + +export * from './stored-card'; diff --git a/apps/web-antd/src/api/member/stored-card.ts b/apps/web-antd/src/api/member/stored-card.ts new file mode 100644 index 0000000..6d7e6e0 --- /dev/null +++ b/apps/web-antd/src/api/member/stored-card.ts @@ -0,0 +1,205 @@ +/** + * 文件职责:会员中心储值卡 API 与 DTO 定义。 + */ +import { requestClient } from '#/api/request'; + +/** 储值卡方案状态。 */ +export type MemberStoredCardPlanStatus = 'disabled' | 'enabled'; + +/** 储值卡充值支付方式。 */ +export type MemberStoredCardPaymentMethod = + | 'alipay' + | 'balance' + | 'card' + | 'cash' + | 'unknown' + | 'wechat'; + +/** 储值卡方案列表查询。 */ +export interface MemberStoredCardPlanListQuery { + storeId: string; +} + +/** 储值卡方案统计。 */ +export interface MemberStoredCardPlanStatsDto { + currentMonthRechargeAmount: number; + rechargeMemberCount: number; + totalGiftAmount: number; + totalRechargeAmount: number; +} + +/** 储值卡方案项。 */ +export interface MemberStoredCardPlanDto { + arrivedAmount: number; + giftAmount: number; + planId: string; + rechargeAmount: number; + rechargeCount: number; + sortOrder: number; + status: MemberStoredCardPlanStatus; + totalRechargeAmount: number; +} + +/** 储值卡方案列表结果。 */ +export interface MemberStoredCardPlanListResultDto { + items: MemberStoredCardPlanDto[]; + stats: MemberStoredCardPlanStatsDto; +} + +/** 保存储值卡方案请求。 */ +export interface SaveMemberStoredCardPlanPayload { + giftAmount: number; + planId?: string; + rechargeAmount: number; + sortOrder: number; + status: MemberStoredCardPlanStatus; + storeId: string; +} + +/** 修改储值卡方案状态请求。 */ +export interface ChangeMemberStoredCardPlanStatusPayload { + planId: string; + status: MemberStoredCardPlanStatus; + storeId: string; +} + +/** 删除储值卡方案请求。 */ +export interface DeleteMemberStoredCardPlanPayload { + planId: string; + storeId: string; +} + +/** 充值记录列表查询。 */ +export interface MemberStoredCardRechargeRecordListQuery { + endDate?: string; + keyword?: string; + page: number; + pageSize: number; + startDate?: string; + storeId: string; +} + +/** 储值卡充值记录项。 */ +export interface MemberStoredCardRechargeRecordDto { + arrivedAmount: number; + giftAmount: number; + memberId: string; + memberMobileMasked: string; + memberName: string; + paymentMethod: MemberStoredCardPaymentMethod; + paymentMethodText: string; + planId?: string; + rechargedAt: string; + rechargeAmount: number; + recordId: string; + recordNo: string; + remark?: string; +} + +/** 储值卡充值记录分页结果。 */ +export interface MemberStoredCardRechargeRecordListResultDto { + items: MemberStoredCardRechargeRecordDto[]; + page: number; + pageSize: number; + totalCount: number; +} + +/** 储值卡充值记录导出查询。 */ +export interface ExportMemberStoredCardRechargeRecordQuery { + endDate?: string; + keyword?: string; + startDate?: string; + storeId: string; +} + +/** 储值卡充值记录导出结果。 */ +export interface MemberStoredCardRechargeRecordExportDto { + fileContentBase64: string; + fileName: string; + totalCount: number; +} + +/** 写入储值卡充值记录请求。 */ +export interface WriteMemberStoredCardRechargeRecordPayload { + giftAmount: number; + memberId: string; + paymentMethod: Exclude; + planId?: string; + rechargedAt?: string; + rechargeAmount: number; + remark?: string; + storeId: string; +} + +/** 查询储值卡方案列表。 */ +export async function getMemberStoredCardPlanListApi( + params: MemberStoredCardPlanListQuery, +) { + return requestClient.get( + '/member/stored-card/plan/list', + { + params, + }, + ); +} + +/** 保存储值卡方案。 */ +export async function saveMemberStoredCardPlanApi( + payload: SaveMemberStoredCardPlanPayload, +) { + return requestClient.post( + '/member/stored-card/plan/save', + payload, + ); +} + +/** 修改储值卡方案状态。 */ +export async function changeMemberStoredCardPlanStatusApi( + payload: ChangeMemberStoredCardPlanStatusPayload, +) { + return requestClient.post( + '/member/stored-card/plan/status', + payload, + ); +} + +/** 删除储值卡方案。 */ +export async function deleteMemberStoredCardPlanApi( + payload: DeleteMemberStoredCardPlanPayload, +) { + return requestClient.post('/member/stored-card/plan/delete', payload); +} + +/** 查询储值卡充值记录。 */ +export async function getMemberStoredCardRechargeRecordListApi( + params: MemberStoredCardRechargeRecordListQuery, +) { + return requestClient.get( + '/member/stored-card/record/list', + { + params, + }, + ); +} + +/** 导出储值卡充值记录。 */ +export async function exportMemberStoredCardRechargeRecordApi( + params: ExportMemberStoredCardRechargeRecordQuery, +) { + return requestClient.get( + '/member/stored-card/record/export', + { + params, + }, + ); +} + +/** 写入储值卡充值记录。 */ +export async function writeMemberStoredCardRechargeRecordApi( + payload: WriteMemberStoredCardRechargeRecordPayload, +) { + return requestClient.post( + '/member/stored-card/record/write', + payload, + ); +} diff --git a/apps/web-antd/src/views/member/stored-card/components/StoredCardPlanCard.vue b/apps/web-antd/src/views/member/stored-card/components/StoredCardPlanCard.vue new file mode 100644 index 0000000..63c8765 --- /dev/null +++ b/apps/web-antd/src/views/member/stored-card/components/StoredCardPlanCard.vue @@ -0,0 +1,67 @@ + + + diff --git a/apps/web-antd/src/views/member/stored-card/components/StoredCardPlanEditorDrawer.vue b/apps/web-antd/src/views/member/stored-card/components/StoredCardPlanEditorDrawer.vue new file mode 100644 index 0000000..05fc24c --- /dev/null +++ b/apps/web-antd/src/views/member/stored-card/components/StoredCardPlanEditorDrawer.vue @@ -0,0 +1,121 @@ + + + diff --git a/apps/web-antd/src/views/member/stored-card/components/StoredCardRecordTable.vue b/apps/web-antd/src/views/member/stored-card/components/StoredCardRecordTable.vue new file mode 100644 index 0000000..c081f38 --- /dev/null +++ b/apps/web-antd/src/views/member/stored-card/components/StoredCardRecordTable.vue @@ -0,0 +1,148 @@ + + + diff --git a/apps/web-antd/src/views/member/stored-card/components/StoredCardRecordToolbar.vue b/apps/web-antd/src/views/member/stored-card/components/StoredCardRecordToolbar.vue new file mode 100644 index 0000000..0387c8c --- /dev/null +++ b/apps/web-antd/src/views/member/stored-card/components/StoredCardRecordToolbar.vue @@ -0,0 +1,53 @@ + + + diff --git a/apps/web-antd/src/views/member/stored-card/components/StoredCardStatsCards.vue b/apps/web-antd/src/views/member/stored-card/components/StoredCardStatsCards.vue new file mode 100644 index 0000000..4e6433d --- /dev/null +++ b/apps/web-antd/src/views/member/stored-card/components/StoredCardStatsCards.vue @@ -0,0 +1,39 @@ + + + diff --git a/apps/web-antd/src/views/member/stored-card/composables/stored-card-page/constants.ts b/apps/web-antd/src/views/member/stored-card/composables/stored-card-page/constants.ts new file mode 100644 index 0000000..78f6d1c --- /dev/null +++ b/apps/web-antd/src/views/member/stored-card/composables/stored-card-page/constants.ts @@ -0,0 +1,58 @@ +import type { MemberStoredCardPaymentMethod } from '#/api/member/stored-card'; +import type { StoredCardTabKey } from '#/views/member/stored-card/types'; + +import dayjs from 'dayjs'; + +/** 会员储值卡查看权限。 */ +export const MEMBER_STORED_CARD_VIEW_PERMISSION = + 'tenant:member:stored-card:view'; + +/** 会员储值卡管理权限。 */ +export const MEMBER_STORED_CARD_MANAGE_PERMISSION = + 'tenant:member:stored-card:manage'; + +/** 页面 Tab 选项。 */ +export const STORED_CARD_TAB_OPTIONS: Array<{ + label: string; + value: StoredCardTabKey; +}> = [ + { label: '充值方案', value: 'plans' }, + { label: '充值记录', value: 'records' }, +]; + +/** 方案状态选项。 */ +export const STORED_CARD_PLAN_STATUS_OPTIONS = [ + { label: '启用', value: 'enabled' }, + { label: '停用', value: 'disabled' }, +]; + +/** 支付方式文案映射。 */ +export const STORED_CARD_PAYMENT_METHOD_TEXT_MAP: Record< + MemberStoredCardPaymentMethod, + string +> = { + wechat: '微信支付', + alipay: '支付宝', + cash: '现金', + card: '刷卡', + balance: '余额', + unknown: '未知', +}; + +/** 支付方式标签颜色。 */ +export const STORED_CARD_PAYMENT_METHOD_COLOR_MAP: Record< + MemberStoredCardPaymentMethod, + string +> = { + wechat: 'green', + alipay: 'blue', + cash: 'orange', + card: 'gold', + balance: 'purple', + unknown: 'default', +}; + +/** 默认记录日期范围(本月 1 日至今日)。 */ +export function createDefaultRecordDateRange() { + return [dayjs().startOf('month'), dayjs()] as [dayjs.Dayjs, dayjs.Dayjs]; +} diff --git a/apps/web-antd/src/views/member/stored-card/composables/stored-card-page/data-actions.ts b/apps/web-antd/src/views/member/stored-card/composables/stored-card-page/data-actions.ts new file mode 100644 index 0000000..c988cba --- /dev/null +++ b/apps/web-antd/src/views/member/stored-card/composables/stored-card-page/data-actions.ts @@ -0,0 +1,151 @@ +import type { Ref } from 'vue'; + +import type { StoreListItemDto } from '#/api/store'; +import type { + StoredCardPlanCardViewModel, + StoredCardPlanStatsViewModel, + StoredCardRecordFilterForm, + StoredCardRecordPager, +} from '#/views/member/stored-card/types'; + +import { message } from 'ant-design-vue'; + +import { + getMemberStoredCardPlanListApi, + getMemberStoredCardRechargeRecordListApi, +} from '#/api/member/stored-card'; +import { getStoreListApi } from '#/api/store'; + +import { mapRecordFilterToQuery } from './helpers'; + +interface CreateDataActionsOptions { + isPlanLoading: Ref; + isRecordLoading: Ref; + isStoreLoading: Ref; + planRows: Ref; + planStats: Ref; + recordFilterForm: StoredCardRecordFilterForm; + recordPager: Ref; + selectedStoreId: Ref; + stores: Ref; +} + +export function createDataActions(options: CreateDataActionsOptions) { + function resetPlanData() { + options.planRows.value = []; + options.planStats.value = { + totalRechargeAmount: 0, + totalGiftAmount: 0, + currentMonthRechargeAmount: 0, + rechargeMemberCount: 0, + }; + } + + function resetRecordData() { + options.recordPager.value = { + ...options.recordPager.value, + items: [], + totalCount: 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 = ''; + resetPlanData(); + 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 loadPlanList() { + if (!options.selectedStoreId.value) { + resetPlanData(); + return; + } + + options.isPlanLoading.value = true; + try { + const result = await getMemberStoredCardPlanListApi({ + storeId: options.selectedStoreId.value, + }); + + options.planRows.value = result.items ?? []; + options.planStats.value = result.stats ?? { + totalRechargeAmount: 0, + totalGiftAmount: 0, + currentMonthRechargeAmount: 0, + rechargeMemberCount: 0, + }; + } catch (error) { + console.error(error); + resetPlanData(); + message.error('加载充值方案失败'); + } finally { + options.isPlanLoading.value = false; + } + } + + async function loadRecordList() { + if (!options.selectedStoreId.value) { + resetRecordData(); + return; + } + + options.isRecordLoading.value = true; + try { + const query = mapRecordFilterToQuery(options.recordFilterForm); + const result = await getMemberStoredCardRechargeRecordListApi({ + 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, + }; + } catch (error) { + console.error(error); + resetRecordData(); + message.error('加载充值记录失败'); + } finally { + options.isRecordLoading.value = false; + } + } + + return { + loadPlanList, + loadRecordList, + loadStores, + }; +} diff --git a/apps/web-antd/src/views/member/stored-card/composables/stored-card-page/drawer-actions.ts b/apps/web-antd/src/views/member/stored-card/composables/stored-card-page/drawer-actions.ts new file mode 100644 index 0000000..5be1007 --- /dev/null +++ b/apps/web-antd/src/views/member/stored-card/composables/stored-card-page/drawer-actions.ts @@ -0,0 +1,125 @@ +import type { Ref } from 'vue'; + +import type { + StoredCardPlanCardViewModel, + StoredCardPlanEditorForm, +} from '#/views/member/stored-card/types'; + +import { message } from 'ant-design-vue'; + +import { saveMemberStoredCardPlanApi } from '#/api/member/stored-card'; + +import { + mapPlanEditorFormToSavePayload, + mapPlanToEditorForm, + resetPlanEditorForm, +} from './helpers'; + +interface CreateDrawerActionsOptions { + canManage: Ref; + drawerMode: Ref<'create' | 'edit'>; + form: StoredCardPlanEditorForm; + isDrawerLoading: Ref; + isDrawerOpen: Ref; + isDrawerSubmitting: Ref; + loadPlanList: () => Promise; + loadRecordList: () => Promise; + selectedStoreId: Ref; +} + +export function createDrawerActions(options: CreateDrawerActionsOptions) { + function setDrawerOpen(value: boolean) { + options.isDrawerOpen.value = value; + } + + function setFormRechargeAmount(value: null | number) { + options.form.rechargeAmount = value; + } + + function setFormGiftAmount(value: null | number) { + options.form.giftAmount = value; + } + + function setFormSortOrder(value: null | number) { + options.form.sortOrder = value; + } + + function setFormStatus(value: 'disabled' | 'enabled') { + options.form.status = value; + } + + function openCreateDrawer() { + if (!options.canManage.value) { + return; + } + + options.drawerMode.value = 'create'; + resetPlanEditorForm(options.form); + options.isDrawerOpen.value = true; + } + + function openEditDrawer(row: StoredCardPlanCardViewModel) { + if (!options.canManage.value) { + return; + } + + options.drawerMode.value = 'edit'; + Object.assign(options.form, mapPlanToEditorForm(row)); + options.isDrawerOpen.value = true; + } + + async function submitDrawer() { + if (!options.canManage.value) { + return; + } + + if (!options.selectedStoreId.value) { + message.warning('请先选择门店'); + return; + } + + const rechargeAmount = Number(options.form.rechargeAmount ?? 0); + const giftAmount = Number(options.form.giftAmount ?? 0); + const sortOrder = Number(options.form.sortOrder ?? 100); + if (!Number.isFinite(rechargeAmount) || rechargeAmount <= 0) { + message.warning('充值金额必须大于 0'); + return; + } + + if (!Number.isFinite(giftAmount) || giftAmount < 0) { + message.warning('赠送金额不能小于 0'); + return; + } + + if (!Number.isFinite(sortOrder) || sortOrder < 0 || sortOrder > 9999) { + message.warning('排序值需在 0-9999 之间'); + return; + } + + options.isDrawerSubmitting.value = true; + try { + await saveMemberStoredCardPlanApi( + mapPlanEditorFormToSavePayload(options.form, options.selectedStoreId.value), + ); + message.success('保存成功'); + options.isDrawerOpen.value = false; + await Promise.all([options.loadPlanList(), options.loadRecordList()]); + } catch (error) { + console.error(error); + message.error('保存失败'); + } finally { + options.isDrawerSubmitting.value = false; + } + } + + return { + openCreateDrawer, + openEditDrawer, + setDrawerOpen, + setFormGiftAmount, + setFormRechargeAmount, + setFormSortOrder, + setFormStatus, + submitDrawer, + }; +} 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 new file mode 100644 index 0000000..8b310a2 --- /dev/null +++ b/apps/web-antd/src/views/member/stored-card/composables/stored-card-page/helpers.ts @@ -0,0 +1,127 @@ +import type { + MemberStoredCardPlanDto, + SaveMemberStoredCardPlanPayload, +} from '#/api/member/stored-card'; +import type { StoredCardPlanEditorForm } from '#/views/member/stored-card/types'; + +import { + createDefaultStoredCardPlanEditorForm, + type StoredCardRecordFilterForm, +} from '#/views/member/stored-card/types'; + +/** 金额格式化。 */ +export function formatCurrency(value: null | number | undefined) { + const amount = Number(value ?? 0); + if (Number.isNaN(amount)) { + return '¥0'; + } + + if (Math.abs(amount) >= 1000) { + return `¥${amount.toLocaleString('zh-CN', { + minimumFractionDigits: 0, + maximumFractionDigits: 2, + })}`; + } + + return `¥${amount.toFixed(amount % 1 === 0 ? 0 : 2)}`; +} + +/** 数量格式化。 */ +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 resolveGiftRatioText( + rechargeAmount: null | number, + giftAmount: null | number, +) { + const recharge = Number(rechargeAmount ?? 0); + const gift = Number(giftAmount ?? 0); + if (recharge <= 0 || gift <= 0) { + return '赠送比例 --'; + } + + const ratio = ((gift / recharge) * 100).toFixed(1); + return `赠送比例 ${ratio}%`; +} + +/** 表单转保存 DTO。 */ +export function mapPlanEditorFormToSavePayload( + form: StoredCardPlanEditorForm, + storeId: string, +): SaveMemberStoredCardPlanPayload { + return { + storeId, + planId: form.planId || undefined, + rechargeAmount: Number(form.rechargeAmount ?? 0), + giftAmount: Number(form.giftAmount ?? 0), + sortOrder: Number(form.sortOrder ?? 100), + status: form.status, + }; +} + +/** 方案转编辑表单。 */ +export function mapPlanToEditorForm( + plan: MemberStoredCardPlanDto, +): StoredCardPlanEditorForm { + return { + planId: plan.planId, + rechargeAmount: plan.rechargeAmount, + giftAmount: plan.giftAmount, + sortOrder: plan.sortOrder, + status: plan.status, + }; +} + +/** 重置编辑表单。 */ +export function resetPlanEditorForm(form: StoredCardPlanEditorForm) { + const defaults = createDefaultStoredCardPlanEditorForm(); + form.planId = defaults.planId; + form.rechargeAmount = defaults.rechargeAmount; + form.giftAmount = defaults.giftAmount; + form.sortOrder = defaults.sortOrder; + form.status = defaults.status; +} + +/** 筛选项转查询参数。 */ +export function mapRecordFilterToQuery( + filterForm: StoredCardRecordFilterForm, +): { endDate?: string; keyword?: string; startDate?: string } { + const keyword = filterForm.keyword.trim(); + if (!filterForm.dateRange) { + return { + keyword: keyword || undefined, + }; + } + + return { + startDate: filterForm.dateRange[0].format('YYYY-MM-DD'), + endDate: filterForm.dateRange[1].format('YYYY-MM-DD'), + keyword: keyword || undefined, + }; +} + +/** 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/stored-card/composables/stored-card-page/plan-actions.ts b/apps/web-antd/src/views/member/stored-card/composables/stored-card-page/plan-actions.ts new file mode 100644 index 0000000..095fd2d --- /dev/null +++ b/apps/web-antd/src/views/member/stored-card/composables/stored-card-page/plan-actions.ts @@ -0,0 +1,81 @@ +import type { Ref } from 'vue'; + +import type { StoredCardPlanCardViewModel } from '#/views/member/stored-card/types'; + +import { Modal, message } from 'ant-design-vue'; + +import { + changeMemberStoredCardPlanStatusApi, + deleteMemberStoredCardPlanApi, +} from '#/api/member/stored-card'; + +interface CreatePlanActionsOptions { + canManage: Ref; + loadPlanList: () => Promise; + loadRecordList: () => Promise; + selectedStoreId: Ref; +} + +export function createPlanActions(options: CreatePlanActionsOptions) { + async function toggleStatus(row: StoredCardPlanCardViewModel) { + if (!options.canManage.value) { + return; + } + + if (!options.selectedStoreId.value) { + message.warning('请先选择门店'); + return; + } + + const targetStatus = row.status === 'enabled' ? 'disabled' : 'enabled'; + try { + await changeMemberStoredCardPlanStatusApi({ + storeId: options.selectedStoreId.value, + planId: row.planId, + status: targetStatus, + }); + message.success(targetStatus === 'enabled' ? '已启用方案' : '已停用方案'); + await options.loadPlanList(); + } catch (error) { + console.error(error); + message.error('状态更新失败'); + } + } + + function removePlan(row: StoredCardPlanCardViewModel) { + if (!options.canManage.value) { + return; + } + + if (!options.selectedStoreId.value) { + message.warning('请先选择门店'); + return; + } + + Modal.confirm({ + title: '删除充值方案', + content: '删除后不可恢复,确认继续吗?', + okText: '删除', + okType: 'danger', + cancelText: '取消', + async onOk() { + try { + await deleteMemberStoredCardPlanApi({ + storeId: options.selectedStoreId.value, + planId: row.planId, + }); + message.success('删除成功'); + await Promise.all([options.loadPlanList(), options.loadRecordList()]); + } catch (error) { + console.error(error); + message.error('删除失败'); + } + }, + }); + } + + return { + removePlan, + toggleStatus, + }; +} diff --git a/apps/web-antd/src/views/member/stored-card/composables/useMemberStoredCardPage.ts b/apps/web-antd/src/views/member/stored-card/composables/useMemberStoredCardPage.ts new file mode 100644 index 0000000..a74c5e4 --- /dev/null +++ b/apps/web-antd/src/views/member/stored-card/composables/useMemberStoredCardPage.ts @@ -0,0 +1,261 @@ +import type { StoreListItemDto } from '#/api/store'; +import type { + StoredCardPlanCardViewModel, + StoredCardTabKey, +} from '#/views/member/stored-card/types'; + +import { computed, onMounted, reactive, ref, watch } from 'vue'; + +import { useAccessStore } from '@vben/stores'; + +import { exportMemberStoredCardRechargeRecordApi } from '#/api/member/stored-card'; + +import { + createDefaultStoredCardPlanEditorForm, + createDefaultStoredCardPlanStats, + createDefaultStoredCardRecordFilterForm, + createDefaultStoredCardRecordPager, +} from '../types'; +import { + createDataActions, +} from './stored-card-page/data-actions'; +import { + createDrawerActions, +} from './stored-card-page/drawer-actions'; +import { + MEMBER_STORED_CARD_MANAGE_PERMISSION, + MEMBER_STORED_CARD_VIEW_PERMISSION, + STORED_CARD_TAB_OPTIONS, + createDefaultRecordDateRange, +} from './stored-card-page/constants'; +import { + downloadBase64File, + mapRecordFilterToQuery, +} from './stored-card-page/helpers'; +import { createPlanActions } from './stored-card-page/plan-actions'; +import { message } from 'ant-design-vue'; + +export function useMemberStoredCardPage() { + const accessStore = useAccessStore(); + + const stores = ref([]); + const selectedStoreId = ref(''); + const isStoreLoading = ref(false); + + const activeTab = ref('plans'); + + const planRows = ref([]); + const planStats = ref(createDefaultStoredCardPlanStats()); + const isPlanLoading = ref(false); + + const recordFilterForm = reactive(createDefaultStoredCardRecordFilterForm()); + recordFilterForm.dateRange = createDefaultRecordDateRange(); + const recordPager = ref(createDefaultStoredCardRecordPager()); + const isRecordLoading = ref(false); + const isExporting = ref(false); + + const drawerMode = ref<'create' | 'edit'>('create'); + const form = reactive(createDefaultStoredCardPlanEditorForm()); + const isDrawerOpen = ref(false); + const isDrawerLoading = ref(false); + const isDrawerSubmitting = ref(false); + + const accessCodeSet = computed( + () => new Set((accessStore.accessCodes ?? []).map(String)), + ); + const canManage = computed(() => + accessCodeSet.value.has(MEMBER_STORED_CARD_MANAGE_PERMISSION), + ); + const canView = computed( + () => + canManage.value || + accessCodeSet.value.has(MEMBER_STORED_CARD_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 { loadPlanList, loadRecordList, loadStores } = createDataActions({ + stores, + selectedStoreId, + isStoreLoading, + isPlanLoading, + planRows, + planStats, + isRecordLoading, + recordFilterForm, + recordPager, + }); + + const { + openCreateDrawer, + openEditDrawer, + setDrawerOpen, + setFormGiftAmount, + setFormRechargeAmount, + setFormSortOrder, + setFormStatus, + submitDrawer, + } = createDrawerActions({ + canManage, + drawerMode, + form, + isDrawerLoading, + isDrawerOpen, + isDrawerSubmitting, + selectedStoreId, + loadPlanList, + loadRecordList, + }); + + const { removePlan, toggleStatus } = createPlanActions({ + canManage, + selectedStoreId, + loadPlanList, + loadRecordList, + }); + + function setSelectedStoreId(value: string) { + selectedStoreId.value = value; + } + + function setActiveTab(value: StoredCardTabKey) { + activeTab.value = value; + } + + function setRecordKeyword(value: string) { + recordFilterForm.keyword = value; + } + + function setRecordDateRange(value: null | [any, any]) { + if (!value) { + recordFilterForm.dateRange = null; + return; + } + + if (value.length === 2) { + recordFilterForm.dateRange = value; + } + } + + async function applyRecordFilters() { + recordPager.value = { + ...recordPager.value, + page: 1, + }; + await loadRecordList(); + } + + async function resetRecordFilters() { + recordFilterForm.keyword = ''; + recordFilterForm.dateRange = createDefaultRecordDateRange(); + 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 exportMemberStoredCardRechargeRecordApi({ + storeId: selectedStoreId.value, + ...query, + }); + downloadBase64File(result.fileName, result.fileContentBase64); + message.success(`导出成功,共 ${result.totalCount} 条`); + } catch (error) { + console.error(error); + message.error('导出失败'); + } finally { + isExporting.value = false; + } + } + + watch(selectedStoreId, async () => { + recordPager.value = { + ...recordPager.value, + page: 1, + pageSize: 8, + }; + await Promise.all([loadPlanList(), loadRecordList()]); + }); + + onMounted(async () => { + await loadStores(); + if (selectedStoreId.value) { + await Promise.all([loadPlanList(), loadRecordList()]); + } + }); + + return { + activeTab, + applyRecordFilters, + canManage, + canView, + drawerMode, + drawerSubmitText, + drawerTitle, + exportRecords, + form, + handleRecordPageChange, + hasStore, + isDrawerLoading, + isDrawerOpen, + isDrawerSubmitting, + isExporting, + isPlanLoading, + isRecordLoading, + isStoreLoading, + openCreateDrawer, + openEditDrawer, + planRows, + planStats, + recordFilterForm, + recordPager, + removePlan, + resetRecordFilters, + selectedStoreId, + setActiveTab, + setDrawerOpen, + setFormGiftAmount, + setFormRechargeAmount, + setFormSortOrder, + setFormStatus, + setRecordDateRange, + setRecordKeyword, + setSelectedStoreId, + storeOptions, + submitDrawer, + tabOptions: STORED_CARD_TAB_OPTIONS, + toggleStatus, + }; +} diff --git a/apps/web-antd/src/views/member/stored-card/index.vue b/apps/web-antd/src/views/member/stored-card/index.vue new file mode 100644 index 0000000..9a5e013 --- /dev/null +++ b/apps/web-antd/src/views/member/stored-card/index.vue @@ -0,0 +1,164 @@ + + + + + diff --git a/apps/web-antd/src/views/member/stored-card/styles/base.less b/apps/web-antd/src/views/member/stored-card/styles/base.less new file mode 100644 index 0000000..83fe37e --- /dev/null +++ b/apps/web-antd/src/views/member/stored-card/styles/base.less @@ -0,0 +1,30 @@ +.page-member-stored-card { + .msc-page { + display: flex; + flex-direction: column; + gap: 16px; + } + + .msc-tab-panel { + display: flex; + flex-direction: column; + gap: 16px; + } + + .msc-empty { + border-radius: 12px; + padding: 40px 20px; + background: #fff; + box-shadow: 0 3px 10px rgb(16 24 40 / 6%); + } + + .msc-readonly-tip { + margin-left: auto; + color: #6b7280; + font-size: 12px; + } + + .msc-spacer { + flex: 1; + } +} diff --git a/apps/web-antd/src/views/member/stored-card/styles/drawer.less b/apps/web-antd/src/views/member/stored-card/styles/drawer.less new file mode 100644 index 0000000..90c1f77 --- /dev/null +++ b/apps/web-antd/src/views/member/stored-card/styles/drawer.less @@ -0,0 +1,52 @@ +.page-member-stored-card { + .msc-plan-drawer { + .ant-drawer-header { + padding: 16px 20px; + } + + .ant-drawer-body { + padding: 18px 20px; + } + } + + .msc-plan-form { + .ant-form-item { + margin-bottom: 16px; + } + } + + .msc-ratio-hint { + display: inline-flex; + align-items: center; + border-radius: 6px; + margin-top: 8px; + padding: 4px 10px; + color: #1677ff; + font-size: 12px; + font-weight: 500; + background: rgb(22 119 255 / 10%); + } + + .msc-form-hint { + margin-top: 6px; + color: #9ca3af; + font-size: 12px; + } + + .msc-status-row { + display: flex; + align-items: center; + gap: 10px; + } + + .msc-status-text { + color: #4b5563; + font-size: 13px; + } + + .msc-drawer-footer { + display: flex; + justify-content: flex-end; + gap: 10px; + } +} diff --git a/apps/web-antd/src/views/member/stored-card/styles/index.less b/apps/web-antd/src/views/member/stored-card/styles/index.less new file mode 100644 index 0000000..83a0332 --- /dev/null +++ b/apps/web-antd/src/views/member/stored-card/styles/index.less @@ -0,0 +1,6 @@ +@import './base.less'; +@import './layout.less'; +@import './plan.less'; +@import './record.less'; +@import './drawer.less'; +@import './responsive.less'; diff --git a/apps/web-antd/src/views/member/stored-card/styles/layout.less b/apps/web-antd/src/views/member/stored-card/styles/layout.less new file mode 100644 index 0000000..3a8d0bf --- /dev/null +++ b/apps/web-antd/src/views/member/stored-card/styles/layout.less @@ -0,0 +1,25 @@ +.page-member-stored-card { + .msc-toolbar { + display: flex; + align-items: center; + gap: 12px; + border-radius: 12px; + padding: 12px 14px; + background: #fff; + box-shadow: 0 2px 8px rgb(15 23 42 / 7%); + } + + .msc-toolbar-top { + flex-wrap: wrap; + } + + .msc-store-select { + width: 220px; + min-width: 220px; + } + + .msc-segmented { + --ant-segmented-item-selected-bg: #fff; + --ant-segmented-item-selected-color: #1677ff; + } +} diff --git a/apps/web-antd/src/views/member/stored-card/styles/plan.less b/apps/web-antd/src/views/member/stored-card/styles/plan.less new file mode 100644 index 0000000..83b64b9 --- /dev/null +++ b/apps/web-antd/src/views/member/stored-card/styles/plan.less @@ -0,0 +1,139 @@ +.page-member-stored-card { + .msc-stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + } + + .msc-stat-card { + border-radius: 12px; + padding: 16px 18px; + background: #fff; + box-shadow: 0 3px 10px rgb(16 24 40 / 6%); + } + + .msc-stat-label { + margin-bottom: 6px; + color: #9ca3af; + font-size: 13px; + } + + .msc-stat-value { + color: #0f172a; + font-size: 24px; + font-weight: 700; + line-height: 1; + } + + .msc-stat-value.is-orange { + color: #fa8c16; + } + + .msc-stat-value.is-green { + color: #16a34a; + } + + .msc-section-title { + border-left: 3px solid #1677ff; + padding-left: 10px; + color: #111827; + font-size: 15px; + font-weight: 600; + } + + .msc-plan-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; + } + + .msc-plan-card { + display: flex; + flex-direction: column; + border-radius: 12px; + overflow: hidden; + background: #fff; + box-shadow: 0 4px 14px rgb(15 23 42 / 8%); + transition: transform 0.2s ease, box-shadow 0.2s ease; + } + + .msc-plan-card:hover { + transform: translateY(-2px); + box-shadow: 0 10px 20px rgb(15 23 42 / 12%); + } + + .msc-plan-card.is-disabled { + opacity: 0.58; + } + + .msc-plan-card-head { + position: relative; + display: flex; + flex-direction: column; + gap: 6px; + padding: 22px 20px 18px; + color: #fff; + background: linear-gradient(135deg, #0f66d8 0%, #4592ff 100%); + } + + .msc-plan-card.is-disabled .msc-plan-card-head { + background: linear-gradient(135deg, #9ca3af 0%, #b0b8c4 100%); + } + + .msc-plan-amount { + font-size: 28px; + font-weight: 700; + letter-spacing: -0.5px; + } + + .msc-plan-gift { + display: inline-flex; + align-self: flex-start; + border-radius: 999px; + padding: 3px 10px; + color: #fff; + font-size: 13px; + font-weight: 600; + background: rgb(255 255 255 / 22%); + } + + .msc-plan-arrived { + font-size: 13px; + opacity: 0.92; + } + + .msc-plan-card-body { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 14px 20px; + } + + .msc-plan-meta-item { + color: #9ca3af; + font-size: 12px; + } + + .msc-plan-meta-item span { + margin-left: 2px; + color: #4b5563; + font-weight: 600; + } + + .msc-plan-card-foot { + display: flex; + justify-content: space-between; + align-items: center; + border-top: 1px solid #f3f4f6; + padding: 12px 20px 14px; + } + + .msc-plan-actions { + display: inline-flex; + gap: 8px; + } + + .msc-create-btn { + align-self: flex-start; + } +} diff --git a/apps/web-antd/src/views/member/stored-card/styles/record.less b/apps/web-antd/src/views/member/stored-card/styles/record.less new file mode 100644 index 0000000..7e3248f --- /dev/null +++ b/apps/web-antd/src/views/member/stored-card/styles/record.less @@ -0,0 +1,82 @@ +.page-member-stored-card { + .msc-record-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + border-radius: 12px; + padding: 12px 14px; + background: #fff; + box-shadow: 0 2px 8px rgb(15 23 42 / 7%); + } + + .msc-record-range { + width: 280px; + } + + .msc-record-keyword { + width: 220px; + } + + .msc-record-table-wrap { + border-radius: 12px; + padding: 0; + background: #fff; + box-shadow: 0 4px 14px rgb(15 23 42 / 8%); + overflow: hidden; + } + + .msc-record-table { + .ant-table-thead > tr > th { + background: #f8fafc; + color: #475569; + font-size: 13px; + font-weight: 600; + } + + .ant-table-tbody > tr > td { + color: #111827; + font-size: 13px; + vertical-align: middle; + } + } + + .msc-record-no { + color: #334155; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; + } + + .msc-record-member { + display: flex; + flex-direction: column; + line-height: 1.2; + gap: 2px; + } + + .msc-record-member-name { + color: #111827; + font-weight: 600; + } + + .msc-record-member-phone { + color: #94a3b8; + font-size: 12px; + } + + .msc-record-amount { + color: #111827; + font-weight: 600; + } + + .msc-record-amount.is-gift { + color: #f59e0b; + font-weight: 500; + } + + .msc-pagination { + display: flex; + justify-content: flex-end; + padding: 14px 16px 16px; + } +} diff --git a/apps/web-antd/src/views/member/stored-card/styles/responsive.less b/apps/web-antd/src/views/member/stored-card/styles/responsive.less new file mode 100644 index 0000000..df4b926 --- /dev/null +++ b/apps/web-antd/src/views/member/stored-card/styles/responsive.less @@ -0,0 +1,34 @@ +.page-member-stored-card { + @media (max-width: 1280px) { + .msc-plan-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + + @media (max-width: 992px) { + .msc-stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .msc-record-range, + .msc-record-keyword, + .msc-store-select { + width: 100%; + min-width: 0; + } + } + + @media (max-width: 768px) { + .msc-plan-grid { + grid-template-columns: 1fr; + } + + .msc-stats { + grid-template-columns: 1fr; + } + + .msc-toolbar { + align-items: stretch; + } + } +} diff --git a/apps/web-antd/src/views/member/stored-card/types.ts b/apps/web-antd/src/views/member/stored-card/types.ts new file mode 100644 index 0000000..a50c719 --- /dev/null +++ b/apps/web-antd/src/views/member/stored-card/types.ts @@ -0,0 +1,79 @@ +import type { Dayjs } from 'dayjs'; + +import type { + MemberStoredCardPlanDto, + MemberStoredCardPlanStatsDto, + MemberStoredCardPlanStatus, + MemberStoredCardRechargeRecordDto, +} from '#/api/member/stored-card'; + +/** 页面主 Tab。 */ +export type StoredCardTabKey = 'plans' | 'records'; + +/** 方案编辑表单。 */ +export interface StoredCardPlanEditorForm { + giftAmount: null | number; + planId: string; + rechargeAmount: null | number; + sortOrder: null | number; + status: MemberStoredCardPlanStatus; +} + +/** 充值记录筛选表单。 */ +export interface StoredCardRecordFilterForm { + dateRange: [Dayjs, Dayjs] | null; + keyword: string; +} + +/** 充值记录分页。 */ +export interface StoredCardRecordPager { + items: MemberStoredCardRechargeRecordDto[]; + page: number; + pageSize: number; + totalCount: number; +} + +/** 方案卡片视图模型。 */ +export type StoredCardPlanCardViewModel = MemberStoredCardPlanDto; + +/** 方案统计视图模型。 */ +export type StoredCardPlanStatsViewModel = MemberStoredCardPlanStatsDto; + +/** 创建默认方案编辑表单。 */ +export function createDefaultStoredCardPlanEditorForm(): StoredCardPlanEditorForm { + return { + planId: '', + rechargeAmount: null, + giftAmount: null, + sortOrder: 100, + status: 'enabled', + }; +} + +/** 创建默认充值记录筛选表单。 */ +export function createDefaultStoredCardRecordFilterForm(): StoredCardRecordFilterForm { + return { + dateRange: null, + keyword: '', + }; +} + +/** 创建默认充值记录分页状态。 */ +export function createDefaultStoredCardRecordPager(): StoredCardRecordPager { + return { + items: [], + page: 1, + pageSize: 8, + totalCount: 0, + }; +} + +/** 创建默认方案统计。 */ +export function createDefaultStoredCardPlanStats(): StoredCardPlanStatsViewModel { + return { + totalRechargeAmount: 0, + totalGiftAmount: 0, + currentMonthRechargeAmount: 0, + rechargeMemberCount: 0, + }; +}