/** * 验证「复制」在 **HTTP(非安全上下文)** 下仍可用。 * * 为什么要有它:`navigator.clipboard` **只在安全上下文存在**(HTTPS / localhost)。 * 用户 2026-09-29 要部署到 HTTP ⇒ 原来的写法 * `navigator.clipboard.writeText(x).catch(() => { …execCommand 兜底… })` * 会在取 `.writeText` 时**同步抛 TypeError**,`.catch()` 根本轮不到 ⇒ 复制按钮直接报错。 * 现在改成「显式判断 + try/catch + execCommand 兜底」。 * * 怎么跑(纯 node,不需要打包): * node harness/tools/verify-clipboard-fallback.mjs * * 做法:从 `FeedbackWidget.vue` 里**抽出真实的 `writeClipboard` 源码**(用 esbuild 去掉 TS 类型), * 配三组桩跑:① HTTP(没有 clipboard)② 正常 HTTPS ③ clipboard 存在但 reject。 */ import { readFileSync } from 'node:fs'; import { transform } from 'esbuild'; let pass = 0; let fail = 0; const check = (label, ok, extra = '') => { if (ok) { pass += 1; console.log(` ok ${label}${extra ? ` —— ${extra}` : ''}`); } else { fail += 1; console.log(` FAIL ${label}${extra ? ` —— ${extra}` : ''}`); } }; const vue = readFileSync('src/components/Common/FeedbackWidget.vue', 'utf8'); const start = vue.indexOf('async function writeClipboard'); if (start === -1) { console.error('❌ 没在 FeedbackWidget.vue 里找到 writeClipboard()'); process.exit(1); } // 花括号配平取出整个函数体(跳过字符串/模板里的花括号;这个函数很简单,够用) let depth = 0; let end = -1; for (let i = vue.indexOf('{', start); i < vue.length; i += 1) { const ch = vue[i]; if (ch === '{') depth += 1; else if (ch === '}') { depth -= 1; if (depth === 0) { end = i + 1; break; } } } const tsSource = vue.slice(start, end); check('从 SFC 里抽到了 writeClipboard 源码', tsSource.includes('execCommand'), `长度 ${tsSource.length}`); const jsSource = (await transform(tsSource, { loader: 'ts', format: 'esm' })).code; /** 造一组最小的 document 桩,记录 execCommand 是否被调用 */ const makeDocument = () => { const calls = { execCommand: 0, appended: 0, removed: 0 }; const el = { value: '', style: {}, select: () => {}, }; const doc = { createElement: () => el, body: { appendChild: () => { calls.appended += 1; }, removeChild: () => { calls.removed += 1; }, }, execCommand: (cmd) => { calls.execCommand += 1; return cmd === 'copy'; }, }; return { doc, calls }; }; const run = async (navigatorStub) => { const { doc, calls } = makeDocument(); const factory = new Function('navigator', 'document', `${jsSource}\nreturn writeClipboard;`); const fn = factory(navigatorStub, doc); let threw = null; try { await fn('待复制的内容'); } catch (e) { threw = e; } return { calls, threw }; }; console.log('\n【1】HTTP(非安全上下文):navigator.clipboard 不存在 → 必须走 execCommand 兜底且不抛错'); { const { calls, threw } = await run({}); check('没有抛错', threw === null, threw ? String(threw).slice(0, 60) : ''); check('走了 execCommand 兜底', calls.execCommand === 1, `execCommand 调用 ${calls.execCommand} 次`); check('临时 textarea 加了又移除(不漏在 DOM 里)', calls.appended === 1 && calls.removed === 1, `append ${calls.appended} / remove ${calls.removed}`); } console.log('\n【2】HTTPS(安全上下文):clipboard.writeText 可用 → 用它,不碰 execCommand'); { let written = ''; const { calls, threw } = await run({ clipboard: { writeText: async (t) => { written = t; } } }); check('没有抛错', threw === null); check('用了 clipboard.writeText', written === '待复制的内容'); check('没有走 execCommand', calls.execCommand === 0); } console.log('\n【3】clipboard 存在但抛错(如权限被拒)→ 回退到 execCommand'); { const { calls, threw } = await run({ clipboard: { writeText: () => { throw new Error('NotAllowedError'); } }, }); check('没有抛错(同步抛错也被接住)', threw === null, threw ? String(threw).slice(0, 60) : ''); check('回退到 execCommand', calls.execCommand === 1); } console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`); process.exit(fail ? 1 : 0);