feat: 完成会员消息触达模块页面与交互
This commit is contained in:
@@ -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'];
|
||||
@@ -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<number>;
|
||||
detail: Ref<MemberMessageReachDetailDto | null>;
|
||||
isDetailLoading: Ref<boolean>;
|
||||
isEstimatingAudience: Ref<boolean>;
|
||||
isMessageLoading: Ref<boolean>;
|
||||
isStatsLoading: Ref<boolean>;
|
||||
isTemplateLoading: Ref<boolean>;
|
||||
messageFilterForm: MessageReachFilterForm;
|
||||
messagePager: Ref<MessageReachPager>;
|
||||
stats: Ref<MemberMessageReachStatsDto>;
|
||||
templateFilterForm: MessageTemplateFilterForm;
|
||||
templatePager: Ref<MessageTemplatePager>;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<MemberMessageReachStatus, string> =
|
||||
{
|
||||
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';
|
||||
}
|
||||
@@ -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<MemberMessageReachTabKey>;
|
||||
audienceEstimateCount: Ref<number>;
|
||||
canManage: Ref<boolean>;
|
||||
detail: Ref<MessageReachDetailViewModel | null>;
|
||||
detailDrawerMessageId: Ref<string>;
|
||||
form: MessageReachEditorForm;
|
||||
isDetailDrawerOpen: Ref<boolean>;
|
||||
isMessageDrawerOpen: Ref<boolean>;
|
||||
isMessageSubmitting: Ref<boolean>;
|
||||
messageDrawerMode: Ref<'create' | 'edit'>;
|
||||
loadMessageDetail: (
|
||||
messageId: string,
|
||||
) => Promise<MessageReachDetailViewModel | null>;
|
||||
loadMessageList: () => Promise<void>;
|
||||
loadStats: () => Promise<void>;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<boolean>;
|
||||
form: MessageTemplateEditorForm;
|
||||
isTemplateEditorLoading: Ref<boolean>;
|
||||
isTemplateEditorOpen: Ref<boolean>;
|
||||
isTemplateSubmitting: Ref<boolean>;
|
||||
loadTemplateList: () => Promise<void>;
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user