feat: restore learning feedback and knowledge capabilities

This commit is contained in:
2026-07-18 00:50:11 +08:00
parent 876a23a123
commit 6bdc86e3fc
152 changed files with 30337 additions and 953 deletions
+361 -33
View File
@@ -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>