diff --git a/apps/web-antd/src/api/member/index.ts b/apps/web-antd/src/api/member/index.ts index c8798aa..74ea27d 100644 --- a/apps/web-antd/src/api/member/index.ts +++ b/apps/web-antd/src/api/member/index.ts @@ -282,4 +282,5 @@ export async function getMemberCouponPickerApi(params: { ); } +export * from './message-reach'; export * from './stored-card'; diff --git a/apps/web-antd/src/api/member/message-reach.ts b/apps/web-antd/src/api/member/message-reach.ts new file mode 100644 index 0000000..50e13f0 --- /dev/null +++ b/apps/web-antd/src/api/member/message-reach.ts @@ -0,0 +1,283 @@ +/** + * 文件职责:会员消息触达模块 API 契约定义。 + */ +import { requestClient } from '#/api/request'; + +/** 消息状态。 */ +export type MemberMessageReachStatus = + | 'draft' + | 'failed' + | 'pending' + | 'sending' + | 'sent'; + +/** 消息渠道。 */ +export type MemberMessageReachChannel = 'inapp' | 'sms' | 'wechat-mini'; + +/** 目标人群类型。 */ +export type MemberMessageAudienceType = 'all' | 'tag'; + +/** 发送时间类型。 */ +export type MemberMessageScheduleType = 'immediate' | 'scheduled'; + +/** 模板分类。 */ +export type MemberMessageTemplateCategory = 'marketing' | 'notice' | 'recall'; + +/** 页面统计。 */ +export interface MemberMessageReachStatsDto { + conversionRate: number; + monthlySentCount: number; + openRate: number; + reachMemberCount: number; +} + +/** 消息列表查询。 */ +export interface MemberMessageReachListQuery { + channel?: MemberMessageReachChannel; + keyword?: string; + page: number; + pageSize: number; + status?: MemberMessageReachStatus; +} + +/** 消息列表项。 */ +export interface MemberMessageReachListItemDto { + audienceText: string; + channels: MemberMessageReachChannel[]; + conversionRate: number; + estimatedReachCount: number; + messageId: string; + openRate: number; + scheduledAt?: string; + sentAt?: string; + status: MemberMessageReachStatus; + title: string; +} + +/** 消息列表结果。 */ +export interface MemberMessageReachListResultDto { + items: MemberMessageReachListItemDto[]; + page: number; + pageSize: number; + totalCount: number; +} + +/** 收件明细。 */ +export interface MemberMessageReachRecipientDto { + channel: MemberMessageReachChannel; + convertedAt?: string; + errorMessage?: string; + memberId: string; + mobile?: string; + openId?: string; + readAt?: string; + sentAt?: string; + status: 'failed' | 'pending' | 'sent'; +} + +/** 消息详情。 */ +export interface MemberMessageReachDetailDto { + audienceTags: string[]; + audienceText: string; + audienceType: MemberMessageAudienceType; + channels: MemberMessageReachChannel[]; + content: string; + conversionRate: number; + convertedCount: number; + estimatedReachCount: number; + lastError?: string; + messageId: string; + openRate: number; + readCount: number; + recipients: MemberMessageReachRecipientDto[]; + scheduleType: MemberMessageScheduleType; + scheduledAt?: string; + sentAt?: string; + sentCount: number; + status: MemberMessageReachStatus; + templateId?: string; + title: string; +} + +/** 保存消息请求。 */ +export interface SaveMemberMessageReachPayload { + audienceTags: string[]; + audienceType: MemberMessageAudienceType; + channels: MemberMessageReachChannel[]; + content: string; + messageId?: string; + scheduleType: MemberMessageScheduleType; + scheduledAt?: string; + storeId?: string; + submitAction: 'draft' | 'send'; + templateId?: string; + title: string; +} + +/** 删除消息请求。 */ +export interface DeleteMemberMessageReachPayload { + messageId: string; +} + +/** 调度元信息。 */ +export interface MemberMessageDispatchMetaDto { + hangfireJobId?: string; + messageId: string; + scheduleType: MemberMessageScheduleType; + scheduledAt?: string; + status: MemberMessageReachStatus; +} + +/** 目标估算请求。 */ +export interface EstimateMemberMessageAudiencePayload { + audienceType: MemberMessageAudienceType; + tags: string[]; +} + +/** 目标估算响应。 */ +export interface MemberMessageAudienceEstimateDto { + reachCount: number; +} + +/** 模板列表查询。 */ +export interface MemberMessageTemplateListQuery { + category?: MemberMessageTemplateCategory; + keyword?: string; + page: number; + pageSize: number; +} + +/** 模板 DTO。 */ +export interface MemberMessageTemplateDto { + category: MemberMessageTemplateCategory; + content: string; + lastUsedAt?: string; + name: string; + templateId: string; + usageCount: number; +} + +/** 模板列表结果。 */ +export interface MemberMessageTemplateListResultDto { + items: MemberMessageTemplateDto[]; + page: number; + pageSize: number; + totalCount: number; +} + +/** 保存模板请求。 */ +export interface SaveMemberMessageTemplatePayload { + category: MemberMessageTemplateCategory; + content: string; + name: string; + templateId?: string; +} + +/** 删除模板请求。 */ +export interface DeleteMemberMessageTemplatePayload { + templateId: string; +} + +/** 查询页面统计。 */ +export async function getMemberMessageReachStatsApi(params?: { + storeId?: string; +}) { + return requestClient.get( + '/member/message-reach/stats', + { + params, + }, + ); +} + +/** 查询消息列表。 */ +export async function getMemberMessageReachListApi( + params: MemberMessageReachListQuery, +) { + return requestClient.get( + '/member/message-reach/list', + { + params, + }, + ); +} + +/** 查询消息详情。 */ +export async function getMemberMessageReachDetailApi(params: { + messageId: string; +}) { + return requestClient.get( + '/member/message-reach/detail', + { + params, + }, + ); +} + +/** 保存消息。 */ +export async function saveMemberMessageReachApi( + payload: SaveMemberMessageReachPayload, +) { + return requestClient.post( + '/member/message-reach/save', + payload, + ); +} + +/** 删除消息。 */ +export async function deleteMemberMessageReachApi( + payload: DeleteMemberMessageReachPayload, +) { + return requestClient.post('/member/message-reach/delete', payload); +} + +/** 估算触达人数。 */ +export async function estimateMemberMessageAudienceApi( + payload: EstimateMemberMessageAudiencePayload, +) { + return requestClient.post( + '/member/message-reach/audience/estimate', + payload, + ); +} + +/** 查询模板列表。 */ +export async function getMemberMessageTemplateListApi( + params: MemberMessageTemplateListQuery, +) { + return requestClient.get( + '/member/message-reach/template/list', + { + params, + }, + ); +} + +/** 查询模板详情。 */ +export async function getMemberMessageTemplateDetailApi(params: { + templateId: string; +}) { + return requestClient.get( + '/member/message-reach/template/detail', + { + params, + }, + ); +} + +/** 保存模板。 */ +export async function saveMemberMessageTemplateApi( + payload: SaveMemberMessageTemplatePayload, +) { + return requestClient.post( + '/member/message-reach/template/save', + payload, + ); +} + +/** 删除模板。 */ +export async function deleteMemberMessageTemplateApi( + payload: DeleteMemberMessageTemplatePayload, +) { + return requestClient.post('/member/message-reach/template/delete', payload); +} diff --git a/apps/web-antd/src/views/member/message-reach/components/MessageReachDetailDrawer.vue b/apps/web-antd/src/views/member/message-reach/components/MessageReachDetailDrawer.vue new file mode 100644 index 0000000..1e66f4b --- /dev/null +++ b/apps/web-antd/src/views/member/message-reach/components/MessageReachDetailDrawer.vue @@ -0,0 +1,273 @@ + + + diff --git a/apps/web-antd/src/views/member/message-reach/components/MessageReachEditorDrawer.vue b/apps/web-antd/src/views/member/message-reach/components/MessageReachEditorDrawer.vue new file mode 100644 index 0000000..8c5b3af --- /dev/null +++ b/apps/web-antd/src/views/member/message-reach/components/MessageReachEditorDrawer.vue @@ -0,0 +1,209 @@ + + + diff --git a/apps/web-antd/src/views/member/message-reach/components/MessageReachFilterBar.vue b/apps/web-antd/src/views/member/message-reach/components/MessageReachFilterBar.vue new file mode 100644 index 0000000..f0c798e --- /dev/null +++ b/apps/web-antd/src/views/member/message-reach/components/MessageReachFilterBar.vue @@ -0,0 +1,70 @@ + + + diff --git a/apps/web-antd/src/views/member/message-reach/components/MessageReachStatsCards.vue b/apps/web-antd/src/views/member/message-reach/components/MessageReachStatsCards.vue new file mode 100644 index 0000000..891e682 --- /dev/null +++ b/apps/web-antd/src/views/member/message-reach/components/MessageReachStatsCards.vue @@ -0,0 +1,51 @@ + + + diff --git a/apps/web-antd/src/views/member/message-reach/components/MessageReachTableCard.vue b/apps/web-antd/src/views/member/message-reach/components/MessageReachTableCard.vue new file mode 100644 index 0000000..074c5b6 --- /dev/null +++ b/apps/web-antd/src/views/member/message-reach/components/MessageReachTableCard.vue @@ -0,0 +1,221 @@ + + + diff --git a/apps/web-antd/src/views/member/message-reach/components/MessageTemplateCardGrid.vue b/apps/web-antd/src/views/member/message-reach/components/MessageTemplateCardGrid.vue new file mode 100644 index 0000000..e1a9c20 --- /dev/null +++ b/apps/web-antd/src/views/member/message-reach/components/MessageTemplateCardGrid.vue @@ -0,0 +1,101 @@ + + + diff --git a/apps/web-antd/src/views/member/message-reach/components/MessageTemplateEditorModal.vue b/apps/web-antd/src/views/member/message-reach/components/MessageTemplateEditorModal.vue new file mode 100644 index 0000000..bad8583 --- /dev/null +++ b/apps/web-antd/src/views/member/message-reach/components/MessageTemplateEditorModal.vue @@ -0,0 +1,76 @@ + + + diff --git a/apps/web-antd/src/views/member/message-reach/components/MessageTemplateToolbar.vue b/apps/web-antd/src/views/member/message-reach/components/MessageTemplateToolbar.vue new file mode 100644 index 0000000..b15c291 --- /dev/null +++ b/apps/web-antd/src/views/member/message-reach/components/MessageTemplateToolbar.vue @@ -0,0 +1,54 @@ + + + diff --git a/apps/web-antd/src/views/member/message-reach/composables/message-reach-page/constants.ts b/apps/web-antd/src/views/member/message-reach/composables/message-reach-page/constants.ts new file mode 100644 index 0000000..61db97b --- /dev/null +++ b/apps/web-antd/src/views/member/message-reach/composables/message-reach-page/constants.ts @@ -0,0 +1,106 @@ +import type { + MemberMessageReachChannel, + MemberMessageReachStatus, + MemberMessageTemplateCategory, +} from '#/api/member/message-reach'; +import type { MemberMessageReachTabKey } from '#/views/member/message-reach/types'; + +/** 消息触达查看权限。 */ +export const MEMBER_MESSAGE_REACH_VIEW_PERMISSION = + 'tenant:member:message-reach:view'; + +/** 消息触达管理权限。 */ +export const MEMBER_MESSAGE_REACH_MANAGE_PERMISSION = + 'tenant:member:message-reach:manage'; + +/** 页面 Tab 选项。 */ +export const MESSAGE_REACH_TAB_OPTIONS: Array<{ + label: string; + value: MemberMessageReachTabKey; +}> = [ + { label: '消息列表', value: 'list' }, + { label: '消息模板', value: 'template' }, +]; + +/** 状态筛选选项。 */ +export const MESSAGE_REACH_STATUS_FILTER_OPTIONS: Array<{ + label: string; + value: '' | MemberMessageReachStatus; +}> = [ + { label: '全部状态', value: '' }, + { label: '草稿', value: 'draft' }, + { label: '待发送', value: 'pending' }, + { label: '发送中', value: 'sending' }, + { label: '已发送', value: 'sent' }, + { label: '发送失败', value: 'failed' }, +]; + +/** 渠道筛选选项。 */ +export const MESSAGE_REACH_CHANNEL_FILTER_OPTIONS: Array<{ + label: string; + value: '' | MemberMessageReachChannel; +}> = [ + { label: '全部渠道', value: '' }, + { label: '站内信', value: 'inapp' }, + { label: '短信', value: 'sms' }, + { label: '微信模板', value: 'wechat-mini' }, +]; + +/** 模板分类筛选选项。 */ +export const MESSAGE_TEMPLATE_CATEGORY_FILTER_OPTIONS: Array<{ + label: string; + value: '' | MemberMessageTemplateCategory; +}> = [ + { label: '全部分类', value: '' }, + { label: '通知', value: 'notice' }, + { label: '营销', value: 'marketing' }, + { label: '召回', value: 'recall' }, +]; + +/** 模板分类编辑选项。 */ +export const MESSAGE_TEMPLATE_CATEGORY_OPTIONS: Array<{ + label: string; + value: MemberMessageTemplateCategory; +}> = [ + { label: '通知', value: 'notice' }, + { label: '营销', value: 'marketing' }, + { label: '召回', value: 'recall' }, +]; + +/** 抽屉渠道选项。 */ +export const MESSAGE_REACH_CHANNEL_OPTIONS: Array<{ + label: string; + value: MemberMessageReachChannel; +}> = [ + { label: '站内信', value: 'inapp' }, + { label: '短信', value: 'sms' }, + { label: '微信模板消息', value: 'wechat-mini' }, +]; + +/** 固定标签选项。 */ +export const MESSAGE_AUDIENCE_TAG_OPTIONS = [ + { label: '高频客户', value: '高频客户' }, + { label: '新客', value: '新客' }, + { label: '沉睡客户', value: '沉睡客户' }, + { label: '流失客户', value: '流失客户' }, + { label: '午餐常客', value: '午餐常客' }, + { label: '大额消费', value: '大额消费' }, +] as const; + +/** 目标人群选项。 */ +export const MESSAGE_AUDIENCE_TYPE_OPTIONS = [ + { label: '全部会员', value: 'all' }, + { label: '按标签筛选', value: 'tag' }, +] as const; + +/** 发送时间选项。 */ +export const MESSAGE_SCHEDULE_TYPE_OPTIONS = [ + { label: '立即发送', value: 'immediate' }, + { label: '定时发送', value: 'scheduled' }, +] as const; + +/** 表格分页尺寸选项。 */ +export const MESSAGE_LIST_PAGE_SIZE_OPTIONS = ['10', '20', '50']; + +/** 模板分页尺寸选项。 */ +export const MESSAGE_TEMPLATE_PAGE_SIZE_OPTIONS = ['12', '24', '48']; diff --git a/apps/web-antd/src/views/member/message-reach/composables/message-reach-page/data-actions.ts b/apps/web-antd/src/views/member/message-reach/composables/message-reach-page/data-actions.ts new file mode 100644 index 0000000..11e781b --- /dev/null +++ b/apps/web-antd/src/views/member/message-reach/composables/message-reach-page/data-actions.ts @@ -0,0 +1,160 @@ +import type { Ref } from 'vue'; + +import type { + MemberMessageReachDetailDto, + MemberMessageReachStatsDto, +} from '#/api/member/message-reach'; +import type { + MessageReachFilterForm, + MessageReachPager, + MessageTemplateFilterForm, + MessageTemplatePager, +} from '#/views/member/message-reach/types'; + +import { message } from 'ant-design-vue'; + +import { + estimateMemberMessageAudienceApi, + getMemberMessageReachDetailApi, + getMemberMessageReachListApi, + getMemberMessageReachStatsApi, + getMemberMessageTemplateListApi, +} from '#/api/member/message-reach'; + +import { mapMessageFilterToQuery, mapTemplateFilterToQuery } from './helpers'; + +interface CreateDataActionsOptions { + audienceEstimateCount: Ref; + detail: Ref; + isDetailLoading: Ref; + isEstimatingAudience: Ref; + isMessageLoading: Ref; + isStatsLoading: Ref; + isTemplateLoading: Ref; + messageFilterForm: MessageReachFilterForm; + messagePager: Ref; + stats: Ref; + templateFilterForm: MessageTemplateFilterForm; + templatePager: Ref; +} + +export function createDataActions(options: CreateDataActionsOptions) { + function resetMessagePager() { + options.messagePager.value = { + ...options.messagePager.value, + items: [], + totalCount: 0, + }; + } + + function resetTemplatePager() { + options.templatePager.value = { + ...options.templatePager.value, + items: [], + totalCount: 0, + }; + } + + async function loadStats() { + options.isStatsLoading.value = true; + try { + options.stats.value = await getMemberMessageReachStatsApi(); + } catch (error) { + console.error(error); + message.error('加载消息统计失败'); + } finally { + options.isStatsLoading.value = false; + } + } + + async function loadMessageList() { + options.isMessageLoading.value = true; + try { + const query = mapMessageFilterToQuery(options.messageFilterForm); + const result = await getMemberMessageReachListApi({ + page: options.messagePager.value.page, + pageSize: options.messagePager.value.pageSize, + ...query, + }); + options.messagePager.value = { + items: result.items ?? [], + page: result.page, + pageSize: result.pageSize, + totalCount: result.totalCount, + }; + } catch (error) { + console.error(error); + resetMessagePager(); + message.error('加载消息列表失败'); + } finally { + options.isMessageLoading.value = false; + } + } + + async function loadTemplateList() { + options.isTemplateLoading.value = true; + try { + const query = mapTemplateFilterToQuery(options.templateFilterForm); + const result = await getMemberMessageTemplateListApi({ + page: options.templatePager.value.page, + pageSize: options.templatePager.value.pageSize, + ...query, + }); + options.templatePager.value = { + items: result.items ?? [], + page: result.page, + pageSize: result.pageSize, + totalCount: result.totalCount, + }; + } catch (error) { + console.error(error); + resetTemplatePager(); + message.error('加载模板列表失败'); + } finally { + options.isTemplateLoading.value = false; + } + } + + async function loadMessageDetail(messageId: string) { + options.isDetailLoading.value = true; + try { + const result = await getMemberMessageReachDetailApi({ messageId }); + options.detail.value = result; + return result; + } catch (error) { + console.error(error); + options.detail.value = null; + message.error('加载消息详情失败'); + return null; + } finally { + options.isDetailLoading.value = false; + } + } + + async function estimateAudience(audienceType: 'all' | 'tag', tags: string[]) { + options.isEstimatingAudience.value = true; + try { + const result = await estimateMemberMessageAudienceApi({ + audienceType, + tags, + }); + options.audienceEstimateCount.value = result.reachCount ?? 0; + return options.audienceEstimateCount.value; + } catch (error) { + console.error(error); + options.audienceEstimateCount.value = 0; + message.error('估算触达人数失败'); + return 0; + } finally { + options.isEstimatingAudience.value = false; + } + } + + return { + estimateAudience, + loadMessageDetail, + loadMessageList, + loadStats, + loadTemplateList, + }; +} diff --git a/apps/web-antd/src/views/member/message-reach/composables/message-reach-page/helpers.ts b/apps/web-antd/src/views/member/message-reach/composables/message-reach-page/helpers.ts new file mode 100644 index 0000000..c00c3b4 --- /dev/null +++ b/apps/web-antd/src/views/member/message-reach/composables/message-reach-page/helpers.ts @@ -0,0 +1,264 @@ +import type { + MemberMessageReachChannel, + MemberMessageReachDetailDto, + MemberMessageReachStatus, + MemberMessageScheduleType, + MemberMessageTemplateCategory, + SaveMemberMessageReachPayload, + SaveMemberMessageTemplatePayload, +} from '#/api/member/message-reach'; +import type { + MessageReachEditorForm, + MessageReachFilterForm, + MessageTemplateEditorForm, + MessageTemplateFilterForm, +} from '#/views/member/message-reach/types'; + +import dayjs from 'dayjs'; + +/** 状态文案。 */ +export const MESSAGE_STATUS_TEXT_MAP: Record = + { + draft: '草稿', + pending: '待发送', + sending: '发送中', + sent: '已发送', + failed: '发送失败', + }; + +/** 状态标签色。 */ +export const MESSAGE_STATUS_COLOR_MAP: Record< + MemberMessageReachStatus, + string +> = { + draft: 'default', + pending: 'processing', + sending: 'warning', + sent: 'success', + failed: 'error', +}; + +/** 渠道文案。 */ +export const MESSAGE_CHANNEL_TEXT_MAP: Record< + MemberMessageReachChannel, + string +> = { + inapp: '站内信', + sms: '短信', + 'wechat-mini': '微信模板', +}; + +/** 渠道标签色。 */ +export const MESSAGE_CHANNEL_COLOR_MAP: Record< + MemberMessageReachChannel, + string +> = { + inapp: 'blue', + sms: 'green', + 'wechat-mini': 'orange', +}; + +/** 模板分类文案。 */ +export const MESSAGE_TEMPLATE_CATEGORY_TEXT_MAP: Record< + MemberMessageTemplateCategory, + string +> = { + marketing: '营销', + notice: '通知', + recall: '召回', +}; + +/** 模板分类颜色。 */ +export const MESSAGE_TEMPLATE_CATEGORY_COLOR_MAP: Record< + MemberMessageTemplateCategory, + string +> = { + marketing: 'magenta', + notice: 'blue', + recall: 'red', +}; + +/** 收件状态文案。 */ +export const MESSAGE_RECIPIENT_STATUS_TEXT_MAP: Record< + 'failed' | 'pending' | 'sent', + string +> = { + pending: '待发送', + sent: '已发送', + failed: '发送失败', +}; + +/** 收件状态颜色。 */ +export const MESSAGE_RECIPIENT_STATUS_COLOR_MAP: Record< + 'failed' | 'pending' | 'sent', + string +> = { + pending: 'processing', + sent: 'success', + failed: 'error', +}; + +/** 格式化百分比。 */ +export function formatPercent(value: null | number | undefined) { + const amount = Number(value ?? 0); + if (!Number.isFinite(amount)) { + return '0%'; + } + + const digits = amount % 1 === 0 ? 0 : 1; + return `${amount.toFixed(digits)}%`; +} + +/** 格式化数量。 */ +export function formatInteger(value: null | number | undefined) { + const amount = Number(value ?? 0); + if (!Number.isFinite(amount)) { + return '0'; + } + return Math.round(amount).toLocaleString('zh-CN'); +} + +/** 格式化时间。 */ +export function formatDateTime(value?: string) { + if (!value) { + return '—'; + } + + const parsed = dayjs(value); + if (!parsed.isValid()) { + return value; + } + return parsed.format('YYYY-MM-DD HH:mm'); +} + +/** 解析消息发送时间。 */ +export function resolveMessageTime( + sentAt?: string, + scheduledAt?: string, +): string { + if (sentAt) { + return formatDateTime(sentAt); + } + if (scheduledAt) { + return formatDateTime(scheduledAt); + } + return '—'; +} + +/** 将列表筛选映射为查询参数。 */ +export function mapMessageFilterToQuery(form: MessageReachFilterForm) { + const keyword = form.keyword.trim(); + return { + status: form.status || undefined, + channel: form.channel || undefined, + keyword: keyword || undefined, + }; +} + +/** 将模板筛选映射为查询参数。 */ +export function mapTemplateFilterToQuery(form: MessageTemplateFilterForm) { + const keyword = form.keyword.trim(); + return { + category: form.category || undefined, + keyword: keyword || undefined, + }; +} + +/** 将详情映射为编辑表单。 */ +export function mapDetailToEditorForm( + detail: MemberMessageReachDetailDto, + form: MessageReachEditorForm, +) { + form.messageId = detail.messageId; + form.templateId = detail.templateId; + form.title = detail.title; + form.content = detail.content; + form.channels = [...detail.channels]; + form.audienceType = detail.audienceType; + form.audienceTags = [...detail.audienceTags]; + form.scheduleType = detail.scheduleType; + form.scheduledAt = + detail.scheduleType === 'scheduled' && detail.scheduledAt + ? dayjs(detail.scheduledAt) + : null; +} + +/** 重置消息编辑表单。 */ +export function resetMessageEditorForm(form: MessageReachEditorForm) { + form.messageId = ''; + form.templateId = undefined; + form.title = ''; + form.content = ''; + form.channels = ['inapp']; + form.audienceType = 'all'; + form.audienceTags = []; + form.scheduleType = 'immediate'; + form.scheduledAt = null; +} + +/** 重置模板编辑表单。 */ +export function resetTemplateEditorForm(form: MessageTemplateEditorForm) { + form.templateId = ''; + form.name = ''; + form.category = 'notice'; + form.content = ''; +} + +/** 消息编辑表单转保存请求。 */ +export function mapMessageEditorFormToSavePayload( + form: MessageReachEditorForm, + submitAction: 'draft' | 'send', +): SaveMemberMessageReachPayload { + const payload: SaveMemberMessageReachPayload = { + messageId: form.messageId || undefined, + templateId: form.templateId || undefined, + title: form.title.trim(), + content: form.content.trim(), + channels: [...form.channels], + audienceType: form.audienceType, + audienceTags: [...form.audienceTags], + scheduleType: form.scheduleType, + scheduledAt: + form.scheduleType === 'scheduled' && form.scheduledAt + ? form.scheduledAt.toISOString() + : undefined, + submitAction, + }; + return payload; +} + +/** 模板编辑表单转保存请求。 */ +export function mapTemplateEditorFormToSavePayload( + form: MessageTemplateEditorForm, +): SaveMemberMessageTemplatePayload { + return { + templateId: form.templateId || undefined, + name: form.name.trim(), + category: form.category, + content: form.content.trim(), + }; +} + +/** 切换渠道。 */ +export function toggleChannel( + channels: MemberMessageReachChannel[], + channel: MemberMessageReachChannel, +) { + if (channels.includes(channel)) { + return channels.filter((item) => item !== channel); + } + return [...channels, channel]; +} + +/** 切换标签。 */ +export function toggleTag(tags: string[], tag: string) { + if (tags.includes(tag)) { + return tags.filter((item) => item !== tag); + } + return [...tags, tag]; +} + +/** 解析时间类型。 */ +export function resolveScheduleType(value: string): MemberMessageScheduleType { + return value === 'scheduled' ? 'scheduled' : 'immediate'; +} diff --git a/apps/web-antd/src/views/member/message-reach/composables/message-reach-page/message-actions.ts b/apps/web-antd/src/views/member/message-reach/composables/message-reach-page/message-actions.ts new file mode 100644 index 0000000..e74a05c --- /dev/null +++ b/apps/web-antd/src/views/member/message-reach/composables/message-reach-page/message-actions.ts @@ -0,0 +1,264 @@ +import type { Dayjs } from 'dayjs'; + +import type { Ref } from 'vue'; + +import type { + MemberMessageTemplateDto, + SaveMemberMessageReachPayload, +} from '#/api/member/message-reach'; +import type { + MemberMessageReachTabKey, + MessageReachDetailViewModel, + MessageReachEditorForm, +} from '#/views/member/message-reach/types'; + +import { message, Modal } from 'ant-design-vue'; + +import { + deleteMemberMessageReachApi, + saveMemberMessageReachApi, +} from '#/api/member/message-reach'; + +import { + mapDetailToEditorForm, + mapMessageEditorFormToSavePayload, + resetMessageEditorForm, + toggleTag, +} from './helpers'; + +interface CreateMessageActionsOptions { + activeTab: Ref; + audienceEstimateCount: Ref; + canManage: Ref; + detail: Ref; + detailDrawerMessageId: Ref; + form: MessageReachEditorForm; + isDetailDrawerOpen: Ref; + isMessageDrawerOpen: Ref; + isMessageSubmitting: Ref; + messageDrawerMode: Ref<'create' | 'edit'>; + loadMessageDetail: ( + messageId: string, + ) => Promise; + loadMessageList: () => Promise; + loadStats: () => Promise; +} + +export function createMessageActions(options: CreateMessageActionsOptions) { + function setMessageDrawerOpen(value: boolean) { + options.isMessageDrawerOpen.value = value; + } + + function setMessageTitle(value: string) { + options.form.title = value; + } + + function setMessageContent(value: string) { + options.form.content = value; + } + + function setMessageChannel(channel: 'inapp' | 'sms' | 'wechat-mini') { + if ( + options.form.channels.length === 1 && + options.form.channels[0] === channel + ) { + return; + } + options.form.channels = [channel]; + } + + function setAudienceType(value: 'all' | 'tag') { + options.form.audienceType = value; + if (value === 'all') { + options.form.audienceTags = []; + } + } + + function toggleAudienceTag(value: string) { + options.form.audienceTags = toggleTag(options.form.audienceTags, value); + } + + function setScheduleType(value: 'immediate' | 'scheduled') { + options.form.scheduleType = value; + if (value === 'immediate') { + options.form.scheduledAt = null; + } + } + + function setScheduledAt(value: Dayjs | null) { + options.form.scheduledAt = value; + } + + function switchToTemplateTab() { + options.activeTab.value = 'template'; + options.isMessageDrawerOpen.value = false; + } + + async function openCreateMessageDrawer() { + if (!options.canManage.value) { + return; + } + + resetMessageEditorForm(options.form); + options.audienceEstimateCount.value = 0; + options.messageDrawerMode.value = 'create'; + options.isMessageDrawerOpen.value = true; + } + + async function openEditMessageDrawer(messageId: string) { + if (!options.canManage.value) { + return; + } + + const detail = await options.loadMessageDetail(messageId); + if (!detail) { + return; + } + + mapDetailToEditorForm(detail, options.form); + options.audienceEstimateCount.value = detail.estimatedReachCount; + options.messageDrawerMode.value = 'edit'; + options.isMessageDrawerOpen.value = true; + } + + async function openDetailDrawer(messageId: string) { + options.detailDrawerMessageId.value = messageId; + options.isDetailDrawerOpen.value = true; + await options.loadMessageDetail(messageId); + } + + async function refreshDetailIfNeeded(messageId: string) { + if (!options.isDetailDrawerOpen.value) { + return; + } + if (options.detailDrawerMessageId.value !== messageId) { + return; + } + await options.loadMessageDetail(messageId); + } + + function useTemplateToCreateMessage(template: MemberMessageTemplateDto) { + if (!options.canManage.value) { + return; + } + + resetMessageEditorForm(options.form); + options.form.templateId = template.templateId; + options.form.title = template.name; + options.form.content = template.content; + options.audienceEstimateCount.value = 0; + options.messageDrawerMode.value = 'create'; + options.isMessageDrawerOpen.value = true; + options.activeTab.value = 'list'; + } + + function validateMessagePayload(payload: SaveMemberMessageReachPayload) { + if (!payload.title) { + message.warning('请输入消息标题'); + return false; + } + if (!payload.content) { + message.warning('请输入消息内容'); + return false; + } + if (payload.channels.length === 0) { + message.warning('请至少选择一个推送渠道'); + return false; + } + if (payload.audienceType === 'tag' && payload.audienceTags.length === 0) { + message.warning('请选择目标标签'); + return false; + } + if (payload.scheduleType === 'scheduled' && !payload.scheduledAt) { + message.warning('请选择定时发送时间'); + return false; + } + return true; + } + + async function submitMessage(submitAction: 'draft' | 'send') { + if (!options.canManage.value) { + return; + } + + const payload = mapMessageEditorFormToSavePayload( + options.form, + submitAction, + ); + if (!validateMessagePayload(payload)) { + return; + } + + options.isMessageSubmitting.value = true; + try { + const result = await saveMemberMessageReachApi(payload); + message.success( + submitAction === 'send' ? '发送任务已提交' : '草稿已保存', + ); + options.isMessageDrawerOpen.value = false; + await Promise.all([options.loadStats(), options.loadMessageList()]); + await refreshDetailIfNeeded(result.messageId); + } catch (error) { + console.error(error); + message.error(submitAction === 'send' ? '发送失败' : '保存草稿失败'); + } finally { + options.isMessageSubmitting.value = false; + } + } + + async function removeMessage(messageId: string) { + if (!options.canManage.value) { + return; + } + + Modal.confirm({ + title: '确认删除消息?', + content: '删除后无法恢复,且会取消未执行的发送任务。', + okText: '删除', + okType: 'danger', + cancelText: '取消', + async onOk() { + try { + await deleteMemberMessageReachApi({ messageId }); + message.success('删除成功'); + if (options.detailDrawerMessageId.value === messageId) { + options.isDetailDrawerOpen.value = false; + options.detailDrawerMessageId.value = ''; + options.detail.value = null; + } + await Promise.all([options.loadStats(), options.loadMessageList()]); + } catch (error) { + console.error(error); + message.error('删除失败'); + } + }, + }); + } + + function setDetailDrawerOpen(value: boolean) { + options.isDetailDrawerOpen.value = value; + if (!value) { + options.detailDrawerMessageId.value = ''; + options.detail.value = null; + } + } + + return { + openCreateMessageDrawer, + openDetailDrawer, + openEditMessageDrawer, + removeMessage, + setAudienceType, + setDetailDrawerOpen, + setMessageContent, + setMessageDrawerOpen, + setMessageChannel, + setMessageTitle, + setScheduleType, + setScheduledAt, + submitMessage, + switchToTemplateTab, + toggleAudienceTag, + useTemplateToCreateMessage, + }; +} diff --git a/apps/web-antd/src/views/member/message-reach/composables/message-reach-page/template-actions.ts b/apps/web-antd/src/views/member/message-reach/composables/message-reach-page/template-actions.ts new file mode 100644 index 0000000..982f467 --- /dev/null +++ b/apps/web-antd/src/views/member/message-reach/composables/message-reach-page/template-actions.ts @@ -0,0 +1,144 @@ +import type { Ref } from 'vue'; + +import type { MessageTemplateEditorForm } from '#/views/member/message-reach/types'; + +import { message, Modal } from 'ant-design-vue'; + +import { + deleteMemberMessageTemplateApi, + getMemberMessageTemplateDetailApi, + saveMemberMessageTemplateApi, +} from '#/api/member/message-reach'; + +import { + mapTemplateEditorFormToSavePayload, + resetTemplateEditorForm, +} from './helpers'; + +interface CreateTemplateActionsOptions { + canManage: Ref; + form: MessageTemplateEditorForm; + isTemplateEditorLoading: Ref; + isTemplateEditorOpen: Ref; + isTemplateSubmitting: Ref; + loadTemplateList: () => Promise; + mode: Ref<'create' | 'edit'>; +} + +export function createTemplateActions(options: CreateTemplateActionsOptions) { + function setTemplateEditorOpen(value: boolean) { + options.isTemplateEditorOpen.value = value; + } + + function setTemplateName(value: string) { + options.form.name = value; + } + + function setTemplateCategory(value: 'marketing' | 'notice' | 'recall') { + options.form.category = value; + } + + function setTemplateContent(value: string) { + options.form.content = value; + } + + function openCreateTemplateModal() { + if (!options.canManage.value) { + return; + } + + options.mode.value = 'create'; + resetTemplateEditorForm(options.form); + options.isTemplateEditorOpen.value = true; + } + + async function openEditTemplateModal(templateId: string) { + if (!options.canManage.value) { + return; + } + + options.isTemplateEditorLoading.value = true; + try { + const detail = await getMemberMessageTemplateDetailApi({ templateId }); + options.mode.value = 'edit'; + options.form.templateId = detail.templateId; + options.form.name = detail.name; + options.form.category = detail.category; + options.form.content = detail.content; + options.isTemplateEditorOpen.value = true; + } catch (error) { + console.error(error); + message.error('加载模板详情失败'); + } finally { + options.isTemplateEditorLoading.value = false; + } + } + + async function submitTemplate() { + if (!options.canManage.value) { + return; + } + + const payload = mapTemplateEditorFormToSavePayload(options.form); + if (!payload.name) { + message.warning('请输入模板名称'); + return; + } + if (!payload.content) { + message.warning('请输入模板内容'); + return; + } + + options.isTemplateSubmitting.value = true; + try { + await saveMemberMessageTemplateApi(payload); + message.success( + options.mode.value === 'create' ? '模板创建成功' : '模板保存成功', + ); + options.isTemplateEditorOpen.value = false; + await options.loadTemplateList(); + } catch (error) { + console.error(error); + message.error( + options.mode.value === 'create' ? '模板创建失败' : '模板保存失败', + ); + } finally { + options.isTemplateSubmitting.value = false; + } + } + + function removeTemplate(templateId: string) { + if (!options.canManage.value) { + return; + } + + Modal.confirm({ + title: '确认删除模板?', + content: '删除后不可恢复,且不影响已发送消息。', + okText: '删除', + okType: 'danger', + cancelText: '取消', + async onOk() { + try { + await deleteMemberMessageTemplateApi({ templateId }); + message.success('删除成功'); + await options.loadTemplateList(); + } catch (error) { + console.error(error); + message.error('删除失败'); + } + }, + }); + } + + return { + openCreateTemplateModal, + openEditTemplateModal, + removeTemplate, + setTemplateCategory, + setTemplateContent, + setTemplateEditorOpen, + setTemplateName, + submitTemplate, + }; +} diff --git a/apps/web-antd/src/views/member/message-reach/composables/useMemberMessageReachPage.ts b/apps/web-antd/src/views/member/message-reach/composables/useMemberMessageReachPage.ts new file mode 100644 index 0000000..136ff0a --- /dev/null +++ b/apps/web-antd/src/views/member/message-reach/composables/useMemberMessageReachPage.ts @@ -0,0 +1,396 @@ +import type { MemberMessageReachTabKey } from '../types'; + +import type { + MemberMessageReachChannel, + MemberMessageReachDetailDto, + MemberMessageReachStatus, + MemberMessageTemplateCategory, +} from '#/api/member/message-reach'; + +import { computed, onActivated, onMounted, reactive, ref, watch } from 'vue'; + +import { useAccessStore } from '@vben/stores'; + +import { + createDefaultMessageReachEditorForm, + createDefaultMessageReachFilterForm, + createDefaultMessageReachPager, + createDefaultMessageReachStats, + createDefaultMessageTemplateEditorForm, + createDefaultMessageTemplateFilterForm, + createDefaultMessageTemplatePager, +} from '../types'; +import { + MEMBER_MESSAGE_REACH_MANAGE_PERMISSION, + MEMBER_MESSAGE_REACH_VIEW_PERMISSION, + MESSAGE_REACH_TAB_OPTIONS, +} from './message-reach-page/constants'; +import { createDataActions } from './message-reach-page/data-actions'; +import { + resetMessageEditorForm, + resetTemplateEditorForm, +} from './message-reach-page/helpers'; +import { createMessageActions } from './message-reach-page/message-actions'; +import { createTemplateActions } from './message-reach-page/template-actions'; + +export function useMemberMessageReachPage() { + const accessStore = useAccessStore(); + + const activeTab = ref('list'); + + const stats = ref(createDefaultMessageReachStats()); + const isStatsLoading = ref(false); + + const messageFilterForm = reactive(createDefaultMessageReachFilterForm()); + const messagePager = ref(createDefaultMessageReachPager()); + const isMessageLoading = ref(false); + + const templateFilterForm = reactive(createDefaultMessageTemplateFilterForm()); + const templatePager = ref(createDefaultMessageTemplatePager()); + const isTemplateLoading = ref(false); + + const detail = ref(null); + const isDetailLoading = ref(false); + const isDetailDrawerOpen = ref(false); + const detailDrawerMessageId = ref(''); + + const messageDrawerMode = ref<'create' | 'edit'>('create'); + const form = reactive(createDefaultMessageReachEditorForm()); + const isMessageDrawerOpen = ref(false); + const isMessageSubmitting = ref(false); + const audienceEstimateCount = ref(0); + const isEstimatingAudience = ref(false); + + const templateEditorMode = ref<'create' | 'edit'>('create'); + const templateForm = reactive(createDefaultMessageTemplateEditorForm()); + const isTemplateEditorOpen = ref(false); + const isTemplateEditorLoading = ref(false); + const isTemplateSubmitting = ref(false); + + const accessCodeSet = computed( + () => new Set((accessStore.accessCodes ?? []).map(String)), + ); + const canManage = computed(() => + accessCodeSet.value.has(MEMBER_MESSAGE_REACH_MANAGE_PERMISSION), + ); + const canView = computed( + () => + canManage.value || + accessCodeSet.value.has(MEMBER_MESSAGE_REACH_VIEW_PERMISSION), + ); + + const messageDrawerTitle = computed(() => + messageDrawerMode.value === 'create' ? '创建消息' : '编辑消息', + ); + + const templateEditorTitle = computed(() => + templateEditorMode.value === 'create' ? '新建模板' : '编辑模板', + ); + + const templateEditorSubmitText = computed(() => + templateEditorMode.value === 'create' ? '创建' : '保存', + ); + + const { + estimateAudience, + loadMessageDetail, + loadMessageList, + loadStats, + loadTemplateList, + } = createDataActions({ + audienceEstimateCount, + detail, + isDetailLoading, + isEstimatingAudience, + isMessageLoading, + isStatsLoading, + isTemplateLoading, + messageFilterForm, + messagePager, + stats, + templateFilterForm, + templatePager, + }); + + const { + openCreateMessageDrawer, + openDetailDrawer, + openEditMessageDrawer, + removeMessage, + setAudienceType, + setDetailDrawerOpen, + setMessageContent, + setMessageDrawerOpen, + setMessageChannel, + setMessageTitle, + setScheduleType, + setScheduledAt, + submitMessage, + switchToTemplateTab, + toggleAudienceTag, + useTemplateToCreateMessage, + } = createMessageActions({ + activeTab, + audienceEstimateCount, + canManage, + detail, + detailDrawerMessageId, + form, + isDetailDrawerOpen, + isMessageDrawerOpen, + isMessageSubmitting, + messageDrawerMode, + loadMessageDetail, + loadMessageList, + loadStats, + }); + + const { + openCreateTemplateModal, + openEditTemplateModal, + removeTemplate, + setTemplateCategory, + setTemplateContent, + setTemplateEditorOpen, + setTemplateName, + submitTemplate, + } = createTemplateActions({ + canManage, + form: templateForm, + isTemplateEditorLoading, + isTemplateEditorOpen, + isTemplateSubmitting, + loadTemplateList, + mode: templateEditorMode, + }); + + function setActiveTab(value: MemberMessageReachTabKey) { + activeTab.value = value; + } + + function setMessageStatusFilter(value: string) { + messageFilterForm.status = (value || undefined) as + | MemberMessageReachStatus + | undefined; + } + + function setMessageChannelFilter(value: string) { + messageFilterForm.channel = (value || undefined) as + | MemberMessageReachChannel + | undefined; + } + + function setMessageKeyword(value: string) { + messageFilterForm.keyword = value; + } + + async function applyMessageFilters() { + messagePager.value = { + ...messagePager.value, + page: 1, + }; + await loadMessageList(); + } + + async function resetMessageFilters() { + messageFilterForm.status = undefined; + messageFilterForm.channel = undefined; + messageFilterForm.keyword = ''; + messagePager.value = { + ...messagePager.value, + page: 1, + }; + await loadMessageList(); + } + + async function handleMessagePageChange(page: number, pageSize: number) { + messagePager.value = { + ...messagePager.value, + page, + pageSize, + }; + await loadMessageList(); + } + + function setTemplateCategoryFilter(value: string) { + templateFilterForm.category = (value || undefined) as + | MemberMessageTemplateCategory + | undefined; + } + + function setTemplateKeyword(value: string) { + templateFilterForm.keyword = value; + } + + async function applyTemplateFilters() { + templatePager.value = { + ...templatePager.value, + page: 1, + }; + await loadTemplateList(); + } + + async function resetTemplateFilters() { + templateFilterForm.category = undefined; + templateFilterForm.keyword = ''; + templatePager.value = { + ...templatePager.value, + page: 1, + }; + await loadTemplateList(); + } + + async function handleTemplatePageChange(page: number, pageSize: number) { + templatePager.value = { + ...templatePager.value, + page, + pageSize, + }; + await loadTemplateList(); + } + + async function setAudienceTypeAndEstimate(value: 'all' | 'tag') { + setAudienceType(value); + if (value === 'all') { + audienceEstimateCount.value = 0; + return; + } + await estimateAudience('tag', form.audienceTags); + } + + async function toggleAudienceTagAndEstimate(tag: string) { + toggleAudienceTag(tag); + if (form.audienceType !== 'tag') { + return; + } + await estimateAudience('tag', form.audienceTags); + } + + async function submitDraftMessage() { + await submitMessage('draft'); + } + + async function submitSendMessage() { + await submitMessage('send'); + } + + function clearByPermission() { + stats.value = createDefaultMessageReachStats(); + messagePager.value = createDefaultMessageReachPager(); + templatePager.value = createDefaultMessageTemplatePager(); + detail.value = null; + isDetailDrawerOpen.value = false; + detailDrawerMessageId.value = ''; + resetMessageEditorForm(form); + audienceEstimateCount.value = 0; + isMessageDrawerOpen.value = false; + resetTemplateEditorForm(templateForm); + isTemplateEditorOpen.value = false; + } + + async function bootstrapPageData() { + await Promise.all([loadStats(), loadMessageList(), loadTemplateList()]); + } + + watch(canView, async (value, oldValue) => { + if (value === oldValue) { + return; + } + + if (!value) { + clearByPermission(); + return; + } + + await bootstrapPageData(); + }); + + onMounted(async () => { + if (!canView.value) { + clearByPermission(); + return; + } + + await bootstrapPageData(); + }); + + onActivated(() => { + if (!canView.value) { + return; + } + + if ( + messagePager.value.totalCount === 0 && + templatePager.value.totalCount === 0 + ) { + void bootstrapPageData(); + } + }); + + return { + activeTab, + applyMessageFilters, + applyTemplateFilters, + audienceEstimateCount, + canManage, + canView, + detail, + form, + handleMessagePageChange, + handleTemplatePageChange, + isDetailDrawerOpen, + isDetailLoading, + isEstimatingAudience, + isMessageDrawerOpen, + isMessageLoading, + isMessageSubmitting, + isStatsLoading, + isTemplateEditorLoading, + isTemplateEditorOpen, + isTemplateLoading, + isTemplateSubmitting, + messageDrawerTitle, + messageFilterForm, + messagePager, + openCreateMessageDrawer, + openCreateTemplateModal, + openDetailDrawer, + openEditMessageDrawer, + openEditTemplateModal, + removeMessage, + removeTemplate, + resetMessageFilters, + resetTemplateFilters, + setActiveTab, + setAudienceTypeAndEstimate, + setDetailDrawerOpen, + setMessageChannel, + setMessageChannelFilter, + setMessageContent, + setMessageDrawerOpen, + setMessageKeyword, + setMessageStatusFilter, + setMessageTitle, + setScheduleType, + setScheduledAt, + setTemplateCategory, + setTemplateCategoryFilter, + setTemplateContent, + setTemplateEditorOpen, + setTemplateKeyword, + setTemplateName, + stats, + submitDraftMessage, + submitSendMessage, + submitTemplate, + switchToTemplateTab, + tabOptions: MESSAGE_REACH_TAB_OPTIONS, + templateEditorSubmitText, + templateEditorTitle, + templateFilterForm, + templateForm, + templatePager, + toggleAudienceTagAndEstimate, + useTemplateToCreateMessage, + }; +} diff --git a/apps/web-antd/src/views/member/message-reach/index.vue b/apps/web-antd/src/views/member/message-reach/index.vue new file mode 100644 index 0000000..ae07192 --- /dev/null +++ b/apps/web-antd/src/views/member/message-reach/index.vue @@ -0,0 +1,197 @@ + + + + + diff --git a/apps/web-antd/src/views/member/message-reach/styles/base.less b/apps/web-antd/src/views/member/message-reach/styles/base.less new file mode 100644 index 0000000..5732945 --- /dev/null +++ b/apps/web-antd/src/views/member/message-reach/styles/base.less @@ -0,0 +1,27 @@ +.page-member-message-reach { + .mmr-page { + display: flex; + flex-direction: column; + gap: 16px; + } + + .mmr-tab-panel { + display: flex; + flex-direction: column; + gap: 16px; + } + + .mmr-spacer { + flex: 1; + } + + .mmr-time-text { + font-size: 12px; + color: #6b7280; + white-space: nowrap; + } + + .mmr-action-link { + padding-inline: 0; + } +} diff --git a/apps/web-antd/src/views/member/message-reach/styles/drawer.less b/apps/web-antd/src/views/member/message-reach/styles/drawer.less new file mode 100644 index 0000000..2fb863d --- /dev/null +++ b/apps/web-antd/src/views/member/message-reach/styles/drawer.less @@ -0,0 +1,163 @@ +.page-member-message-reach { + .mmr-editor-form { + .ant-form-item { + margin-bottom: 16px; + } + } + + .mmr-pill-group { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .mmr-pill { + padding: 4px 12px; + margin-right: 0; + font-size: 13px; + line-height: 22px; + border-radius: 999px; + } + + .mmr-pill.ant-tag-checkable-checked { + color: #fff; + background: #1677ff; + } + + .mmr-tag-panel { + margin-top: 10px; + } + + .mmr-reach-row { + margin-top: 10px; + font-size: 12px; + font-weight: 500; + color: #1677ff; + } + + .mmr-form-hint { + margin-top: 6px; + font-size: 12px; + color: #9ca3af; + } + + .mmr-scheduled-picker { + width: 260px; + margin-top: 10px; + } + + .mmr-template-link-wrap { + margin-bottom: 0; + } + + .mmr-template-link { + padding-left: 0; + } + + .mmr-drawer-footer { + display: flex; + gap: 10px; + justify-content: flex-end; + } + + .mmr-detail-section { + margin-bottom: 20px; + } + + .mmr-detail-section-title { + margin-bottom: 12px; + font-size: 14px; + font-weight: 600; + color: #111827; + } + + .mmr-detail-title-row { + display: flex; + gap: 10px; + align-items: center; + margin-bottom: 12px; + } + + .mmr-detail-title { + font-size: 18px; + font-weight: 700; + color: #111827; + } + + .mmr-detail-meta { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px 16px; + font-size: 13px; + + .label { + color: #6b7280; + } + + .channels { + display: inline-flex; + gap: 4px; + align-items: center; + } + } + + .mmr-detail-stat-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + } + + .mmr-detail-stat-item { + padding: 10px 12px; + background: #f8fafc; + border-radius: 8px; + + .label { + margin-bottom: 4px; + font-size: 12px; + color: #6b7280; + } + + .value { + font-size: 18px; + font-weight: 700; + color: #0f172a; + } + } + + .mmr-detail-content { + padding: 10px 12px; + font-size: 13px; + line-height: 1.65; + color: #374151; + white-space: pre-wrap; + background: #f8fafc; + border: 1px solid #f1f5f9; + border-radius: 8px; + } + + .mmr-detail-error { + margin-top: 10px; + font-size: 12px; + color: #dc2626; + } + + .mmr-recipient-table { + .ant-table-thead > tr > th { + font-size: 12px; + background: #f8fafc; + } + + .ant-table-tbody > tr > td { + font-size: 12px; + } + } + + .mmr-error-text { + display: inline-block; + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} diff --git a/apps/web-antd/src/views/member/message-reach/styles/index.less b/apps/web-antd/src/views/member/message-reach/styles/index.less new file mode 100644 index 0000000..2a408a3 --- /dev/null +++ b/apps/web-antd/src/views/member/message-reach/styles/index.less @@ -0,0 +1,7 @@ +@import './base.less'; +@import './layout.less'; +@import './list.less'; +@import './template.less'; +@import './drawer.less'; +@import './modal.less'; +@import './responsive.less'; diff --git a/apps/web-antd/src/views/member/message-reach/styles/layout.less b/apps/web-antd/src/views/member/message-reach/styles/layout.less new file mode 100644 index 0000000..d7929cc --- /dev/null +++ b/apps/web-antd/src/views/member/message-reach/styles/layout.less @@ -0,0 +1,78 @@ +.page-member-message-reach { + .mmr-topbar { + display: flex; + gap: 12px; + align-items: center; + justify-content: space-between; + } + + .mmr-tab-switch { + --ant-segmented-item-selected-bg: #fff; + --ant-segmented-item-selected-color: #1677ff; + } + + .mmr-readonly-tip { + font-size: 12px; + color: #6b7280; + } + + .mmr-toolbar { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + padding: 12px 14px; + background: #fff; + border-radius: 10px; + box-shadow: 0 2px 8px rgb(15 23 42 / 7%); + } + + .mmr-select { + width: 120px; + } + + .mmr-keyword-input { + width: 200px; + } + + .mmr-stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + } + + .mmr-stat-card { + padding: 16px 18px; + background: #fff; + border-radius: 10px; + box-shadow: 0 3px 10px rgb(15 23 42 / 7%); + transition: + transform 0.2s ease, + box-shadow 0.2s ease; + } + + .mmr-stat-card:hover { + box-shadow: 0 10px 20px rgb(15 23 42 / 11%); + transform: translateY(-1px); + } + + .mmr-stat-label { + margin-bottom: 6px; + font-size: 12px; + color: #9ca3af; + } + + .mmr-stat-value { + font-size: 24px; + font-weight: 700; + line-height: 1.1; + color: #111827; + } + + .mmr-stat-unit { + margin-left: 2px; + font-size: 13px; + font-weight: 500; + color: #6b7280; + } +} diff --git a/apps/web-antd/src/views/member/message-reach/styles/list.less b/apps/web-antd/src/views/member/message-reach/styles/list.less new file mode 100644 index 0000000..432d8f8 --- /dev/null +++ b/apps/web-antd/src/views/member/message-reach/styles/list.less @@ -0,0 +1,53 @@ +.page-member-message-reach { + .mmr-table-wrap { + overflow: hidden; + background: #fff; + border-radius: 10px; + box-shadow: 0 4px 14px rgb(15 23 42 / 8%); + } + + .mmr-table { + .ant-table-thead > tr > th { + font-size: 13px; + font-weight: 600; + color: #475569; + background: #f8fafc; + white-space: nowrap; + } + + .ant-table-tbody > tr > td { + font-size: 13px; + vertical-align: middle; + color: #0f172a; + } + } + + .mmr-message-title { + font-weight: 600; + color: #0f172a; + } + + .mmr-message-metric { + margin-top: 2px; + font-size: 12px; + color: #6b7280; + } + + .mmr-channel-list { + display: flex; + flex-wrap: wrap; + gap: 4px; + } + + .mmr-table-actions { + display: inline-flex; + gap: 8px; + align-items: center; + } + + .mmr-pagination { + display: flex; + justify-content: flex-end; + padding: 14px 16px 16px; + } +} diff --git a/apps/web-antd/src/views/member/message-reach/styles/modal.less b/apps/web-antd/src/views/member/message-reach/styles/modal.less new file mode 100644 index 0000000..1cc7010 --- /dev/null +++ b/apps/web-antd/src/views/member/message-reach/styles/modal.less @@ -0,0 +1,7 @@ +.page-member-message-reach { + .mmr-template-modal-form { + .ant-form-item { + margin-bottom: 14px; + } + } +} diff --git a/apps/web-antd/src/views/member/message-reach/styles/responsive.less b/apps/web-antd/src/views/member/message-reach/styles/responsive.less new file mode 100644 index 0000000..f419235 --- /dev/null +++ b/apps/web-antd/src/views/member/message-reach/styles/responsive.less @@ -0,0 +1,48 @@ +.page-member-message-reach { + @media (max-width: 1200px) { + .mmr-stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .mmr-template-grid { + grid-template-columns: 1fr; + } + + .mmr-detail-stat-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + + @media (max-width: 768px) { + .mmr-toolbar { + align-items: stretch; + padding: 12px; + } + + .mmr-topbar { + flex-direction: column; + align-items: flex-start; + } + + .mmr-select, + .mmr-keyword-input { + width: 100%; + } + + .mmr-stats { + grid-template-columns: 1fr; + } + + .mmr-detail-meta { + grid-template-columns: 1fr; + } + + .mmr-detail-stat-grid { + grid-template-columns: 1fr; + } + + .mmr-scheduled-picker { + width: 100%; + } + } +} diff --git a/apps/web-antd/src/views/member/message-reach/styles/template.less b/apps/web-antd/src/views/member/message-reach/styles/template.less new file mode 100644 index 0000000..6524c26 --- /dev/null +++ b/apps/web-antd/src/views/member/message-reach/styles/template.less @@ -0,0 +1,85 @@ +.page-member-message-reach { + .mmr-template-wrap { + display: flex; + flex-direction: column; + gap: 16px; + } + + .mmr-template-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; + } + + .mmr-template-card { + display: flex; + flex-direction: column; + gap: 12px; + min-height: 172px; + padding: 18px 20px; + background: #fff; + border-radius: 10px; + box-shadow: 0 4px 14px rgb(15 23 42 / 8%); + transition: + transform 0.2s ease, + box-shadow 0.2s ease; + } + + .mmr-template-card:hover { + box-shadow: 0 10px 20px rgb(15 23 42 / 12%); + transform: translateY(-1px); + } + + .mmr-template-head { + display: flex; + gap: 10px; + align-items: center; + justify-content: space-between; + } + + .mmr-template-name { + flex: 1; + overflow: hidden; + font-size: 14px; + font-weight: 600; + color: #0f172a; + text-overflow: ellipsis; + white-space: nowrap; + } + + .mmr-template-preview { + display: -webkit-box; + overflow: hidden; + font-size: 12px; + line-height: 1.65; + color: #6b7280; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + min-height: 40px; + } + + .mmr-template-foot { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: auto; + } + + .mmr-template-usage { + font-size: 12px; + color: #9ca3af; + } + + .mmr-template-actions { + display: inline-flex; + gap: 8px; + align-items: center; + } + + .mmr-template-empty { + padding: 36px 0; + background: #fff; + border-radius: 10px; + box-shadow: 0 3px 10px rgb(15 23 42 / 7%); + } +} diff --git a/apps/web-antd/src/views/member/message-reach/types.ts b/apps/web-antd/src/views/member/message-reach/types.ts new file mode 100644 index 0000000..f17b9f9 --- /dev/null +++ b/apps/web-antd/src/views/member/message-reach/types.ts @@ -0,0 +1,147 @@ +/** + * 文件职责:会员消息触达页面类型定义与默认值工厂。 + */ +import type { Dayjs } from 'dayjs'; + +import type { + MemberMessageAudienceType, + MemberMessageReachChannel, + MemberMessageReachDetailDto, + MemberMessageReachListItemDto, + MemberMessageReachStatsDto, + MemberMessageReachStatus, + MemberMessageScheduleType, + MemberMessageTemplateCategory, + MemberMessageTemplateDto, +} from '#/api/member/message-reach'; + +/** 页面主 Tab。 */ +export type MemberMessageReachTabKey = 'list' | 'template'; + +/** 消息列表筛选表单。 */ +export interface MessageReachFilterForm { + channel?: MemberMessageReachChannel; + keyword: string; + status?: MemberMessageReachStatus; +} + +/** 模板列表筛选表单。 */ +export interface MessageTemplateFilterForm { + category?: MemberMessageTemplateCategory; + keyword: string; +} + +/** 消息列表分页状态。 */ +export interface MessageReachPager { + items: MemberMessageReachListItemDto[]; + page: number; + pageSize: number; + totalCount: number; +} + +/** 模板列表分页状态。 */ +export interface MessageTemplatePager { + items: MemberMessageTemplateDto[]; + page: number; + pageSize: number; + totalCount: number; +} + +/** 消息编辑表单。 */ +export interface MessageReachEditorForm { + audienceTags: string[]; + audienceType: MemberMessageAudienceType; + channels: MemberMessageReachChannel[]; + content: string; + messageId: string; + scheduleType: MemberMessageScheduleType; + scheduledAt: Dayjs | null; + templateId?: string; + title: string; +} + +/** 模板编辑表单。 */ +export interface MessageTemplateEditorForm { + category: MemberMessageTemplateCategory; + content: string; + name: string; + templateId: string; +} + +/** 页面统计视图模型。 */ +export type MessageReachStatsViewModel = MemberMessageReachStatsDto; + +/** 消息详情视图模型。 */ +export type MessageReachDetailViewModel = MemberMessageReachDetailDto; + +/** 默认消息列表筛选。 */ +export function createDefaultMessageReachFilterForm(): MessageReachFilterForm { + return { + status: undefined, + channel: undefined, + keyword: '', + }; +} + +/** 默认模板列表筛选。 */ +export function createDefaultMessageTemplateFilterForm(): MessageTemplateFilterForm { + return { + category: undefined, + keyword: '', + }; +} + +/** 默认消息分页。 */ +export function createDefaultMessageReachPager(): MessageReachPager { + return { + items: [], + page: 1, + pageSize: 10, + totalCount: 0, + }; +} + +/** 默认模板分页。 */ +export function createDefaultMessageTemplatePager(): MessageTemplatePager { + return { + items: [], + page: 1, + pageSize: 12, + totalCount: 0, + }; +} + +/** 默认消息编辑表单。 */ +export function createDefaultMessageReachEditorForm(): MessageReachEditorForm { + return { + messageId: '', + templateId: undefined, + title: '', + content: '', + channels: ['inapp'], + audienceType: 'all', + audienceTags: [], + scheduleType: 'immediate', + scheduledAt: null, + }; +} + +/** 默认模板编辑表单。 */ +export function createDefaultMessageTemplateEditorForm(): MessageTemplateEditorForm { + return { + templateId: '', + name: '', + category: 'notice', + content: '', + }; +} + +/** 默认统计数据。 */ +export function createDefaultMessageReachStats(): MessageReachStatsViewModel { + return { + monthlySentCount: 0, + reachMemberCount: 0, + openRate: 0, + conversionRate: 0, + }; +}