fix(mobile): load supervisor review audio with auth

This commit is contained in:
2026-07-09 16:19:11 +08:00
parent f1a975b5f6
commit 7de6fa9969
2 changed files with 112 additions and 7 deletions
@@ -170,10 +170,21 @@
<view v-if="detail.dialogue?.length" class="subsection">
<text class="subsection-title">对话片段</text>
<view v-for="turn in detail.dialogue" :key="turn.role + turn.text" class="dialogue-row">
<view v-for="(turn, index) in detail.dialogue" :key="turn.role + turn.text + index" class="dialogue-row">
<text class="dialogue-label">{{ turn.label }}</text>
<text class="dialogue-text">{{ turn.text }}</text>
<audio v-if="audioSrc(turn)" class="dialogue-audio" controls :src="audioSrc(turn)"></audio>
<view v-if="hasAudio(turn)" class="dialogue-audio-block">
<audio v-if="audioSrc(turn, index)" class="dialogue-audio" controls :src="audioSrc(turn, index)"></audio>
<button
v-else
class="secondary-button audio-button"
:disabled="audioLoading[audioKey(index)]"
@click="loadAudio(turn, index)"
>
{{ audioLoading[audioKey(index)] ? '加载中' : '加载录音' }}
</button>
<text v-if="audioErrors[audioKey(index)]" class="audio-error">{{ audioErrors[audioKey(index)] }}</text>
</view>
</view>
</view>
@@ -235,12 +246,12 @@
</template>
<script setup lang="ts">
import { computed, nextTick, ref } from 'vue';
import { computed, nextTick, onBeforeUnmount, ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import type { PracticeAlert, PracticeAssignment, PracticeRecord, ReviewDetail } from '@/types/api';
import type { PracticeAlert, PracticeAssignment, PracticeRecord, ReviewDetail, ReviewDialogue } from '@/types/api';
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { resetPageScroll } from '@/services/navigation';
import { apiUrl } from '@/services/api';
import { authenticatedFileUrl, releaseAuthenticatedFileUrl } from '@/services/api';
import {
createPracticeAssignment,
getPracticeAlerts,
@@ -269,14 +280,43 @@ const loading = ref(false);
const marking = ref(false);
const assigning = ref(false);
const message = ref('');
const audioSources = ref<Record<string, string>>({});
const audioLoading = ref<Record<string, boolean>>({});
const audioErrors = ref<Record<string, string>>({});
const scenarioOptions = [
{ id: 'fee-parking', name: '催费沟通' },
{ id: 'complaint-water', name: '投诉接待' },
{ id: 'repair-elevator-delay', name: '电梯故障' }
];
const audioSrc = (turn: { audioOssId?: number | string; audioUrl?: string }) =>
turn.audioOssId ? apiUrl(`/api/aihr/mobile/oss/${turn.audioOssId}`) : turn.audioUrl || '';
const audioKey = (index: number) => `${detail.value?.id || 'review'}-${index}`;
const hasAudio = (turn: ReviewDialogue) => Boolean(turn.audioOssId || turn.audioUrl);
const audioSrc = (turn: ReviewDialogue, index: number) =>
audioSources.value[audioKey(index)] || (!turn.audioOssId ? turn.audioUrl || '' : '');
const releaseAudioSources = () => {
Object.values(audioSources.value).forEach(releaseAuthenticatedFileUrl);
audioSources.value = {};
audioLoading.value = {};
audioErrors.value = {};
};
const loadAudio = async (turn: ReviewDialogue, index: number) => {
const key = audioKey(index);
if (audioSrc(turn, index) || !turn.audioOssId || audioLoading.value[key]) return;
audioLoading.value = { ...audioLoading.value, [key]: true };
audioErrors.value = { ...audioErrors.value, [key]: '' };
try {
audioSources.value = {
...audioSources.value,
[key]: await authenticatedFileUrl(`/api/aihr/mobile/oss/${turn.audioOssId}`)
};
} catch {
audioErrors.value = { ...audioErrors.value, [key]: '录音加载失败' };
} finally {
audioLoading.value = { ...audioLoading.value, [key]: false };
}
};
const scenarioIdForText = (text: string) => {
if (/电梯/.test(text)) return 'repair-elevator-delay';
@@ -317,6 +357,7 @@ const resetAssignmentDraft = () => {
};
const clearSupervisorData = () => {
releaseAudioSources();
reviews.value = [];
assignments.value = [];
alerts.value = [];
@@ -497,6 +538,7 @@ const loadPage = async () => {
getPracticeAlerts()
]);
if (detail.value && !reviews.value.some((item) => item.id === detail.value?.id)) {
releaseAudioSources();
detail.value = null;
}
} catch (error) {
@@ -564,6 +606,7 @@ const openReview = async (item: PracticeRecord) => {
return;
}
message.value = '';
releaseAudioSources();
try {
detail.value = await getPracticeReviewDetail(item.id);
advice.value = '';
@@ -603,6 +646,7 @@ const markReviewed = async () => {
nextReason: nextScenarioId.value ? nextReason.value.trim() : undefined
});
uni.showToast({ title: assignedNext ? '已复盘并派专项' : '已复盘', icon: 'none' });
releaseAudioSources();
detail.value = null;
advice.value = '';
incentivePoint.value = '';
@@ -625,6 +669,8 @@ onShow(() => {
resetPageScroll();
void loadPage();
});
onBeforeUnmount(releaseAudioSources);
</script>
<style scoped>
@@ -1039,9 +1085,24 @@ onShow(() => {
.dialogue-audio {
display: block;
width: 100%;
}
.dialogue-audio-block {
margin-top: 10px;
}
.audio-button {
width: 104px;
min-height: 36px;
}
.audio-error {
display: block;
margin-top: 6px;
color: #dc2626;
font-size: 12px;
}
.score-value {
color: #0f172a;
font-size: 20px;
+44
View File
@@ -102,3 +102,47 @@ export const apiRequest = async <T>(options: RequestOptions): Promise<T> => {
return readPayload<T>(response.statusCode, response.data);
};
export const authenticatedFileUrl = async (url: string): Promise<string> => {
const fullUrl = normalizeUrl(url);
if (typeof XMLHttpRequest !== 'undefined' && typeof URL !== 'undefined') {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', fullUrl);
xhr.responseType = 'blob';
Object.entries(authHeaders(false)).forEach(([key, value]) => xhr.setRequestHeader(key, value));
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(URL.createObjectURL(xhr.response));
return;
}
if (xhr.status === 401 || xhr.status === 403) clearAuth();
reject(new Error(`HTTP ${xhr.status}`));
};
xhr.onerror = () => reject(new Error('网络请求失败'));
xhr.send();
});
}
return new Promise((resolve, reject) => {
uni.downloadFile({
url: fullUrl,
header: authHeaders(false),
success: (response) => {
if (response.statusCode >= 200 && response.statusCode < 300) {
resolve(response.tempFilePath);
return;
}
if (response.statusCode === 401 || response.statusCode === 403) clearAuth();
reject(new Error(`HTTP ${response.statusCode}`));
},
fail: () => reject(new Error('网络请求失败'))
});
});
};
export const releaseAuthenticatedFileUrl = (url: string) => {
if (url.startsWith('blob:') && typeof URL !== 'undefined') {
URL.revokeObjectURL(url);
}
};