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:
@@ -35,20 +35,54 @@
|
||||
:aria-checked="form.channelCode === channel.code"
|
||||
@click="chooseChannel(channel)"
|
||||
>
|
||||
<text>{{ channel.name }}</text>
|
||||
<view class="channel-icon">
|
||||
<uni-icons
|
||||
:type="channelIcon(channel.code)"
|
||||
size="19"
|
||||
:color="form.channelCode === channel.code ? '#e60012' : '#647083'"
|
||||
/>
|
||||
</view>
|
||||
<text class="channel-name">{{ channel.name }}</text>
|
||||
<text v-if="channel.defaultAnonymous" class="channel-hint">默认匿名</text>
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<label class="field-label" for="direct-content">反馈内容</label>
|
||||
<view class="field-head">
|
||||
<label class="field-label" for="direct-content">反馈内容</label>
|
||||
<button
|
||||
class="voice-chip"
|
||||
:class="{ recording: voiceRecording, busy: voiceTranscribing || voiceStarting || voiceStopping }"
|
||||
:disabled="voiceTranscribing || voiceStarting || voiceStopping"
|
||||
:aria-pressed="voiceRecording"
|
||||
aria-label="语音输入"
|
||||
@click="toggleVoiceCapture"
|
||||
>
|
||||
<uni-icons
|
||||
:type="voiceRecording ? 'mic-filled' : 'mic'"
|
||||
size="15"
|
||||
:color="voiceRecording ? '#ffffff' : '#e60012'"
|
||||
/>
|
||||
<text>{{ voiceChipLabel }}</text>
|
||||
</button>
|
||||
</view>
|
||||
<textarea
|
||||
id="direct-content"
|
||||
v-model="form.content"
|
||||
class="feedback-input"
|
||||
maxlength="5000"
|
||||
:maxlength="MAX_CONTENT_LENGTH"
|
||||
placeholder="请说明具体情况、影响和希望得到的处理。"
|
||||
aria-label="反馈内容"
|
||||
/>
|
||||
<view
|
||||
v-if="voiceBusy || voiceHint"
|
||||
class="voice-status"
|
||||
:class="{ recording: voiceRecording }"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<uni-icons v-if="voiceRecording" type="sound" size="13" color="#e60012" />
|
||||
<text>{{ voiceStatusText }}</text>
|
||||
</view>
|
||||
<view class="form-foot">
|
||||
<label class="anonymous-control">
|
||||
<switch
|
||||
@@ -57,17 +91,20 @@
|
||||
aria-label="匿名提交"
|
||||
@change="changeAnonymous"
|
||||
/>
|
||||
<view class="anonymous-icon">
|
||||
<uni-icons type="eye-slash" size="16" color="#925b00" />
|
||||
</view>
|
||||
<view>
|
||||
<text class="anonymous-title">匿名提交</text>
|
||||
<text class="section-note">处理端不显示提交人;系统内部仍保留审计记录。</text>
|
||||
</view>
|
||||
</label>
|
||||
<text class="counter">{{ form.content.length }}/5000</text>
|
||||
<text class="counter">{{ form.content.length }}/{{ MAX_CONTENT_LENGTH }}</text>
|
||||
</view>
|
||||
<text v-if="submitMessage" class="status-message" :class="{ error: submitFailed }" role="status" aria-live="polite">
|
||||
{{ submitMessage }}
|
||||
</text>
|
||||
<button class="primary-button submit-button" :disabled="submitting || !canSubmit" @click="submit">
|
||||
<button class="primary-button submit-button" :disabled="submitting || voiceBusy || !canSubmit" @click="submit">
|
||||
{{ submitting ? '提交中…' : '确认提交' }}
|
||||
</button>
|
||||
</view>
|
||||
@@ -85,6 +122,7 @@
|
||||
<button class="secondary-button" @click="loadMine(true)">重新加载</button>
|
||||
</view>
|
||||
<view v-else-if="!items.length" class="panel employee-card empty-card">
|
||||
<uni-icons type="chatboxes" size="36" color="#c3c9d4" />
|
||||
<text class="section-title">还没有反馈</text>
|
||||
<text class="muted">提交后会在这里显示处理状态。</text>
|
||||
</view>
|
||||
@@ -128,6 +166,8 @@ import {
|
||||
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
import { resetPageScroll } from '@/services/navigation';
|
||||
import { createPageRequestScope } from '@/services/request-scope';
|
||||
import { startSpeechCapture, type SpeechCapture, type SpeechCaptureSession } from '@/services/speech-capture';
|
||||
import { transcribeSpeechCapture } from '@/services/speech';
|
||||
|
||||
const path = '/pages/user/direct/index';
|
||||
const pageSize = 20;
|
||||
@@ -150,8 +190,174 @@ const form = reactive({
|
||||
content: ''
|
||||
});
|
||||
|
||||
const MAX_CONTENT_LENGTH = 5000;
|
||||
const voiceRecording = ref(false);
|
||||
const voiceStarting = ref(false);
|
||||
const voiceStopping = ref(false);
|
||||
const voiceTranscribing = ref(false);
|
||||
const voiceSeconds = ref(0);
|
||||
const voiceHint = ref('');
|
||||
let voiceCapture: SpeechCaptureSession | null = null;
|
||||
let voiceGeneration = 0;
|
||||
let abortVoiceTranscription: (() => void) | null = null;
|
||||
let voiceTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let voiceStartedAt = 0;
|
||||
|
||||
const hasMore = computed(() => items.value.length < total.value);
|
||||
const canSubmit = computed(() => Boolean(form.channelCode) && Boolean(form.content.trim()));
|
||||
const voiceBusy = computed(() => voiceStarting.value || voiceRecording.value || voiceStopping.value || voiceTranscribing.value);
|
||||
const voiceChipLabel = computed(() => {
|
||||
if (voiceStopping.value) return '结束中…';
|
||||
if (voiceRecording.value) return `停止 ${formatVoiceSeconds(voiceSeconds.value)}`;
|
||||
if (voiceTranscribing.value) return '转写中…';
|
||||
if (voiceStarting.value) return '启动中…';
|
||||
return '语音输入';
|
||||
});
|
||||
const voiceStatusText = computed(() => {
|
||||
if (voiceStopping.value) return '录音结束,正在转写';
|
||||
if (voiceRecording.value) return '正在录音,再次点击结束';
|
||||
if (voiceTranscribing.value) return '语音转写中…';
|
||||
if (voiceStarting.value) return '正在启动麦克风…';
|
||||
return voiceHint.value;
|
||||
});
|
||||
|
||||
const channelIcons: Record<DirectChannelCode, string> = {
|
||||
PRESIDENT: 'vip',
|
||||
FINANCE: 'wallet',
|
||||
HR: 'staff',
|
||||
AUDIT: 'eye',
|
||||
OPERATIONS: 'gear'
|
||||
};
|
||||
// 后端可能新增渠道 code,映射缺失时兜底 paperplane,避免图标渲染为空。
|
||||
const channelIcon = (code: DirectChannelCode) => channelIcons[code] ?? 'paperplane';
|
||||
|
||||
const formatVoiceSeconds = (total: number) => {
|
||||
const minutes = Math.floor(total / 60);
|
||||
const seconds = total % 60;
|
||||
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const stopVoiceTimer = () => {
|
||||
if (voiceTimer) clearInterval(voiceTimer);
|
||||
voiceTimer = null;
|
||||
};
|
||||
|
||||
const startVoiceTimer = () => {
|
||||
stopVoiceTimer();
|
||||
voiceStartedAt = Date.now();
|
||||
voiceSeconds.value = 0;
|
||||
voiceTimer = setInterval(() => {
|
||||
voiceSeconds.value = Math.floor((Date.now() - voiceStartedAt) / 1000);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const appendTranscript = (text: string) => {
|
||||
const current = form.content.trim() ? form.content : '';
|
||||
const joined = current ? `${current}\n${text}` : text;
|
||||
if (joined.length > MAX_CONTENT_LENGTH) {
|
||||
form.content = joined.slice(0, MAX_CONTENT_LENGTH);
|
||||
voiceHint.value = `内容已达 ${MAX_CONTENT_LENGTH} 字上限,超出部分已截断`;
|
||||
return;
|
||||
}
|
||||
form.content = joined;
|
||||
voiceHint.value = '语音已转成文字,请确认后提交';
|
||||
};
|
||||
|
||||
const handleVoiceCapture = async (capture: SpeechCapture) => {
|
||||
const generation = ++voiceGeneration;
|
||||
voiceTranscribing.value = true;
|
||||
try {
|
||||
const asr = await transcribeSpeechCapture(capture, 'direct-feedback.webm', (abort) => {
|
||||
if (generation === voiceGeneration) abortVoiceTranscription = abort;
|
||||
});
|
||||
if (generation !== voiceGeneration) return;
|
||||
const text = (asr.text || '').trim();
|
||||
if (!text) {
|
||||
voiceHint.value = '语音没有识别到文字,请再说一遍';
|
||||
return;
|
||||
}
|
||||
appendTranscript(text);
|
||||
} catch (error) {
|
||||
if (generation !== voiceGeneration) return;
|
||||
voiceHint.value = error instanceof Error && error.message ? error.message : '语音转写失败,请改用文字输入';
|
||||
} finally {
|
||||
if (generation === voiceGeneration) {
|
||||
abortVoiceTranscription = null;
|
||||
voiceTranscribing.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const startVoiceCapture = async () => {
|
||||
if (voiceStarting.value || voiceRecording.value || voiceTranscribing.value) return;
|
||||
const generation = ++voiceGeneration;
|
||||
voiceStarting.value = true;
|
||||
voiceHint.value = '';
|
||||
try {
|
||||
const session = await startSpeechCapture({
|
||||
onCapture: (capture) => {
|
||||
if (generation !== voiceGeneration) return;
|
||||
voiceCapture = null;
|
||||
voiceRecording.value = false;
|
||||
voiceStopping.value = false;
|
||||
stopVoiceTimer();
|
||||
void handleVoiceCapture(capture);
|
||||
},
|
||||
onError: () => {
|
||||
if (generation !== voiceGeneration) return;
|
||||
voiceCapture = null;
|
||||
voiceRecording.value = false;
|
||||
voiceStopping.value = false;
|
||||
stopVoiceTimer();
|
||||
voiceHint.value = '麦克风不可用,请检查权限后直接输入文字';
|
||||
}
|
||||
});
|
||||
if (!session || generation !== voiceGeneration) {
|
||||
session?.cancel();
|
||||
if (generation === voiceGeneration) voiceHint.value = '麦克风不可用,请检查权限后直接输入文字';
|
||||
return;
|
||||
}
|
||||
voiceCapture = session;
|
||||
voiceRecording.value = true;
|
||||
startVoiceTimer();
|
||||
} catch {
|
||||
if (generation === voiceGeneration) voiceHint.value = '麦克风不可用,请检查权限后直接输入文字';
|
||||
} finally {
|
||||
if (generation === voiceGeneration) voiceStarting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const stopVoiceCapture = () => {
|
||||
if (!voiceRecording.value || voiceStopping.value || !voiceCapture) return;
|
||||
voiceStopping.value = true;
|
||||
stopVoiceTimer();
|
||||
voiceCapture.stop();
|
||||
};
|
||||
|
||||
const toggleVoiceCapture = () => {
|
||||
if (voiceStarting.value || voiceStopping.value || voiceTranscribing.value) return;
|
||||
if (voiceRecording.value) {
|
||||
stopVoiceCapture();
|
||||
return;
|
||||
}
|
||||
void startVoiceCapture();
|
||||
};
|
||||
|
||||
const teardownVoice = () => {
|
||||
voiceGeneration += 1;
|
||||
abortVoiceTranscription?.();
|
||||
abortVoiceTranscription = null;
|
||||
voiceTranscribing.value = false;
|
||||
voiceStarting.value = false;
|
||||
voiceStopping.value = false;
|
||||
voiceCapture?.cancel();
|
||||
voiceCapture = null;
|
||||
voiceRecording.value = false;
|
||||
stopVoiceTimer();
|
||||
voiceSeconds.value = 0;
|
||||
voiceHint.value = '';
|
||||
};
|
||||
|
||||
const createRequestId = () =>
|
||||
globalThis.crypto?.randomUUID?.() ?? `direct-${Date.now()}-${Math.random().toString(36).slice(2, 12)}`;
|
||||
|
||||
@@ -205,7 +411,7 @@ const loadMine = async (reset: boolean) => {
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!canSubmit.value || submitting.value || !form.channelCode) return;
|
||||
if (!canSubmit.value || submitting.value || voiceBusy.value || !form.channelCode) return;
|
||||
submitting.value = true;
|
||||
submitFailed.value = false;
|
||||
submitMessage.value = '';
|
||||
@@ -219,6 +425,7 @@ const submit = async () => {
|
||||
});
|
||||
form.requestId = '';
|
||||
form.content = '';
|
||||
voiceHint.value = '';
|
||||
submitMessage.value = '反馈已提交,可在“我的反馈”查看处理结果。';
|
||||
await loadMine(true);
|
||||
} catch (error) {
|
||||
@@ -240,8 +447,14 @@ onShow(async () => {
|
||||
loadFailed.value = true;
|
||||
}
|
||||
});
|
||||
onHide(() => requests.invalidate());
|
||||
onUnload(() => requests.invalidate());
|
||||
onHide(() => {
|
||||
requests.invalidate();
|
||||
teardownVoice();
|
||||
});
|
||||
onUnload(() => {
|
||||
requests.invalidate();
|
||||
teardownVoice();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -249,8 +462,18 @@ onUnload(() => requests.invalidate());
|
||||
.login-panel,.submit-card,.loading-card,.empty-card{display:grid;gap:12px;margin-top:18px;padding:18px}
|
||||
.section-head,.history-head,.feedback-head,.form-foot,.anonymous-control{display:flex;align-items:center;justify-content:space-between;gap:12px}
|
||||
.section-title{display:block;color:var(--ink);font-size:18px;font-weight:900}.section-note,.muted{display:block;margin-top:4px;color:var(--muted);font-size:12px;line-height:1.6}
|
||||
.channel-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.channel-button{display:grid;min-height:60px;align-content:center;gap:3px;padding:10px;border:1px solid #e6e8ec;border-radius:14px;background:#fff;color:var(--ink);font-size:14px;font-weight:900}.channel-button.active{border-color:var(--brand);background:#fff5f6;color:var(--brand)}.channel-button::after,.refresh-button::after{border:0}.channel-hint{font-size:11px;font-weight:700;opacity:.72}
|
||||
.field-label{margin-top:4px;color:var(--ink);font-size:14px;font-weight:900}.feedback-input{box-sizing:border-box;width:100%;height:150px;padding:14px;border:1px solid #dfe2e7;border-radius:14px;background:#fafafa;color:var(--ink);font-size:15px;line-height:1.6}
|
||||
.form-foot{align-items:flex-start}.anonymous-control{justify-content:flex-start;flex:1}.anonymous-title{display:block;font-size:14px;font-weight:900}.counter{color:var(--muted);font-size:12px}.submit-button{margin-top:2px}.status-message{padding:10px 12px;border-radius:10px;background:#edf8f2;color:#147a45;font-size:13px}.status-message.error{background:#fff0f1;color:#a9000d}
|
||||
.channel-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.channel-button{display:grid;min-height:60px;align-content:center;justify-items:center;gap:3px;padding:10px;border:1px solid #e6e8ec;border-radius:14px;background:#fff;color:var(--ink);font-size:14px;font-weight:900;text-align:center;transition:border-color .2s ease,box-shadow .2s ease,transform .1s ease}.channel-button.active{border-color:var(--brand);background:#fff5f6;color:var(--brand);box-shadow:0 6px 16px rgba(230,0,18,.10)}.channel-button:active{transform:scale(.98)}.channel-button::after,.refresh-button::after{border:0}.channel-hint{font-size:11px;font-weight:700;opacity:.72}
|
||||
.channel-icon{display:grid;width:34px;height:34px;place-items:center;border-radius:12px;background:#f4f5f7;transition:background .2s ease}
|
||||
.channel-button.active .channel-icon{background:var(--employee-primary-soft)}
|
||||
.channel-name{line-height:1.3}
|
||||
.field-head{display:flex;margin-top:4px;align-items:center;justify-content:space-between;gap:12px}.field-label{color:var(--ink);font-size:14px;font-weight:900}.feedback-input{box-sizing:border-box;width:100%;height:150px;padding:14px;border:1px solid #dfe2e7;border-radius:14px;background:#fafafa;color:var(--ink);font-size:15px;line-height:1.6;transition:border-color .2s ease,background .2s ease}.feedback-input:focus{border-color:var(--brand);background:#fff}
|
||||
.voice-chip{display:inline-flex;flex-shrink:0;align-items:center;gap:5px;margin:0;padding:6px 12px;border:1px solid var(--employee-line);border-radius:999px;background:#fff;color:var(--brand);font-size:12px;font-weight:800;line-height:1.2;transition:background .2s ease,color .2s ease,opacity .2s ease}
|
||||
.voice-chip::after{border:0}
|
||||
.voice-chip:active{background:var(--employee-primary-soft)}
|
||||
.voice-chip.recording{border-color:var(--brand);background:var(--brand);color:#fff}
|
||||
.voice-chip.busy{opacity:.6}
|
||||
.voice-status{display:flex;margin-top:8px;align-items:center;gap:6px;padding:8px 12px;border-radius:10px;background:#f7f8fa;color:var(--muted);font-size:12px;line-height:1.5}
|
||||
.voice-status.recording{background:var(--employee-primary-soft);color:var(--brand)}
|
||||
.form-foot{align-items:flex-start}.anonymous-control{justify-content:flex-start;flex:1}.anonymous-icon{display:grid;flex-shrink:0;width:32px;height:32px;place-items:center;border-radius:10px;background:#fff7e8}.anonymous-title{display:block;font-size:14px;font-weight:900}.counter{color:var(--muted);font-size:12px}.submit-button{margin-top:2px}.status-message{padding:10px 12px;border-radius:10px;background:#edf8f2;color:#147a45;font-size:13px}.status-message.error{background:#fff0f1;color:#a9000d}
|
||||
.history-head{margin-top:28px}.refresh-button{padding:7px 12px;border-radius:999px;background:#fff0f1;color:var(--brand);font-size:13px;font-weight:800}.feedback-list{display:grid;gap:12px;margin-top:12px}.feedback-card{display:grid;gap:11px;padding:16px}.feedback-tags{display:flex;flex-wrap:wrap;gap:6px}.channel-tag,.anonymous-tag,.status-tag{padding:4px 8px;border-radius:999px;font-size:11px;font-weight:900}.channel-tag{background:#f2f3f5;color:#343b46}.anonymous-tag{background:#fff7e8;color:#925b00}.status-tag{background:#fff0f1;color:var(--brand)}.status-tag.replied{background:#edf8f2;color:#147a45}.feedback-content,.reply-content{white-space:pre-wrap;overflow-wrap:anywhere;color:var(--ink);font-size:15px;line-height:1.7}.reply-box{display:grid;gap:6px;padding:12px;border-left:3px solid var(--brand);border-radius:4px 10px 10px 4px;background:#faf6f6}.reply-label{color:var(--brand);font-size:12px;font-weight:900}.feedback-time{color:var(--muted);font-size:12px}.load-more{width:100%}
|
||||
</style>
|
||||
|
||||
@@ -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