feat(mobile): add voice transcription to practice
This commit is contained in:
@@ -226,6 +226,7 @@ mobile-uni/
|
|||||||
- 员工今日页“今日安排”已优先回读真实 `/api/aihr/mobile/practice/assignments`;有待训练专项或每日三题时覆盖公开 home seed,真实浏览器已验证 `13900000998` 首页显示当天 daily 任务并可跳到练习页。
|
- 员工今日页“今日安排”已优先回读真实 `/api/aihr/mobile/practice/assignments`;有待训练专项或每日三题时覆盖公开 home seed,真实浏览器已验证 `13900000998` 首页显示当天 daily 任务并可跳到练习页。
|
||||||
- 员工今日页到练习页已补任务上下文传递:首页点“开始”会把当前 assignment 带到练习页,daily 任务高亮并提示填写,主管专项可直接启动,减少跳页后的二次选择。
|
- 员工今日页到练习页已补任务上下文传递:首页点“开始”会把当前 assignment 带到练习页,daily 任务高亮并提示填写,主管专项可直接启动,减少跳页后的二次选择。
|
||||||
- 移动端 hash 路由滚动复位已补:员工/候选人/主管/登录/建设中页面激活时回到顶部,真实浏览器验证从长页切回今日页后 `scrollY=0`,避免首屏被旧滚动位置截断。
|
- 移动端 hash 路由滚动复位已补:员工/候选人/主管/登录/建设中页面激活时回到顶部,真实浏览器验证从长页切回今日页后 `scrollY=0`,避免首屏被旧滚动位置截断。
|
||||||
|
- 员工练习页已补语音输入入口:回复区支持浏览器录音转写和选择音频转写,ASR 返回文本进入原提交框且音频 URL/OssId 随 turn 提交供主管复盘回放;真实浏览器验证按钮在 390px 手机视口不换行,文本 turn 回归可进入第 2 轮。
|
||||||
|
|
||||||
## 7. 风险
|
## 7. 风险
|
||||||
|
|
||||||
|
|||||||
@@ -126,6 +126,12 @@
|
|||||||
placeholder="先承接业主情绪,再说明处理动作和反馈时间。"
|
placeholder="先承接业主情绪,再说明处理动作和反馈时间。"
|
||||||
/>
|
/>
|
||||||
<view class="reply-actions">
|
<view class="reply-actions">
|
||||||
|
<button class="secondary-button" :disabled="busy || transcribing" @click="toggleRecording">
|
||||||
|
{{ recording ? '停止录音' : '录音转写' }}
|
||||||
|
</button>
|
||||||
|
<button class="ghost-button" :disabled="busy || recording || transcribing" @click="pickAudio">
|
||||||
|
{{ transcribing ? '转写中' : '选音频' }}
|
||||||
|
</button>
|
||||||
<button class="secondary-button" :disabled="busy || helpLoading" @click="loadHelp">
|
<button class="secondary-button" :disabled="busy || helpLoading" @click="loadHelp">
|
||||||
{{ helpLoading ? '求助中' : '求助' }}
|
{{ helpLoading ? '求助中' : '求助' }}
|
||||||
</button>
|
</button>
|
||||||
@@ -134,6 +140,7 @@
|
|||||||
{{ status === 'submitting' ? '提交中' : '提交回应' }}
|
{{ status === 'submitting' ? '提交中' : '提交回应' }}
|
||||||
</button>
|
</button>
|
||||||
</view>
|
</view>
|
||||||
|
<text v-if="draftAudioUrl" class="voice-state">已附带语音回放</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-if="helpAnswer || helpRefs.length" class="panel help-card">
|
<view v-if="helpAnswer || helpRefs.length" class="panel help-card">
|
||||||
@@ -170,6 +177,7 @@ import { answerDailyDrill, finishPractice, getPracticeAssignments, getPracticePr
|
|||||||
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||||
import { searchKnowledge } from '@/services/knowledge';
|
import { searchKnowledge } from '@/services/knowledge';
|
||||||
import { resetPageScroll } from '@/services/navigation';
|
import { resetPageScroll } from '@/services/navigation';
|
||||||
|
import { chooseSpeechAudio, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech';
|
||||||
|
|
||||||
type PracticeStatus = 'idle' | 'starting' | 'active' | 'submitting' | 'finished';
|
type PracticeStatus = 'idle' | 'starting' | 'active' | 'submitting' | 'finished';
|
||||||
|
|
||||||
@@ -203,6 +211,12 @@ const answeringId = ref<number | null>(null);
|
|||||||
const activeAssignmentId = ref<number | undefined>();
|
const activeAssignmentId = ref<number | undefined>();
|
||||||
const highlightedAssignmentId = ref<number | null>(null);
|
const highlightedAssignmentId = ref<number | null>(null);
|
||||||
const loggedIn = ref(false);
|
const loggedIn = ref(false);
|
||||||
|
const recording = ref(false);
|
||||||
|
const transcribing = ref(false);
|
||||||
|
const draftAudioUrl = ref('');
|
||||||
|
const draftAudioOssId = ref<number | undefined>();
|
||||||
|
const mediaRecorder = ref<MediaRecorder | null>(null);
|
||||||
|
const mediaStream = ref<MediaStream | null>(null);
|
||||||
const busy = computed(() => status.value === 'starting' || status.value === 'submitting');
|
const busy = computed(() => status.value === 'starting' || status.value === 'submitting');
|
||||||
const canStartPractice = computed(() => status.value === 'idle' || status.value === 'finished');
|
const canStartPractice = computed(() => status.value === 'idle' || status.value === 'finished');
|
||||||
const hasTraineeReply = computed(() => turns.value.some((item) => item.role === 'trainee'));
|
const hasTraineeReply = computed(() => turns.value.some((item) => item.role === 'trainee'));
|
||||||
@@ -237,6 +251,7 @@ const refreshAuthState = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const clearPracticeData = () => {
|
const clearPracticeData = () => {
|
||||||
|
cancelRecording();
|
||||||
assignments.value = [];
|
assignments.value = [];
|
||||||
drillAnswers.value = {};
|
drillAnswers.value = {};
|
||||||
prepCard.value = null;
|
prepCard.value = null;
|
||||||
@@ -261,6 +276,8 @@ const clearPracticeData = () => {
|
|||||||
answeringId.value = null;
|
answeringId.value = null;
|
||||||
activeAssignmentId.value = undefined;
|
activeAssignmentId.value = undefined;
|
||||||
highlightedAssignmentId.value = null;
|
highlightedAssignmentId.value = null;
|
||||||
|
transcribing.value = false;
|
||||||
|
clearDraftAudio();
|
||||||
};
|
};
|
||||||
|
|
||||||
const requireLogin = () => {
|
const requireLogin = () => {
|
||||||
@@ -276,6 +293,117 @@ const appendTurn = (role: PracticeRole, text?: string) => {
|
|||||||
turns.value.push({ role, text });
|
turns.value.push({ role, text });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clearDraftAudio = () => {
|
||||||
|
draftAudioUrl.value = '';
|
||||||
|
draftAudioOssId.value = undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopMediaTracks = () => {
|
||||||
|
mediaStream.value?.getTracks().forEach((track) => track.stop());
|
||||||
|
mediaStream.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelRecording = () => {
|
||||||
|
const recorder = mediaRecorder.value;
|
||||||
|
if (recorder && recorder.state !== 'inactive') {
|
||||||
|
recorder.ondataavailable = null;
|
||||||
|
recorder.onstop = null;
|
||||||
|
recorder.stop();
|
||||||
|
}
|
||||||
|
mediaRecorder.value = null;
|
||||||
|
stopMediaTracks();
|
||||||
|
recording.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyAsrResult = (result: { text?: string; audioUrl?: string; ossId?: number | string }) => {
|
||||||
|
const text = (result.text || '').trim();
|
||||||
|
if (!text) throw new Error('语音没有识别到文字');
|
||||||
|
const current = draft.value.trim();
|
||||||
|
draft.value = current ? `${current}\n${text}` : text;
|
||||||
|
draftAudioUrl.value = result.audioUrl || '';
|
||||||
|
const ossId = Number(result.ossId);
|
||||||
|
draftAudioOssId.value = Number.isFinite(ossId) ? ossId : undefined;
|
||||||
|
message.value = '语音已转成文字,请确认后提交';
|
||||||
|
};
|
||||||
|
|
||||||
|
const transcribeBlob = async (blob: Blob) => {
|
||||||
|
if (transcribing.value) return;
|
||||||
|
transcribing.value = true;
|
||||||
|
message.value = '语音转写中';
|
||||||
|
try {
|
||||||
|
applyAsrResult(await transcribeSpeechBlob(blob));
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, '语音转写失败');
|
||||||
|
} finally {
|
||||||
|
transcribing.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickAudio = async () => {
|
||||||
|
if (busy.value || recording.value || transcribing.value) return;
|
||||||
|
if (!requireLogin()) return;
|
||||||
|
transcribing.value = true;
|
||||||
|
message.value = '语音转写中';
|
||||||
|
try {
|
||||||
|
const file = await chooseSpeechAudio();
|
||||||
|
applyAsrResult(await transcribeSpeechFile(file));
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, '语音转写失败');
|
||||||
|
} finally {
|
||||||
|
transcribing.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startRecording = async () => {
|
||||||
|
if (busy.value || transcribing.value) return;
|
||||||
|
if (!requireLogin()) return;
|
||||||
|
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
|
||||||
|
await pickAudio();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
const recorder = new MediaRecorder(stream);
|
||||||
|
const chunks: Blob[] = [];
|
||||||
|
mediaStream.value = stream;
|
||||||
|
mediaRecorder.value = recorder;
|
||||||
|
recorder.ondataavailable = (event) => {
|
||||||
|
if (event.data?.size) chunks.push(event.data);
|
||||||
|
};
|
||||||
|
recorder.onstop = () => {
|
||||||
|
recording.value = false;
|
||||||
|
mediaRecorder.value = null;
|
||||||
|
stopMediaTracks();
|
||||||
|
const blob = new Blob(chunks, { type: recorder.mimeType || 'audio/webm' });
|
||||||
|
if (!blob.size) {
|
||||||
|
message.value = '未录到音频,请改用文字输入';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void transcribeBlob(blob);
|
||||||
|
};
|
||||||
|
recorder.start();
|
||||||
|
recording.value = true;
|
||||||
|
message.value = '正在录音';
|
||||||
|
} catch (error) {
|
||||||
|
await pickAudio();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopRecording = () => {
|
||||||
|
const recorder = mediaRecorder.value;
|
||||||
|
if (!recording.value || !recorder || recorder.state === 'inactive') return;
|
||||||
|
message.value = '录音结束,正在转写';
|
||||||
|
recorder.stop();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleRecording = () => {
|
||||||
|
if (recording.value) {
|
||||||
|
stopRecording();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void startRecording();
|
||||||
|
};
|
||||||
|
|
||||||
const handleError = (error: unknown, fallback: string) => {
|
const handleError = (error: unknown, fallback: string) => {
|
||||||
const text = error instanceof Error ? error.message : fallback;
|
const text = error instanceof Error ? error.message : fallback;
|
||||||
refreshAuthState();
|
refreshAuthState();
|
||||||
@@ -386,6 +514,7 @@ const start = async (scenarioId = defaultScenarioId, assignmentId?: number) => {
|
|||||||
result.value = null;
|
result.value = null;
|
||||||
turns.value = [];
|
turns.value = [];
|
||||||
draft.value = '';
|
draft.value = '';
|
||||||
|
clearDraftAudio();
|
||||||
helpAnswer.value = '';
|
helpAnswer.value = '';
|
||||||
helpRefs.value = [];
|
helpRefs.value = [];
|
||||||
lastTraineeText.value = '';
|
lastTraineeText.value = '';
|
||||||
@@ -482,7 +611,10 @@ const submit = async () => {
|
|||||||
}
|
}
|
||||||
status.value = 'submitting';
|
status.value = 'submitting';
|
||||||
message.value = '';
|
message.value = '';
|
||||||
|
const audioUrl = draftAudioUrl.value || undefined;
|
||||||
|
const audioOssId = draftAudioOssId.value;
|
||||||
draft.value = '';
|
draft.value = '';
|
||||||
|
clearDraftAudio();
|
||||||
const retryingRedFlag = redFlagActive.value && lastCoachRoundIndex.value === roundIndex.value;
|
const retryingRedFlag = redFlagActive.value && lastCoachRoundIndex.value === roundIndex.value;
|
||||||
if (retryingRedFlag) discardLastCoachAttempt();
|
if (retryingRedFlag) discardLastCoachAttempt();
|
||||||
appendTurn('trainee', text);
|
appendTurn('trainee', text);
|
||||||
@@ -491,7 +623,9 @@ const submit = async () => {
|
|||||||
const data = await submitPracticeTurn({
|
const data = await submitPracticeTurn({
|
||||||
sessionId: sessionId.value,
|
sessionId: sessionId.value,
|
||||||
roundIndex: submittedRound,
|
roundIndex: submittedRound,
|
||||||
traineeText: text
|
traineeText: text,
|
||||||
|
traineeAudioUrl: audioUrl,
|
||||||
|
traineeAudioOssId: audioOssId
|
||||||
});
|
});
|
||||||
lastTraineeText.value = text;
|
lastTraineeText.value = text;
|
||||||
lastCoachRoundIndex.value = submittedRound;
|
lastCoachRoundIndex.value = submittedRound;
|
||||||
@@ -513,6 +647,9 @@ const submit = async () => {
|
|||||||
status.value = 'active';
|
status.value = 'active';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
status.value = 'active';
|
status.value = 'active';
|
||||||
|
draft.value = text;
|
||||||
|
draftAudioUrl.value = audioUrl || '';
|
||||||
|
draftAudioOssId.value = audioOssId;
|
||||||
handleError(error, '提交失败');
|
handleError(error, '提交失败');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -699,10 +836,27 @@ onShow(() => {
|
|||||||
.reply-actions {
|
.reply-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.reply-actions {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-actions button {
|
||||||
|
min-height: 48px;
|
||||||
|
min-width: 92px;
|
||||||
|
flex: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
word-break: keep-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-state {
|
||||||
|
color: #2563eb;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
.round-label {
|
.round-label {
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
export const resetPageScroll = () => {
|
export const resetPageScroll = () => {
|
||||||
const scrollTop = () => {
|
const scrollTop = () => {
|
||||||
uni.pageScrollTo({ scrollTop: 0, duration: 0 });
|
uni.pageScrollTo({ scrollTop: 0, duration: 0 });
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.scrollTo({ top: 0, left: 0, behavior: 'auto' });
|
||||||
|
document.documentElement.scrollTop = 0;
|
||||||
|
document.body.scrollTop = 0;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
scrollTop();
|
scrollTop();
|
||||||
setTimeout(scrollTop, 0);
|
setTimeout(scrollTop, 0);
|
||||||
setTimeout(scrollTop, 80);
|
setTimeout(scrollTop, 80);
|
||||||
|
setTimeout(scrollTop, 240);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ export const submitPracticeTurn = (params: {
|
|||||||
sessionId: string;
|
sessionId: string;
|
||||||
roundIndex: number;
|
roundIndex: number;
|
||||||
traineeText: string;
|
traineeText: string;
|
||||||
|
traineeAudioUrl?: string;
|
||||||
|
traineeAudioOssId?: number;
|
||||||
regenerate?: boolean;
|
regenerate?: boolean;
|
||||||
style?: string;
|
style?: string;
|
||||||
}) =>
|
}) =>
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type { AsrResponse, SpeechSelectedFile } from '@/types/api';
|
||||||
|
import { apiUrl, authHeaders, readTextPayload } from './api';
|
||||||
|
|
||||||
|
const audioExtensions = ['.mp3', '.wav', '.m4a', '.webm', '.aac', '.ogg'];
|
||||||
|
|
||||||
|
export const chooseSpeechAudio = () =>
|
||||||
|
new Promise<SpeechSelectedFile>((resolve, reject) => {
|
||||||
|
uni.chooseFile({
|
||||||
|
count: 1,
|
||||||
|
type: 'all',
|
||||||
|
extension: audioExtensions,
|
||||||
|
success: (result) => {
|
||||||
|
const tempFiles = Array.isArray(result.tempFiles) ? result.tempFiles : [];
|
||||||
|
const file = (tempFiles[0] || {}) as Partial<SpeechSelectedFile>;
|
||||||
|
const path = file.path || result.tempFilePaths?.[0] || '';
|
||||||
|
if (!path) {
|
||||||
|
reject(new Error('未获取到音频文件'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve({
|
||||||
|
path,
|
||||||
|
name: file.name || path.split('/').pop() || 'practice-audio',
|
||||||
|
size: Number(file.size || 0)
|
||||||
|
});
|
||||||
|
},
|
||||||
|
fail: () => reject(new Error('未选择音频'))
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export const transcribeSpeechFile = (file: SpeechSelectedFile) =>
|
||||||
|
new Promise<AsrResponse>((resolve, reject) => {
|
||||||
|
uni.uploadFile({
|
||||||
|
url: apiUrl('/api/ai/asr'),
|
||||||
|
filePath: file.path,
|
||||||
|
name: 'file',
|
||||||
|
header: authHeaders(false),
|
||||||
|
success: (response) => {
|
||||||
|
try {
|
||||||
|
resolve(readTextPayload<AsrResponse>(response.statusCode, response.data));
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: () => reject(new Error('语音上传失败'))
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export const transcribeSpeechBlob = (blob: Blob, filename = 'practice-audio.webm') =>
|
||||||
|
new Promise<AsrResponse>((resolve, reject) => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', blob, filename);
|
||||||
|
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('POST', apiUrl('/api/ai/asr'));
|
||||||
|
xhr.timeout = 45000;
|
||||||
|
Object.entries(authHeaders(false)).forEach(([key, value]) => xhr.setRequestHeader(key, value));
|
||||||
|
xhr.onload = () => {
|
||||||
|
try {
|
||||||
|
resolve(readTextPayload<AsrResponse>(xhr.status, xhr.responseText || '{}'));
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
xhr.onerror = () => reject(new Error('语音上传失败'));
|
||||||
|
xhr.ontimeout = () => reject(new Error('语音转写超时'));
|
||||||
|
xhr.send(form);
|
||||||
|
});
|
||||||
@@ -145,6 +145,13 @@ export interface PracticeFinishResponse {
|
|||||||
trustCurve?: number[];
|
trustCurve?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AsrResponse {
|
||||||
|
text: string;
|
||||||
|
source: string;
|
||||||
|
ossId?: number | string;
|
||||||
|
audioUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PracticeRecord {
|
export interface PracticeRecord {
|
||||||
id?: number;
|
id?: number;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
@@ -255,6 +262,12 @@ export interface CaseSelectedFile {
|
|||||||
size: number;
|
size: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SpeechSelectedFile {
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CaseTag {
|
export interface CaseTag {
|
||||||
text: string;
|
text: string;
|
||||||
type: string;
|
type: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user