fix(mobile): load supervisor review audio with auth
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user