useBusinessAssistantUpload.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289
  1. import { ref, type Ref } from 'vue';
  2. import { uploadDmsAttachment, buildDmsFileDisplayUrl } from '@/network/api/dms/file-upload';
  3. import {
  4. BusinessAssistantUploadedFile,
  5. BusinessAssistantInputFileTransmission,
  6. createBusinessAssistantId,
  7. MAX_FILE_SIZE,
  8. MAX_IMAGE_SIZE,
  9. SUPPORTED_ATTACHMENT_EXTENSIONS,
  10. SUPPORTED_IMAGE_EXTENSIONS,
  11. SUPPORTED_IMAGE_LABEL,
  12. SUPPORTED_ATTACHMENT_LABEL,
  13. } from './shared';
  14. const DRAFT_HOME_KEY = '__business_assistant_home_draft__';
  15. export function useBusinessAssistantUpload(options?: {
  16. imageInputRef?: Ref<HTMLInputElement | null>;
  17. fileInputRef?: Ref<HTMLInputElement | null>;
  18. /**
  19. * 校验失败时的提示(**用界面的 toast,不要 window.alert** —— 用户 2026-09-23 要求)。
  20. * 不传时退回 console.warn(验证脚本等无界面场景)。
  21. */
  22. notify?: (message: string) => void;
  23. }) {
  24. const notify = (message: string) => {
  25. if (options?.notify) {
  26. options.notify(message);
  27. return;
  28. }
  29. console.warn('[upload]', message);
  30. };
  31. const uploadedFiles = ref<BusinessAssistantUploadedFile[]>([]);
  32. const internalImageInputRef = ref<HTMLInputElement | null>(null);
  33. const internalFileInputRef = ref<HTMLInputElement | null>(null);
  34. const imageInputRef = options?.imageInputRef ?? internalImageInputRef;
  35. const fileInputRef = options?.fileInputRef ?? internalFileInputRef;
  36. const draftFilesByKey = new Map<string, BusinessAssistantUploadedFile[]>();
  37. let activeDraftKey = DRAFT_HOME_KEY;
  38. const triggerImageUpload = () => imageInputRef.value?.click();
  39. const triggerFileUpload = () => fileInputRef.value?.click();
  40. const getFileExtension = (fileName: string) => fileName.split('.').pop()?.toLowerCase() || '';
  41. /**
  42. * 图片是否受支持 —— **按扩展名**判(2026-09-23 修)。
  43. *
  44. * ⚠️ 原来写的是 `file.type.startsWith('image/') || 扩展名在白名单里`,等于
  45. * **任何 `image/*` 都放行**:SVG / BMP / TIFF / HEIC 全被当图片收下,
  46. * 与白名单和给用户的提示(JPEG、PNG、GIF、WebP)自相矛盾。
  47. * 现在**只有白名单里的扩展名算数**;文件名没有扩展名时(例如某些粘贴来的图)
  48. * 才退回看 MIME,仍需落在白名单里(`image/jpeg` → `jpeg`)。
  49. */
  50. const isSupportedImageFile = (file: File) => {
  51. const extension = getFileExtension(file.name);
  52. if (extension) {
  53. return SUPPORTED_IMAGE_EXTENSIONS.includes(extension);
  54. }
  55. const fromMime = String(file.type || '').replace(/^image\//i, '').toLowerCase();
  56. return Boolean(fromMime) && SUPPORTED_IMAGE_EXTENSIONS.includes(fromMime);
  57. };
  58. const isSupportedAttachmentFile = (file: File) => {
  59. const extension = getFileExtension(file.name);
  60. return SUPPORTED_ATTACHMENT_EXTENSIONS.includes(extension);
  61. };
  62. const isSupportedDropFile = (file: File) => isSupportedImageFile(file) || isSupportedAttachmentFile(file);
  63. const getFileList = (files: FileList | File[] | null) => {
  64. if (!files) return [];
  65. return Array.isArray(files) ? files : Array.from(files);
  66. };
  67. const getFileDedupKey = (file: File) =>
  68. [file.name, file.size, file.lastModified, file.type].join('__');
  69. const getUploadedFileDedupKey = (file: BusinessAssistantUploadedFile) =>
  70. [file.name, file.size, file.lastModified, file.type].join('__');
  71. const syncActiveDraft = () => {
  72. draftFilesByKey.set(activeDraftKey, uploadedFiles.value);
  73. };
  74. const updateUploadedFile = (id: string, patch: Partial<BusinessAssistantUploadedFile>) => {
  75. const index = uploadedFiles.value.findIndex((file) => file.id === id);
  76. if (index === -1) {
  77. return;
  78. }
  79. uploadedFiles.value[index] = {
  80. ...uploadedFiles.value[index],
  81. ...patch,
  82. };
  83. syncActiveDraft();
  84. };
  85. const setUploadDraftKey = (key?: string | null) => {
  86. syncActiveDraft();
  87. activeDraftKey = key || DRAFT_HOME_KEY;
  88. uploadedFiles.value = draftFilesByKey.get(activeDraftKey) || [];
  89. syncActiveDraft();
  90. };
  91. const clearUploadDraft = (key?: string | null) => {
  92. const draftKey = key || activeDraftKey;
  93. const files = draftFilesByKey.get(draftKey) || [];
  94. files.forEach((file) => {
  95. file.abort?.();
  96. if (file.url?.startsWith('blob:')) {
  97. URL.revokeObjectURL(file.url);
  98. }
  99. });
  100. draftFilesByKey.delete(draftKey);
  101. if (draftKey === activeDraftKey) {
  102. uploadedFiles.value = [];
  103. syncActiveDraft();
  104. }
  105. };
  106. const uploadFile = async (file: File, uploadItem: BusinessAssistantUploadedFile) => {
  107. const abortController = new AbortController();
  108. const abortSignature = () => abortController.abort();
  109. updateUploadedFile(uploadItem.id, { abort: abortSignature });
  110. try {
  111. // 2026-09-23 起改用 **DMS 的文件服务**(用户指定):返回的是**相对路径**,
  112. // 它随聊天请求体的 `pictures` / `files` 发给后端(见 file-upload.ts 的实测契约)。
  113. // (更早那版是直传厂商 OSS,实现已在 2026-09-24 按用户要求删除,见 git 历史。)
  114. const relativePath = await uploadDmsAttachment(file, {
  115. isImage: Boolean(uploadItem.isImage),
  116. signal: abortController.signal,
  117. });
  118. const currentFile = uploadedFiles.value.find((item) => item.id === uploadItem.id);
  119. const localPreviewUrl = currentFile?.url;
  120. // 展示地址:相对路径直接打不开,要拼上代理前缀(实测 `/dms` + 相对路径 = 200)。
  121. // 图片才需要;非图片在气泡里显示成文件条,用不上 url。
  122. const displayUrl = currentFile?.isImage ? buildDmsFileDisplayUrl(relativePath) : undefined;
  123. updateUploadedFile(uploadItem.id, {
  124. status: 'success',
  125. progress: 100,
  126. // ⚠️ 两个字段分工不同,别混:
  127. // fileUrl —— **相对路径**,随请求体 pictures / files 发给后端
  128. // url —— **可显示地址**(拼了代理前缀),只给预览用
  129. fileUrl: relativePath,
  130. url: displayUrl,
  131. abort: undefined,
  132. });
  133. // 本地 blob 用完就撤(换成代理地址后不再需要)
  134. if (localPreviewUrl?.startsWith('blob:') && localPreviewUrl !== displayUrl) {
  135. URL.revokeObjectURL(localPreviewUrl);
  136. }
  137. } catch (error) {
  138. if (abortController.signal.aborted || (error instanceof Error && error.message === '上传已取消')) {
  139. return;
  140. }
  141. updateUploadedFile(uploadItem.id, {
  142. status: 'error',
  143. errorMsg: error instanceof Error ? error.message : '上传异常',
  144. abort: undefined,
  145. });
  146. }
  147. };
  148. const handleFiles = (files: FileList | File[] | null, isImage: boolean) => {
  149. const fileList = getFileList(files);
  150. if (fileList.length === 0) return;
  151. const existingFileKeys = new Set(uploadedFiles.value.map(getUploadedFileDedupKey));
  152. fileList.forEach((file) => {
  153. const fileKey = getFileDedupKey(file);
  154. if (existingFileKeys.has(fileKey)) {
  155. return;
  156. }
  157. const maxSize = isImage ? MAX_IMAGE_SIZE : MAX_FILE_SIZE;
  158. const isSupported = isImage ? isSupportedImageFile(file) : isSupportedAttachmentFile(file);
  159. if (!isSupported) {
  160. // 不支持的格式:**提示 + 不加入输入框**(这里 return 就是不加)
  161. notify(
  162. isImage
  163. ? `「${file.name}」格式不支持,图片请用 ${SUPPORTED_IMAGE_LABEL}`
  164. : `「${file.name}」格式不支持,文件请用 ${SUPPORTED_ATTACHMENT_LABEL}`
  165. );
  166. return;
  167. }
  168. if (file.size > maxSize) {
  169. notify(`「${file.name}」超过大小限制(${isImage ? '20MB' : '50MB'})`);
  170. return;
  171. }
  172. const uploadItem: BusinessAssistantUploadedFile = {
  173. id: createBusinessAssistantId(),
  174. name: file.name,
  175. size: file.size,
  176. type: file.type,
  177. lastModified: file.lastModified,
  178. progress: 0,
  179. status: 'uploading',
  180. isImage,
  181. url: isImage ? URL.createObjectURL(file) : undefined,
  182. };
  183. existingFileKeys.add(fileKey);
  184. uploadedFiles.value.push(uploadItem);
  185. syncActiveDraft();
  186. void uploadFile(file, uploadItem);
  187. });
  188. if (isImage && imageInputRef.value) imageInputRef.value.value = '';
  189. if (!isImage && fileInputRef.value) fileInputRef.value.value = '';
  190. };
  191. const handleImageSelected = (event: Event) => {
  192. handleFiles((event.target as HTMLInputElement).files, true);
  193. };
  194. const handleFileSelected = (event: Event) => {
  195. handleFiles((event.target as HTMLInputElement).files, false);
  196. };
  197. const removeUploadedFile = (id: string) => {
  198. const index = uploadedFiles.value.findIndex((file) => file.id === id);
  199. if (index === -1) return;
  200. const file = uploadedFiles.value[index];
  201. file.abort?.();
  202. if (file.url?.startsWith('blob:')) {
  203. URL.revokeObjectURL(file.url);
  204. }
  205. uploadedFiles.value.splice(index, 1);
  206. syncActiveDraft();
  207. };
  208. const consumeSuccessfulFileTransmission = (filePos: number): BusinessAssistantInputFileTransmission => {
  209. const successfulFiles = uploadedFiles.value.filter((file) => file.status === 'success' && file.fileUrl);
  210. const successfulIds = new Set(successfulFiles.map((file) => file.id));
  211. const files = successfulFiles.map((file) => file.fileUrl as string);
  212. // ⚠️ 两个字段分工不同(2026-09-23 换 DMS 后尤其明显),**别混**:
  213. // fileUrl —— 相对路径,随请求体 `pictures` / `files` 发给后端(toChatAttachmentUrls 取它)
  214. // url —— 可显示地址,气泡里的 <img src> 取它
  215. // 这里曾经只填 `url: file.fileUrl`(那时 fileUrl 还是 OSS 绝对地址,能直接当 src)。
  216. // 换成 DMS 相对路径后那一行就成了 bug:气泡里的图**不显示**(相对路径经 dev server
  217. // 返回的是 index.html,<img> 解不出来)。用户 2026-09-23 报「提交之后看不到图片了」。
  218. // 显示地址在这里**按相对路径重新拼**(不沿用上传时的 file.url):那条路径可能还是
  219. // 本地 blob(已被 revoke)或压根没来得及写,重拼一次才是确定的。
  220. const attachments = successfulFiles.map((file) => ({
  221. id: file.id,
  222. name: file.name,
  223. size: file.size,
  224. isImage: file.isImage,
  225. fileUrl: file.fileUrl as string,
  226. url: file.isImage ? buildDmsFileDisplayUrl(file.fileUrl as string) || undefined : undefined,
  227. }));
  228. uploadedFiles.value = uploadedFiles.value.filter((file) => !successfulIds.has(file.id));
  229. syncActiveDraft();
  230. return {
  231. files,
  232. file_pos: files.map(() => filePos),
  233. attachments,
  234. };
  235. };
  236. return {
  237. uploadedFiles,
  238. imageInputRef,
  239. fileInputRef,
  240. triggerImageUpload,
  241. triggerFileUpload,
  242. handleFiles,
  243. handleImageSelected,
  244. handleFileSelected,
  245. removeUploadedFile,
  246. isSupportedImageFile,
  247. isSupportedAttachmentFile,
  248. isSupportedDropFile,
  249. consumeSuccessfulFileTransmission,
  250. setUploadDraftKey,
  251. clearUploadDraft,
  252. };
  253. }