fix(frontend): play review audio in calibration page

This commit is contained in:
2026-07-09 16:13:29 +08:00
parent bba1e00425
commit f1a975b5f6
2 changed files with 81 additions and 4 deletions
+13 -2
View File
@@ -1,4 +1,5 @@
import request from '@/utils/request'; import axios from 'axios';
import request, { globalHeaders } from '@/utils/request';
type ApiResult<T> = { type ApiResult<T> = {
code: number; code: number;
@@ -24,7 +25,7 @@ export type ReviewDetail = ReviewRecord & {
reviewAdvice: string; reviewAdvice: string;
incentivePoint: number; incentivePoint: number;
scoreItems: Array<{ label: string; value: number | string; note: string }>; scoreItems: Array<{ label: string; value: number | string; note: string }>;
dialogue: Array<{ role: string; label: string; text: string; audioUrl?: string }>; dialogue: Array<{ role: string; label: string; text: string; audioUrl?: string; audioOssId?: number | string }>;
annotations: Array<{ turnIndex: number; sourceText: string; issue: string; rewrite: string; sopPoint: string }>; annotations: Array<{ turnIndex: number; sourceText: string; issue: string; rewrite: string; sopPoint: string }>;
}; };
@@ -175,6 +176,16 @@ export function getPracticeReview(id: number): Promise<ApiResult<ReviewDetail>>
}); });
} }
export async function getPracticeAudioBlobUrl(ossId: number | string): Promise<string> {
const response = await axios({
method: 'get',
url: `${import.meta.env.VITE_APP_BASE_API}/api/aihr/mobile/oss/${ossId}`,
responseType: 'blob',
headers: globalHeaders()
});
return URL.createObjectURL(response.data);
}
export function markPracticeReviewed(id: number, data: ReviewAdviceRequest): Promise<ApiResult<null>> { export function markPracticeReviewed(id: number, data: ReviewAdviceRequest): Promise<ApiResult<null>> {
return request({ return request({
url: `/api/aihr/mobile/practice/reviews/${id}/reviewed`, url: `/api/aihr/mobile/practice/reviews/${id}/reviewed`,
+67 -1
View File
@@ -59,8 +59,18 @@
<div class="dialogue-list"> <div class="dialogue-list">
<div v-for="(turn, index) in detail.dialogue" :key="`${turn.role}-${index}`" class="dialogue-item"> <div v-for="(turn, index) in detail.dialogue" :key="`${turn.role}-${index}`" class="dialogue-item">
<div class="dialogue-head">
<strong>{{ turn.label }}</strong> <strong>{{ turn.label }}</strong>
<el-tag v-if="hasAudio(turn)" size="small" type="info">有录音</el-tag>
</div>
<p>{{ turn.text }}</p> <p>{{ turn.text }}</p>
<div v-if="hasAudio(turn)" class="dialogue-audio-row">
<audio v-if="audioSource(turn, index)" controls :src="audioSource(turn, index)" />
<el-button v-else size="small" :loading="audioLoading[audioKey(index)]" @click.stop="loadAudio(turn, index)">
加载录音
</el-button>
<span v-if="audioErrors[audioKey(index)]" class="audio-error">{{ audioErrors[audioKey(index)] }}</span>
</div>
</div> </div>
</div> </div>
@@ -105,9 +115,10 @@
<script setup name="TrainReviews" lang="ts"> <script setup name="TrainReviews" lang="ts">
import { Refresh } from '@element-plus/icons-vue'; import { Refresh } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus'; import { ElMessage } from 'element-plus';
import { computed, onMounted, reactive, ref, watch } from 'vue'; import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { listPracticeScenarios, type PracticeScenarioResponse } from '@/api/aihr/practice'; import { listPracticeScenarios, type PracticeScenarioResponse } from '@/api/aihr/practice';
import { import {
getPracticeAudioBlobUrl,
getOrgSnapshot, getOrgSnapshot,
getPracticeReview, getPracticeReview,
listPracticeReviews, listPracticeReviews,
@@ -125,6 +136,9 @@ const scenarios = ref<PracticeScenarioResponse[]>([]);
const orgSnapshot = ref<OrgSnapshotResponse | null>(null); const orgSnapshot = ref<OrgSnapshotResponse | null>(null);
const selectedProjectCode = ref(''); const selectedProjectCode = ref('');
const selectedExtPartyId = ref(''); const selectedExtPartyId = ref('');
const audioSources = ref<Record<string, string>>({});
const audioLoading = ref<Record<string, boolean>>({});
const audioErrors = ref<Record<string, string>>({});
const reviewForm = reactive({ const reviewForm = reactive({
reviewer: '主管', reviewer: '主管',
@@ -136,6 +150,33 @@ const reviewForm = reactive({
const orgPeople = computed(() => orgSnapshot.value?.rows || []); const orgPeople = computed(() => orgSnapshot.value?.rows || []);
const visibleReviews = computed(() => reviews.value); const visibleReviews = computed(() => reviews.value);
type ReviewDialogueTurn = ReviewDetail['dialogue'][number];
const audioKey = (index: number) => `${detail.value?.id || 'review'}-${index}`;
const hasAudio = (turn: ReviewDialogueTurn) => Boolean(turn.audioUrl || turn.audioOssId);
const audioSource = (turn: ReviewDialogueTurn, index: number) => audioSources.value[audioKey(index)] || (!turn.audioOssId ? turn.audioUrl || '' : '');
const releaseAudioSources = () => {
Object.values(audioSources.value).forEach((url) => URL.revokeObjectURL(url));
audioSources.value = {};
audioLoading.value = {};
audioErrors.value = {};
};
const loadAudio = async (turn: ReviewDialogueTurn, index: number) => {
const key = audioKey(index);
if (audioSource(turn, index) || !turn.audioOssId || audioLoading.value[key]) return;
audioLoading.value = { ...audioLoading.value, [key]: true };
audioErrors.value = { ...audioErrors.value, [key]: '' };
try {
const url = await getPracticeAudioBlobUrl(turn.audioOssId);
audioSources.value = { ...audioSources.value, [key]: url };
} catch {
audioErrors.value = { ...audioErrors.value, [key]: '录音加载失败' };
} finally {
audioLoading.value = { ...audioLoading.value, [key]: false };
}
};
const loadScenarios = async () => { const loadScenarios = async () => {
try { try {
@@ -178,6 +219,7 @@ const loadReviews = async () => {
}; };
const selectReview = async (row: ReviewRecord) => { const selectReview = async (row: ReviewRecord) => {
releaseAudioSources();
const result = await getPracticeReview(row.id); const result = await getPracticeReview(row.id);
detail.value = result.data; detail.value = result.data;
reviewForm.advice = result.data.reviewAdvice || ''; reviewForm.advice = result.data.reviewAdvice || '';
@@ -190,6 +232,7 @@ const selectFirstVisibleReview = async () => {
if (visibleReviews.value.some((item) => item.id === detail.value?.id)) return; if (visibleReviews.value.some((item) => item.id === detail.value?.id)) return;
const first = visibleReviews.value[0]; const first = visibleReviews.value[0];
if (!first) { if (!first) {
releaseAudioSources();
detail.value = null; detail.value = null;
return; return;
} }
@@ -208,6 +251,7 @@ const submitReview = async () => {
nextReason: reviewForm.nextReason nextReason: reviewForm.nextReason
}); });
ElMessage.success('已完成复盘处理'); ElMessage.success('已完成复盘处理');
releaseAudioSources();
detail.value = null; detail.value = null;
await loadReviews(); await loadReviews();
} catch (error) { } catch (error) {
@@ -232,6 +276,8 @@ onMounted(() => {
loadOrgSnapshot(); loadOrgSnapshot();
loadReviews(); loadReviews();
}); });
onBeforeUnmount(releaseAudioSources);
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@@ -338,6 +384,26 @@ onMounted(() => {
} }
} }
.dialogue-head,
.dialogue-audio-row {
display: flex;
align-items: center;
gap: 10px;
}
.dialogue-audio-row {
margin-top: 10px;
audio {
width: min(360px, 100%);
}
}
.audio-error {
color: #d92d20;
font-size: 12px;
}
.annotation-list { .annotation-list {
margin-top: 18px; margin-top: 18px;