feat(aihr): add voice answers to daily drills
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user