liu
2026-09-30 71beff1699701e18c2503791c7ba1437c89966aa
feat(wxcp): 活动封面改用附件中间表,并修正附件地址同源

配合后端「活动封面改用附件中间表」的调整:
- activity.ts:coverUrl 字符串字段改为 blobIds(保存)+ attachmentList(回显)
- form.vue / index.vue / data.ts / signup-list.vue:封面选取与展示
- utils/fileUrl.ts:新增 toAccessibleFileUrl(),把后端返回的绝对附件地址
(带后端自身配置的域名)改写成同源路径,否则 <img> 在域名不可达时加载失败,
表现为「图片不显示」
- utils/index.ts:导出 fileUrl

Co-Authored-By: Claude <noreply@anthropic.com>
已修改6个文件
已添加1个文件
160 ■■■■■ 文件已修改
src/api/wxcp/activity.ts 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/fileUrl.ts 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wxcp/activity/data.ts 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wxcp/activity/index.vue 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wxcp/activity/modules/form.vue 54 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wxcp/activity/modules/signup-list.vue 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/wxcp/activity.ts
@@ -23,7 +23,15 @@
    id?: number;
    title?: string;
    content?: string;
    coverUrl?: string;
    /**
     * å°é¢å›¾æ–‡ä»¶ç¼–号(最多 1 å¼ ï¼‰
     *
     * å°é¢ä¸è½ä¸šåŠ¡è¡¨å­—æ®µï¼Œç»Ÿä¸€èµ° system_storage_attachment ä¸­é—´è¡¨ï¼Œ
     * ä¿å­˜æ—¶æäº¤ blobIds,回显时读 attachmentList。
     */
    blobIds?: number[];
    /** å°é¢å›¾é™„件列表(后端返回,最多 1 å¼ ï¼‰ */
    attachmentList?: SignupAttachment[];
    location?: string;
    startTime?: string;
    endTime?: string;
src/utils/fileUrl.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,42 @@
/**
 * ä¿®æ­£åŽç«¯è¿”回的附件访问地址
 *
 * åŽç«¯ç”Ÿæˆ url / previewURL / downloadURL æ—¶ä¼šå¸¦ä¸Šå®ƒè‡ªå·±é…ç½®çš„访问域名
 * ï¼ˆå½“前后端配的是 192.168.1.6:48080)。只要该域名与前端不同源、或者该主机
 * åœ¨å½“前网络里不可达,`<img src>` å°±ä¼šç›´æŽ¥åŠ è½½å¤±è´¥ï¼Œé¡µé¢ä¸Šè¡¨çŽ°ä¸ºã€Œå›¾ç‰‡ä¸æ˜¾ç¤ºã€ã€‚
 *
 * è¿™é‡ŒæŠŠè¿™ç±»ç»å¯¹åœ°å€æ”¹å†™æˆç›¸å¯¹ API å‰ç¼€çš„路径,让它走当前站点的同源请求:
 * å¼€å‘环境经 vite çš„ /admin-api ä»£ç†è½¬å‘到后端,生产环境前后端同源时同样成立。
 *
 * å·²ç»æ˜¯ç›¸å¯¹è·¯å¾„、同源地址,以及 data:/blob: ä¸€å¾‹åŽŸæ ·è¿”å›žã€‚
 */
export function toAccessibleFileUrl(raw?: string): string {
  if (!raw) {
    return '';
  }
  // ç›¸å¯¹åœ°å€ã€data:、blob: ç›´æŽ¥æ”¾è¡Œ
  if (!/^https?:\/\//i.test(raw)) {
    return raw;
  }
  try {
    const url = new URL(raw);
    // æœ¬æ¥å°±åŒæºï¼Œæ— éœ€å¤„理
    if (url.origin === window.location.origin) {
      return raw;
    }
    const apiPrefix = (import.meta.env.VITE_GLOB_API_URL || '/admin-api').replace(
      /\/+$/,
      '',
    );
    // å‘½ä¸­ API å‰ç¼€æ—¶ï¼Œæˆªå–前缀及其之后的路径与查询串
    const at = url.pathname.indexOf(`${apiPrefix}/`);
    if (at >= 0) {
      return `${url.pathname.slice(at)}${url.search}`;
    }
    // ä¸æ˜¯æ–‡ä»¶æŽ¥å£çš„地址,不做处理
    return raw;
  } catch {
    // URL è§£æžå¤±è´¥æ—¶ä¿æŒåŽŸå€¼ï¼Œé¿å…æŠŠå¯ç”¨åœ°å€æ”¹å
    return raw;
  }
}
src/utils/index.ts
@@ -1,5 +1,6 @@
import type { Recordable } from '@vben/types';
export * from './fileUrl';
export * from './rangePickerProps';
export * from './routerHelper';
src/views/wxcp/activity/data.ts
@@ -85,10 +85,17 @@
      help: 'å¡« 0 è¡¨ç¤ºä¸é™åé¢',
    },
    {
      fieldName: 'coverUrl',
      // å°é¢èµ°é™„件中间表:这里提交 blobIds,回显读 attachmentList(见 form.vue çš„æ˜ å°„函数)
      fieldName: 'blobIds',
      label: '封面图',
      component: 'Input',
      componentProps: { placeholder: '封面图地址(选填)', disabled: readonly },
      component: 'ImageUpload',
      componentProps: {
        valueKey: 'id',
        maxNumber: 1,
        multiple: false,
        disabled: readonly,
      },
      help: '选填,建议 16:9;保存后展示在活动报名页顶部',
      formItemClass: 'col-span-2',
    },
    {
src/views/wxcp/activity/index.vue
@@ -14,6 +14,7 @@
  getActivityPage,
  publishActivity,
} from '#/api/wxcp/activity';
import { toAccessibleFileUrl } from '#/utils';
import {
  ACTIVITY_STATUS,
@@ -61,6 +62,13 @@
function handleCreate() {
  formModalApi.setData({ formType: 'create' }).open();
}
/** åˆ—表缩略图取封面附件的第一张,没传封面返回空串 */
function coverUrlOf(row: WxCpActivityApi.Activity): string {
  const cover = row.attachmentList?.[0];
  // åŽç«¯è¿”回的是它自己配置域名的绝对地址,需改写成同源路径才能被 <img> åŠ è½½
  return toAccessibleFileUrl(cover?.url || cover?.previewURL);
}
function handleDetail(row: WxCpActivityApi.Activity) {
@@ -144,9 +152,18 @@
      </template>
      <template #title="{ row }">
        <Button type="link" class="p-0" @click="handleDetail(row)">
          {{ row.title }}
        </Button>
        <div class="flex items-center gap-2">
          <!-- å°é¢æ¥è‡ªé™„件中间表,未上传时不占位 -->
          <img
            v-if="coverUrlOf(row)"
            :src="coverUrlOf(row)"
            alt="封面"
            class="h-9 w-14 shrink-0 rounded object-cover"
          />
          <Button type="link" class="p-0" @click="handleDetail(row)">
            {{ row.title }}
          </Button>
        </div>
      </template>
      <template #signupCount="{ row }">
src/views/wxcp/activity/modules/form.vue
@@ -9,6 +9,7 @@
import { Button, message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { toAccessibleFileUrl } from '#/utils';
import {
  createActivity,
  getActivity,
@@ -44,6 +45,47 @@
  wrapperClass: 'grid-cols-2',
});
/**
 * æŠŠæ´»åŠ¨è¯¦æƒ…æ˜ å°„æˆè¡¨å•å€¼
 *
 * å°é¢åœ¨åŽç«¯ä¸è½ä¸šåŠ¡è¡¨å­—æ®µï¼Œè€Œæ˜¯å­˜åœ¨é™„ä»¶ä¸­é—´è¡¨é‡Œï¼Œ
 * å›žæ˜¾æ—¶è¦æŠŠ attachmentList è½¬æˆä¸Šä¼ ç»„件能识别的文件对象。
 */
function activityToForm(
  activity: WxCpActivityApi.Activity,
): Record<string, unknown> {
  const form: Record<string, unknown> = { ...activity };
  form.blobIds = (activity.attachmentList ?? []).map((item) => ({
    uid: String(item.id),
    name: item.name || item.originalFilename || '',
    url: toAccessibleFileUrl(item.url || item.previewURL),
    status: 'done',
    id: item.id,
  }));
  return form;
}
/**
 * æŠŠä¸Šä¼ ç»„件的值规整成 blobId æ•°ç»„
 *
 * ImageUpload åœ¨ maxNumber=1 æ—¶è¿”回的是单个值(而不是数组),
 * åŽç«¯ç­¾åæ˜¯ List<Long>,这里统一包成数组再提交。
 */
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));
}
const [Modal, modalApi] = useVbenModal({
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
@@ -59,7 +101,7 @@
      modalApi.lock();
      try {
        const detail = await getActivity(data.id);
        await formApi.setValues(detail as unknown as Record<string, unknown>);
        await formApi.setValues(activityToForm(detail));
      } finally {
        modalApi.unlock();
      }
@@ -76,12 +118,16 @@
  }
  submitting.value = true;
  try {
    const data = (await formApi.getValues()) as WxCpActivityApi.Activity;
    const data = (await formApi.getValues()) as Record<string, unknown>;
    const payload = {
      ...data,
      blobIds: normalizeBlobIds(data.blobIds),
    } as WxCpActivityApi.Activity;
    if (id.value) {
      await updateActivity({ ...data, id: id.value });
      await updateActivity({ ...payload, id: id.value });
      message.success('修改成功');
    } else {
      await createActivity(data);
      await createActivity(payload);
      message.success('创建成功,请在列表中发布后再推送');
    }
    await modalApi.close();
src/views/wxcp/activity/modules/signup-list.vue
@@ -16,6 +16,7 @@
} from 'ant-design-vue';
import { TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { toAccessibleFileUrl } from '#/utils';
import {
  cancelSignup,
  checkInSignup,
@@ -59,6 +60,16 @@
function isImage(item: WxCpActivityApi.SignupAttachment) {
  return (item.contentType || '').startsWith('image/');
}
/** åŽç«¯é™„件地址带的是后端自己配置的域名,改写成同源路径后才能被 <img> åŠ è½½ */
function fileUrl(item: WxCpActivityApi.SignupAttachment) {
  return toAccessibleFileUrl(item.previewURL || item.url);
}
/** ä¸‹è½½é“¾æŽ¥åŒç†ï¼Œä¼˜å…ˆç”¨ downloadURL */
function downloadUrl(item: WxCpActivityApi.SignupAttachment) {
  return toAccessibleFileUrl(item.downloadURL || item.url);
}
function formatSize(bytes?: number) {
@@ -215,18 +226,18 @@
            </div>
            <a
              v-if="isImage(item)"
              :href="item.previewURL || item.url"
              :href="fileUrl(item)"
              target="_blank"
              rel="noopener noreferrer"
            >
              <img
                :src="item.previewURL || item.url"
                :src="fileUrl(item)"
                alt="附件预览"
                class="max-h-40 rounded object-contain"
              />
            </a>
            <a
              :href="item.downloadURL || item.url"
              :href="downloadUrl(item)"
              target="_blank"
              rel="noopener noreferrer"
              class="text-xs text-blue-500 hover:underline"