瀏覽代碼

feat(upload): 附件格式对齐产品提示,不支持的格式改 toast 且不入输入框

用户要求(附产品提示截图):文件支持 Excel/DOCX/文字PDF,图片支持 JPEG/PNG/GIF/WebP;
不支持的格式要 toast 提示且不要放入输入框。

先查现状,三处与提示对不上:
1) 图片白名单 jpg/jpeg/png/webp —— 漏了 GIF
2) 文件白名单 pdf/doc/docx/txt/md —— 缺 Excel,还多了提示里没有的 doc/txt/md
3) 校验失败用 window.alert(阻塞弹窗),不是 toast

实现:
- shared.ts 白名单改为 ['jpg','jpeg','png','gif','webp'] / ['xls','xlsx','docx','pdf'],
  新增 SUPPORTED_*_LABEL 常量统一提示文案;注释写明改白名单要同时改三处
  (白名单 / input accept / 提示文案)
- 两端 <input accept> 同步(图片加 image/gif、文件改 .xls,.xlsx,.docx,.pdf)
- 上传模块加 notify 回调(用界面 toast,不传退回 console.warn),两处 alert 改 notify;
  chat hook 暴露 showToast(它带"同文案可重复弹出"处理);因创建顺序(chat hook 依赖上传模块),
  用可变回调 notifyUpload 先占位、拿到 showToast 后接通
- PC 拖拽路径不再提前过滤(此前不支持的文件被静默丢弃),交给 handleFiles 统一校验逐个 toast

🐛 顺带修掉测试抓到的真 bug:isSupportedImageFile 里的 file.type.startsWith('image/')
等于任何 image/* 都放行 —— SVG/BMP/TIFF/HEIC 全被当图片收下,与白名单和提示矛盾。
改为按扩展名判(无扩展名才退回看 MIME,仍需在白名单内)。

验证:verify-attachment-question 39/39(白名单与提示口径 + accept 三处一致 + 调真实上传模块
验证 toast 与"不入输入框" + 伪图片也要拦 + 支持格式不误伤 + 超限走 toast);
实测"阉掉修复即失败"(改回 MIME 版 → 5 项失败);回归全绿;build 与 validate-harness 通过。

Co-Authored-By: Claude Code <noreply@anthropic.com>
gongtianxiao 1 月之前
父節點
當前提交
e57d07fe84

+ 7 - 0
README.md

@@ -359,3 +359,10 @@
     所以刷新页面后气泡里的图还在
   - 实测细节:`type` 决定目录(0 图 → `/static/photo/`,其余 → `/static/file/`);
     少任何一个字段都返回 `{"code":-1,"message":"缺少必传字段"}`
+- **附件支持格式对齐产品提示,且不支持的格式改为 toast 提示**(用户 2026-09-23):
+  - 图片:**JPEG、PNG、GIF、WebP**;文件:**Excel(xls/xlsx)、DOCX、文字 PDF**
+    (此前图片漏了 GIF,文件多了 doc/txt/md 却**缺 Excel** —— 与提示对不上)
+  - 不支持的格式:**弹 toast 提示 + 不放进输入框**(此前是 `window.alert`;
+    拖拽路径还会把不支持的文件**静默丢弃**,用户不知道文件为什么没进去)
+  - 顺带修掉一个真 bug:图片校验此前是 `file.type.startsWith('image/')`,
+    等于**任何 image/* 都放行** —— SVG / BMP / HEIC 全被当图片收下;现改为按扩展名判

+ 39 - 0
harness/progress.md

@@ -3748,3 +3748,42 @@ DMS 代理**注入全权限 token 且不区分调用者** → 任何能打开站
 ⚠️ **过程中踩的坑(记录)**:批量注释时把**钩子函数签名一起吃进注释**了(接口定义在钩子之前),
 `npm run build` 直接报 `Unexpected "}"`。是分块注释时按行号切的锅 —— 教训:**块注释要按语义单元切,
 并用类型检查复核**(`vue-tsc` 报的行号直接指向了问题)。
+
+### 续 25:附件支持格式对齐提示 + 不支持的格式改 toast(用户要求)
+
+**用户要求**(附产品提示截图):「这是支持的格式,第一行是文件(**Excel/DOCX/文字PDF**),
+第二行是图片(**JPEG、PNG、GIF、WebP**),不支持的格式要 **toast 提示**且**不要放入输入框**」。
+
+**先查现状,发现三处对不上**:
+1. 图片白名单是 `jpg/jpeg/png/webp` —— **漏了 GIF**(提示里写了支持)
+2. 文件白名单是 `pdf/doc/docx/txt/md` —— **缺 Excel**,还多了提示里没有的 doc/txt/md
+3. 校验失败用的是 **`window.alert`**(阻塞式弹窗),不是 toast
+
+**实现**:
+- `shared.ts`:白名单改成 `['jpg','jpeg','png','gif','webp']` / `['xls','xlsx','docx','pdf']`,
+  并加 `SUPPORTED_IMAGE_LABEL` / `SUPPORTED_ATTACHMENT_LABEL` 常量(提示文案与白名单同一口径),
+  注释里写明**改白名单要同时改三处**(白名单 / `<input accept>` / 提示文案)
+- 两端 `<input accept>` 同步(图片加 `image/gif`、文件改 `.xls,.xlsx,.docx,.pdf`)
+- 上传模块新增 `notify` 回调(**用界面 toast,不传时退回 console.warn**),
+  两处 `window.alert` 改为 `notify(...)`,文案带上文件名与格式要求
+- 创建顺序问题:`useBusinessAssistantChat`(提供 `showToast`)在上传模块**之后**创建,
+  且它依赖上传模块的 `consumeSuccessfulFileTransmission`,没法调换 →
+  用可变回调 `notifyUpload` 先占位、拿到 `showToast` 后接通;顺带把 `showToast` 从 chat hook 暴露出来
+- PC 拖拽路径不再提前过滤(此前不支持的文件被**静默丢弃**)→ 交给 `handleFiles` 统一校验,逐个 toast
+
+**🐛 顺带修掉一个真 bug(测试抓到的)**:`isSupportedImageFile` 里写的是
+`file.type.startsWith('image/') || 扩展名在白名单里` —— 等于**任何 `image/*` 都放行**:
+SVG / BMP / TIFF / HEIC 全被当图片收下,与白名单和提示都矛盾。
+改为**按扩展名判**(没有扩展名时才退回看 MIME,仍需落在白名单)。
+
+**运行过的验证**:`verify-attachment-question.mjs` 扩到 **39/39**:
+- 【5】白名单与提示口径:GIF/Excel 确实在白名单里、提示文案里的格式都在白名单里、
+  **PC/Mobile 的 `accept` 与白名单一致**(三处口径不一致正是这次问题的成因)
+- 【6】**调真实上传模块**:不支持的文件各弹一次 toast 且**一个都不进输入框**、
+  **伪图片(image/* 但扩展名不在白名单)也要拦**、支持的格式不被误伤、超限也走 toast
+- **实测「阉掉修复即失败」**:把图片校验改回 MIME 版 → 5 项断言立刻失败(SVG/BMP/HEIC 被收下)
+回归全绿(附件 39/39、DMS 上传 21/21、复制 19/19、答案流式 165/165、DMS 65/65、载荷 19/19、
+企业分类 84/84、判空 30/30、聊天任务 33/33、政策卡 24/24);build 与 validate-harness 通过。
+
+⚠️ **未处理(等你决定)**:语音输入那几处错误提示仍是 `window.alert`(PC 1 处 + Mobile 3 处),
+属于同一类观感问题,但不在这次要求范围内,没有擅自改动。

+ 11 - 0
harness/tools/_entry-attachments.ts

@@ -6,3 +6,14 @@
  */
 export { toChatAttachmentUrls } from "../../src/components/business-assistant/shared";
 export { ApiChatCoordinator } from "../../src/components/api-chat-coordinator";
+
+/** 上传校验(格式白名单 / toast / 不加入输入框)—— 验证脚本直接调真实实现 */
+export { useBusinessAssistantUpload } from '../../src/components/business-assistant/useBusinessAssistantUpload';
+
+/** 支持格式的白名单与提示文案(两处必须同一口径) */
+export {
+  SUPPORTED_IMAGE_EXTENSIONS,
+  SUPPORTED_ATTACHMENT_EXTENSIONS,
+  SUPPORTED_IMAGE_LABEL,
+  SUPPORTED_ATTACHMENT_LABEL,
+} from '../../src/components/business-assistant/shared';

+ 125 - 1
harness/tools/verify-attachment-question.mjs

@@ -27,7 +27,15 @@
 
 import { readFileSync, readdirSync } from 'node:fs';
 
-const { toChatAttachmentUrls, ApiChatCoordinator } = await import('./_attachments.mjs');
+const {
+  toChatAttachmentUrls,
+  ApiChatCoordinator,
+  useBusinessAssistantUpload,
+  SUPPORTED_IMAGE_EXTENSIONS,
+  SUPPORTED_ATTACHMENT_EXTENSIONS,
+  SUPPORTED_IMAGE_LABEL,
+  SUPPORTED_ATTACHMENT_LABEL,
+} = await import('./_attachments.mjs');
 
 let pass = 0;
 let fail = 0;
@@ -162,5 +170,121 @@ console.log('\n【4】旧方案已停用(源码级)');
   check('没有活跃调用点(地址不再拼进 question)', callers.length === 0, callers.join(', '));
 }
 
+console.log('\n【5】支持格式的白名单(与产品提示同一口径)');
+{
+  check(
+    '图片:JPEG / PNG / GIF / WebP',
+    JSON.stringify(SUPPORTED_IMAGE_EXTENSIONS) === JSON.stringify(['jpg', 'jpeg', 'png', 'gif', 'webp']),
+    JSON.stringify(SUPPORTED_IMAGE_EXTENSIONS)
+  );
+  check(
+    '文件:Excel / DOCX / PDF',
+    JSON.stringify(SUPPORTED_ATTACHMENT_EXTENSIONS) === JSON.stringify(['xls', 'xlsx', 'docx', 'pdf']),
+    JSON.stringify(SUPPORTED_ATTACHMENT_EXTENSIONS)
+  );
+  // 用户 2026-09-23 指出的缺口:提示里写了 GIF,白名单里原来没有
+  check('**GIF 确实在白名单里**(提示说支持就得真支持)', SUPPORTED_IMAGE_EXTENSIONS.includes('gif'));
+  check('**Excel 确实在白名单里**', SUPPORTED_ATTACHMENT_EXTENSIONS.includes('xlsx') && SUPPORTED_ATTACHMENT_EXTENSIONS.includes('xls'));
+
+  // 提示文案与白名单必须描述同一批格式:文案里出现的每个格式名都要能在白名单里找到
+  const labelCovers = (label, list, aliases = {}) => {
+    const named = String(label).split(/[、,,/]/).map((x) => x.trim()).filter(Boolean);
+    return named.every((name) => {
+      const key = aliases[name] || name.toLowerCase();
+      // 文案里可能写成「文字PDF」这类带修饰的写法 → 用包含匹配(PDF 在白名单里即可)
+      return list.includes(key) || list.some((ext) => name.toLowerCase().includes(ext));
+    });
+  };
+  check(
+    '提示文案里的图片格式都在白名单里',
+    labelCovers(SUPPORTED_IMAGE_LABEL, SUPPORTED_IMAGE_EXTENSIONS),
+    `${SUPPORTED_IMAGE_LABEL} ↔ ${JSON.stringify(SUPPORTED_IMAGE_EXTENSIONS)}`
+  );
+  check(
+    '提示文案里的文件格式都在白名单里',
+    labelCovers(SUPPORTED_ATTACHMENT_LABEL, SUPPORTED_ATTACHMENT_EXTENSIONS, { Excel: 'xlsx', PDF: 'pdf', DOCX: 'docx' }),
+    `${SUPPORTED_ATTACHMENT_LABEL} ↔ ${JSON.stringify(SUPPORTED_ATTACHMENT_EXTENSIONS)}`
+  );
+
+  // ⚠️ 另一处口径:PC / Mobile 的 <input accept="…"> 若与白名单不一致,就会出现
+  //    「文件选择框里选不了某些格式」或「选得进但被校验拒掉」(用户这次报的就是这类)
+  for (const file of ['src/components/BusinessAssistantPC.vue', 'src/components/BusinessAssistantMobile.vue']) {
+    const src = readFileSync(file, 'utf8');
+    const imageAccept = (src.match(/accept="(image\/[^"]+)"/) || [])[1] || '';
+    const fileAccept = (src.match(/accept="((?:\.[a-z0-9]+)(?:,\.[a-z0-9]+)*)"/) || [])[1] || '';
+    // ⚠️ jpg 与 jpeg 是**同一个 MIME**(image/jpeg)—— 两边都要归一后再比,
+    //    否则「accept 写 image/jpeg、白名单同时列了 jpg 与 jpeg」会被误报成不一致
+    const norm = (exts) => [...new Set(exts.map((x) => (x === 'jpeg' ? 'jpg' : x)))].sort();
+    const acceptExts = imageAccept
+      .split(',')
+      .map((x) => x.trim().replace(/^image\//, ''))
+      .filter(Boolean);
+    check(
+      `${file.split('/').pop()} 的图片 accept 与白名单一致`,
+      JSON.stringify(norm(acceptExts)) === JSON.stringify(norm(SUPPORTED_IMAGE_EXTENSIONS)),
+      `${imageAccept} ↔ ${JSON.stringify(SUPPORTED_IMAGE_EXTENSIONS)}`
+    );
+    check(
+      `${file.split('/').pop()} 的文件 accept 与白名单一致`,
+      JSON.stringify(fileAccept.split(',').map((x) => x.trim().replace(/^\./, '')).sort()) ===
+        JSON.stringify(SUPPORTED_ATTACHMENT_EXTENSIONS.slice().sort()),
+      `${fileAccept} ↔ ${JSON.stringify(SUPPORTED_ATTACHMENT_EXTENSIONS)}`
+    );
+  }
+}
+
+console.log('\n【6】不支持的格式:toast 提示 + **不放进输入框**(调真实上传模块)');
+{
+  // node 里没有这几样,补上桩(只在「支持的格式」那条路径会用到)
+  if (!globalThis.URL.createObjectURL) {
+    globalThis.URL.createObjectURL = () => 'blob:stub';
+    globalThis.URL.revokeObjectURL = () => {};
+  }
+  const stubFetchOk = () =>
+    new Response(JSON.stringify({ code: 200, content: '/static/photo/1889-c_question-x.png' }), { status: 200 });
+  const restoreFetch = globalThis.fetch;
+
+  const toasts = [];
+  const { uploadedFiles, handleFiles } = useBusinessAssistantUpload({ notify: (m) => toasts.push(m) });
+  const f = (name, type = '') => new File(['x'], name, { type });
+
+  globalThis.fetch = async () => stubFetchOk();
+
+  // 6.1 全是不支持的格式 → 每个都提示一次,且一个都不加入
+  handleFiles([f('a.svg', 'image/svg+xml'), f('b.zip'), f('c.doc'), f('d.txt')], false);
+  await new Promise((r) => setTimeout(r, 20));
+  check('**四个不支持的文件各弹一次提示**', toasts.length === 4, JSON.stringify(toasts));
+  check('提示里带文件名与格式要求', toasts[0].includes('a.svg') && toasts[0].includes('Excel'), toasts[0]);
+  check('**一个都没放进输入框**', uploadedFiles.value.length === 0, JSON.stringify(uploadedFiles.value.map((x) => x.name)));
+
+  // 6.2 图片通道同样校验 —— **MIME 是 image/* 但扩展名不在白名单的(SVG/BMP/HEIC)也必须拦**
+  //     (原来这里只看 `type.startsWith('image/')`,SVG 会被当图片收下 —— 已修)
+  toasts.length = 0;
+  handleFiles([f('e.svg', 'image/svg+xml'), f('f.bmp', 'image/bmp'), f('g.heic', 'image/heic')], true);
+  await new Promise((r) => setTimeout(r, 20));
+  check('伪图片(image/* 但扩展名不在白名单)**也要拦**', toasts.length === 3, JSON.stringify(toasts));
+  check('图片通道的提示给的是图片格式', toasts[0]?.includes('JPEG'), JSON.stringify(toasts[0]));
+  check('伪图片没放进去', uploadedFiles.value.length === 0, JSON.stringify(uploadedFiles.value.map((x) => x.name)));
+
+  // 6.3 支持的格式照旧放进去(证明上面的拦截没误伤)—— 注意图片走图片通道、文件走文件通道
+  toasts.length = 0;
+  handleFiles([f('ok.xlsx')], false);
+  handleFiles([f('ok2.png', 'image/png'), f('ok3.gif', 'image/gif'), f('ok4.jpeg', 'image/jpeg')], true);
+  await new Promise((r) => setTimeout(r, 30));
+  check('支持的格式**没有**被拦', toasts.length === 0, JSON.stringify(toasts));
+  check('支持的格式进了输入框(xlsx + png + gif + jpeg)', uploadedFiles.value.length === 4, JSON.stringify(uploadedFiles.value.map((x) => x.name)));
+
+  // 6.4 超过大小限制也走同一条提示(此前同样是 window.alert)
+  toasts.length = 0;
+  const tooBig = f('big.pdf');
+  Object.defineProperty(tooBig, 'size', { value: 51 * 1024 * 1024 }); // 只改 size,不真分配 51MB
+  handleFiles([tooBig], false);
+  await new Promise((r) => setTimeout(r, 20));
+  check('超限时提示(且不是 window.alert)', toasts.length === 1 && toasts[0].includes('大小限制'), JSON.stringify(toasts));
+  check('超限的文件没进输入框', uploadedFiles.value.length === 4, String(uploadedFiles.value.length));
+
+  globalThis.fetch = restoreFetch;
+}
+
 console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`);
 process.exit(fail ? 1 : 0);

+ 14 - 3
src/components/BusinessAssistantMobile.vue

@@ -383,9 +383,9 @@
       </div>
     </Teleport>
 
-    <input ref="imageInputRef" type="file" accept="image/jpeg,image/png,image/jpg,image/webp" multiple hidden
+    <input ref="imageInputRef" type="file" accept="image/jpeg,image/png,image/gif,image/webp" multiple hidden
       @change="handleImageSelected" />
-    <input ref="fileInputRef" type="file" accept=".pdf,.doc,.docx,.txt,.md" multiple hidden
+    <input ref="fileInputRef" type="file" accept=".xls,.xlsx,.docx,.pdf" multiple hidden
       @change="handleFileSelected" />
   </div>
 </template>
@@ -520,7 +520,14 @@ const {
   consumeSuccessfulFileTransmission,
   setUploadDraftKey,
   clearUploadDraft,
-} = useBusinessAssistantUpload({ imageInputRef, fileInputRef });
+} = useBusinessAssistantUpload({ imageInputRef, fileInputRef, notify: (message) => notifyUpload(message) });
+
+/**
+ * 上传校验失败时的提示通道。
+ * 创建顺序上 `useBusinessAssistantChat`(它提供 showToast)在后面,而且它依赖上传模块的
+ * `consumeSuccessfulFileTransmission`,没法调换顺序 —— 所以这里先用占位,拿到 showToast 再接通。
+ */
+let notifyUpload: (message: string) => void = (message) => console.warn('[upload]', message);
 
 const {
   suggestions,
@@ -533,6 +540,7 @@ const {
   isGenerating,
   currentSessionGenerating,
   toastMessage,
+  showToast,
   stopAllTasks,
   isDeepThink,
   loadHistory,
@@ -566,6 +574,9 @@ const {
   getInputFileTransmission: consumeSuccessfulFileTransmission,
 });
 
+// 上传校验的提示通道接通(见上面 notifyUpload 的说明)
+notifyUpload = showToast;
+
 const { isLoggedIn, creditCode, initAuth } = useEnterpriseAuth();
 
 const handleAccountLogout = () => {

+ 19 - 11
src/components/BusinessAssistantPC.vue

@@ -331,9 +331,9 @@
       </div>
     </Teleport>
 
-    <input type="file" ref="imageInputRef" accept="image/jpeg,image/png,image/jpg,image/webp" style="display: none"
+    <input type="file" ref="imageInputRef" accept="image/jpeg,image/png,image/gif,image/webp" style="display: none"
       @change="handleImageSelected" multiple />
-    <input type="file" ref="fileInputRef" accept=".pdf,.doc,.docx,.txt,.md" style="display: none"
+    <input type="file" ref="fileInputRef" accept=".xls,.xlsx,.docx,.pdf" style="display: none"
       @change="handleFileSelected" multiple />
   </div>
 </template>
@@ -383,7 +383,14 @@ const {
   consumeSuccessfulFileTransmission,
   setUploadDraftKey,
   clearUploadDraft,
-} = useBusinessAssistantUpload({ imageInputRef, fileInputRef });
+} = useBusinessAssistantUpload({ imageInputRef, fileInputRef, notify: (message) => notifyUpload(message) });
+
+/**
+ * 上传校验失败时的提示通道。
+ * 创建顺序上 `useBusinessAssistantChat`(它提供 showToast)在后面,而且它依赖上传模块的
+ * `consumeSuccessfulFileTransmission`,没法调换顺序 —— 所以这里先用占位,拿到 showToast 再接通。
+ */
+let notifyUpload: (message: string) => void = (message) => console.warn('[upload]', message);
 
 const {
   isLoggedIn,
@@ -403,6 +410,7 @@ const {
   isGenerating,
   currentSessionGenerating,
   toastMessage,
+  showToast,
   stopAllTasks,
   isDeepThink,
   isChatting,
@@ -434,6 +442,9 @@ const {
   getInputFileTransmission: consumeSuccessfulFileTransmission,
 });
 
+// 上传校验的提示通道接通(见上面 notifyUpload 的说明)
+notifyUpload = showToast;
+
 watch(
   () => currentSession.value?.id || null,
   (sessionId) => {
@@ -540,14 +551,11 @@ const handleMainDrop = (event: DragEvent) => {
   const fileList = Array.from(event.dataTransfer?.files || []);
   if (fileList.length === 0) return;
 
-  const supportedFiles = fileList.filter(isSupportedDropFile);
-  if (supportedFiles.length === 0) {
-    alert('仅支持拖拽上传图片、PDF、Word、TXT 和 Markdown 文件');
-    return;
-  }
-
-  const imageFiles = supportedFiles.filter(isSupportedImageFile);
-  const attachmentFiles = supportedFiles.filter((file) => !isSupportedImageFile(file));
+  // 只按「是不是图片」分两条路,**格式校验交给 handleFiles 统一做** ——
+  // 这样拖进来的不支持格式会走同一条 toast 提示(此前是这里提前过滤掉、静默丢弃,
+  // 用户不知道文件为什么没进去;用户 2026-09-23 要求「不支持的格式要 toast 提示」)。
+  const imageFiles = fileList.filter(isSupportedImageFile);
+  const attachmentFiles = fileList.filter((file) => !isSupportedImageFile(file));
 
   if (imageFiles.length > 0) {
     void handleFiles(imageFiles, true);

+ 16 - 2
src/components/business-assistant/shared.ts

@@ -19,8 +19,22 @@ export const THINKING_SCOPE_PLACEHOLDER = '<scope title="正在思考中...">\n<
 
 export const MAX_IMAGE_SIZE = 20 * 1024 * 1024;
 export const MAX_FILE_SIZE = 50 * 1024 * 1024;
-export const SUPPORTED_IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp'];
-export const SUPPORTED_ATTACHMENT_EXTENSIONS = ['pdf', 'doc', 'docx', 'txt', 'md'];
+
+/**
+ * 支持的格式 —— 与产品提示文案**一一对应**(用户 2026-09-23 按提示对齐):
+ *   文件:**Excel(xls/xlsx)、DOCX、文字 PDF**
+ *   图片:**JPEG、PNG、GIF、WebP**
+ *
+ * ⚠️ 改动这里要**同时改三处**,否则会出现"提示说支持、实际被拒":
+ *   ① 本文件的两个扩展名数组 ② PC / Mobile 里 `<input accept="…">`
+ *   ③ 提示文案(用下面的 `*_LABEL` 常量拼,别各写各的)
+ */
+export const SUPPORTED_IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
+export const SUPPORTED_ATTACHMENT_EXTENSIONS = ['xls', 'xlsx', 'docx', 'pdf'];
+
+/** 提示文案里给的格式说明(给人看的写法,与上面的白名单保持同一口径) */
+export const SUPPORTED_IMAGE_LABEL = 'JPEG、PNG、GIF、WebP';
+export const SUPPORTED_ATTACHMENT_LABEL = 'Excel、DOCX、文字PDF';
 
 export interface BusinessAssistantMessage {
   id: string;

+ 5 - 0
src/components/business-assistant/useBusinessAssistantChat.ts

@@ -1844,6 +1844,11 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
     currentSessionGenerating,
     /** 全局提示文案(壳组件挂 <Toast :message="toastMessage" />) */
     toastMessage,
+    /**
+     * 弹一条提示。**壳组件(含上传校验)统一用它**,不要直接改 toastMessage ——
+     * 这里处理了"同一句文案要能再次弹出"(先清空再 nextTick 置回)。
+     */
+    showToast,
     isDeepThink,
     isChatting,
     loadHistory,

+ 36 - 3
src/components/business-assistant/useBusinessAssistantUpload.ts

@@ -8,6 +8,8 @@ import {
   MAX_IMAGE_SIZE,
   SUPPORTED_ATTACHMENT_EXTENSIONS,
   SUPPORTED_IMAGE_EXTENSIONS,
+  SUPPORTED_IMAGE_LABEL,
+  SUPPORTED_ATTACHMENT_LABEL,
 } from './shared';
 
 /* [已停用] OSS 签名响应类型 —— 2026-09-23 起改用 DMS 文件服务(src/network/api/dms/file-upload.ts)
@@ -46,7 +48,20 @@ const getOssField = (payload: OssSignaturePayload, kebabName: string, snakeName:
 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);
@@ -60,9 +75,22 @@ export function useBusinessAssistantUpload(options?: {
 
   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);
-    return file.type.startsWith('image/') || SUPPORTED_IMAGE_EXTENSIONS.includes(extension);
+    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) => {
@@ -244,12 +272,17 @@ export function useBusinessAssistantUpload(options?: {
       const isSupported = isImage ? isSupportedImageFile(file) : isSupportedAttachmentFile(file);
 
       if (!isSupported) {
-        window.alert(`文件 ${file.name} 格式暂不支持`);
+        // 不支持的格式:**提示 + 不加入输入框**(这里 return 就是不加)
+        notify(
+          isImage
+            ? `「${file.name}」格式不支持,图片请用 ${SUPPORTED_IMAGE_LABEL}`
+            : `「${file.name}」格式不支持,文件请用 ${SUPPORTED_ATTACHMENT_LABEL}`
+        );
         return;
       }
 
       if (file.size > maxSize) {
-        window.alert(`文件 ${file.name} 超过大小限制(${isImage ? '20MB' : '50MB'})`);
+        notify(`「${file.name}」超过大小限制(${isImage ? '20MB' : '50MB'})`);
         return;
       }