From 128ad99d8a5a362bc2569f1de1bf56ccf33ca41d Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Tue, 3 Mar 2026 20:37:43 +0800 Subject: [PATCH] feat(@vben/web-antd): add member center management module --- apps/web-antd/src/api/member/index.ts | 283 ++++++++++ .../components/MemberCouponPickerModal.vue | 68 +++ .../list/components/MemberDaySettingCard.vue | 118 ++++ .../list/components/MemberDetailDrawer.vue | 257 +++++++++ .../list/components/MemberFilterBar.vue | 98 ++++ .../member/list/components/MemberStatsBar.vue | 44 ++ .../list/components/MemberTableCard.vue | 160 ++++++ .../components/MemberTierEditorDrawer.vue | 521 ++++++++++++++++++ .../list/components/MemberTierSystem.vue | 106 ++++ .../list/composables/member-page/constants.ts | 100 ++++ .../composables/member-page/coupon-actions.ts | 111 ++++ .../composables/member-page/data-actions.ts | 159 ++++++ .../composables/member-page/detail-actions.ts | 77 +++ .../composables/member-page/export-actions.ts | 51 ++ .../composables/member-page/filter-actions.ts | 49 ++ .../list/composables/member-page/helpers.ts | 258 +++++++++ .../composables/member-page/tier-actions.ts | 153 +++++ .../list/composables/useMemberListPage.ts | 371 +++++++++++++ apps/web-antd/src/views/member/list/index.vue | 189 +++++++ .../src/views/member/list/styles/base.less | 15 + .../src/views/member/list/styles/drawer.less | 244 ++++++++ .../src/views/member/list/styles/index.less | 6 + .../src/views/member/list/styles/layout.less | 39 ++ .../src/views/member/list/styles/list.less | 74 +++ .../views/member/list/styles/responsive.less | 52 ++ .../src/views/member/list/styles/tier.less | 173 ++++++ apps/web-antd/src/views/member/list/types.ts | 61 ++ 27 files changed, 3837 insertions(+) create mode 100644 apps/web-antd/src/api/member/index.ts create mode 100644 apps/web-antd/src/views/member/list/components/MemberCouponPickerModal.vue create mode 100644 apps/web-antd/src/views/member/list/components/MemberDaySettingCard.vue create mode 100644 apps/web-antd/src/views/member/list/components/MemberDetailDrawer.vue create mode 100644 apps/web-antd/src/views/member/list/components/MemberFilterBar.vue create mode 100644 apps/web-antd/src/views/member/list/components/MemberStatsBar.vue create mode 100644 apps/web-antd/src/views/member/list/components/MemberTableCard.vue create mode 100644 apps/web-antd/src/views/member/list/components/MemberTierEditorDrawer.vue create mode 100644 apps/web-antd/src/views/member/list/components/MemberTierSystem.vue create mode 100644 apps/web-antd/src/views/member/list/composables/member-page/constants.ts create mode 100644 apps/web-antd/src/views/member/list/composables/member-page/coupon-actions.ts create mode 100644 apps/web-antd/src/views/member/list/composables/member-page/data-actions.ts create mode 100644 apps/web-antd/src/views/member/list/composables/member-page/detail-actions.ts create mode 100644 apps/web-antd/src/views/member/list/composables/member-page/export-actions.ts create mode 100644 apps/web-antd/src/views/member/list/composables/member-page/filter-actions.ts create mode 100644 apps/web-antd/src/views/member/list/composables/member-page/helpers.ts create mode 100644 apps/web-antd/src/views/member/list/composables/member-page/tier-actions.ts create mode 100644 apps/web-antd/src/views/member/list/composables/useMemberListPage.ts create mode 100644 apps/web-antd/src/views/member/list/index.vue create mode 100644 apps/web-antd/src/views/member/list/styles/base.less create mode 100644 apps/web-antd/src/views/member/list/styles/drawer.less create mode 100644 apps/web-antd/src/views/member/list/styles/index.less create mode 100644 apps/web-antd/src/views/member/list/styles/layout.less create mode 100644 apps/web-antd/src/views/member/list/styles/list.less create mode 100644 apps/web-antd/src/views/member/list/styles/responsive.less create mode 100644 apps/web-antd/src/views/member/list/styles/tier.less create mode 100644 apps/web-antd/src/views/member/list/types.ts diff --git a/apps/web-antd/src/api/member/index.ts b/apps/web-antd/src/api/member/index.ts new file mode 100644 index 0000000..0ce403c --- /dev/null +++ b/apps/web-antd/src/api/member/index.ts @@ -0,0 +1,283 @@ +/** + * 文件职责:会员中心会员管理 API 契约定义。 + */ +import { requestClient } from '#/api/request'; + +/** 会员列表筛选参数。 */ +export interface MemberListFilterQuery { + keyword?: string; + storeId: string; + tierId?: string; +} + +/** 会员列表分页参数。 */ +export interface MemberListQuery extends MemberListFilterQuery { + page: number; + pageSize: number; +} + +/** 会员列表行。 */ +export interface MemberListItemDto { + avatarColor: string; + avatarText: string; + isDormant: boolean; + lastOrderAt: string; + memberId: string; + mobileMasked: string; + name: string; + orderCount: number; + pointsBalance: number; + storedBalance: number; + tierColorHex: string; + tierId?: string; + tierName: string; + totalAmount: number; +} + +/** 会员列表结果。 */ +export interface MemberListResultDto { + items: MemberListItemDto[]; + page: number; + pageSize: number; + total: number; +} + +/** 会员列表统计。 */ +export interface MemberListStatsDto { + activeMembers: number; + dormantMembers: number; + monthlyNewMembers: number; + totalMembers: number; +} + +/** 最近订单。 */ +export interface MemberRecentOrderDto { + amount: number; + orderNo: string; + orderedAt: string; + statusText: string; +} + +/** 会员详情。 */ +export interface MemberDetailDto { + avatarColor: string; + avatarText: string; + averageAmount: number; + joinedAt: string; + memberId: string; + mobileMasked: string; + name: string; + orderCount: number; + pointsBalance: number; + recentOrders: MemberRecentOrderDto[]; + storedBalance: number; + storedGiftBalance: number; + storedRechargeBalance: number; + tags: string[]; + tierColorHex: string; + tierId?: string; + tierName: string; + totalAmount: number; +} + +/** 会员导出回执。 */ +export interface MemberExportDto { + fileContentBase64: string; + fileName: string; + totalCount: number; +} + +/** 会员等级列表项。 */ +export interface MemberTierListItemDto { + canDelete: boolean; + colorHex: string; + conditionText: string; + iconKey: string; + isDefault: boolean; + memberCount: number; + name: string; + perks: string[]; + sortOrder: number; + tierId: string; +} + +/** 升降级规则。 */ +export interface MemberTierRuleDto { + downgradeWindowDays: number; + upgradeAmountThreshold?: number; + upgradeOrderCountThreshold?: number; + upgradeRuleType: 'amount' | 'both' | 'count' | 'none'; +} + +/** 折扣权益。 */ +export interface MemberTierDiscountBenefitDto { + discountRate?: number; + enabled: boolean; +} + +/** 积分倍率权益。 */ +export interface MemberTierPointMultiplierBenefitDto { + enabled: boolean; + multiplier?: number; +} + +/** 生日特权。 */ +export interface MemberTierBirthdayBenefitDto { + couponTemplateIds: string[]; + doublePointsEnabled: boolean; + enabled: boolean; +} + +/** 每月赠券。 */ +export interface MemberTierMonthlyCouponBenefitDto { + couponTemplateIds: string[]; + enabled: boolean; + grantDay: number; +} + +/** 免配送费权益。 */ +export interface MemberTierFreeDeliveryBenefitDto { + enabled: boolean; + monthlyFreeTimes: number; +} + +/** 等级权益。 */ +export interface MemberTierBenefitsDto { + birthday: MemberTierBirthdayBenefitDto; + discount: MemberTierDiscountBenefitDto; + exclusiveServiceEnabled: boolean; + freeDelivery: MemberTierFreeDeliveryBenefitDto; + monthlyCoupon: MemberTierMonthlyCouponBenefitDto; + pointMultiplier: MemberTierPointMultiplierBenefitDto; + priorityDeliveryEnabled: boolean; +} + +/** 等级详情。 */ +export interface MemberTierDetailDto { + benefits: MemberTierBenefitsDto; + canDelete: boolean; + colorHex: string; + iconKey: string; + isDefault: boolean; + name: string; + rule: MemberTierRuleDto; + sortOrder: number; + tierId?: string; +} + +/** 保存等级请求。 */ +export interface SaveMemberTierPayload { + benefits: MemberTierBenefitsDto; + colorHex: string; + iconKey: string; + isDefault: boolean; + name: string; + rule: MemberTierRuleDto; + sortOrder: number; + tierId?: string; +} + +/** 会员日配置。 */ +export interface MemberDaySettingDto { + extraDiscountRate: number; + isEnabled: boolean; + weekday: number; +} + +/** 优惠券选择项。 */ +export interface MemberCouponPickerItemDto { + couponTemplateId: string; + couponType: string; + displayText: string; + minimumSpend?: number; + name: string; + value: number; +} + +/** 查询会员列表。 */ +export async function getMemberListApi(params: MemberListQuery) { + return requestClient.get('/member/list/list', { + params, + }); +} + +/** 查询会员列表统计。 */ +export async function getMemberListStatsApi(params: MemberListFilterQuery) { + return requestClient.get('/member/list/stats', { + params, + }); +} + +/** 查询会员详情。 */ +export async function getMemberDetailApi(params: { + memberId: string; + storeId: string; +}) { + return requestClient.get('/member/list/detail', { + params, + }); +} + +/** 保存会员标签。 */ +export async function saveMemberTagsApi(payload: { + memberId: string; + storeId?: string; + tags: string[]; +}) { + return requestClient.post('/member/list/tags', payload); +} + +/** 导出会员 CSV。 */ +export async function exportMemberCsvApi(params: MemberListFilterQuery) { + return requestClient.get('/member/list/export', { + params, + }); +} + +/** 查询会员等级列表。 */ +export async function getMemberTierListApi() { + return requestClient.get('/member/tier/list'); +} + +/** 查询会员等级详情。 */ +export async function getMemberTierDetailApi(params: { tierId?: string }) { + return requestClient.get('/member/tier/detail', { + params, + }); +} + +/** 保存会员等级。 */ +export async function saveMemberTierApi(payload: SaveMemberTierPayload) { + return requestClient.post('/member/tier/save', payload); +} + +/** 删除会员等级。 */ +export async function deleteMemberTierApi(payload: { tierId: string }) { + return requestClient.post('/member/tier/delete', payload); +} + +/** 查询会员日配置。 */ +export async function getMemberDaySettingApi() { + return requestClient.get('/member/tier/day-setting'); +} + +/** 保存会员日配置。 */ +export async function saveMemberDaySettingApi(payload: MemberDaySettingDto) { + return requestClient.post( + '/member/tier/day-setting', + payload, + ); +} + +/** 查询优惠券选择器列表。 */ +export async function getMemberCouponPickerApi(params: { + keyword?: string; + storeId?: string; +}) { + return requestClient.get( + '/member/tier/coupon-picker', + { + params, + }, + ); +} diff --git a/apps/web-antd/src/views/member/list/components/MemberCouponPickerModal.vue b/apps/web-antd/src/views/member/list/components/MemberCouponPickerModal.vue new file mode 100644 index 0000000..abf8599 --- /dev/null +++ b/apps/web-antd/src/views/member/list/components/MemberCouponPickerModal.vue @@ -0,0 +1,68 @@ + + + diff --git a/apps/web-antd/src/views/member/list/components/MemberDaySettingCard.vue b/apps/web-antd/src/views/member/list/components/MemberDaySettingCard.vue new file mode 100644 index 0000000..7ae2e4d --- /dev/null +++ b/apps/web-antd/src/views/member/list/components/MemberDaySettingCard.vue @@ -0,0 +1,118 @@ + + + diff --git a/apps/web-antd/src/views/member/list/components/MemberDetailDrawer.vue b/apps/web-antd/src/views/member/list/components/MemberDetailDrawer.vue new file mode 100644 index 0000000..2e59985 --- /dev/null +++ b/apps/web-antd/src/views/member/list/components/MemberDetailDrawer.vue @@ -0,0 +1,257 @@ + + + diff --git a/apps/web-antd/src/views/member/list/components/MemberFilterBar.vue b/apps/web-antd/src/views/member/list/components/MemberFilterBar.vue new file mode 100644 index 0000000..c2cc46b --- /dev/null +++ b/apps/web-antd/src/views/member/list/components/MemberFilterBar.vue @@ -0,0 +1,98 @@ + + + diff --git a/apps/web-antd/src/views/member/list/components/MemberStatsBar.vue b/apps/web-antd/src/views/member/list/components/MemberStatsBar.vue new file mode 100644 index 0000000..2046d18 --- /dev/null +++ b/apps/web-antd/src/views/member/list/components/MemberStatsBar.vue @@ -0,0 +1,44 @@ + + + diff --git a/apps/web-antd/src/views/member/list/components/MemberTableCard.vue b/apps/web-antd/src/views/member/list/components/MemberTableCard.vue new file mode 100644 index 0000000..b40681c --- /dev/null +++ b/apps/web-antd/src/views/member/list/components/MemberTableCard.vue @@ -0,0 +1,160 @@ + + + diff --git a/apps/web-antd/src/views/member/list/components/MemberTierEditorDrawer.vue b/apps/web-antd/src/views/member/list/components/MemberTierEditorDrawer.vue new file mode 100644 index 0000000..680e6d4 --- /dev/null +++ b/apps/web-antd/src/views/member/list/components/MemberTierEditorDrawer.vue @@ -0,0 +1,521 @@ + + + diff --git a/apps/web-antd/src/views/member/list/components/MemberTierSystem.vue b/apps/web-antd/src/views/member/list/components/MemberTierSystem.vue new file mode 100644 index 0000000..664e361 --- /dev/null +++ b/apps/web-antd/src/views/member/list/components/MemberTierSystem.vue @@ -0,0 +1,106 @@ + + + diff --git a/apps/web-antd/src/views/member/list/composables/member-page/constants.ts b/apps/web-antd/src/views/member/list/composables/member-page/constants.ts new file mode 100644 index 0000000..cebff62 --- /dev/null +++ b/apps/web-antd/src/views/member/list/composables/member-page/constants.ts @@ -0,0 +1,100 @@ +import type { MemberFilterState, OptionItem } from '../../types'; + +import type { + MemberDaySettingDto, + MemberListStatsDto, + MemberTierBenefitsDto, + MemberTierRuleDto, +} from '#/api/member'; + +/** 会员管理查看权限。 */ +export const MEMBER_VIEW_PERMISSION = 'tenant:member:view'; + +/** 会员管理编辑权限。 */ +export const MEMBER_MANAGE_PERMISSION = 'tenant:member:manage'; + +/** 等级图标选项。 */ +export const MEMBER_TIER_ICON_OPTIONS: OptionItem[] = [ + { label: '默认', value: 'user' }, + { label: '奖章', value: 'award' }, + { label: '奖杯', value: 'trophy' }, + { label: '钻石', value: 'gem' }, + { label: '皇冠', value: 'crown' }, + { label: '星星', value: 'star' }, +]; + +/** 等级颜色选项。 */ +export const MEMBER_TIER_COLOR_OPTIONS = [ + '#999999', + '#1890ff', + '#fa8c16', + '#722ed1', + '#eb2f96', + '#f5222d', +]; + +/** 升级规则选项。 */ +export const MEMBER_TIER_RULE_OPTIONS: OptionItem[] = [ + { label: '按累计消费金额', value: 'amount' }, + { label: '按消费次数', value: 'count' }, + { label: '同时满足', value: 'both' }, +]; + +/** 默认筛选项。 */ +export function createDefaultFilters(): MemberFilterState { + return { + keyword: '', + tierId: '', + }; +} + +/** 默认统计。 */ +export const DEFAULT_MEMBER_STATS: MemberListStatsDto = { + totalMembers: 0, + monthlyNewMembers: 0, + activeMembers: 0, + dormantMembers: 0, +}; + +/** 默认会员日配置。 */ +export const DEFAULT_MEMBER_DAY_SETTING: MemberDaySettingDto = { + isEnabled: true, + weekday: 2, + extraDiscountRate: 9, +}; + +/** 默认等级规则。 */ +export const DEFAULT_MEMBER_TIER_RULE: MemberTierRuleDto = { + upgradeRuleType: 'amount', + upgradeAmountThreshold: 500, + upgradeOrderCountThreshold: undefined, + downgradeWindowDays: 90, +}; + +/** 默认等级权益。 */ +export const DEFAULT_MEMBER_TIER_BENEFITS: MemberTierBenefitsDto = { + discount: { + enabled: false, + discountRate: 9.5, + }, + pointMultiplier: { + enabled: false, + multiplier: 1.5, + }, + birthday: { + enabled: false, + doublePointsEnabled: false, + couponTemplateIds: [], + }, + monthlyCoupon: { + enabled: false, + grantDay: 1, + couponTemplateIds: [], + }, + freeDelivery: { + enabled: false, + monthlyFreeTimes: 0, + }, + priorityDeliveryEnabled: false, + exclusiveServiceEnabled: false, +}; diff --git a/apps/web-antd/src/views/member/list/composables/member-page/coupon-actions.ts b/apps/web-antd/src/views/member/list/composables/member-page/coupon-actions.ts new file mode 100644 index 0000000..7566d73 --- /dev/null +++ b/apps/web-antd/src/views/member/list/composables/member-page/coupon-actions.ts @@ -0,0 +1,111 @@ +import type { MemberTierEditorForm } from '../../types'; + +import type { MemberCouponPickerItemDto } from '#/api/member'; + +interface CouponActionOptions { + couponItems: { value: MemberCouponPickerItemDto[] }; + couponKeyword: { value: string }; + couponModalOpen: { value: boolean }; + couponTarget: { value: '' | 'birthday' | 'monthly' }; + loadCouponItems: (keyword: string) => Promise; + selectedCouponIds: { value: string[] }; + tierForm: { value: MemberTierEditorForm | null }; +} + +/** + * 文件职责:等级编辑抽屉内优惠券选择动作。 + */ +export function createCouponActions(options: CouponActionOptions) { + function setCouponModalOpen(value: boolean) { + options.couponModalOpen.value = value; + if (!value) { + options.selectedCouponIds.value = []; + options.couponTarget.value = ''; + options.couponKeyword.value = ''; + } + } + + function setCouponKeyword(value: string) { + options.couponKeyword.value = value; + } + + async function openCouponPicker( + target: 'birthday' | 'monthly', + selectedIds: string[], + ) { + options.couponTarget.value = target; + options.selectedCouponIds.value = [...selectedIds]; + options.couponModalOpen.value = true; + options.couponKeyword.value = ''; + await options.loadCouponItems(''); + } + + async function searchCoupons() { + await options.loadCouponItems(options.couponKeyword.value); + } + + function toggleCoupon(couponId: string) { + const exists = options.selectedCouponIds.value.includes(couponId); + if (exists) { + options.selectedCouponIds.value = options.selectedCouponIds.value.filter( + (item) => item !== couponId, + ); + return; + } + + options.selectedCouponIds.value = [ + ...options.selectedCouponIds.value, + couponId, + ]; + } + + function applyCoupons() { + if (!options.tierForm.value || !options.couponTarget.value) { + setCouponModalOpen(false); + return; + } + + options.tierForm.value = + options.couponTarget.value === 'birthday' + ? { + ...options.tierForm.value, + benefits: { + ...options.tierForm.value.benefits, + birthday: { + ...options.tierForm.value.benefits.birthday, + couponTemplateIds: [...options.selectedCouponIds.value], + }, + }, + } + : { + ...options.tierForm.value, + benefits: { + ...options.tierForm.value.benefits, + monthlyCoupon: { + ...options.tierForm.value.benefits.monthlyCoupon, + couponTemplateIds: [...options.selectedCouponIds.value], + }, + }, + }; + + setCouponModalOpen(false); + } + + function resolveCouponLabelMap() { + const labelMap: Record = {}; + for (const item of options.couponItems.value) { + labelMap[item.couponTemplateId] = item.displayText || item.name; + } + return labelMap; + } + + return { + applyCoupons, + openCouponPicker, + resolveCouponLabelMap, + searchCoupons, + setCouponKeyword, + setCouponModalOpen, + toggleCoupon, + }; +} diff --git a/apps/web-antd/src/views/member/list/composables/member-page/data-actions.ts b/apps/web-antd/src/views/member/list/composables/member-page/data-actions.ts new file mode 100644 index 0000000..6729778 --- /dev/null +++ b/apps/web-antd/src/views/member/list/composables/member-page/data-actions.ts @@ -0,0 +1,159 @@ +import type { MemberFilterState, MemberPaginationState } from '../../types'; + +import type { + MemberCouponPickerItemDto, + MemberDaySettingDto, + MemberListItemDto, + MemberListStatsDto, + MemberTierListItemDto, +} from '#/api/member'; +import type { StoreListItemDto } from '#/api/store'; + +import { + getMemberCouponPickerApi, + getMemberDaySettingApi, + getMemberListApi, + getMemberListStatsApi, + getMemberTierListApi, +} from '#/api/member'; +import { getStoreListApi } from '#/api/store'; + +import { DEFAULT_MEMBER_DAY_SETTING, DEFAULT_MEMBER_STATS } from './constants'; +import { buildFilterPayload, buildQueryPayload } from './helpers'; + +interface DataActionOptions { + couponItems: { value: MemberCouponPickerItemDto[] }; + couponLoading: { value: boolean }; + daySetting: { value: MemberDaySettingDto | null }; + filters: MemberFilterState; + isListLoading: { value: boolean }; + isStoreLoading: { value: boolean }; + pageSizeMax?: number; + pagination: MemberPaginationState; + rows: { value: MemberListItemDto[] }; + selectedStoreId: { value: string }; + stats: MemberListStatsDto; + stores: { value: StoreListItemDto[] }; + tierLoading: { value: boolean }; + tiers: { value: MemberTierListItemDto[] }; +} + +/** + * 文件职责:会员页基础数据加载动作。 + */ +export function createDataActions(options: DataActionOptions) { + function resetStats() { + options.stats.totalMembers = DEFAULT_MEMBER_STATS.totalMembers; + options.stats.monthlyNewMembers = DEFAULT_MEMBER_STATS.monthlyNewMembers; + options.stats.activeMembers = DEFAULT_MEMBER_STATS.activeMembers; + options.stats.dormantMembers = DEFAULT_MEMBER_STATS.dormantMembers; + } + + async function loadStores() { + options.isStoreLoading.value = true; + try { + const result = await getStoreListApi({ page: 1, pageSize: 200 }); + options.stores.value = result.items; + + if (result.items.length === 0) { + options.selectedStoreId.value = ''; + options.rows.value = []; + options.pagination.total = 0; + resetStats(); + return; + } + + const matched = result.items.some( + (item) => item.id === options.selectedStoreId.value, + ); + if (!matched) { + options.selectedStoreId.value = result.items[0]?.id ?? ''; + } + } finally { + options.isStoreLoading.value = false; + } + } + + async function loadListAndStats() { + if (!options.selectedStoreId.value) { + options.rows.value = []; + options.pagination.total = 0; + resetStats(); + return; + } + + options.isListLoading.value = true; + try { + const [listResult, statsResult] = await Promise.all([ + getMemberListApi( + buildQueryPayload( + options.selectedStoreId.value, + options.filters, + options.pagination.page, + options.pagination.pageSize, + ), + ), + getMemberListStatsApi( + buildFilterPayload(options.selectedStoreId.value, options.filters), + ), + ]); + + options.rows.value = listResult.items; + options.pagination.total = listResult.total; + options.pagination.page = listResult.page; + options.pagination.pageSize = listResult.pageSize; + + options.stats.totalMembers = statsResult.totalMembers; + options.stats.monthlyNewMembers = statsResult.monthlyNewMembers; + options.stats.activeMembers = statsResult.activeMembers; + options.stats.dormantMembers = statsResult.dormantMembers; + } finally { + options.isListLoading.value = false; + } + } + + async function loadTierData() { + options.tierLoading.value = true; + try { + const [tierResult, daySettingResult] = await Promise.all([ + getMemberTierListApi(), + getMemberDaySettingApi(), + ]); + + options.tiers.value = tierResult; + options.daySetting.value = daySettingResult; + } finally { + options.tierLoading.value = false; + } + } + + async function loadCouponItems(keyword: string) { + if (!options.selectedStoreId.value) { + options.couponItems.value = []; + return; + } + + options.couponLoading.value = true; + try { + options.couponItems.value = await getMemberCouponPickerApi({ + storeId: options.selectedStoreId.value, + keyword: keyword.trim() || undefined, + }); + } finally { + options.couponLoading.value = false; + } + } + + function resetDaySetting() { + options.daySetting.value = { ...DEFAULT_MEMBER_DAY_SETTING }; + } + + return { + loadCouponItems, + loadListAndStats, + loadStores, + loadTierData, + resetDaySetting, + resetStats, + }; +} diff --git a/apps/web-antd/src/views/member/list/composables/member-page/detail-actions.ts b/apps/web-antd/src/views/member/list/composables/member-page/detail-actions.ts new file mode 100644 index 0000000..6a74144 --- /dev/null +++ b/apps/web-antd/src/views/member/list/composables/member-page/detail-actions.ts @@ -0,0 +1,77 @@ +import type { MemberDetailDto } from '#/api/member'; + +import { message } from 'ant-design-vue'; + +import { getMemberDetailApi, saveMemberTagsApi } from '#/api/member'; + +interface DetailActionOptions { + canManage: { value: boolean }; + detail: { value: MemberDetailDto | null }; + detailLoading: { value: boolean }; + detailOpen: { value: boolean }; + selectedStoreId: { value: string }; + tagSaving: { value: boolean }; +} + +/** + * 文件职责:会员详情抽屉与标签编辑动作。 + */ +export function createDetailActions(options: DetailActionOptions) { + function setDetailOpen(value: boolean) { + options.detailOpen.value = value; + if (!value) { + options.detail.value = null; + } + } + + async function openDetail(memberId: string) { + if (!options.selectedStoreId.value || !memberId) { + return; + } + + options.detailOpen.value = true; + options.detail.value = null; + options.detailLoading.value = true; + try { + options.detail.value = await getMemberDetailApi({ + storeId: options.selectedStoreId.value, + memberId, + }); + } finally { + options.detailLoading.value = false; + } + } + + async function saveTags(tags: string[]) { + if (!options.detail.value) { + return; + } + + if (!options.canManage.value) { + message.warning('暂无标签编辑权限'); + return; + } + + options.tagSaving.value = true; + try { + await saveMemberTagsApi({ + storeId: options.selectedStoreId.value, + memberId: options.detail.value.memberId, + tags, + }); + options.detail.value = { + ...options.detail.value, + tags: [...tags], + }; + message.success('标签已保存'); + } finally { + options.tagSaving.value = false; + } + } + + return { + openDetail, + saveTags, + setDetailOpen, + }; +} diff --git a/apps/web-antd/src/views/member/list/composables/member-page/export-actions.ts b/apps/web-antd/src/views/member/list/composables/member-page/export-actions.ts new file mode 100644 index 0000000..06b11df --- /dev/null +++ b/apps/web-antd/src/views/member/list/composables/member-page/export-actions.ts @@ -0,0 +1,51 @@ +import type { MemberFilterState } from '../../types'; + +import { message } from 'ant-design-vue'; + +import { exportMemberCsvApi } from '#/api/member'; + +import { buildFilterPayload, downloadBase64File } from './helpers'; + +interface ExportActionOptions { + canManage: { value: boolean }; + filters: MemberFilterState; + isExporting: { value: boolean }; + selectedStoreId: { value: string }; +} + +/** + * 文件职责:会员列表导出动作。 + */ +export function createExportActions(options: ExportActionOptions) { + async function handleExport() { + if (!options.selectedStoreId.value) { + return; + } + + if (!options.canManage.value) { + message.warning('暂无导出权限'); + return; + } + + options.isExporting.value = true; + try { + const result = await exportMemberCsvApi( + buildFilterPayload(options.selectedStoreId.value, options.filters), + ); + + if (!result.fileContentBase64) { + message.info('当前筛选条件暂无可导出数据'); + return; + } + + downloadBase64File(result.fileName, result.fileContentBase64); + message.success(`导出成功,共 ${result.totalCount} 条记录`); + } finally { + options.isExporting.value = false; + } + } + + return { + handleExport, + }; +} diff --git a/apps/web-antd/src/views/member/list/composables/member-page/filter-actions.ts b/apps/web-antd/src/views/member/list/composables/member-page/filter-actions.ts new file mode 100644 index 0000000..759ddb5 --- /dev/null +++ b/apps/web-antd/src/views/member/list/composables/member-page/filter-actions.ts @@ -0,0 +1,49 @@ +import type { MemberFilterState, MemberPaginationState } from '../../types'; + +import { createDefaultFilters } from './constants'; + +interface FilterActionOptions { + filters: MemberFilterState; + loadListAndStats: () => Promise; + pagination: MemberPaginationState; +} + +/** + * 文件职责:会员列表筛选与分页动作。 + */ +export function createFilterActions(options: FilterActionOptions) { + function setKeyword(value: string) { + options.filters.keyword = value; + } + + function setTierId(value: string) { + options.filters.tierId = value || ''; + } + + async function handleSearch() { + options.pagination.page = 1; + await options.loadListAndStats(); + } + + async function handleReset() { + const defaults = createDefaultFilters(); + options.filters.keyword = defaults.keyword; + options.filters.tierId = defaults.tierId; + options.pagination.page = 1; + await options.loadListAndStats(); + } + + async function handlePageChange(page: number, pageSize: number) { + options.pagination.page = page; + options.pagination.pageSize = pageSize; + await options.loadListAndStats(); + } + + return { + handlePageChange, + handleReset, + handleSearch, + setKeyword, + setTierId, + }; +} diff --git a/apps/web-antd/src/views/member/list/composables/member-page/helpers.ts b/apps/web-antd/src/views/member/list/composables/member-page/helpers.ts new file mode 100644 index 0000000..9700142 --- /dev/null +++ b/apps/web-antd/src/views/member/list/composables/member-page/helpers.ts @@ -0,0 +1,258 @@ +import type { MemberFilterState, MemberTierEditorForm } from '../../types'; + +import type { + MemberDetailDto, + MemberListFilterQuery, + MemberListQuery, + MemberTierDetailDto, + SaveMemberTierPayload, +} from '#/api/member'; + +import { + DEFAULT_MEMBER_TIER_BENEFITS, + DEFAULT_MEMBER_TIER_RULE, +} from './constants'; + +export function buildFilterPayload( + storeId: string, + filters: MemberFilterState, +): MemberListFilterQuery { + return { + storeId, + keyword: filters.keyword.trim() || undefined, + tierId: filters.tierId || undefined, + }; +} + +export function buildQueryPayload( + storeId: string, + filters: MemberFilterState, + page: number, + pageSize: number, +): MemberListQuery { + return { + ...buildFilterPayload(storeId, filters), + page, + pageSize, + }; +} + +export function formatCurrency(value: number) { + return new Intl.NumberFormat('zh-CN', { + style: 'currency', + currency: 'CNY', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(Number.isFinite(value) ? value : 0); +} + +export function formatInteger(value: number) { + return new Intl.NumberFormat('zh-CN', { + maximumFractionDigits: 0, + }).format(Number.isFinite(value) ? value : 0); +} + +export function decodeBase64ToBlob(base64: string) { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.codePointAt(index) ?? 0; + } + return new Blob([bytes], { type: 'text/csv;charset=utf-8;' }); +} + +export function downloadBase64File( + fileName: string, + fileContentBase64: string, +) { + const blob = decodeBase64ToBlob(fileContentBase64); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = fileName; + anchor.click(); + URL.revokeObjectURL(url); +} + +function normalizeHexColor(color: string) { + const source = (color || '#999999').trim(); + if (!source.startsWith('#')) { + return `#${source}`; + } + return source; +} + +function hexToRgb(hex: string) { + const normalized = normalizeHexColor(hex).replace('#', ''); + if (normalized.length !== 6) { + return { r: 153, g: 153, b: 153 }; + } + + return { + r: Number.parseInt(normalized.slice(0, 2), 16), + g: Number.parseInt(normalized.slice(2, 4), 16), + b: Number.parseInt(normalized.slice(4, 6), 16), + }; +} + +export function resolveTierTagStyle(colorHex: string) { + const rgb = hexToRgb(colorHex); + return { + color: normalizeHexColor(colorHex), + backgroundColor: `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.12)`, + borderColor: `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.35)`, + }; +} + +export function resolveTierIcon(iconKey: string) { + const normalized = (iconKey || 'user').trim().toLowerCase(); + if (normalized === 'award') return 'lucide:award'; + if (normalized === 'trophy') return 'lucide:trophy'; + if (normalized === 'gem') return 'lucide:gem'; + if (normalized === 'crown') return 'lucide:crown'; + if (normalized === 'star') return 'lucide:star'; + return 'lucide:user'; +} + +export function createTierEditorForm( + source: MemberTierDetailDto, +): MemberTierEditorForm { + return { + tierId: source.tierId, + sortOrder: source.sortOrder, + name: source.name, + iconKey: source.iconKey, + colorHex: source.colorHex, + isDefault: source.isDefault, + rule: { + upgradeRuleType: source.rule.upgradeRuleType, + upgradeAmountThreshold: source.rule.upgradeAmountThreshold, + upgradeOrderCountThreshold: source.rule.upgradeOrderCountThreshold, + downgradeWindowDays: source.rule.downgradeWindowDays, + }, + benefits: { + discount: { + enabled: source.benefits.discount.enabled, + discountRate: source.benefits.discount.discountRate, + }, + pointMultiplier: { + enabled: source.benefits.pointMultiplier.enabled, + multiplier: source.benefits.pointMultiplier.multiplier, + }, + birthday: { + enabled: source.benefits.birthday.enabled, + doublePointsEnabled: source.benefits.birthday.doublePointsEnabled, + couponTemplateIds: [...source.benefits.birthday.couponTemplateIds], + }, + monthlyCoupon: { + enabled: source.benefits.monthlyCoupon.enabled, + grantDay: source.benefits.monthlyCoupon.grantDay, + couponTemplateIds: [...source.benefits.monthlyCoupon.couponTemplateIds], + }, + freeDelivery: { + enabled: source.benefits.freeDelivery.enabled, + monthlyFreeTimes: source.benefits.freeDelivery.monthlyFreeTimes, + }, + priorityDeliveryEnabled: source.benefits.priorityDeliveryEnabled, + exclusiveServiceEnabled: source.benefits.exclusiveServiceEnabled, + }, + }; +} + +export function createEmptyTierEditorForm(): MemberTierEditorForm { + return { + tierId: undefined, + sortOrder: 1, + name: '', + iconKey: 'user', + colorHex: '#999999', + isDefault: false, + rule: { + ...DEFAULT_MEMBER_TIER_RULE, + }, + benefits: { + discount: { ...DEFAULT_MEMBER_TIER_BENEFITS.discount }, + pointMultiplier: { ...DEFAULT_MEMBER_TIER_BENEFITS.pointMultiplier }, + birthday: { + ...DEFAULT_MEMBER_TIER_BENEFITS.birthday, + couponTemplateIds: [], + }, + monthlyCoupon: { + ...DEFAULT_MEMBER_TIER_BENEFITS.monthlyCoupon, + couponTemplateIds: [], + }, + freeDelivery: { ...DEFAULT_MEMBER_TIER_BENEFITS.freeDelivery }, + priorityDeliveryEnabled: + DEFAULT_MEMBER_TIER_BENEFITS.priorityDeliveryEnabled, + exclusiveServiceEnabled: + DEFAULT_MEMBER_TIER_BENEFITS.exclusiveServiceEnabled, + }, + }; +} + +export function buildTierSavePayload( + form: MemberTierEditorForm, +): SaveMemberTierPayload { + return { + tierId: form.tierId, + sortOrder: Number(form.sortOrder || 1), + name: form.name.trim(), + iconKey: form.iconKey, + colorHex: normalizeHexColor(form.colorHex), + isDefault: !!form.isDefault, + rule: { + upgradeRuleType: form.rule.upgradeRuleType, + upgradeAmountThreshold: + form.rule.upgradeAmountThreshold === undefined + ? undefined + : Number(form.rule.upgradeAmountThreshold), + upgradeOrderCountThreshold: + form.rule.upgradeOrderCountThreshold === undefined + ? undefined + : Number(form.rule.upgradeOrderCountThreshold), + downgradeWindowDays: Number(form.rule.downgradeWindowDays || 90), + }, + benefits: { + discount: { + enabled: !!form.benefits.discount.enabled, + discountRate: + form.benefits.discount.discountRate === undefined + ? undefined + : Number(form.benefits.discount.discountRate), + }, + pointMultiplier: { + enabled: !!form.benefits.pointMultiplier.enabled, + multiplier: + form.benefits.pointMultiplier.multiplier === undefined + ? undefined + : Number(form.benefits.pointMultiplier.multiplier), + }, + birthday: { + enabled: !!form.benefits.birthday.enabled, + doublePointsEnabled: !!form.benefits.birthday.doublePointsEnabled, + couponTemplateIds: [ + ...new Set(form.benefits.birthday.couponTemplateIds), + ], + }, + monthlyCoupon: { + enabled: !!form.benefits.monthlyCoupon.enabled, + grantDay: Number(form.benefits.monthlyCoupon.grantDay || 1), + couponTemplateIds: [ + ...new Set(form.benefits.monthlyCoupon.couponTemplateIds), + ], + }, + freeDelivery: { + enabled: !!form.benefits.freeDelivery.enabled, + monthlyFreeTimes: Number( + form.benefits.freeDelivery.monthlyFreeTimes || 0, + ), + }, + priorityDeliveryEnabled: !!form.benefits.priorityDeliveryEnabled, + exclusiveServiceEnabled: !!form.benefits.exclusiveServiceEnabled, + }, + }; +} + +export function resolveMemberName(detail: MemberDetailDto | null) { + return detail?.name || '会员'; +} diff --git a/apps/web-antd/src/views/member/list/composables/member-page/tier-actions.ts b/apps/web-antd/src/views/member/list/composables/member-page/tier-actions.ts new file mode 100644 index 0000000..fcbddc7 --- /dev/null +++ b/apps/web-antd/src/views/member/list/composables/member-page/tier-actions.ts @@ -0,0 +1,153 @@ +import type { MemberTierEditorForm } from '../../types'; + +import type { + MemberDaySettingDto, + MemberTierListItemDto, + SaveMemberTierPayload, +} from '#/api/member'; + +import { message, Modal } from 'ant-design-vue'; + +import { + deleteMemberTierApi, + getMemberTierDetailApi, + saveMemberDaySettingApi, + saveMemberTierApi, +} from '#/api/member'; + +import { buildTierSavePayload, createTierEditorForm } from './helpers'; + +interface TierActionOptions { + canManage: { value: boolean }; + daySetting: { value: MemberDaySettingDto | null }; + daySettingSaving: { value: boolean }; + loadTierData: () => Promise; + tierEditorLoading: { value: boolean }; + tierEditorOpen: { value: boolean }; + tierEditorSubmitting: { value: boolean }; + tierForm: { value: MemberTierEditorForm | null }; + tiers: { value: MemberTierListItemDto[] }; +} + +/** + * 文件职责:等级体系与会员日动作。 + */ +export function createTierActions(options: TierActionOptions) { + function setTierEditorOpen(value: boolean) { + options.tierEditorOpen.value = value; + if (!value) { + options.tierForm.value = null; + } + } + + async function openCreateTier() { + if (!options.canManage.value) { + message.warning('暂无编辑权限'); + return; + } + + options.tierEditorOpen.value = true; + options.tierEditorLoading.value = true; + options.tierForm.value = null; + try { + const detail = await getMemberTierDetailApi({}); + options.tierForm.value = createTierEditorForm(detail); + } finally { + options.tierEditorLoading.value = false; + } + } + + async function openEditTier(tierId: string) { + if (!options.canManage.value) { + message.warning('暂无编辑权限'); + return; + } + + options.tierEditorOpen.value = true; + options.tierEditorLoading.value = true; + options.tierForm.value = null; + try { + const detail = await getMemberTierDetailApi({ tierId }); + options.tierForm.value = createTierEditorForm(detail); + } finally { + options.tierEditorLoading.value = false; + } + } + + async function submitTier(form: MemberTierEditorForm) { + if (!options.canManage.value) { + message.warning('暂无编辑权限'); + return; + } + + const payload: SaveMemberTierPayload = buildTierSavePayload(form); + options.tierEditorSubmitting.value = true; + try { + await saveMemberTierApi(payload); + message.success('等级保存成功'); + options.tierEditorOpen.value = false; + options.tierForm.value = null; + await options.loadTierData(); + } finally { + options.tierEditorSubmitting.value = false; + } + } + + async function deleteTier(tierId: string) { + if (!options.canManage.value) { + message.warning('暂无编辑权限'); + return; + } + + const row = options.tiers.value.find((item) => item.tierId === tierId); + if (!row || !row.canDelete) { + message.warning('该等级暂不允许删除'); + return; + } + + await new Promise((resolve, reject) => { + Modal.confirm({ + title: '确认删除该等级吗?', + content: '删除后无法恢复,请确认当前等级下没有会员。', + okText: '确认删除', + okType: 'danger', + cancelText: '取消', + onOk: async () => { + try { + await deleteMemberTierApi({ tierId }); + message.success('等级已删除'); + await options.loadTierData(); + resolve(); + } catch (error) { + reject(error); + } + }, + onCancel: () => resolve(), + }); + }); + } + + async function saveDaySetting(payload: MemberDaySettingDto) { + if (!options.canManage.value) { + message.warning('暂无编辑权限'); + return; + } + + options.daySettingSaving.value = true; + try { + options.daySetting.value = await saveMemberDaySettingApi(payload); + message.success('会员日配置已保存'); + } finally { + options.daySettingSaving.value = false; + } + } + + return { + deleteTier, + openCreateTier, + openEditTier, + saveDaySetting, + setTierEditorOpen, + submitTier, + }; +} diff --git a/apps/web-antd/src/views/member/list/composables/useMemberListPage.ts b/apps/web-antd/src/views/member/list/composables/useMemberListPage.ts new file mode 100644 index 0000000..378fc5b --- /dev/null +++ b/apps/web-antd/src/views/member/list/composables/useMemberListPage.ts @@ -0,0 +1,371 @@ +import type { MemberTabKey, MemberTierEditorForm } from '../types'; + +import type { + MemberCouponPickerItemDto, + MemberDaySettingDto, + MemberDetailDto, + MemberListItemDto, + MemberTierListItemDto, +} from '#/api/member'; +import type { StoreListItemDto } from '#/api/store'; + +/** + * 文件职责:会员管理页面状态与动作编排。 + */ +import { computed, onActivated, onMounted, reactive, ref, watch } from 'vue'; + +import { useAccessStore } from '@vben/stores'; + +import { + createDefaultFilters, + DEFAULT_MEMBER_DAY_SETTING, + DEFAULT_MEMBER_STATS, + MEMBER_MANAGE_PERMISSION, + MEMBER_VIEW_PERMISSION, +} from './member-page/constants'; +import { createCouponActions } from './member-page/coupon-actions'; +import { createDataActions } from './member-page/data-actions'; +import { createDetailActions } from './member-page/detail-actions'; +import { createExportActions } from './member-page/export-actions'; +import { createFilterActions } from './member-page/filter-actions'; +import { createTierActions } from './member-page/tier-actions'; + +export function useMemberListPage() { + const accessStore = useAccessStore(); + + const stores = ref([]); + const selectedStoreId = ref(''); + const isStoreLoading = ref(false); + + const activeTab = ref('list'); + const filters = reactive(createDefaultFilters()); + const rows = ref([]); + const pagination = reactive({ + page: 1, + pageSize: 10, + total: 0, + }); + + const stats = reactive({ ...DEFAULT_MEMBER_STATS }); + const isListLoading = ref(false); + const isExporting = ref(false); + + const detail = ref(null); + const detailOpen = ref(false); + const detailLoading = ref(false); + const tagSaving = ref(false); + + const tiers = ref([]); + const tierLoading = ref(false); + + const daySetting = ref({ + ...DEFAULT_MEMBER_DAY_SETTING, + }); + const daySettingSaving = ref(false); + + const tierForm = ref(null); + const tierEditorOpen = ref(false); + const tierEditorLoading = ref(false); + const tierEditorSubmitting = ref(false); + + const couponItems = ref([]); + const couponLoading = ref(false); + const couponKeyword = ref(''); + const couponModalOpen = ref(false); + const selectedCouponIds = ref([]); + const couponTarget = ref<'' | 'birthday' | 'monthly'>(''); + + const storeOptions = computed(() => + stores.value.map((item) => ({ + label: item.name, + value: item.id, + })), + ); + + const tierOptions = computed(() => [ + { + label: '全部等级', + value: '', + }, + ...tiers.value.map((item) => ({ + label: item.name, + value: item.tierId, + })), + ]); + + const accessCodeSet = computed( + () => new Set((accessStore.accessCodes ?? []).map(String)), + ); + + const canView = computed( + () => + accessCodeSet.value.has(MEMBER_VIEW_PERMISSION) || + accessCodeSet.value.has(MEMBER_MANAGE_PERMISSION), + ); + + const canManage = computed(() => + accessCodeSet.value.has(MEMBER_MANAGE_PERMISSION), + ); + + const { + loadCouponItems, + loadListAndStats, + loadStores, + loadTierData, + resetDaySetting, + resetStats, + } = createDataActions({ + stores, + selectedStoreId, + filters, + rows, + pagination, + stats, + isStoreLoading, + isListLoading, + tiers, + tierLoading, + daySetting, + couponItems, + couponLoading, + }); + + const { handlePageChange, handleReset, handleSearch, setKeyword, setTierId } = + createFilterActions({ + filters, + pagination, + loadListAndStats, + }); + + const { openDetail, saveTags, setDetailOpen } = createDetailActions({ + canManage, + detail, + detailLoading, + detailOpen, + selectedStoreId, + tagSaving, + }); + + const { handleExport } = createExportActions({ + canManage, + filters, + isExporting, + selectedStoreId, + }); + + const { + deleteTier, + openCreateTier, + openEditTier, + saveDaySetting, + setTierEditorOpen, + submitTier, + } = createTierActions({ + canManage, + daySetting, + daySettingSaving, + loadTierData, + tierEditorOpen, + tierEditorLoading, + tierEditorSubmitting, + tierForm, + tiers, + }); + + const { + applyCoupons, + openCouponPicker, + resolveCouponLabelMap, + searchCoupons, + setCouponKeyword, + setCouponModalOpen, + toggleCoupon, + } = createCouponActions({ + couponItems, + couponKeyword, + couponModalOpen, + couponTarget, + loadCouponItems, + selectedCouponIds, + tierForm, + }); + + const couponLabelMap = computed(() => resolveCouponLabelMap()); + + const couponModalTitle = computed(() => + couponTarget.value === 'birthday' ? '选择生日赠券' : '选择每月赠券', + ); + + function clearPageDataByPermission() { + stores.value = []; + selectedStoreId.value = ''; + rows.value = []; + pagination.total = 0; + detail.value = null; + detailOpen.value = false; + tiers.value = []; + couponItems.value = []; + resetStats(); + resetDaySetting(); + } + + async function bootstrapPageData() { + await Promise.all([loadStores(), loadTierData()]); + } + + function setActiveTab(value: MemberTabKey) { + activeTab.value = value; + } + + function setSelectedStoreId(value: string) { + selectedStoreId.value = value; + } + + async function handleOpenCreateTier() { + await openCreateTier(); + await loadCouponItems(''); + } + + async function handleOpenEditTier(tierId: string) { + await openEditTier(tierId); + await loadCouponItems(''); + } + + async function handleDeleteTier(tierId: string) { + await deleteTier(tierId); + } + + async function handleSaveDaySetting(payload: MemberDaySettingDto) { + await saveDaySetting(payload); + } + + async function handleSubmitTier(form: MemberTierEditorForm) { + await submitTier(form); + } + + function handleOpenCouponPicker( + target: 'birthday' | 'monthly', + selectedIds: string[], + ) { + void openCouponPicker(target, selectedIds); + } + + watch(selectedStoreId, async (storeId) => { + if (!storeId) { + rows.value = []; + pagination.total = 0; + detail.value = null; + detailOpen.value = false; + resetStats(); + return; + } + + pagination.page = 1; + await loadListAndStats(); + }); + + watch(activeTab, async (tab) => { + if (!canView.value) { + return; + } + + if (tab === 'tiers' && tiers.value.length === 0) { + await loadTierData(); + } + }); + + watch(canView, async (value, oldValue) => { + if (value === oldValue) { + return; + } + + if (!value) { + clearPageDataByPermission(); + return; + } + + await bootstrapPageData(); + }); + + onMounted(async () => { + if (!canView.value) { + clearPageDataByPermission(); + return; + } + + await bootstrapPageData(); + }); + + onActivated(() => { + if (!canView.value) { + return; + } + + if (stores.value.length === 0) { + void loadStores(); + } + + if (tiers.value.length === 0) { + void loadTierData(); + } + }); + + return { + activeTab, + applyCoupons, + canManage, + canView, + couponItems, + couponKeyword, + couponLabelMap, + couponLoading, + couponModalOpen, + couponModalTitle, + daySetting, + daySettingSaving, + detail, + detailLoading, + detailOpen, + filters, + handleDeleteTier, + handleExport, + handleOpenCouponPicker, + handleOpenCreateTier, + handleOpenEditTier, + handlePageChange, + handleReset, + handleSaveDaySetting, + handleSearch, + handleSubmitTier, + isExporting, + isListLoading, + isStoreLoading, + openDetail, + pagination, + rows, + saveTags, + searchCoupons, + selectedCouponIds, + selectedStoreId, + setActiveTab, + setCouponKeyword, + setCouponModalOpen, + setDetailOpen, + setKeyword, + setSelectedStoreId, + setTierEditorOpen, + setTierId, + stats, + storeOptions, + tagSaving, + tierEditorLoading, + tierEditorOpen, + tierEditorSubmitting, + tierForm, + tierLoading, + tierOptions, + tiers, + toggleCoupon, + resetDaySetting, + }; +} diff --git a/apps/web-antd/src/views/member/list/index.vue b/apps/web-antd/src/views/member/list/index.vue new file mode 100644 index 0000000..6e17075 --- /dev/null +++ b/apps/web-antd/src/views/member/list/index.vue @@ -0,0 +1,189 @@ + + + + + diff --git a/apps/web-antd/src/views/member/list/styles/base.less b/apps/web-antd/src/views/member/list/styles/base.less new file mode 100644 index 0000000..8791ca5 --- /dev/null +++ b/apps/web-antd/src/views/member/list/styles/base.less @@ -0,0 +1,15 @@ +.page-member-list { + .ant-card { + border-radius: 10px; + } +} + +.mm-page { + display: flex; + flex-direction: column; + gap: 12px; +} + +.mm-tab-switch { + width: 260px; +} diff --git a/apps/web-antd/src/views/member/list/styles/drawer.less b/apps/web-antd/src/views/member/list/styles/drawer.less new file mode 100644 index 0000000..285ce29 --- /dev/null +++ b/apps/web-antd/src/views/member/list/styles/drawer.less @@ -0,0 +1,244 @@ +.mm-detail-head { + display: flex; + gap: 12px; + align-items: center; + padding-bottom: 14px; + border-bottom: 1px solid var(--ant-color-border-secondary); +} + +.mm-detail-avatar { + display: inline-flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + font-size: 18px; + font-weight: 600; + color: #fff; + border-radius: 50%; +} + +.mm-detail-title-wrap { + min-width: 0; +} + +.mm-detail-name-row { + display: flex; + gap: 8px; + align-items: center; +} + +.mm-detail-name { + font-size: 16px; + font-weight: 600; +} + +.mm-detail-meta { + margin-top: 4px; + font-size: 12px; + color: var(--ant-color-text-tertiary); +} + +.mm-overview-grid { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 10px; + margin-top: 10px; +} + +.mm-overview-item { + padding: 10px; + text-align: center; + background: var(--ant-color-fill-quaternary); + border-radius: 8px; +} + +.mm-overview-item .value { + font-size: 16px; + font-weight: 600; +} + +.mm-overview-item .value.primary { + color: #1677ff; +} + +.mm-overview-item .label { + margin-top: 4px; + font-size: 12px; + color: var(--ant-color-text-tertiary); +} + +.mm-overview-item .sub { + margin-top: 2px; + font-size: 11px; + color: var(--ant-color-text-tertiary); +} + +.mm-section { + margin-top: 16px; +} + +.mm-section-title { + padding-left: 10px; + margin-bottom: 10px; + font-weight: 600; + border-left: 3px solid #1677ff; +} + +.mm-order-action-placeholder { + font-size: 12px; + color: var(--ant-color-primary); +} + +.mm-tag-editor { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.mm-tag-input { + width: 120px; +} + +.mm-detail-footer, +.mm-editor-footer { + display: flex; + gap: 8px; + justify-content: flex-end; +} + +.mm-editor-form { + display: flex; + flex-direction: column; + gap: 14px; +} + +.mm-form-row { + display: flex; + flex-direction: column; + gap: 8px; +} + +.mm-form-label { + font-size: 13px; + color: var(--ant-color-text-secondary); +} + +.mm-form-label.required::after { + color: #ff4d4f; + content: ' *'; +} + +.mm-icon-group { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.mm-icon-pill { + display: inline-flex; + gap: 5px; + align-items: center; + padding: 4px 10px; + cursor: pointer; + background: #fff; + border: 1px solid var(--ant-color-border-secondary); + border-radius: 16px; +} + +.mm-icon-pill.active { + color: #1677ff; + background: rgb(22 119 255 / 6%); + border-color: #1677ff; +} + +.mm-color-group { + display: flex; + gap: 10px; +} + +.mm-color-dot { + width: 28px; + height: 28px; + cursor: pointer; + border: 2px solid transparent; + border-radius: 50%; +} + +.mm-color-dot.active { + border-color: #1677ff; +} + +.mm-rule-panel { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px; + background: var(--ant-color-fill-quaternary); + border: 1px solid var(--ant-color-border-secondary); + border-radius: 8px; +} + +.mm-rule-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.mm-rule-hint { + margin-top: 6px; + font-size: 12px; + color: var(--ant-color-text-tertiary); +} + +.mm-benefit-card { + padding: 10px 12px; + margin-bottom: 10px; + background: var(--ant-color-fill-quaternary); + border: 1px solid var(--ant-color-border-secondary); + border-radius: 8px; +} + +.mm-benefit-head { + display: flex; + align-items: center; + justify-content: space-between; +} + +.mm-benefit-body { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + margin-top: 10px; +} + +.mm-benefit-body.col { + flex-direction: column; + align-items: flex-start; +} + +.mm-benefit-inline { + display: flex; + gap: 12px; +} + +.mm-benefit-inline .item { + display: flex; + flex: 1; + align-items: center; + justify-content: space-between; + padding: 8px 10px; + border: 1px solid var(--ant-color-border-secondary); + border-radius: 8px; +} + +.mm-coupon-box { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} diff --git a/apps/web-antd/src/views/member/list/styles/index.less b/apps/web-antd/src/views/member/list/styles/index.less new file mode 100644 index 0000000..1f38ecd --- /dev/null +++ b/apps/web-antd/src/views/member/list/styles/index.less @@ -0,0 +1,6 @@ +@import './base.less'; +@import './layout.less'; +@import './list.less'; +@import './tier.less'; +@import './drawer.less'; +@import './responsive.less'; diff --git a/apps/web-antd/src/views/member/list/styles/layout.less b/apps/web-antd/src/views/member/list/styles/layout.less new file mode 100644 index 0000000..85e806b --- /dev/null +++ b/apps/web-antd/src/views/member/list/styles/layout.less @@ -0,0 +1,39 @@ +.mm-list-tab, +.mm-tier-tab { + display: flex; + flex-direction: column; + gap: 12px; +} + +.mm-toolbar { + display: flex; + gap: 10px; + align-items: center; + padding: 12px 14px; + background: var(--ant-color-bg-container); + border: 1px solid var(--ant-color-border-secondary); + border-radius: 10px; +} + +.mm-store-select { + width: 220px; +} + +.mm-tier-select { + width: 150px; +} + +.mm-search-input { + width: 240px; +} + +.mm-toolbar-right { + margin-left: auto; +} + +.mm-table-card { + overflow: hidden; + background: var(--ant-color-bg-container); + border: 1px solid var(--ant-color-border-secondary); + border-radius: 10px; +} diff --git a/apps/web-antd/src/views/member/list/styles/list.less b/apps/web-antd/src/views/member/list/styles/list.less new file mode 100644 index 0000000..b59791a --- /dev/null +++ b/apps/web-antd/src/views/member/list/styles/list.less @@ -0,0 +1,74 @@ +.mm-stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.mm-stat-card { + padding: 14px 18px; + background: var(--ant-color-bg-container); + border: 1px solid var(--ant-color-border-secondary); + border-radius: 10px; +} + +.mm-stat-label { + font-size: 12px; + color: var(--ant-color-text-tertiary); +} + +.mm-stat-value { + margin-top: 4px; + font-size: 24px; + font-weight: 700; + color: var(--ant-color-text); +} + +.mm-stat-value.blue { + color: #1677ff; +} + +.mm-stat-value.green { + color: #52c41a; +} + +.mm-stat-value.orange { + color: #fa8c16; +} + +.mm-stat-sub { + margin-top: 2px; + font-size: 11px; + color: var(--ant-color-text-tertiary); +} + +.mm-member-cell { + display: flex; + gap: 10px; + align-items: center; +} + +.mm-avatar { + display: inline-flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + font-size: 13px; + font-weight: 600; + color: #fff; + border-radius: 50%; +} + +.mm-member-name { + white-space: nowrap; +} + +.mm-amount { + font-weight: 500; + color: var(--ant-color-text); +} + +.mm-row-dimmed { + opacity: 0.66; +} diff --git a/apps/web-antd/src/views/member/list/styles/responsive.less b/apps/web-antd/src/views/member/list/styles/responsive.less new file mode 100644 index 0000000..cddd6de --- /dev/null +++ b/apps/web-antd/src/views/member/list/styles/responsive.less @@ -0,0 +1,52 @@ +@media (max-width: 1200px) { + .mm-toolbar { + flex-wrap: wrap; + } + + .mm-toolbar-right { + margin-left: 0; + } + + .mm-stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .mm-overview-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 768px) { + .mm-tab-switch { + width: 100%; + } + + .mm-store-select, + .mm-tier-select, + .mm-search-input { + width: 100%; + } + + .mm-stats { + grid-template-columns: repeat(1, minmax(0, 1fr)); + } + + .mm-tier-card { + flex-direction: column; + align-items: flex-start; + } + + .mm-tier-side { + align-items: flex-start; + width: 100%; + } + + .mm-day-row { + flex-direction: column; + align-items: flex-start; + } + + .mm-day-label { + width: auto; + } +} diff --git a/apps/web-antd/src/views/member/list/styles/tier.less b/apps/web-antd/src/views/member/list/styles/tier.less new file mode 100644 index 0000000..8f7fecf --- /dev/null +++ b/apps/web-antd/src/views/member/list/styles/tier.less @@ -0,0 +1,173 @@ +.mm-tier-system, +.mm-member-day { + padding: 14px; + background: var(--ant-color-bg-container); + border: 1px solid var(--ant-color-border-secondary); + border-radius: 10px; +} + +.mm-tier-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.mm-tier-card { + display: flex; + gap: 12px; + align-items: center; + padding: 14px; + background: #fff; + border: 1px solid var(--ant-color-border-secondary); + border-radius: 10px; +} + +.mm-tier-order { + width: 28px; + font-size: 20px; + font-weight: 700; + color: var(--ant-color-text-quaternary); + text-align: center; +} + +.mm-tier-icon { + display: inline-flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + font-size: 22px; + border-radius: 50%; +} + +.mm-tier-main { + flex: 1; + min-width: 0; +} + +.mm-tier-name-row { + display: flex; + gap: 8px; + align-items: center; +} + +.mm-tier-name { + font-size: 15px; + font-weight: 600; +} + +.mm-tier-condition { + margin-top: 2px; + font-size: 12px; + color: var(--ant-color-text-tertiary); +} + +.mm-tier-perks { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 6px; +} + +.mm-tier-perk { + margin-inline-end: 0; +} + +.mm-tier-side { + display: flex; + flex-direction: column; + gap: 8px; + align-items: flex-end; + min-width: 120px; +} + +.mm-tier-count { + font-size: 12px; + color: var(--ant-color-text-secondary); +} + +.mm-tier-actions { + display: flex; + gap: 6px; +} + +.mm-tier-add-btn { + margin-top: 12px; +} + +.mm-day-card { + padding: 14px; + background: #fff; + border: 1px solid var(--ant-color-border-secondary); + border-radius: 10px; +} + +.mm-day-row { + display: flex; + gap: 12px; + align-items: center; + margin-bottom: 12px; +} + +.mm-day-row:last-child { + margin-bottom: 0; +} + +.mm-day-label { + flex-shrink: 0; + width: 90px; + font-size: 13px; + color: var(--ant-color-text-secondary); +} + +.mm-day-select { + width: 180px; +} + +.mm-day-benefit { + display: flex; + gap: 8px; + align-items: center; +} + +.mm-day-actions { + display: flex; + justify-content: flex-end; +} + +.mm-coupon-search { + display: flex; + gap: 8px; +} + +.mm-coupon-list { + max-height: 360px; + margin-top: 12px; + overflow: auto; + border: 1px solid var(--ant-color-border-secondary); + border-radius: 8px; +} + +.mm-coupon-item { + display: flex; + gap: 8px; + align-items: center; + padding: 10px 12px; + border-bottom: 1px solid var(--ant-color-border-secondary); +} + +.mm-coupon-item:last-child { + border-bottom: none; +} + +.mm-coupon-item .name { + flex: 1; + min-width: 0; +} + +.mm-coupon-empty { + padding: 24px; + color: var(--ant-color-text-tertiary); + text-align: center; +} diff --git a/apps/web-antd/src/views/member/list/types.ts b/apps/web-antd/src/views/member/list/types.ts new file mode 100644 index 0000000..94dfb63 --- /dev/null +++ b/apps/web-antd/src/views/member/list/types.ts @@ -0,0 +1,61 @@ +import type { + MemberDaySettingDto, + MemberDetailDto, + MemberListItemDto, + MemberListStatsDto, + MemberTierBenefitsDto, + MemberTierRuleDto, +} from '#/api/member'; +import type { StoreListItemDto } from '#/api/store'; + +export type MemberTabKey = 'list' | 'tiers'; + +export interface MemberFilterState { + keyword: string; + tierId: string; +} + +export interface MemberPaginationState { + page: number; + pageSize: number; + total: number; +} + +export interface MemberTierEditorForm { + benefits: MemberTierBenefitsDto; + colorHex: string; + iconKey: string; + isDefault: boolean; + name: string; + rule: MemberTierRuleDto; + sortOrder: number; + tierId?: string; +} + +export interface OptionItem { + label: string; + value: string; +} + +export interface MemberListPageState { + activeTab: MemberTabKey; + detail: MemberDetailDto | null; + detailOpen: boolean; + detailLoading: boolean; + filters: MemberFilterState; + isExporting: boolean; + isListLoading: boolean; + pagination: MemberPaginationState; + rows: MemberListItemDto[]; + selectedStoreId: string; + stats: MemberListStatsDto; + stores: StoreListItemDto[]; + tagSaving: boolean; + tierEditorOpen: boolean; + tierEditorLoading: boolean; + tierEditorSubmitting: boolean; + tierForm: MemberTierEditorForm | null; + tierLoading: boolean; + weekdaySaving: boolean; + daySetting: MemberDaySettingDto | null; +}