/** * 验证「全站提示统一走 Ant Design 的 message」这条链路 —— 重点是**语音输入那几处**。 * * 用户 2026-09-23:先要求全局 toast 换成顶部弹出的轻提示(AntD message), * 随后要求「另外那几处语音的 window.alert 一同更换」。 * * 为什么用静态检查而不是跑 UI:语音失败路径要真触发得**拒绝麦克风权限**, * 没法在 harness 里稳定复现;而这次改动恰恰是「**调用点**从阻塞弹窗换成轻提示」, * 用源码断言正好覆盖它的失效方式(改回 alert、漏掉某一处、提示通道串线)。 * * 怎么跑(无需打包): * node harness/tools/verify-toast-channel.mjs */ import { readFileSync, readdirSync, statSync } from 'node:fs'; import { join, relative } from 'node:path'; let pass = 0; let fail = 0; const check = (name, ok, extra = '') => { if (ok) { pass++; console.log(' ok ' + name); } else { fail++; console.log(' FAIL ' + name + ' ' + extra); } }; const ROOT = 'src'; const read = (p) => readFileSync(p, 'utf8').replace(/\r\n/g, '\n'); /** 去掉注释再判 —— 注释里写「不要用 window.alert」是**说明**,不该被当成调用点 */ const stripComments = (src) => src.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:])\/\/[^\n]*/g, '$1 '); /** * 阻塞式弹窗调用点:`window.alert(...)` 或裸 `alert(...)`。 * * ⚠️ 两条分支缺一不可 —— 第一版只写了 `(^|[^.\w$])alert\(`,本意是排除 `onAlert(` / `myAlert(`, * 结果把 `window.alert(`(点号也在排除集里)一起排掉了,断言**永远为真**。 * 是「阉掉修复即失败」的变异测试把这枚哑弹抓出来的(见 progress.md 续 27)。 */ const ALERT_RE = /window\s*\.\s*alert\s*\(|(^|[^.\w$])alert\s*\(/; const walk = (dir, out = []) => { for (const name of readdirSync(dir)) { const full = join(dir, name); if (statSync(full).isDirectory()) walk(full, out); else if (/\.(ts|vue|js|tsx)$/.test(name)) out.push(full); } return out; }; const files = walk(ROOT); console.log(`\n【1】全仓库(${files.length} 个源文件)没有阻塞式 alert 调用点`); { const hits = []; for (const file of files) { stripComments(read(file)) .split('\n') .forEach((line, i) => { if (ALERT_RE.test(line)) hits.push(`${relative(ROOT, file)}:${i + 1} ${line.trim()}`); }); } check(`src 下 0 处 alert(`, hits.length === 0, hits.join(' | ')); // 用户 2026-09-23 之前确实有 4 处(PC 1 + Mobile 3),钉住数量为 0 而不是"变少了" const voiceFiles = ['components/BusinessAssistantPC.vue', 'components/BusinessAssistantMobile.vue']; for (const f of voiceFiles) { check(`${f} 里没有 alert(`, !ALERT_RE.test(stripComments(read(join(ROOT, f))))); } } /** 取出一个顶层 `const xxx = async () => { ... };` 的函数体(到顶格 `};` 为止) */ const extractFn = (src, startMarker) => { const start = src.indexOf(startMarker); if (start < 0) return ''; const end = src.indexOf('\n};', start); return end < 0 ? src.slice(start) : src.slice(start, end); }; console.log('\n【2】语音输入 toggleVoiceInput:失败路径逐处换成 showToast'); const voice = { PC: extractFn(read(join(ROOT, 'components/BusinessAssistantPC.vue')), 'const toggleVoiceInput = async () => {'), Mobile: extractFn( read(join(ROOT, 'components/BusinessAssistantMobile.vue')), 'const toggleVoiceInput = async () => {' ), }; { check('两端的 toggleVoiceInput 都取到了(不是空片段)', voice.PC.length > 200 && voice.Mobile.length > 200, `PC=${voice.PC.length} Mobile=${voice.Mobile.length}`); const micText = '未获取到麦克风权限,请在系统或浏览器设置中启用麦克风'; const count = (s, sub) => s.split(sub).length - 1; // PC:1 处(catch 里的 error.message,原来就是 alert) check('PC:catch 里改成 showToast(含 error.message 兜底)', voice.PC.includes('showToast(error instanceof Error ? error.message : "语音输入初始化失败")')); check('PC:语音段里没有 alert', !ALERT_RE.test(voice.PC)); // Mobile:3 处(麦克风权限 ×2 + 初始化失败 ×1) check('Mobile:麦克风失败提示出现 2 次(抛错路径 + openMicroPhone 返回 false 路径)', count(voice.Mobile, `showToast("${micText}")`) === 2, `实际 showToast ${count(voice.Mobile, `showToast("${micText}")`)} 次`); check('Mobile:初始化失败改成 showToast(含「请稍后重试」文案)', voice.Mobile.includes('showToast(error instanceof Error ? error.message : "语音输入初始化失败,请稍后重试")')); check('Mobile:语音段里 showToast 恰好 3 处(不多不少)', count(voice.Mobile, 'showToast(') === 3, `实际 ${count(voice.Mobile, 'showToast(')} 处`); check('Mobile:语音段里没有 alert', !ALERT_RE.test(voice.Mobile)); // 提示不能把原来的收尾动作挤掉:麦克风失败后仍要释放实例 / 复位状态 check('Mobile:麦克风失败后仍 releaseVoiceInputInstance()', count(voice.Mobile, 'releaseVoiceInputInstance()') >= 2, `实际 ${count(voice.Mobile, 'releaseVoiceInputInstance()')} 处`); } console.log('\n【3】showToast 的可见性与实现链路'); { const chatHook = read(join(ROOT, 'components/business-assistant/useBusinessAssistantChat.ts')); const appMessage = read(join(ROOT, 'utils/app-message.ts')); for (const f of ['components/BusinessAssistantPC.vue', 'components/BusinessAssistantMobile.vue']) { const src = read(join(ROOT, f)); // showToast 必须从 hook 解构出来(否则语音里那句 showToast 会 ReferenceError) const destructured = /}\s*=\s*useBusinessAssistantChat\(\{/.test(src) && /^\s*showToast,\s*$/m.test(src); check(`${f}:showToast 由 useBusinessAssistantChat 解构(setup 顶层可见)`, destructured); // 解构必须发生在 toggleVoiceInput 之前(TDZ 保护;顺序反了运行时会炸) check(`${f}:解构位置在 toggleVoiceInput 之前`, src.indexOf('showToast,') < src.indexOf('const toggleVoiceInput')); } check('hook:showToast 调 showAppMessage(不再自绘 toastMessage)', /showToast = \(message: string\) => \{\s*showAppMessage\(message\);/.test(chatHook)); check('app-message:用 ant-design-vue 的 message', /import \{ message \} from "ant-design-vue"/.test(appMessage)); check('app-message:四种类型齐全(error/warning/success/info)', ["error", "warning", "success", "info"].every((t) => appMessage.includes(`"${t}"`))); const duration = appMessage.match(/APP_MESSAGE_DURATION = (\d+(?:\.\d+)?)/); check('app-message:时长常量可解析且是短提示(≤5s)', duration && Number(duration[1]) <= 5, duration ? duration[1] : '没匹配到'); check('app-message:空文案早返回(不弹空气泡)', /if \(!content\) return;/.test(appMessage)); // 之前踩过:antd message 返回 Promise,不吞掉会变成 unhandled rejection check('app-message:antd 的 Promise 与异常都被吞掉(不会 unhandled rejection)', appMessage.includes('.catch(') && appMessage.includes('try {') && appMessage.includes('catch (err)')); check('app-message:clearAppMessages 仍导出(切会话清场用)', /export const clearAppMessages/.test(appMessage)); } console.log('\n【4】同类的另两条通道没被这次改动带偏'); { const uploadHook = read(join(ROOT, 'components/business-assistant/useBusinessAssistantUpload.ts')); check('上传校验模块没有 alert(附件提示走 notify 回调)', !ALERT_RE.test(stripComments(uploadHook))); for (const f of ['components/BusinessAssistantPC.vue', 'components/BusinessAssistantMobile.vue']) { const src = read(join(ROOT, f)); check(`${f}:notifyUpload 仍接通 showToast`, /notifyUpload = showToast;/.test(src)); } // 保留项:这两处「确认要清空吗」是需要用户做决定的**模态确认**,不是轻提示 —— 不要顺手换掉 for (const f of ['components/BusinessAssistantPC.vue', 'components/BusinessAssistantMobile.vue']) { check(`${f}:清空输入的 window.confirm 保留(模态确认 ≠ 轻提示)`, read(join(ROOT, f)).includes('window.confirm("可能会清空之前的文字,确认要清空吗?")')); } } console.log('\n【5】已知残留(不算失败,但要自己知道)'); { const pc = read(join(ROOT, 'components/BusinessAssistantPC.vue')); const silent = /const success = await asrInstance.openMicroPhone\(\);\s*\n\s*if \(!success\) return;/.test(pc); console.log( silent ? ' ⚠ PC:openMicroPhone 返回 false 时仍是静默 return(无任何提示)——\n' + ' Mobile 同样情况会弹「未获取到麦克风权限」。**未擅自补**(用户只要求替换 alert),\n' + ' 已报告,等用户决定是否补齐。' : ' (PC 的 openMicroPhone 失败分支已不是静默 return,脚本可删掉这段提示)' ); } console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`); process.exit(fail ? 1 : 0);