feat: restore learning feedback and knowledge capabilities
This commit is contained in:
@@ -84,6 +84,13 @@
|
||||
<view class="assignment-copy">
|
||||
<text class="assignment-title">{{ item.question || item.scenarioName }}</text>
|
||||
<text v-if="assignmentDesc(item)" class="assignment-desc">{{ assignmentDesc(item) }}</text>
|
||||
<button
|
||||
class="turn-voice-button daily-question-voice"
|
||||
:disabled="speechState.status === 'loading' && speechState.key !== `daily-${item.id}`"
|
||||
@click.stop="speakGenerated(`daily-${item.id}`, item.question || item.scenarioName)"
|
||||
>
|
||||
{{ speechButtonLabel(`daily-${item.id}`) }}
|
||||
</button>
|
||||
</view>
|
||||
<view class="daily-answer">
|
||||
<textarea
|
||||
@@ -111,6 +118,25 @@
|
||||
</button>
|
||||
</view>
|
||||
<text v-if="dailyAudioUrls[item.id]" class="voice-state">已附带语音回放</text>
|
||||
<view v-if="item.status === '已完成'" class="daily-feedback-card">
|
||||
<view class="daily-feedback-head">
|
||||
<text class="daily-feedback-title">点评与参考答案</text>
|
||||
<button
|
||||
class="turn-voice-button"
|
||||
:disabled="speechState.status === 'loading' && speechState.key !== `daily-result-${item.id}`"
|
||||
@click.stop="speakGenerated(`daily-result-${item.id}`, dailyFeedbackSpeech(item))"
|
||||
>
|
||||
{{ speechButtonLabel(`daily-result-${item.id}`) }}
|
||||
</button>
|
||||
</view>
|
||||
<text v-if="item.feedback" class="daily-feedback-copy">点评:{{ item.feedback }}</text>
|
||||
<text v-if="item.referenceAnswer" class="daily-reference-copy">参考答案:{{ item.referenceAnswer }}</text>
|
||||
<view class="item-feedback-actions">
|
||||
<text>这道题对工作有帮助吗?</text>
|
||||
<button :class="{ active: item.itemFeedback === 'useful' }" :disabled="feedbackSubmittingId === item.id" @click.stop="submitItemFeedback(item.id, 'useful')">有用</button>
|
||||
<button :class="{ active: item.itemFeedback === 'not_useful' }" :disabled="feedbackSubmittingId === item.id" @click.stop="submitItemFeedback(item.id, 'not_useful')">无用</button>
|
||||
</view>
|
||||
</view>
|
||||
<button class="primary-button small-button" :disabled="Boolean(answeringId) || item.status === '已完成'" @click="submitDaily(item)">
|
||||
{{ item.status === '已完成' ? '已完成' : '提交' }}
|
||||
</button>
|
||||
@@ -154,7 +180,17 @@
|
||||
</view>
|
||||
<view class="turn-list">
|
||||
<view v-for="(turn, index) in turns" :key="index" class="turn-row" :class="turn.role">
|
||||
<text class="turn-role">{{ roleLabel(turn.role) }}</text>
|
||||
<view class="turn-heading">
|
||||
<text class="turn-role">{{ roleLabel(turn.role) }}</text>
|
||||
<button
|
||||
v-if="turn.role !== 'trainee'"
|
||||
class="turn-voice-button"
|
||||
:disabled="speechState.status === 'loading' && speechState.key !== `turn-${index}`"
|
||||
@click="speakGenerated(`turn-${index}`, turn.text)"
|
||||
>
|
||||
{{ speechButtonLabel(`turn-${index}`) }}
|
||||
</button>
|
||||
</view>
|
||||
<text class="turn-text">{{ turn.text }}</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -206,13 +242,27 @@
|
||||
</view>
|
||||
|
||||
<view v-if="result" class="panel result-card employee-card">
|
||||
<text class="result-label">本次评分</text>
|
||||
<view class="result-title-row">
|
||||
<text class="result-label">本次评分</text>
|
||||
<button
|
||||
class="turn-voice-button result-voice-button"
|
||||
:disabled="speechState.status === 'loading' && speechState.key !== 'practice-result'"
|
||||
@click="speakGenerated('practice-result', practiceResultSpeech())"
|
||||
>
|
||||
{{ speechButtonLabel('practice-result') }}
|
||||
</button>
|
||||
</view>
|
||||
<text class="result-score">{{ result.totalScore }}</text>
|
||||
<text class="result-comment">{{ result.aiComment }}</text>
|
||||
<view v-if="result.mentorRewrite" class="mentor-rewrite">
|
||||
<text>老师傅建议</text>
|
||||
<text>{{ result.mentorRewrite }}</text>
|
||||
</view>
|
||||
<view v-if="completedAssignmentId" class="item-feedback-actions result-feedback-actions">
|
||||
<text>这个专项对工作有帮助吗?</text>
|
||||
<button :class="{ active: completedAssignmentFeedback === 'useful' }" :disabled="feedbackSubmittingId === completedAssignmentId" @click="submitItemFeedback(completedAssignmentId, 'useful')">有用</button>
|
||||
<button :class="{ active: completedAssignmentFeedback === 'not_useful' }" :disabled="feedbackSubmittingId === completedAssignmentId" @click="submitItemFeedback(completedAssignmentId, 'not_useful')">无用</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="result" class="panel satisfaction-card employee-card">
|
||||
@@ -258,21 +308,23 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
|
||||
import type {
|
||||
PracticeFinishResponse,
|
||||
PracticePrepCard,
|
||||
PracticeAssignment,
|
||||
PracticeRole,
|
||||
SpeechPlaybackStatus,
|
||||
PracticeTurn,
|
||||
PracticeRecord
|
||||
} from '@/types/api';
|
||||
import { answerDailyDrill, finishPractice, getPracticeAssignments, getPracticeHistory, getPracticePrepCard, startPractice, submitPracticeSatisfaction, submitPracticeTurn } from '@/services/practice';
|
||||
import { answerDailyDrill, finishPractice, getPracticeAssignments, getPracticeHistory, getPracticePrepCard, startPractice, submitPracticeQuestionFeedback, submitPracticeSatisfaction, submitPracticeTurn } from '@/services/practice';
|
||||
import { getSelectedPosition, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
import { searchKnowledge } from '@/services/knowledge';
|
||||
import { resetPageScroll } from '@/services/navigation';
|
||||
import { ensureEmployeePosition } from '@/services/position';
|
||||
import { chooseSpeechAudio, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech';
|
||||
import { chooseSpeechAudio, createSpeechPlaybackController, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech';
|
||||
import { createPageRequestScope, currentAccountKey, type RequestScopeTicket } from '@/services/request-scope';
|
||||
|
||||
type PracticeStatus = 'idle' | 'starting' | 'active' | 'submitting' | 'finished';
|
||||
type RecentPracticeItem = {
|
||||
@@ -322,15 +374,32 @@ const dailyAudioOssIds = ref<Record<number, number | undefined>>({});
|
||||
const assignmentLoading = ref(false);
|
||||
const answeringId = ref<number | null>(null);
|
||||
const activeAssignmentId = ref<number | undefined>();
|
||||
const completedAssignmentId = ref<number | undefined>();
|
||||
const completedAssignmentFeedback = ref<'useful' | 'not_useful' | null>(null);
|
||||
const feedbackSubmittingId = ref<number | null>(null);
|
||||
const highlightedAssignmentId = ref<number | null>(null);
|
||||
const loggedIn = ref(false);
|
||||
const recording = ref(false);
|
||||
const recordingStarting = ref(false);
|
||||
const recordingAssignmentId = ref<number | null>(null);
|
||||
const transcribing = ref(false);
|
||||
const draftAudioUrl = ref('');
|
||||
const draftAudioOssId = ref<number | undefined>();
|
||||
const speechState = ref<{ key: string; status: SpeechPlaybackStatus }>({ key: '', status: 'idle' });
|
||||
const mediaRecorder = ref<MediaRecorder | null>(null);
|
||||
const mediaStream = ref<MediaStream | null>(null);
|
||||
let recordingCaptureGeneration = 0;
|
||||
let transcriptionGeneration = 0;
|
||||
let abortTranscription: (() => void) | null = null;
|
||||
let visibleAccountKey = '';
|
||||
const requests = createPageRequestScope();
|
||||
const speechPlayback = createSpeechPlaybackController(
|
||||
(state) => { speechState.value = state; },
|
||||
(text) => {
|
||||
message.value = text;
|
||||
uni.showToast({ title: text, icon: 'none' });
|
||||
}
|
||||
);
|
||||
const busy = computed(() => status.value === 'starting' || status.value === 'submitting');
|
||||
const practiceView = computed<'prep' | 'active' | 'result'>(() => {
|
||||
if (result.value) return 'result';
|
||||
@@ -385,6 +454,32 @@ const roleLabel = (role: PracticeRole) => {
|
||||
return '我';
|
||||
};
|
||||
|
||||
const speechButtonLabel = (key: string) => {
|
||||
if (speechState.value.key !== key) return '语音播报';
|
||||
if (speechState.value.status === 'loading') return '生成中';
|
||||
if (speechState.value.status === 'playing') return '停止播报';
|
||||
return '语音播报';
|
||||
};
|
||||
|
||||
const speakGenerated = (key: string, text?: string) => {
|
||||
void speechPlayback.toggle(key, text || '');
|
||||
};
|
||||
|
||||
const dailyFeedbackSpeech = (item: PracticeAssignment) => [
|
||||
typeof item.score === 'number' ? `本题得分${item.score}分` : '',
|
||||
item.feedback ? `点评,${item.feedback}` : '',
|
||||
item.referenceAnswer ? `参考答案,${item.referenceAnswer}` : ''
|
||||
].filter(Boolean).join('。');
|
||||
|
||||
const practiceResultSpeech = () => {
|
||||
if (!result.value) return '';
|
||||
return [
|
||||
`本次评分${result.value.totalScore}分`,
|
||||
result.value.aiComment ? `点评,${result.value.aiComment}` : '',
|
||||
result.value.mentorRewrite ? `老师傅建议,${result.value.mentorRewrite}` : ''
|
||||
].filter(Boolean).join('。');
|
||||
};
|
||||
|
||||
const assignmentDesc = (item: PracticeAssignment) => {
|
||||
if (item.source === 'daily' && item.status !== '已完成') return '提交后显示反馈与参考答案';
|
||||
if (item.source === 'daily' && item.status === '已完成') {
|
||||
@@ -411,6 +506,7 @@ const refreshAuthState = () => {
|
||||
};
|
||||
|
||||
const clearPracticeData = () => {
|
||||
speechPlayback.stop();
|
||||
cancelRecording();
|
||||
assignments.value = [];
|
||||
practiceHistory.value = [];
|
||||
@@ -443,6 +539,9 @@ const clearPracticeData = () => {
|
||||
answeringId.value = null;
|
||||
recordingAssignmentId.value = null;
|
||||
activeAssignmentId.value = undefined;
|
||||
completedAssignmentId.value = undefined;
|
||||
completedAssignmentFeedback.value = null;
|
||||
feedbackSubmittingId.value = null;
|
||||
highlightedAssignmentId.value = null;
|
||||
transcribing.value = false;
|
||||
clearDraftAudio();
|
||||
@@ -471,7 +570,17 @@ const stopMediaTracks = () => {
|
||||
mediaStream.value = null;
|
||||
};
|
||||
|
||||
const cancelTranscription = () => {
|
||||
transcriptionGeneration += 1;
|
||||
abortTranscription?.();
|
||||
abortTranscription = null;
|
||||
transcribing.value = false;
|
||||
};
|
||||
|
||||
const cancelRecording = () => {
|
||||
recordingCaptureGeneration += 1;
|
||||
cancelTranscription();
|
||||
recordingStarting.value = false;
|
||||
const recorder = mediaRecorder.value;
|
||||
if (recorder && recorder.state !== 'inactive') {
|
||||
recorder.ondataavailable = null;
|
||||
@@ -517,54 +626,77 @@ const applyDailyAsrResult = (assignmentId: number, result: { text?: string; audi
|
||||
|
||||
const transcribeBlob = async (blob: Blob, assignmentId?: number) => {
|
||||
if (transcribing.value) return;
|
||||
const generation = ++transcriptionGeneration;
|
||||
transcribing.value = true;
|
||||
message.value = '语音转写中';
|
||||
try {
|
||||
const result = await transcribeSpeechBlob(blob);
|
||||
const result = await transcribeSpeechBlob(blob, 'practice-audio.webm', (abort) => {
|
||||
if (generation === transcriptionGeneration) abortTranscription = abort;
|
||||
});
|
||||
if (generation !== transcriptionGeneration) return;
|
||||
if (assignmentId === undefined) {
|
||||
applyAsrResult(result);
|
||||
} else {
|
||||
applyDailyAsrResult(assignmentId, result);
|
||||
}
|
||||
} catch (error) {
|
||||
if (generation !== transcriptionGeneration) return;
|
||||
handleError(error, '语音转写失败');
|
||||
} finally {
|
||||
transcribing.value = false;
|
||||
if (generation === transcriptionGeneration) {
|
||||
abortTranscription = null;
|
||||
transcribing.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const pickAudio = async (assignmentId?: number) => {
|
||||
if (busy.value || recording.value || transcribing.value) return;
|
||||
if (!requireLogin()) return;
|
||||
const generation = ++transcriptionGeneration;
|
||||
transcribing.value = true;
|
||||
message.value = '语音转写中';
|
||||
try {
|
||||
const file = await chooseSpeechAudio();
|
||||
const result = await transcribeSpeechFile(file);
|
||||
if (generation !== transcriptionGeneration) return;
|
||||
const result = await transcribeSpeechFile(file, (abort) => {
|
||||
if (generation === transcriptionGeneration) abortTranscription = abort;
|
||||
});
|
||||
if (generation !== transcriptionGeneration) return;
|
||||
if (assignmentId === undefined) {
|
||||
applyAsrResult(result);
|
||||
} else {
|
||||
applyDailyAsrResult(assignmentId, result);
|
||||
}
|
||||
} catch (error) {
|
||||
if (generation !== transcriptionGeneration) return;
|
||||
handleError(error, '语音转写失败');
|
||||
} finally {
|
||||
transcribing.value = false;
|
||||
if (generation === transcriptionGeneration) {
|
||||
abortTranscription = null;
|
||||
transcribing.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const startRecording = async (assignmentId?: number) => {
|
||||
if (busy.value || transcribing.value) return;
|
||||
if (busy.value || recordingStarting.value || recording.value || transcribing.value) return;
|
||||
if (!requireLogin()) return;
|
||||
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
|
||||
await pickAudio(assignmentId);
|
||||
return;
|
||||
}
|
||||
const generation = ++recordingCaptureGeneration;
|
||||
recordingStarting.value = true;
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
if (generation !== recordingCaptureGeneration) {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
return;
|
||||
}
|
||||
mediaStream.value = stream;
|
||||
const recorder = new MediaRecorder(stream);
|
||||
const chunks: Blob[] = [];
|
||||
mediaStream.value = stream;
|
||||
mediaRecorder.value = recorder;
|
||||
recordingAssignmentId.value = assignmentId ?? null;
|
||||
recorder.ondataavailable = (event) => {
|
||||
@@ -588,7 +720,10 @@ const startRecording = async (assignmentId?: number) => {
|
||||
message.value = '正在录音';
|
||||
} catch (error) {
|
||||
recordingAssignmentId.value = null;
|
||||
await pickAudio(assignmentId);
|
||||
stopMediaTracks();
|
||||
if (generation === recordingCaptureGeneration) await pickAudio(assignmentId);
|
||||
} finally {
|
||||
if (generation === recordingCaptureGeneration) recordingStarting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -600,6 +735,7 @@ const stopRecording = () => {
|
||||
};
|
||||
|
||||
const toggleRecording = (assignmentId?: number) => {
|
||||
if (recordingStarting.value) return;
|
||||
if (recording.value) {
|
||||
stopRecording();
|
||||
return;
|
||||
@@ -622,7 +758,7 @@ const handleError = (error: unknown, fallback: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const loadAssignments = async () => {
|
||||
const loadAssignments = async (request: RequestScopeTicket = requests.begin('assignments')) => {
|
||||
refreshAuthState();
|
||||
if (!loggedIn.value) {
|
||||
clearPracticeData();
|
||||
@@ -630,26 +766,32 @@ const loadAssignments = async () => {
|
||||
}
|
||||
assignmentLoading.value = true;
|
||||
try {
|
||||
assignments.value = await getPracticeAssignments(true);
|
||||
const nextAssignments = await getPracticeAssignments(true);
|
||||
if (!requests.isCurrent(request)) return;
|
||||
let nextHistory: PracticeRecord[] = [];
|
||||
try {
|
||||
practiceHistory.value = await getPracticeHistory();
|
||||
nextHistory = await getPracticeHistory();
|
||||
} catch {
|
||||
practiceHistory.value = [];
|
||||
nextHistory = [];
|
||||
}
|
||||
drillAnswers.value = Object.fromEntries(assignments.value.map((item) => [item.id, item.userAnswer || '']));
|
||||
if (!requests.isCurrent(request)) return;
|
||||
assignments.value = nextAssignments;
|
||||
practiceHistory.value = nextHistory;
|
||||
drillAnswers.value = Object.fromEntries(nextAssignments.map((item) => [item.id, item.userAnswer || '']));
|
||||
dailyAudioUrls.value = Object.fromEntries(
|
||||
assignments.value.filter((item) => item.audioUrl).map((item) => [item.id, item.audioUrl || ''])
|
||||
nextAssignments.filter((item) => item.audioUrl).map((item) => [item.id, item.audioUrl || ''])
|
||||
);
|
||||
dailyAudioOssIds.value = Object.fromEntries(
|
||||
assignments.value
|
||||
nextAssignments
|
||||
.filter((item) => item.audioOssId !== undefined && item.audioOssId !== null)
|
||||
.map((item) => [item.id, Number(item.audioOssId)])
|
||||
);
|
||||
consumePendingAssignment();
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
handleError(error, '今日任务加载失败');
|
||||
} finally {
|
||||
assignmentLoading.value = false;
|
||||
if (requests.isCurrent(request)) assignmentLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -693,10 +835,12 @@ const submitDaily = async (item: PracticeAssignment) => {
|
||||
message.value = '请先输入或录制处理话术';
|
||||
return;
|
||||
}
|
||||
const request = requests.begin(`daily-${item.id}`);
|
||||
answeringId.value = item.id;
|
||||
message.value = '';
|
||||
try {
|
||||
const result = await answerDailyDrill(item.id, answer, audioUrl, audioOssId);
|
||||
if (!requests.isCurrent(request)) return;
|
||||
assignments.value = assignments.value.map((row) =>
|
||||
row.id === item.id
|
||||
? {
|
||||
@@ -715,9 +859,28 @@ const submitDaily = async (item: PracticeAssignment) => {
|
||||
);
|
||||
message.value = `每日三题得分 ${result.score}(${result.scoreMode === 'LLM' ? 'AI语义评估' : '规则兜底'})`;
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
handleError(error, '每日三题提交失败');
|
||||
} finally {
|
||||
answeringId.value = null;
|
||||
if (requests.isCurrent(request)) answeringId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const submitItemFeedback = async (assignmentId: number, verdict: 'useful' | 'not_useful') => {
|
||||
if (feedbackSubmittingId.value) return;
|
||||
const request = requests.begin(`feedback-${assignmentId}`);
|
||||
feedbackSubmittingId.value = assignmentId;
|
||||
try {
|
||||
await submitPracticeQuestionFeedback(assignmentId, verdict);
|
||||
if (!requests.isCurrent(request)) return;
|
||||
assignments.value = assignments.value.map((item) => item.id === assignmentId ? { ...item, itemFeedback: verdict } : item);
|
||||
if (completedAssignmentId.value === assignmentId) completedAssignmentFeedback.value = verdict;
|
||||
message.value = verdict === 'useful' ? '已记录为有用' : '已记录为无用,题目会进入优化队列';
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
handleError(error, '题目反馈提交失败');
|
||||
} finally {
|
||||
if (requests.isCurrent(request)) feedbackSubmittingId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -729,7 +892,10 @@ const startAssignment = (item: PracticeAssignment) => {
|
||||
void start(item.scenarioId || defaultScenarioId, item.id);
|
||||
};
|
||||
|
||||
const loadPrepCard = async (scenarioId = selectedScenarioId.value) => {
|
||||
const loadPrepCard = async (
|
||||
scenarioId = selectedScenarioId.value,
|
||||
request: RequestScopeTicket = requests.begin('prep-card')
|
||||
) => {
|
||||
refreshAuthState();
|
||||
if (!loggedIn.value) {
|
||||
prepCard.value = null;
|
||||
@@ -737,18 +903,24 @@ const loadPrepCard = async (scenarioId = selectedScenarioId.value) => {
|
||||
}
|
||||
prepLoading.value = true;
|
||||
try {
|
||||
prepCard.value = await getPracticePrepCard(scenarioId);
|
||||
const nextPrepCard = await getPracticePrepCard(scenarioId);
|
||||
if (!requests.isCurrent(request)) return;
|
||||
prepCard.value = nextPrepCard;
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
prepCard.value = null;
|
||||
handleError(error, '预习卡加载失败');
|
||||
} finally {
|
||||
prepLoading.value = false;
|
||||
if (requests.isCurrent(request)) prepLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const start = async (scenarioId = defaultScenarioId, assignmentId?: number) => {
|
||||
if (!canStartPractice.value) return;
|
||||
if (!requireLogin()) return;
|
||||
const request = requests.begin('session-start');
|
||||
speechPlayback.stop();
|
||||
cancelRecording();
|
||||
selectedScenarioId.value = scenarioId;
|
||||
status.value = 'starting';
|
||||
message.value = '';
|
||||
@@ -771,6 +943,7 @@ const start = async (scenarioId = defaultScenarioId, assignmentId?: number) => {
|
||||
activeAssignmentId.value = assignmentId;
|
||||
try {
|
||||
const data = await startPractice(scenarioId, assignmentId);
|
||||
if (!requests.isCurrent(request)) return;
|
||||
activeScenarioName.value = data.scenarioName || activeScenarioName.value;
|
||||
emotionScore.value = data.emotion ?? 0;
|
||||
trustScore.value = data.trust ?? 0;
|
||||
@@ -778,6 +951,7 @@ const start = async (scenarioId = defaultScenarioId, assignmentId?: number) => {
|
||||
appendTurn('customer', data.customerText);
|
||||
status.value = 'active';
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
status.value = 'idle';
|
||||
activeAssignmentId.value = undefined;
|
||||
handleError(error, '训练启动失败');
|
||||
@@ -787,19 +961,22 @@ const start = async (scenarioId = defaultScenarioId, assignmentId?: number) => {
|
||||
const loadHelp = async () => {
|
||||
if (helpLoading.value || busy.value) return;
|
||||
if (!requireLogin()) return;
|
||||
const request = requests.begin('help');
|
||||
helpLoading.value = true;
|
||||
message.value = '';
|
||||
try {
|
||||
const data = await searchKnowledge(`${activeScenarioName.value} SOP 处理要点`);
|
||||
if (!requests.isCurrent(request)) return;
|
||||
if (!data.answer) {
|
||||
throw new Error('未检索到可用 SOP 提示');
|
||||
}
|
||||
helpAnswer.value = data.answer;
|
||||
helpRefs.value = (data.snippets || []).map((item) => item.docTitle).filter(Boolean).slice(0, 3);
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
handleError(error, '求助失败');
|
||||
} finally {
|
||||
helpLoading.value = false;
|
||||
if (requests.isCurrent(request)) helpLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -824,6 +1001,7 @@ const discardLastCoachAttempt = () => {
|
||||
const adjustCoachHint = async (style: string) => {
|
||||
if (busy.value || adjustingStyle.value) return;
|
||||
if (!sessionId.value || lastCoachRoundIndex.value === null || !lastTraineeText.value) return;
|
||||
const request = requests.begin('coach-adjust');
|
||||
adjustingStyle.value = style;
|
||||
message.value = '';
|
||||
try {
|
||||
@@ -834,15 +1012,17 @@ const adjustCoachHint = async (style: string) => {
|
||||
regenerate: true,
|
||||
style
|
||||
});
|
||||
if (!requests.isCurrent(request)) return;
|
||||
replaceLastCoachHint(data.coachHint);
|
||||
emotionScore.value = data.emotion ?? emotionScore.value;
|
||||
trustScore.value = data.trust ?? trustScore.value;
|
||||
redFlagActive.value = Boolean(data.redFlag);
|
||||
if (data.message) message.value = data.message;
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
handleError(error, '建议调整失败');
|
||||
} finally {
|
||||
adjustingStyle.value = '';
|
||||
if (requests.isCurrent(request)) adjustingStyle.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -853,6 +1033,7 @@ const submit = async () => {
|
||||
message.value = '请先输入回应';
|
||||
return;
|
||||
}
|
||||
const request = requests.begin('turn-submit');
|
||||
status.value = 'submitting';
|
||||
message.value = '';
|
||||
const audioUrl = draftAudioUrl.value || undefined;
|
||||
@@ -872,6 +1053,7 @@ const submit = async () => {
|
||||
traineeAudioUrl: audioUrl,
|
||||
traineeAudioOssId: audioOssId
|
||||
});
|
||||
if (!requests.isCurrent(request)) return;
|
||||
lastTraineeText.value = text;
|
||||
lastCoachRoundIndex.value = submittedRound;
|
||||
emotionScore.value = data.emotion ?? emotionScore.value;
|
||||
@@ -891,12 +1073,14 @@ const submit = async () => {
|
||||
roundIndex.value = data.roundIndex;
|
||||
status.value = 'active';
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
status.value = 'active';
|
||||
turns.value = previousTurns;
|
||||
draft.value = text;
|
||||
draftAudioUrl.value = audioUrl || '';
|
||||
draftAudioOssId.value = audioOssId;
|
||||
handleError(error, '提交失败');
|
||||
uni.showToast({ title: message.value || '提交失败', icon: 'none', duration: 3000 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -907,15 +1091,22 @@ const finish = async (force = false) => {
|
||||
message.value = redFlagActive.value ? '请先按老师傅建议重说本轮' : '请先完成至少一轮回应';
|
||||
return;
|
||||
}
|
||||
const request = requests.begin('finish');
|
||||
status.value = 'submitting';
|
||||
message.value = '';
|
||||
try {
|
||||
result.value = await finishPractice(sessionId.value, activeAssignmentId.value);
|
||||
const assignmentId = activeAssignmentId.value;
|
||||
const nextResult = await finishPractice(sessionId.value, assignmentId);
|
||||
if (!requests.isCurrent(request)) return;
|
||||
result.value = nextResult;
|
||||
status.value = 'finished';
|
||||
message.value = '训练完成,主管端待复盘已更新';
|
||||
completedAssignmentId.value = assignmentId;
|
||||
completedAssignmentFeedback.value = null;
|
||||
activeAssignmentId.value = undefined;
|
||||
await loadAssignments();
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
status.value = 'active';
|
||||
handleError(error, '评分失败');
|
||||
}
|
||||
@@ -923,32 +1114,66 @@ const finish = async (force = false) => {
|
||||
|
||||
const sendSatisfaction = async () => {
|
||||
if (!sessionId.value || satisfactionScore.value < 1 || satisfactionScore.value > 5 || satisfactionBusy.value) return;
|
||||
const request = requests.begin('satisfaction');
|
||||
satisfactionBusy.value = true;
|
||||
message.value = '';
|
||||
try {
|
||||
await submitPracticeSatisfaction(sessionId.value, satisfactionScore.value, satisfactionComment.value.trim());
|
||||
if (!requests.isCurrent(request)) return;
|
||||
satisfactionSubmitted.value = true;
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
handleError(error, '体验反馈提交失败');
|
||||
} finally {
|
||||
satisfactionBusy.value = false;
|
||||
if (requests.isCurrent(request)) satisfactionBusy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleShow = async () => {
|
||||
const pageRequest = requests.begin('page');
|
||||
resetPageScroll();
|
||||
refreshAuthState();
|
||||
if (loggedIn.value && !(await ensureEmployeePosition('/pages/user/practice/index'))) {
|
||||
if (loggedIn.value && (!(await ensureEmployeePosition('/pages/user/practice/index')) || !requests.isCurrent(pageRequest))) {
|
||||
return;
|
||||
}
|
||||
if (!requests.isCurrent(pageRequest)) return;
|
||||
positionLabel.value = getSelectedPosition() || '生活顾问';
|
||||
void loadAssignments();
|
||||
void loadPrepCard();
|
||||
void loadAssignments(requests.begin('assignments'));
|
||||
void loadPrepCard(selectedScenarioId.value, requests.begin('prep-card'));
|
||||
};
|
||||
|
||||
const invalidateRequests = () => {
|
||||
requests.invalidate();
|
||||
assignmentLoading.value = false;
|
||||
prepLoading.value = false;
|
||||
answeringId.value = null;
|
||||
feedbackSubmittingId.value = null;
|
||||
helpLoading.value = false;
|
||||
adjustingStyle.value = '';
|
||||
satisfactionBusy.value = false;
|
||||
if (status.value === 'starting') status.value = 'idle';
|
||||
if (status.value === 'submitting') status.value = sessionId.value ? 'active' : 'idle';
|
||||
};
|
||||
|
||||
onShow(() => {
|
||||
const nextAccountKey = currentAccountKey();
|
||||
if (visibleAccountKey && visibleAccountKey !== nextAccountKey) clearPracticeData();
|
||||
visibleAccountKey = nextAccountKey;
|
||||
requests.activate();
|
||||
void handleShow();
|
||||
});
|
||||
|
||||
onHide(() => {
|
||||
invalidateRequests();
|
||||
speechPlayback.stop();
|
||||
cancelRecording();
|
||||
});
|
||||
|
||||
onUnload(() => {
|
||||
invalidateRequests();
|
||||
speechPlayback.destroy();
|
||||
cancelRecording();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -1694,7 +1919,7 @@ onShow(() => {
|
||||
|
||||
.prep-mini text:last-child {
|
||||
color: var(--employee-muted);
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
@@ -1833,10 +2058,16 @@ onShow(() => {
|
||||
}
|
||||
|
||||
.daily-row .daily-answer {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.daily-row .small-button {
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
background: linear-gradient(135deg, #ed1f28, #ff4b4d);
|
||||
box-shadow: 0 6px 12px rgba(230, 0, 18, 0.16);
|
||||
}
|
||||
@@ -1872,7 +2103,7 @@ onShow(() => {
|
||||
|
||||
.capability-strip text:last-child {
|
||||
color: var(--employee-muted);
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.practice-head .page-title {
|
||||
@@ -2019,4 +2250,101 @@ onShow(() => {
|
||||
background: linear-gradient(135deg, #ed1f28, #ff4b4d);
|
||||
box-shadow: 0 8px 16px rgba(231, 0, 20, 0.18);
|
||||
}
|
||||
|
||||
.turn-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.turn-voice-button {
|
||||
width: auto;
|
||||
min-width: 76px;
|
||||
min-height: 32px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #f2b9bf;
|
||||
border-radius: 999px;
|
||||
background: #ffffff;
|
||||
color: #e60012;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
.daily-question-voice {
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
|
||||
.daily-feedback-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid #f4c9cd;
|
||||
border-radius: 14px;
|
||||
background: #fff8f8;
|
||||
}
|
||||
|
||||
.daily-feedback-head,
|
||||
.result-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.daily-feedback-title {
|
||||
color: var(--employee-text);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.daily-feedback-copy,
|
||||
.daily-reference-copy {
|
||||
color: var(--employee-text);
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.item-feedback-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 4px;
|
||||
color: var(--employee-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.item-feedback-actions button {
|
||||
min-height: 34px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #d4dce6;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
color: #4f5c70;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.item-feedback-actions button.active {
|
||||
border-color: #ef9da5;
|
||||
background: var(--employee-primary-soft);
|
||||
color: var(--employee-primary);
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.result-feedback-actions {
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--employee-line);
|
||||
}
|
||||
|
||||
.daily-reference-copy {
|
||||
color: var(--employee-muted);
|
||||
}
|
||||
|
||||
.result-voice-button {
|
||||
flex: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user