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">
|
<view v-if="detail.dialogue?.length" class="subsection">
|
||||||
<text class="subsection-title">对话片段</text>
|
<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-label">{{ turn.label }}</text>
|
||||||
<text class="dialogue-text">{{ turn.text }}</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>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -235,12 +246,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, ref } from 'vue';
|
import { computed, nextTick, onBeforeUnmount, ref } from 'vue';
|
||||||
import { onShow } from '@dcloudio/uni-app';
|
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 { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||||
import { resetPageScroll } from '@/services/navigation';
|
import { resetPageScroll } from '@/services/navigation';
|
||||||
import { apiUrl } from '@/services/api';
|
import { authenticatedFileUrl, releaseAuthenticatedFileUrl } from '@/services/api';
|
||||||
import {
|
import {
|
||||||
createPracticeAssignment,
|
createPracticeAssignment,
|
||||||
getPracticeAlerts,
|
getPracticeAlerts,
|
||||||
@@ -269,14 +280,43 @@ const loading = ref(false);
|
|||||||
const marking = ref(false);
|
const marking = ref(false);
|
||||||
const assigning = ref(false);
|
const assigning = ref(false);
|
||||||
const message = ref('');
|
const message = ref('');
|
||||||
|
const audioSources = ref<Record<string, string>>({});
|
||||||
|
const audioLoading = ref<Record<string, boolean>>({});
|
||||||
|
const audioErrors = ref<Record<string, string>>({});
|
||||||
const scenarioOptions = [
|
const scenarioOptions = [
|
||||||
{ id: 'fee-parking', name: '催费沟通' },
|
{ id: 'fee-parking', name: '催费沟通' },
|
||||||
{ id: 'complaint-water', name: '投诉接待' },
|
{ id: 'complaint-water', name: '投诉接待' },
|
||||||
{ id: 'repair-elevator-delay', name: '电梯故障' }
|
{ id: 'repair-elevator-delay', name: '电梯故障' }
|
||||||
];
|
];
|
||||||
|
|
||||||
const audioSrc = (turn: { audioOssId?: number | string; audioUrl?: string }) =>
|
const audioKey = (index: number) => `${detail.value?.id || 'review'}-${index}`;
|
||||||
turn.audioOssId ? apiUrl(`/api/aihr/mobile/oss/${turn.audioOssId}`) : turn.audioUrl || '';
|
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) => {
|
const scenarioIdForText = (text: string) => {
|
||||||
if (/电梯/.test(text)) return 'repair-elevator-delay';
|
if (/电梯/.test(text)) return 'repair-elevator-delay';
|
||||||
@@ -317,6 +357,7 @@ const resetAssignmentDraft = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const clearSupervisorData = () => {
|
const clearSupervisorData = () => {
|
||||||
|
releaseAudioSources();
|
||||||
reviews.value = [];
|
reviews.value = [];
|
||||||
assignments.value = [];
|
assignments.value = [];
|
||||||
alerts.value = [];
|
alerts.value = [];
|
||||||
@@ -497,6 +538,7 @@ const loadPage = async () => {
|
|||||||
getPracticeAlerts()
|
getPracticeAlerts()
|
||||||
]);
|
]);
|
||||||
if (detail.value && !reviews.value.some((item) => item.id === detail.value?.id)) {
|
if (detail.value && !reviews.value.some((item) => item.id === detail.value?.id)) {
|
||||||
|
releaseAudioSources();
|
||||||
detail.value = null;
|
detail.value = null;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -564,6 +606,7 @@ const openReview = async (item: PracticeRecord) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
message.value = '';
|
message.value = '';
|
||||||
|
releaseAudioSources();
|
||||||
try {
|
try {
|
||||||
detail.value = await getPracticeReviewDetail(item.id);
|
detail.value = await getPracticeReviewDetail(item.id);
|
||||||
advice.value = '';
|
advice.value = '';
|
||||||
@@ -603,6 +646,7 @@ const markReviewed = async () => {
|
|||||||
nextReason: nextScenarioId.value ? nextReason.value.trim() : undefined
|
nextReason: nextScenarioId.value ? nextReason.value.trim() : undefined
|
||||||
});
|
});
|
||||||
uni.showToast({ title: assignedNext ? '已复盘并派专项' : '已复盘', icon: 'none' });
|
uni.showToast({ title: assignedNext ? '已复盘并派专项' : '已复盘', icon: 'none' });
|
||||||
|
releaseAudioSources();
|
||||||
detail.value = null;
|
detail.value = null;
|
||||||
advice.value = '';
|
advice.value = '';
|
||||||
incentivePoint.value = '';
|
incentivePoint.value = '';
|
||||||
@@ -625,6 +669,8 @@ onShow(() => {
|
|||||||
resetPageScroll();
|
resetPageScroll();
|
||||||
void loadPage();
|
void loadPage();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(releaseAudioSources);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -1039,9 +1085,24 @@ onShow(() => {
|
|||||||
.dialogue-audio {
|
.dialogue-audio {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialogue-audio-block {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.audio-button {
|
||||||
|
width: 104px;
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-error {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6px;
|
||||||
|
color: #dc2626;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.score-value {
|
.score-value {
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
|
|||||||
@@ -102,3 +102,47 @@ export const apiRequest = async <T>(options: RequestOptions): Promise<T> => {
|
|||||||
|
|
||||||
return readPayload<T>(response.statusCode, response.data);
|
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