| | |
| | | import { useVbenModal } from '@vben/common-ui'; |
| | | import { Alert, Button, Input, Select, Textarea, message } from 'ant-design-vue'; |
| | | |
| | | import { pushNotice } from '#/api/wxcp/notice'; |
| | | import FileUpload from '#/components/upload/file-upload.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 linkUrl = ref(''); |
| | | const linkBtnText = ref(''); |
| | | const coverUrl = ref(''); |
| | | const fileName = ref(''); |
| | | const fileList = ref<number[]>([]); |
| | | /** |
| | | * 上传组件的回显值 |
| | | * |
| | | * 组件 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 '成员会收到一条说明文本和一个文件,文件上限 20MB。'; |
| | | 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: |
| | |
| | | }); |
| | | |
| | | const [Modal, modalApi] = useVbenModal({ |
| | | onOpenChange(isOpen: boolean) { |
| | | async onOpenChange(isOpen: boolean) { |
| | | if (!isOpen) { |
| | | notice.value = {}; |
| | | resultMessage.value = ''; |
| | |
| | | linkUrl.value = item.linkUrl ?? ''; |
| | | linkBtnText.value = item.linkBtnText ?? ''; |
| | | coverUrl.value = item.coverUrl ?? ''; |
| | | fileName.value = item.fileName ?? ''; |
| | | fileList.value = item.fileBlobId ? [item.fileBlobId] : []; |
| | | 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 resolveFileBlobId(): number | undefined { |
| | | const raw = fileList.value as unknown; |
| | | if (Array.isArray(raw)) { |
| | | const first = raw[0]; |
| | | if (typeof first === 'number') { |
| | | return first; |
| | | } |
| | | return (first as { id?: number } | undefined)?.id; |
| | | /** 把附件列表(或 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 || '' }, |
| | | })); |
| | | } |
| | | return typeof raw === 'number' ? raw : undefined; |
| | | 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() { |
| | |
| | | message.warning('图文卡片/图文消息的跳转链接不能为空'); |
| | | return; |
| | | } |
| | | const fileBlobId = resolveFileBlobId(); |
| | | if (isFile.value && !fileBlobId) { |
| | | message.warning('文件通知必须上传文件'); |
| | | 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; |
| | | } |
| | | |
| | |
| | | linkUrl: linkUrl.value, |
| | | linkBtnText: linkBtnText.value, |
| | | coverUrl: coverUrl.value, |
| | | fileBlobId, |
| | | fileName: fileName.value || undefined, |
| | | imageBlobIds, |
| | | fileBlobIds, |
| | | videoBlobIds, |
| | | articleList: isMultiNews.value ? articles.value : undefined, |
| | | }); |
| | | if (res?.success) { |
| | | message.success('推送成功'); |
| | |
| | | /> |
| | | </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> |
| | | <div class="mb-2 text-sm font-medium">推送附件</div> |
| | | <FileUpload |
| | | v-model:value="fileList" |
| | | class="mb-3" |
| | | :max-number="1" |
| | | :multiple="false" |
| | | 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" /> |
| | | |