import { ref, type Ref } from 'vue'; import { uploadDmsAttachment, buildDmsFileDisplayUrl } from '@/network/api/dms/file-upload'; import { BusinessAssistantUploadedFile, BusinessAssistantInputFileTransmission, createBusinessAssistantId, MAX_FILE_SIZE, MAX_IMAGE_SIZE, SUPPORTED_ATTACHMENT_EXTENSIONS, SUPPORTED_IMAGE_EXTENSIONS, SUPPORTED_IMAGE_LABEL, SUPPORTED_ATTACHMENT_LABEL, } from './shared'; const DRAFT_HOME_KEY = '__business_assistant_home_draft__'; export function useBusinessAssistantUpload(options?: { imageInputRef?: Ref; fileInputRef?: Ref; /** * 校验失败时的提示(**用界面的 toast,不要 window.alert** —— 用户 2026-09-23 要求)。 * 不传时退回 console.warn(验证脚本等无界面场景)。 */ notify?: (message: string) => void; }) { const notify = (message: string) => { if (options?.notify) { options.notify(message); return; } console.warn('[upload]', message); }; const uploadedFiles = ref([]); const internalImageInputRef = ref(null); const internalFileInputRef = ref(null); const imageInputRef = options?.imageInputRef ?? internalImageInputRef; const fileInputRef = options?.fileInputRef ?? internalFileInputRef; const draftFilesByKey = new Map(); let activeDraftKey = DRAFT_HOME_KEY; const triggerImageUpload = () => imageInputRef.value?.click(); const triggerFileUpload = () => fileInputRef.value?.click(); const getFileExtension = (fileName: string) => fileName.split('.').pop()?.toLowerCase() || ''; /** * 图片是否受支持 —— **按扩展名**判(2026-09-23 修)。 * * ⚠️ 原来写的是 `file.type.startsWith('image/') || 扩展名在白名单里`,等于 * **任何 `image/*` 都放行**:SVG / BMP / TIFF / HEIC 全被当图片收下, * 与白名单和给用户的提示(JPEG、PNG、GIF、WebP)自相矛盾。 * 现在**只有白名单里的扩展名算数**;文件名没有扩展名时(例如某些粘贴来的图) * 才退回看 MIME,仍需落在白名单里(`image/jpeg` → `jpeg`)。 */ const isSupportedImageFile = (file: File) => { const extension = getFileExtension(file.name); if (extension) { return SUPPORTED_IMAGE_EXTENSIONS.includes(extension); } const fromMime = String(file.type || '').replace(/^image\//i, '').toLowerCase(); return Boolean(fromMime) && SUPPORTED_IMAGE_EXTENSIONS.includes(fromMime); }; const isSupportedAttachmentFile = (file: File) => { const extension = getFileExtension(file.name); return SUPPORTED_ATTACHMENT_EXTENSIONS.includes(extension); }; const isSupportedDropFile = (file: File) => isSupportedImageFile(file) || isSupportedAttachmentFile(file); const getFileList = (files: FileList | File[] | null) => { if (!files) return []; return Array.isArray(files) ? files : Array.from(files); }; const getFileDedupKey = (file: File) => [file.name, file.size, file.lastModified, file.type].join('__'); const getUploadedFileDedupKey = (file: BusinessAssistantUploadedFile) => [file.name, file.size, file.lastModified, file.type].join('__'); const syncActiveDraft = () => { draftFilesByKey.set(activeDraftKey, uploadedFiles.value); }; const updateUploadedFile = (id: string, patch: Partial) => { const index = uploadedFiles.value.findIndex((file) => file.id === id); if (index === -1) { return; } uploadedFiles.value[index] = { ...uploadedFiles.value[index], ...patch, }; syncActiveDraft(); }; const setUploadDraftKey = (key?: string | null) => { syncActiveDraft(); activeDraftKey = key || DRAFT_HOME_KEY; uploadedFiles.value = draftFilesByKey.get(activeDraftKey) || []; syncActiveDraft(); }; const clearUploadDraft = (key?: string | null) => { const draftKey = key || activeDraftKey; const files = draftFilesByKey.get(draftKey) || []; files.forEach((file) => { file.abort?.(); if (file.url?.startsWith('blob:')) { URL.revokeObjectURL(file.url); } }); draftFilesByKey.delete(draftKey); if (draftKey === activeDraftKey) { uploadedFiles.value = []; syncActiveDraft(); } }; const uploadFile = async (file: File, uploadItem: BusinessAssistantUploadedFile) => { const abortController = new AbortController(); const abortSignature = () => abortController.abort(); updateUploadedFile(uploadItem.id, { abort: abortSignature }); try { // 2026-09-23 起改用 **DMS 的文件服务**(用户指定):返回的是**相对路径**, // 它随聊天请求体的 `pictures` / `files` 发给后端(见 file-upload.ts 的实测契约)。 // (更早那版是直传厂商 OSS,实现已在 2026-09-24 按用户要求删除,见 git 历史。) const relativePath = await uploadDmsAttachment(file, { isImage: Boolean(uploadItem.isImage), signal: abortController.signal, }); const currentFile = uploadedFiles.value.find((item) => item.id === uploadItem.id); const localPreviewUrl = currentFile?.url; // 展示地址:相对路径直接打不开,要拼上代理前缀(实测 `/dms` + 相对路径 = 200)。 // 图片才需要;非图片在气泡里显示成文件条,用不上 url。 const displayUrl = currentFile?.isImage ? buildDmsFileDisplayUrl(relativePath) : undefined; updateUploadedFile(uploadItem.id, { status: 'success', progress: 100, // ⚠️ 两个字段分工不同,别混: // fileUrl —— **相对路径**,随请求体 pictures / files 发给后端 // url —— **可显示地址**(拼了代理前缀),只给预览用 fileUrl: relativePath, url: displayUrl, abort: undefined, }); // 本地 blob 用完就撤(换成代理地址后不再需要) if (localPreviewUrl?.startsWith('blob:') && localPreviewUrl !== displayUrl) { URL.revokeObjectURL(localPreviewUrl); } } catch (error) { if (abortController.signal.aborted || (error instanceof Error && error.message === '上传已取消')) { return; } updateUploadedFile(uploadItem.id, { status: 'error', errorMsg: error instanceof Error ? error.message : '上传异常', abort: undefined, }); } }; const handleFiles = (files: FileList | File[] | null, isImage: boolean) => { const fileList = getFileList(files); if (fileList.length === 0) return; const existingFileKeys = new Set(uploadedFiles.value.map(getUploadedFileDedupKey)); fileList.forEach((file) => { const fileKey = getFileDedupKey(file); if (existingFileKeys.has(fileKey)) { return; } const maxSize = isImage ? MAX_IMAGE_SIZE : MAX_FILE_SIZE; const isSupported = isImage ? isSupportedImageFile(file) : isSupportedAttachmentFile(file); if (!isSupported) { // 不支持的格式:**提示 + 不加入输入框**(这里 return 就是不加) notify( isImage ? `「${file.name}」格式不支持,图片请用 ${SUPPORTED_IMAGE_LABEL}` : `「${file.name}」格式不支持,文件请用 ${SUPPORTED_ATTACHMENT_LABEL}` ); return; } if (file.size > maxSize) { notify(`「${file.name}」超过大小限制(${isImage ? '20MB' : '50MB'})`); return; } const uploadItem: BusinessAssistantUploadedFile = { id: createBusinessAssistantId(), name: file.name, size: file.size, type: file.type, lastModified: file.lastModified, progress: 0, status: 'uploading', isImage, url: isImage ? URL.createObjectURL(file) : undefined, }; existingFileKeys.add(fileKey); uploadedFiles.value.push(uploadItem); syncActiveDraft(); void uploadFile(file, uploadItem); }); if (isImage && imageInputRef.value) imageInputRef.value.value = ''; if (!isImage && fileInputRef.value) fileInputRef.value.value = ''; }; const handleImageSelected = (event: Event) => { handleFiles((event.target as HTMLInputElement).files, true); }; const handleFileSelected = (event: Event) => { handleFiles((event.target as HTMLInputElement).files, false); }; const removeUploadedFile = (id: string) => { const index = uploadedFiles.value.findIndex((file) => file.id === id); if (index === -1) return; const file = uploadedFiles.value[index]; file.abort?.(); if (file.url?.startsWith('blob:')) { URL.revokeObjectURL(file.url); } uploadedFiles.value.splice(index, 1); syncActiveDraft(); }; const consumeSuccessfulFileTransmission = (filePos: number): BusinessAssistantInputFileTransmission => { const successfulFiles = uploadedFiles.value.filter((file) => file.status === 'success' && file.fileUrl); const successfulIds = new Set(successfulFiles.map((file) => file.id)); const files = successfulFiles.map((file) => file.fileUrl as string); // ⚠️ 两个字段分工不同(2026-09-23 换 DMS 后尤其明显),**别混**: // fileUrl —— 相对路径,随请求体 `pictures` / `files` 发给后端(toChatAttachmentUrls 取它) // url —— 可显示地址,气泡里的 取它 // 这里曾经只填 `url: file.fileUrl`(那时 fileUrl 还是 OSS 绝对地址,能直接当 src)。 // 换成 DMS 相对路径后那一行就成了 bug:气泡里的图**不显示**(相对路径经 dev server // 返回的是 index.html, 解不出来)。用户 2026-09-23 报「提交之后看不到图片了」。 // 显示地址在这里**按相对路径重新拼**(不沿用上传时的 file.url):那条路径可能还是 // 本地 blob(已被 revoke)或压根没来得及写,重拼一次才是确定的。 const attachments = successfulFiles.map((file) => ({ id: file.id, name: file.name, size: file.size, isImage: file.isImage, fileUrl: file.fileUrl as string, url: file.isImage ? buildDmsFileDisplayUrl(file.fileUrl as string) || undefined : undefined, })); uploadedFiles.value = uploadedFiles.value.filter((file) => !successfulIds.has(file.id)); syncActiveDraft(); return { files, file_pos: files.map(() => filePos), attachments, }; }; return { uploadedFiles, imageInputRef, fileInputRef, triggerImageUpload, triggerFileUpload, handleFiles, handleImageSelected, handleFileSelected, removeUploadedFile, isSupportedImageFile, isSupportedAttachmentFile, isSupportedDropFile, consumeSuccessfulFileTransmission, setUploadDraftKey, clearUploadDraft, }; }