liu
3 天以前 2b695bfabdceccb0ee19c12c8e655bd7f47a6e2f
src/views/wxcp/notice/modules/push-modal.vue
@@ -6,12 +6,13 @@
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' });
@@ -29,20 +30,87 @@
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:
@@ -51,7 +119,7 @@
});
const [Modal, modalApi] = useVbenModal({
  onOpenChange(isOpen: boolean) {
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      notice.value = {};
      resultMessage.value = '';
@@ -67,22 +135,74 @@
    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() {
@@ -101,9 +221,41 @@
    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;
  }
@@ -119,8 +271,10 @@
      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('推送成功');
@@ -187,18 +341,76 @@
        />
      </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" />