<script lang="ts" setup>
|
import type { WxCpNoticeApi } from '#/api/wxcp/notice';
|
|
import { computed, ref } from 'vue';
|
|
import { useVbenModal } from '@vben/common-ui';
|
import { Alert, Button, Input, Select, Textarea, message } from 'ant-design-vue';
|
|
import { getNotice, pushNotice } from '#/api/wxcp/notice';
|
import { FileUpload, ImageUpload } from '#/components/upload';
|
|
import PushScopePicker from '../../components/push-scope-picker.vue';
|
import { PUSH_SCOPE } from '../../data';
|
import { NOTICE_TYPE, NOTICE_TYPE_OPTIONS } from '../data';
|
import ArticleEditor from './article-editor.vue';
|
|
defineOptions({ name: 'WxCpNoticePushModal' });
|
|
const emit = defineEmits<{ success: [] }>();
|
|
const notice = ref<WxCpNoticeApi.Notice>({});
|
const scope = ref<number>(PUSH_SCOPE.ALL);
|
const target = ref<string>('');
|
const pushing = ref(false);
|
const resultMessage = ref('');
|
|
// 推送时选择的消息模板与参数(默认取通知上已保存的配置)
|
const noticeType = ref<number>(NOTICE_TYPE.TEXT);
|
const summary = ref('');
|
const linkUrl = ref('');
|
const linkBtnText = ref('');
|
const coverUrl = ref('');
|
/**
|
* 上传组件的回显值
|
*
|
* 组件 props 声明的类型是 string | string[] | number[],但开了 valueKey/resultField
|
* 后实际回传的是文件对象。这里用 local 类型保存对象(为了展示文件名),
|
* 只在绑定给组件时做一次类型收窄,避免运行逻辑被 props 类型限制。
|
*/
|
type UploadItem = Record<string, unknown>;
|
const imageList = ref<UploadItem[]>([]);
|
const fileList = ref<UploadItem[]>([]);
|
const videoList = ref<UploadItem[]>([]);
|
/** 多图文条目 */
|
const articles = ref<WxCpNoticeApi.NoticeArticle[]>([]);
|
|
/** 组件值统一成文件对象数组 */
|
function toUploadItems(value: unknown): UploadItem[] {
|
const list = Array.isArray(value)
|
? value
|
: value === '' || value === null || value === undefined
|
? []
|
: [value];
|
return list.map((item) =>
|
item && typeof item === 'object'
|
? (item as UploadItem)
|
: { uid: String(item), id: Number(item), name: `文件 ${String(item)}` },
|
);
|
}
|
|
const imageUploadValue = computed({
|
get: () => imageList.value as unknown as number[],
|
set: (value: number[]) => {
|
imageList.value = toUploadItems(value);
|
},
|
});
|
const fileUploadValue = computed({
|
get: () => fileList.value as unknown as number[],
|
set: (value: number[]) => {
|
fileList.value = toUploadItems(value);
|
},
|
});
|
const videoUploadValue = computed({
|
get: () => videoList.value as unknown as number[],
|
set: (value: number[]) => {
|
videoList.value = toUploadItems(value);
|
},
|
});
|
|
const isCard = computed(() => noticeType.value === NOTICE_TYPE.CARD);
|
const isNews = computed(() => noticeType.value === NOTICE_TYPE.NEWS);
|
const isFile = computed(() => noticeType.value === NOTICE_TYPE.FILE);
|
const isImage = computed(() => noticeType.value === NOTICE_TYPE.IMAGE);
|
const isVideo = computed(() => noticeType.value === NOTICE_TYPE.VIDEO);
|
const isMarkdown = computed(() => noticeType.value === NOTICE_TYPE.MARKDOWN);
|
const isMultiNews = computed(
|
() => noticeType.value === NOTICE_TYPE.MULTI_NEWS,
|
);
|
const showLink = computed(() => isCard.value || isNews.value);
|
|
// 后端未配置外网地址前缀时不返回预览链接,此时不提示「可查看全部」
|
const previewUrl = computed(() => notice.value.previewUrl || '');
|
|
const pushHint = computed(() => {
|
switch (noticeType.value) {
|
case NOTICE_TYPE.CARD:
|
return '成员会收到图文卡片,点击按钮可跳转到填报或详情页面。';
|
case NOTICE_TYPE.FILE:
|
return previewUrl.value
|
? '成员会逐个收到文件,另收到一张「查看全部附件」卡片,点开可预览或下载。'
|
: '成员会逐个收到文件消息,文件上限 20MB。';
|
case NOTICE_TYPE.IMAGE:
|
return previewUrl.value
|
? '成员会逐张收到图片,另收到一张「查看全部图片」卡片,点开可在页面内放大查看。'
|
: '成员会逐张收到图片消息,单张上限 10MB。';
|
case NOTICE_TYPE.VIDEO:
|
return previewUrl.value
|
? '成员会收到视频消息,另收到一张「查看通知详情」卡片,点开可全屏播放。'
|
: '成员会收到视频消息,视频上限 10MB。';
|
case NOTICE_TYPE.MARKDOWN:
|
return '成员会收到 Markdown 消息,支持标题、加粗、列表、引用等排版,正文上限 2048 字节。';
|
case NOTICE_TYPE.MULTI_NEWS:
|
return '成员会收到一条多图文消息,可一次展示最多 8 篇图文,点击任意一篇跳转对应链接。';
|
case NOTICE_TYPE.NEWS:
|
return '成员会收到图文消息,展示标题、摘要、封面并可点击跳转。';
|
default:
|
return '成员会收到纯文本消息,内容超过 600 字会自动截断。';
|
}
|
});
|
|
const [Modal, modalApi] = useVbenModal({
|
async onOpenChange(isOpen: boolean) {
|
if (!isOpen) {
|
notice.value = {};
|
resultMessage.value = '';
|
return;
|
}
|
const data = modalApi.getData<{ notice: WxCpNoticeApi.Notice }>();
|
const item = data.notice ?? {};
|
notice.value = item;
|
scope.value = item.pushScope ?? PUSH_SCOPE.ALL;
|
target.value = item.pushTarget ?? '';
|
noticeType.value = item.noticeType ?? NOTICE_TYPE.TEXT;
|
summary.value = item.summary ?? '';
|
linkUrl.value = item.linkUrl ?? '';
|
linkBtnText.value = item.linkBtnText ?? '';
|
coverUrl.value = item.coverUrl ?? '';
|
imageList.value = toUploadFiles(item.imageAttachmentList, item.imageBlobIds);
|
fileList.value = toUploadFiles(item.fileAttachmentList, item.fileBlobIds, item.fileBlobId);
|
videoList.value = toUploadFiles(item.videoAttachmentList, item.videoBlobIds);
|
articles.value = (item.articleList ?? []).map((article) => ({ ...article }));
|
|
// 列表行不带附件明细,进弹窗后补拉一次详情,保证图片/附件能正确回显
|
if (!item.imageAttachmentList?.length && !item.fileAttachmentList?.length && item.id) {
|
try {
|
const detail = await getNotice(item.id);
|
notice.value = { ...item, ...detail };
|
noticeType.value = detail.noticeType ?? noticeType.value;
|
summary.value = detail.summary ?? summary.value;
|
linkUrl.value = detail.linkUrl ?? linkUrl.value;
|
linkBtnText.value = detail.linkBtnText ?? linkBtnText.value;
|
coverUrl.value = detail.coverUrl ?? coverUrl.value;
|
imageList.value = toUploadFiles(detail.imageAttachmentList, detail.imageBlobIds);
|
fileList.value = toUploadFiles(detail.fileAttachmentList, detail.fileBlobIds, detail.fileBlobId);
|
videoList.value = toUploadFiles(detail.videoAttachmentList, detail.videoBlobIds);
|
articles.value = (detail.articleList ?? []).map((article) => ({
|
...article,
|
}));
|
} catch {
|
// 拉详情失败不阻塞推送,按列表行已有信息继续
|
}
|
}
|
},
|
});
|
|
/** 把附件列表(或 blobId 列表)转成上传组件的回显对象 */
|
function toUploadFiles(
|
attachments: WxCpNoticeApi.NoticeAttachment[] | undefined,
|
blobIds?: number[],
|
fallbackBlobId?: number,
|
): UploadItem[] {
|
if (attachments?.length) {
|
return attachments.map((item) => ({
|
uid: String(item.id),
|
name: item.name || item.originalFilename || '',
|
url: item.url || item.previewURL || '',
|
status: 'done',
|
id: item.id,
|
response: { id: item.id, name: item.name || item.originalFilename || '' },
|
}));
|
}
|
const ids = blobIds?.length ? blobIds : fallbackBlobId ? [fallbackBlobId] : [];
|
return ids.map((id) => ({
|
uid: String(id),
|
name: `文件 ${id}`,
|
status: 'done',
|
id,
|
response: { id },
|
}));
|
}
|
|
/** 上传组件可能返回数组或对象,统一取出 blobId 数组 */
|
function normalizeBlobIds(value: unknown): number[] {
|
const list = Array.isArray(value)
|
? value
|
: value === '' || value === null || value === undefined
|
? []
|
: [value];
|
return list
|
.map((item) =>
|
item && typeof item === 'object' && 'id' in item
|
? Number((item as { id: unknown }).id)
|
: Number(item),
|
)
|
.filter((blobId) => Number.isFinite(blobId));
|
}
|
|
async function handlePush() {
|
if (!notice.value.id) {
|
return;
|
}
|
if (scope.value !== PUSH_SCOPE.ALL && !target.value.trim()) {
|
message.warning('选择「指定部门」或「指定成员」时,请先选择推送目标');
|
return;
|
}
|
if (noticeType.value === NOTICE_TYPE.TEXT && !notice.value.content?.trim()) {
|
message.warning('纯文本通知的正文不能为空');
|
return;
|
}
|
if (showLink.value && !linkUrl.value.trim()) {
|
message.warning('图文卡片/图文消息的跳转链接不能为空');
|
return;
|
}
|
const imageBlobIds = normalizeBlobIds(imageList.value);
|
if (isImage.value && !imageBlobIds.length) {
|
message.warning('图片通知必须至少上传 1 张图片');
|
return;
|
}
|
const fileBlobIds = normalizeBlobIds(fileList.value);
|
if (isFile.value && !fileBlobIds.length) {
|
message.warning('文件通知必须至少上传 1 个附件');
|
return;
|
}
|
const videoBlobIds = normalizeBlobIds(videoList.value);
|
if (isVideo.value && !videoBlobIds.length) {
|
message.warning('视频通知必须上传 1 个视频');
|
return;
|
}
|
if (isMarkdown.value && !notice.value.content?.trim()) {
|
message.warning('Markdown 通知的正文不能为空');
|
return;
|
}
|
if (isMultiNews.value && !articles.value.length) {
|
message.warning('多图文消息至少需要 1 篇图文');
|
return;
|
}
|
if (
|
isMultiNews.value &&
|
articles.value.some((article) => !article.title?.trim())
|
) {
|
message.warning('请填写每篇图文的标题');
|
return;
|
}
|
if (
|
isMultiNews.value &&
|
articles.value.some((article) => !article.linkUrl?.trim())
|
) {
|
message.warning('请填写每篇图文的跳转链接');
|
return;
|
}
|
|
pushing.value = true;
|
resultMessage.value = '';
|
try {
|
const res = await pushNotice({
|
id: notice.value.id,
|
pushScope: scope.value,
|
pushTarget: scope.value === PUSH_SCOPE.ALL ? '' : target.value,
|
noticeType: noticeType.value,
|
summary: summary.value,
|
linkUrl: linkUrl.value,
|
linkBtnText: linkBtnText.value,
|
coverUrl: coverUrl.value,
|
imageBlobIds,
|
fileBlobIds,
|
videoBlobIds,
|
articleList: isMultiNews.value ? articles.value : undefined,
|
});
|
if (res?.success) {
|
message.success('推送成功');
|
emit('success');
|
await modalApi.close();
|
} else {
|
resultMessage.value = res?.message || '推送失败';
|
}
|
} catch (error: unknown) {
|
resultMessage.value = (error as Error)?.message || '推送失败';
|
} finally {
|
pushing.value = false;
|
}
|
}
|
</script>
|
|
<template>
|
<Modal title="推送通知" class="w-2/5">
|
<div class="px-4">
|
<div class="mb-4 rounded bg-gray-50 p-3 text-sm dark:bg-gray-800">
|
<div class="mb-1 font-medium">{{ notice.title }}</div>
|
<div class="text-gray-500">{{ pushHint }}</div>
|
</div>
|
|
<div class="mb-2 text-sm font-medium">消息模板</div>
|
<Select
|
v-model:value="noticeType"
|
:options="NOTICE_TYPE_OPTIONS"
|
class="mb-4 w-full"
|
placeholder="请选择推送模板类型"
|
/>
|
|
<template v-if="showLink">
|
<div class="mb-2 text-sm font-medium">摘要</div>
|
<Textarea
|
v-model:value="summary"
|
class="mb-3"
|
:rows="2"
|
placeholder="图文卡片/图文消息展示的摘要"
|
/>
|
<div class="mb-2 text-sm font-medium">跳转链接</div>
|
<Input
|
v-model:value="linkUrl"
|
class="mb-3"
|
placeholder="填报页面或详情页地址,如 https://..."
|
/>
|
</template>
|
|
<template v-if="isCard">
|
<div class="mb-2 text-sm font-medium">按钮文案</div>
|
<Input
|
v-model:value="linkBtnText"
|
class="mb-3"
|
placeholder="如:去填报 / 查看详情"
|
/>
|
</template>
|
|
<template v-if="isNews">
|
<div class="mb-2 text-sm font-medium">封面图地址</div>
|
<Input
|
v-model:value="coverUrl"
|
class="mb-3"
|
placeholder="封面图地址(选填)"
|
/>
|
</template>
|
|
<template v-if="isImage">
|
<div class="mb-2 text-sm font-medium">推送图片</div>
|
<ImageUpload
|
v-model:value="imageUploadValue"
|
class="mb-1"
|
:max-number="9"
|
:multiple="true"
|
value-key="id"
|
result-field="id"
|
/>
|
<div class="mb-3 text-xs text-gray-500">
|
单张不超过 10MB,最多 9 张;推送后成员可在预览页内放大查看全部图片
|
</div>
|
</template>
|
|
<template v-if="isFile">
|
<div class="mb-2 text-sm font-medium">推送附件</div>
|
<FileUpload
|
v-model:value="fileUploadValue"
|
class="mb-1"
|
:max-number="5"
|
:multiple="true"
|
value-key="id"
|
:show-description="true"
|
/>
|
<div class="mb-3 text-xs text-gray-500">
|
单个不超过 20MB,最多 5 个;推送后成员可在预览页内查看全部附件
|
</div>
|
</template>
|
|
<template v-if="isVideo">
|
<div class="mb-2 text-sm font-medium">推送视频</div>
|
<FileUpload
|
v-model:value="videoUploadValue"
|
class="mb-1"
|
:max-number="1"
|
:multiple="false"
|
:accept="['mp4', 'mov', 'm4v']"
|
value-key="id"
|
:show-description="true"
|
/>
|
<div class="mb-3 text-xs text-gray-500">
|
建议使用 mp4,单个不超过 10MB;推送后成员可在预览页全屏播放
|
</div>
|
</template>
|
|
<template v-if="isMarkdown">
|
<div class="mb-2 text-sm font-medium">Markdown 正文</div>
|
<div
|
class="mb-3 max-h-40 overflow-auto whitespace-pre-wrap rounded bg-gray-50 p-2 text-xs text-gray-600 dark:bg-gray-800 dark:text-gray-300"
|
>
|
{{ notice.content || '(正文为空,请先在编辑通知中填写 Markdown 内容)' }}
|
</div>
|
<div class="mb-3 text-xs text-gray-500">
|
支持 # 标题、**加粗**、- 列表、> 引用;正文上限 2048 字节
|
</div>
|
</template>
|
|
<template v-if="isMultiNews">
|
<ArticleEditor v-model="articles" />
|
</template>
|
|
<div v-if="previewUrl && (isImage || isFile || isVideo)" class="mb-3">
|
<div class="mb-1 text-sm font-medium">成员预览页</div>
|
<Input :value="previewUrl" readonly />
|
<div class="mt-1 text-xs text-gray-500">
|
推送时会自动附带「查看全部图片/附件」卡片,也可直接复制此链接发给成员
|
</div>
|
</div>
|
|
<div class="mb-2 mt-4 text-sm font-medium">推送范围</div>
|
<PushScopePicker v-model:scope="scope" v-model:target="target" />
|
|
<Alert
|
v-if="resultMessage"
|
class="mt-4"
|
type="error"
|
:message="resultMessage"
|
show-icon
|
/>
|
</div>
|
|
<template #footer>
|
<div class="flex justify-end gap-2">
|
<Button @click="modalApi.close()">取消</Button>
|
<Button type="primary" :loading="pushing" @click="handlePush">
|
确认推送
|
</Button>
|
</div>
|
</template>
|
</Modal>
|
</template>
|