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>
| | |
| | | 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; |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | /** |
| | | * ä¿®æ£å端è¿åçé件访é®å°å |
| | | * |
| | | * åç«¯çæ 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; |
| | | } |
| | | } |
| | |
| | | import type { Recordable } from '@vben/types'; |
| | | |
| | | export * from './fileUrl'; |
| | | export * from './rangePickerProps'; |
| | | export * from './routerHelper'; |
| | | |
| | |
| | | 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', |
| | | }, |
| | | { |
| | |
| | | getActivityPage, |
| | | publishActivity, |
| | | } from '#/api/wxcp/activity'; |
| | | import { toAccessibleFileUrl } from '#/utils'; |
| | | |
| | | import { |
| | | ACTIVITY_STATUS, |
| | |
| | | |
| | | 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) { |
| | |
| | | </template> |
| | | |
| | | <template #title="{ row }"> |
| | | <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 }"> |
| | |
| | | import { Button, message } from 'ant-design-vue'; |
| | | |
| | | import { useVbenForm } from '#/adapter/form'; |
| | | import { toAccessibleFileUrl } from '#/utils'; |
| | | import { |
| | | createActivity, |
| | | getActivity, |
| | |
| | | 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) { |
| | |
| | | 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(); |
| | | } |
| | |
| | | } |
| | | 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(); |
| | |
| | | } from 'ant-design-vue'; |
| | | |
| | | import { TableAction, useVbenVxeGrid } from '#/adapter/vxe-table'; |
| | | import { toAccessibleFileUrl } from '#/utils'; |
| | | import { |
| | | cancelSignup, |
| | | checkInSignup, |
| | |
| | | |
| | | 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) { |
| | |
| | | </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" |