feat(mobile): add voice-to-text input to direct feedback page

- 反馈内容 label 行新增语音 chip:toggle 录音(启动/录音计时/结束中/转写
  四态),转写结果以换行追加,JS 侧截断至 5000 字上限并提示
- 复用既有 services/speech.ts + /api/ai/asr 链路;表单页文字为主路径,
  录音不可用或权限失败仅提示改文字输入,不做选文件降级
- voiceStopping 状态修复 stop→onCapture 竞态(重复点按不再丢录音)
- onHide/onUnload 统一 teardownVoice:generation++、abort 转写、
  cancel 录音、停计时,切页无迟来写回
- 渠道按钮加图标(vip/wallet/staff/eye/gear),匿名行与空历史态补图标
- 测试改用函数体提取断言,覆盖清理/竞态/截断/模板契约;150/150
  单测、vue-tsc、H5 构建通过,mock API 浏览器验证通过

已经过 Codex 审查
This commit is contained in:
2026-07-25 03:00:30 +08:00
parent d06ea63214
commit 3a19b475a8
5 changed files with 307 additions and 15 deletions
@@ -643,3 +643,70 @@ test('主管学员模式只允许查询本人训练概况且登录后保留待
assert.match(source, /const generation = \+\+knowledgeSearchGeneration/);
assert.match(source, /if \(!loggedIn\.value\) \{[\s\S]*goLogin\(\);[\s\S]*return;[\s\S]*\}[\s\S]*uni\.removeStorageSync\(pendingToolKey\)/);
});
test('直通车页支持语音输入并管控切页清理与字数上限', async () => {
const source = await pageSource('../src/pages/user/direct/index.vue');
const bodyOf = (pattern) => source.match(pattern)?.[1] || '';
// 语音链路复用既有服务,函数与关键契约存在。
assert.match(source, /import \{[^}]*startSpeechCapture[^}]*\} from '@\/services\/speech-capture'/);
assert.match(source, /transcribeSpeechCapture\(capture, 'direct-feedback\.webm'/);
assert.match(source, /const toggleVoiceCapture = \(\) =>/);
assert.match(source, /const appendTranscript = \(text: string\) =>/);
assert.match(source, /:maxlength="MAX_CONTENT_LENGTH"/);
// 渠道图标映射与兜底。
assert.match(source, /const channelIcon = \(code: DirectChannelCode\) => channelIcons\[code\] \?\? 'paperplane'/);
assert.match(source, /PRESIDENT: 'vip'/);
assert.match(source, /FINANCE: 'wallet'/);
assert.match(source, /HR: 'staff'/);
assert.match(source, /AUDIT: 'eye'/);
assert.match(source, /OPERATIONS: 'gear'/);
// 转写结果以换行追加并在 JS 侧截断到字数上限(maxlength 管不住编程式赋值)。
assert.match(source, /current \? `\$\{current\}\\n\$\{text\}` : text/);
assert.match(source, /slice\(0, MAX_CONTENT_LENGTH\)/);
// stop→onCapture 异步窗口:stopping 状态禁用重复 stop,避免 App 端二次 stop 使录音丢失。
const stopBody = bodyOf(/const stopVoiceCapture = \(\) => \{([\s\S]*?)\};\n/);
assert.match(stopBody, /voiceStopping\.value = true/);
const toggleBody = bodyOf(/const toggleVoiceCapture = \(\) => \{([\s\S]*?)\};\n/);
assert.match(toggleBody, /voiceStopping\.value/);
// 切页清理:onHide/onUnload 各自函数体内必须直接调用 teardownVoice(不允许跨函数误匹配)。
const onHideBody = bodyOf(/onHide\(\(\) => \{([\s\S]*?)\}\);/);
const onUnloadBody = bodyOf(/onUnload\(\(\) => \{([\s\S]*?)\}\);/);
assert.match(onHideBody, /requests\.invalidate\(\)/);
assert.match(onHideBody, /teardownVoice\(\)/);
assert.match(onUnloadBody, /requests\.invalidate\(\)/);
assert.match(onUnloadBody, /teardownVoice\(\)/);
// teardown 必须同时处理:generation 递增、转写 abort、录音 cancel、状态复位、计时器停止。
const teardownBody = bodyOf(/const teardownVoice = \(\) => \{([\s\S]*?)\};\n/);
assert.match(teardownBody, /voiceGeneration \+= 1/);
assert.match(teardownBody, /abortVoiceTranscription\?\.\(\)/);
assert.match(teardownBody, /voiceCapture\?\.cancel\(\)/);
assert.match(teardownBody, /voiceRecording\.value = false/);
assert.match(teardownBody, /voiceStopping\.value = false/);
assert.match(teardownBody, /stopVoiceTimer\(\)/);
// 计时器用 Date.now 差值,避免后台节流导致秒数漂移。
assert.match(source, /Math\.floor\(\(Date\.now\(\) - voiceStartedAt\) \/ 1000\)/);
// 模板契约:语音 chip、状态条 aria、提交禁用、装饰图标。
const chipBody = bodyOf(/<button\s+class="voice-chip"([\s\S]*?)>/);
assert.match(chipBody, /:aria-pressed="voiceRecording"/);
assert.match(chipBody, /aria-label="语音输入"/);
assert.match(chipBody, /:disabled="voiceTranscribing \|\| voiceStarting \|\| voiceStopping"/);
const statusBody = bodyOf(/<view\s+v-if="voiceBusy \|\| voiceHint"([\s\S]*?)>/);
assert.match(statusBody, /class="voice-status"/);
assert.match(statusBody, /aria-live="polite"/);
assert.match(source, /:disabled="submitting \|\| voiceBusy \|\| !canSubmit"/);
assert.match(source, /submitting\.value \|\| voiceBusy\.value/);
assert.match(source, /type="chatboxes"/);
assert.match(source, /type="eye-slash"/);
// 提交成功后清理语音提示,避免残留“语音已转成文字”。
const submitBody = bodyOf(/const submit = async \(\) => \{([\s\S]*?)await loadMine\(true\)/);
assert.match(submitBody, /voiceHint\.value = ''/);
});