| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289 |
- 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<HTMLInputElement | null>;
- fileInputRef?: Ref<HTMLInputElement | null>;
- /**
- * 校验失败时的提示(**用界面的 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<BusinessAssistantUploadedFile[]>([]);
- const internalImageInputRef = ref<HTMLInputElement | null>(null);
- const internalFileInputRef = ref<HTMLInputElement | null>(null);
- const imageInputRef = options?.imageInputRef ?? internalImageInputRef;
- const fileInputRef = options?.fileInputRef ?? internalFileInputRef;
- const draftFilesByKey = new Map<string, BusinessAssistantUploadedFile[]>();
- 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<BusinessAssistantUploadedFile>) => {
- 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 —— 可显示地址,气泡里的 <img src> 取它
- // 这里曾经只填 `url: file.fileUrl`(那时 fileUrl 还是 OSS 绝对地址,能直接当 src)。
- // 换成 DMS 相对路径后那一行就成了 bug:气泡里的图**不显示**(相对路径经 dev server
- // 返回的是 index.html,<img> 解不出来)。用户 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,
- };
- }
|