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
+234 -11
View File
@@ -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 = ''/);
});