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:
@@ -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 = ''/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user