feat(aihr): add voice answers to daily drills

This commit is contained in:
2026-07-14 05:44:26 +08:00
parent 841df00740
commit 9bbfd575a3
10 changed files with 156 additions and 32 deletions
+107 -16
View File
@@ -90,6 +90,24 @@
:disabled="item.status === '已完成'"
placeholder="写一句你的处理话术"
/>
<view class="daily-voice-actions">
<button
class="secondary-button"
:class="{ 'recording-active': recording && recordingAssignmentId === item.id }"
:disabled="Boolean(answeringId) || transcribing || (recording && recordingAssignmentId !== item.id) || item.status === '已完成'"
@click="() => toggleRecording(item.id)"
>
{{ recording && recordingAssignmentId === item.id ? '停止录音' : '录音转写' }}
</button>
<button
class="ghost-button"
:disabled="Boolean(answeringId) || recording || transcribing || item.status === '已完成'"
@click="() => pickAudio(item.id)"
>
{{ transcribing ? '转写中' : '选音频' }}
</button>
</view>
<text v-if="dailyAudioUrls[item.id]" class="voice-state">已附带语音回放</text>
<button class="primary-button small-button" :disabled="Boolean(answeringId) || item.status === '已完成'" @click="submitDaily(item)">
{{ item.status === '已完成' ? '已完成' : '提交' }}
</button>
@@ -159,10 +177,10 @@
placeholder="先承接业主情绪,再说明处理动作和反馈时间。"
/>
<view class="reply-actions">
<button class="secondary-button" :class="{ 'recording-active': recording }" :disabled="busy || transcribing" @click="toggleRecording">
<button class="secondary-button" :class="{ 'recording-active': recording }" :disabled="busy || transcribing" @click="() => toggleRecording()">
{{ recording ? '停止录音' : '录音转写' }}
</button>
<button class="ghost-button" :disabled="busy || recording || transcribing" @click="pickAudio">
<button class="ghost-button" :disabled="busy || recording || transcribing" @click="() => pickAudio()">
{{ transcribing ? '转写中' : '选音频' }}
</button>
<button class="secondary-button" :disabled="busy || helpLoading" @click="loadHelp">
@@ -289,12 +307,15 @@ const redFlagActive = ref(false);
const assignments = ref<PracticeAssignment[]>([]);
const practiceHistory = ref<PracticeRecord[]>([]);
const drillAnswers = ref<Record<number, string>>({});
const dailyAudioUrls = ref<Record<number, string>>({});
const dailyAudioOssIds = ref<Record<number, number | undefined>>({});
const assignmentLoading = ref(false);
const answeringId = ref<number | null>(null);
const activeAssignmentId = ref<number | undefined>();
const highlightedAssignmentId = ref<number | null>(null);
const loggedIn = ref(false);
const recording = ref(false);
const recordingAssignmentId = ref<number | null>(null);
const transcribing = ref(false);
const draftAudioUrl = ref('');
const draftAudioOssId = ref<number | undefined>();
@@ -359,6 +380,8 @@ const clearPracticeData = () => {
assignments.value = [];
practiceHistory.value = [];
drillAnswers.value = {};
dailyAudioUrls.value = {};
dailyAudioOssIds.value = {};
prepCard.value = null;
prepLoading.value = false;
turns.value = [];
@@ -383,6 +406,7 @@ const clearPracticeData = () => {
trustScore.value = 0;
redFlagActive.value = false;
answeringId.value = null;
recordingAssignmentId.value = null;
activeAssignmentId.value = undefined;
highlightedAssignmentId.value = null;
transcribing.value = false;
@@ -422,6 +446,7 @@ const cancelRecording = () => {
mediaRecorder.value = null;
stopMediaTracks();
recording.value = false;
recordingAssignmentId.value = null;
};
const applyAsrResult = (result: { text?: string; audioUrl?: string; ossId?: number | string }) => {
@@ -435,12 +460,37 @@ const applyAsrResult = (result: { text?: string; audioUrl?: string; ossId?: numb
message.value = '语音已转成文字,请确认后提交';
};
const transcribeBlob = async (blob: Blob) => {
const applyDailyAsrResult = (assignmentId: number, result: { text?: string; audioUrl?: string; ossId?: number | string }) => {
const text = (result.text || '').trim();
if (!text) throw new Error('语音没有识别到文字');
const current = (drillAnswers.value[assignmentId] || '').trim();
drillAnswers.value = {
...drillAnswers.value,
[assignmentId]: current ? `${current}\n${text}` : text
};
dailyAudioUrls.value = {
...dailyAudioUrls.value,
[assignmentId]: result.audioUrl || ''
};
const ossId = Number(result.ossId);
dailyAudioOssIds.value = {
...dailyAudioOssIds.value,
[assignmentId]: Number.isFinite(ossId) ? ossId : undefined
};
message.value = '每日一练语音已转成文字,请确认后提交';
};
const transcribeBlob = async (blob: Blob, assignmentId?: number) => {
if (transcribing.value) return;
transcribing.value = true;
message.value = '语音转写中';
try {
applyAsrResult(await transcribeSpeechBlob(blob));
const result = await transcribeSpeechBlob(blob);
if (assignmentId === undefined) {
applyAsrResult(result);
} else {
applyDailyAsrResult(assignmentId, result);
}
} catch (error) {
handleError(error, '语音转写失败');
} finally {
@@ -448,14 +498,19 @@ const transcribeBlob = async (blob: Blob) => {
}
};
const pickAudio = async () => {
const pickAudio = async (assignmentId?: number) => {
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));
const result = await transcribeSpeechFile(file);
if (assignmentId === undefined) {
applyAsrResult(result);
} else {
applyDailyAsrResult(assignmentId, result);
}
} catch (error) {
handleError(error, '语音转写失败');
} finally {
@@ -463,11 +518,11 @@ const pickAudio = async () => {
}
};
const startRecording = async () => {
const startRecording = async (assignmentId?: number) => {
if (busy.value || transcribing.value) return;
if (!requireLogin()) return;
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
await pickAudio();
await pickAudio(assignmentId);
return;
}
try {
@@ -476,6 +531,7 @@ const startRecording = async () => {
const chunks: Blob[] = [];
mediaStream.value = stream;
mediaRecorder.value = recorder;
recordingAssignmentId.value = assignmentId ?? null;
recorder.ondataavailable = (event) => {
if (event.data?.size) chunks.push(event.data);
};
@@ -488,13 +544,16 @@ const startRecording = async () => {
message.value = '未录到音频,请改用文字输入';
return;
}
void transcribeBlob(blob);
const targetAssignmentId = assignmentId;
recordingAssignmentId.value = null;
void transcribeBlob(blob, targetAssignmentId);
};
recorder.start();
recording.value = true;
message.value = '正在录音';
} catch (error) {
await pickAudio();
recordingAssignmentId.value = null;
await pickAudio(assignmentId);
}
};
@@ -505,12 +564,12 @@ const stopRecording = () => {
recorder.stop();
};
const toggleRecording = () => {
const toggleRecording = (assignmentId?: number) => {
if (recording.value) {
stopRecording();
return;
}
void startRecording();
void startRecording(assignmentId);
};
const handleError = (error: unknown, fallback: string) => {
@@ -543,6 +602,14 @@ const loadAssignments = async () => {
practiceHistory.value = [];
}
drillAnswers.value = Object.fromEntries(assignments.value.map((item) => [item.id, item.userAnswer || '']));
dailyAudioUrls.value = Object.fromEntries(
assignments.value.filter((item) => item.audioUrl).map((item) => [item.id, item.audioUrl || ''])
);
dailyAudioOssIds.value = Object.fromEntries(
assignments.value
.filter((item) => item.audioOssId !== undefined && item.audioOssId !== null)
.map((item) => [item.id, Number(item.audioOssId)])
);
consumePendingAssignment();
} catch (error) {
handleError(error, '今日任务加载失败');
@@ -581,17 +648,28 @@ const consumePendingAssignment = () => {
const submitDaily = async (item: PracticeAssignment) => {
if (answeringId.value) return;
const answer = (drillAnswers.value[item.id] || '').trim();
if (!answer) {
message.value = '请先填写处理话术';
const audioUrl = dailyAudioUrls.value[item.id] || undefined;
const audioOssId = dailyAudioOssIds.value[item.id];
if (!answer && !audioUrl) {
message.value = '请先输入或录制处理话术';
return;
}
answeringId.value = item.id;
message.value = '';
try {
const result = await answerDailyDrill(item.id, answer);
const result = await answerDailyDrill(item.id, answer, audioUrl, audioOssId);
assignments.value = assignments.value.map((row) =>
row.id === item.id
? { ...row, status: result.status, userAnswer: answer, feedback: result.feedback, referenceAnswer: result.referenceAnswer, correct: result.correct }
? {
...row,
status: result.status,
userAnswer: answer,
feedback: result.feedback,
referenceAnswer: result.referenceAnswer,
correct: result.correct,
audioUrl: result.audioUrl || audioUrl,
audioOssId: result.audioOssId || audioOssId
}
: row
);
message.value = `每日三题得分 ${result.score}`;
@@ -1041,6 +1119,19 @@ onShow(() => {
align-items: flex-end;
}
.daily-voice-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin-top: 8px;
}
.daily-voice-actions button {
min-width: 0;
padding: 0 8px;
white-space: nowrap;
}
.daily-textarea {
min-height: 82px;
}
+2 -2
View File
@@ -174,11 +174,11 @@ export const createPracticeAssignment = (data: PracticeAssignmentRequest) =>
timeout: 15000
});
export const answerDailyDrill = (id: number, answer: string) =>
export const answerDailyDrill = (id: number, answer: string, audioUrl?: string, audioOssId?: number) =>
apiRequest<DailyDrillAnswerResponse>({
url: `/api/aihr/mobile/practice/assignments/${id}/answer`,
method: 'POST',
data: { answer },
data: { answer, audioUrl, audioOssId },
timeout: 15000
});
+4
View File
@@ -296,6 +296,8 @@ export interface PracticeAssignment {
userAnswer?: string;
feedback?: string;
correct?: boolean | null;
audioUrl?: string;
audioOssId?: number | string;
}
export interface PracticeAssignmentRequest {
@@ -314,6 +316,8 @@ export interface DailyDrillAnswerResponse {
score: number;
feedback: string;
referenceAnswer: string;
audioUrl?: string;
audioOssId?: number | string;
}
export interface PracticeAlert {