1227 lines
33 KiB
Vue
1227 lines
33 KiB
Vue
<template>
|
||
<view class="page practice-page employee-page-shell">
|
||
<view class="practice-head">
|
||
<text class="employee-kicker practice-eyebrow">数字师傅 · 场景训练</text>
|
||
<text class="page-title">场景模拟</text>
|
||
<text class="page-subtitle">与业主、老师傅完成一轮真实工作对话,结束后自动写入训练记录。</text>
|
||
<text class="practice-tip">先看预习卡,再开始练习;红线命中时按老师傅建议重说本轮。</text>
|
||
</view>
|
||
|
||
<view v-if="message" class="status-message">{{ message }}</view>
|
||
|
||
<view v-if="!loggedIn" class="panel login-panel employee-card">
|
||
<view>
|
||
<text class="panel-title">需要登录</text>
|
||
<text class="muted block">登录后查看今日任务、开始练习并保存训练记录。</text>
|
||
</view>
|
||
<button class="primary-button login-button" @click="goLogin">去登录</button>
|
||
</view>
|
||
|
||
<view v-if="loggedIn" class="panel scenario-card employee-card">
|
||
<view class="scenario-headline">
|
||
<text class="scenario-badge">今日小训</text>
|
||
<text class="scenario-title">{{ activeScenarioName }}</text>
|
||
<text class="scenario-desc">{{ scenarioDescription }}</text>
|
||
</view>
|
||
<view v-if="prepCard" class="prep-card">
|
||
<view class="prep-head">
|
||
<text>训练前预习卡</text>
|
||
</view>
|
||
<view class="prep-block">
|
||
<text class="prep-label">要点</text>
|
||
<text v-for="item in prepCard.keyPoints" :key="`point-${item}`" class="prep-line">{{ item }}</text>
|
||
</view>
|
||
<view class="prep-block">
|
||
<text class="prep-label">红线</text>
|
||
<text v-for="item in prepCard.redLines" :key="`red-${item}`" class="prep-line">{{ item }}</text>
|
||
</view>
|
||
<view class="prep-block">
|
||
<text class="prep-label">话术</text>
|
||
<text v-for="item in prepCard.scripts" :key="`script-${item}`" class="prep-line">{{ item }}</text>
|
||
</view>
|
||
</view>
|
||
<text v-else-if="prepLoading" class="muted">预习卡加载中</text>
|
||
<button
|
||
v-if="status === 'idle' || status === 'finished'"
|
||
class="primary-button"
|
||
:disabled="busy"
|
||
@click="start()"
|
||
>
|
||
{{ status === 'finished' ? '再练一次' : '开始练习' }}
|
||
</button>
|
||
</view>
|
||
|
||
<view v-if="loggedIn" class="panel assignment-panel">
|
||
<view class="assignment-head">
|
||
<text class="panel-title">今日任务</text>
|
||
<button class="ghost-button refresh-button" :disabled="assignmentLoading" @click="loadAssignments">刷新</button>
|
||
</view>
|
||
<view v-if="assignments.length" class="assignment-list">
|
||
<view v-for="item in assignments" :key="item.id" class="assignment-row" :class="{ highlighted: highlightedAssignmentId === item.id }">
|
||
<view class="assignment-copy">
|
||
<text class="assignment-badge" :class="{ daily: item.source === 'daily' }">{{ item.source === 'daily' ? '每日三题' : '主管专项' }}</text>
|
||
<text class="assignment-title">{{ item.question || item.scenarioName }}</text>
|
||
<text v-if="assignmentDesc(item)" class="assignment-desc">{{ assignmentDesc(item) }}</text>
|
||
<text v-if="item.status === '已完成' && item.referenceAnswer" class="assignment-reference">参考:{{ item.referenceAnswer }}</text>
|
||
</view>
|
||
<view v-if="item.source === 'daily'" class="daily-answer">
|
||
<textarea
|
||
v-model="drillAnswers[item.id]"
|
||
class="textarea daily-textarea"
|
||
maxlength="220"
|
||
:disabled="item.status === '已完成'"
|
||
placeholder="写一句你的处理话术"
|
||
/>
|
||
<button class="primary-button small-button" :disabled="Boolean(answeringId) || item.status === '已完成'" @click="submitDaily(item)">
|
||
{{ item.status === '已完成' ? '已完成' : '提交' }}
|
||
</button>
|
||
</view>
|
||
<button v-else class="secondary-button small-button" :disabled="!canStartPractice" @click="startAssignment(item)">开始专项</button>
|
||
</view>
|
||
</view>
|
||
<view v-else class="empty">暂无今日任务</view>
|
||
</view>
|
||
|
||
<view v-if="turns.length" class="panel dialogue-card employee-card">
|
||
<view class="dialogue-head">
|
||
<view class="dialogue-heading-block">
|
||
<text class="panel-title">对话</text>
|
||
<text class="dialogue-caption">保持承接情绪、说明动作、给出反馈节点。</text>
|
||
</view>
|
||
<text class="round-label">第 {{ Math.min(roundIndex + 1, 3) }} 轮</text>
|
||
</view>
|
||
<view v-if="hasCoachState" class="coach-state">
|
||
<view class="state-row">
|
||
<text>情绪 {{ emotionScore }}</text>
|
||
<view class="state-bar">
|
||
<view class="state-fill emotion-fill" :style="{ width: `${emotionScore}%` }" />
|
||
</view>
|
||
</view>
|
||
<view class="state-row">
|
||
<text>信任 {{ trustScore }}</text>
|
||
<view class="state-bar">
|
||
<view class="state-fill trust-fill" :style="{ width: `${trustScore}%` }" />
|
||
</view>
|
||
</view>
|
||
<text v-if="redFlagActive" class="red-flag">红线纠偏:先承接诉求,避免推责、绝对承诺或情绪对抗。</text>
|
||
</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>
|
||
<text class="turn-text">{{ turn.text }}</text>
|
||
</view>
|
||
</view>
|
||
<view v-if="canAdjustCoach" class="style-actions">
|
||
<button
|
||
v-for="item in styleOptions"
|
||
:key="item.value"
|
||
class="chip style-chip"
|
||
:disabled="busy || Boolean(adjustingStyle)"
|
||
@click="adjustCoachHint(item.value)"
|
||
>
|
||
{{ adjustingStyle === item.value ? '调整中' : item.label }}
|
||
</button>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="status === 'active' || status === 'submitting'" class="panel reply-card employee-card">
|
||
<text class="panel-title">你的回应</text>
|
||
<textarea
|
||
v-model="draft"
|
||
class="textarea practice-textarea"
|
||
maxlength="500"
|
||
placeholder="先承接业主情绪,再说明处理动作和反馈时间。"
|
||
/>
|
||
<view class="reply-actions">
|
||
<button class="secondary-button" :class="{ 'recording-active': recording }" :disabled="busy || transcribing" @click="toggleRecording">
|
||
{{ recording ? '停止录音' : '录音转写' }}
|
||
</button>
|
||
<button class="ghost-button" :disabled="busy || recording || transcribing" @click="pickAudio">
|
||
{{ transcribing ? '转写中' : '选音频' }}
|
||
</button>
|
||
<button class="secondary-button" :disabled="busy || helpLoading" @click="loadHelp">
|
||
{{ helpLoading ? '求助中' : '求助' }}
|
||
</button>
|
||
<button class="secondary-button" :disabled="busy || !canFinishPractice" @click="finish()">结束评分</button>
|
||
<button class="primary-button" :disabled="busy" @click="submit">
|
||
{{ status === 'submitting' ? '提交中' : '提交回应' }}
|
||
</button>
|
||
</view>
|
||
<text v-if="draftAudioUrl" class="voice-state">已附带语音回放</text>
|
||
</view>
|
||
|
||
<view v-if="helpAnswer || helpRefs.length" class="panel help-card employee-card">
|
||
<text class="panel-title">SOP 提示</text>
|
||
<text v-if="helpAnswer" class="help-answer">{{ helpAnswer }}</text>
|
||
<view v-if="helpRefs.length" class="help-refs">
|
||
<text v-for="item in helpRefs" :key="item" class="help-ref">{{ item }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="result" class="panel result-card employee-card">
|
||
<text class="result-label">本次评分</text>
|
||
<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>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, ref } from 'vue';
|
||
import { onShow } from '@dcloudio/uni-app';
|
||
import type {
|
||
PracticeFinishResponse,
|
||
PracticePrepCard,
|
||
PracticeAssignment,
|
||
PracticeRole,
|
||
PracticeTurn
|
||
} from '@/types/api';
|
||
import { answerDailyDrill, finishPractice, getPracticeAssignments, getPracticePrepCard, startPractice, submitPracticeTurn } from '@/services/practice';
|
||
import { 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';
|
||
|
||
type PracticeStatus = 'idle' | 'starting' | 'active' | 'submitting' | 'finished';
|
||
|
||
const defaultScenarioId = 'complaint-water';
|
||
const defaultScenarioName = '投诉接待';
|
||
const pendingAssignmentKey = 'aihr_pending_assignment_id';
|
||
const status = ref<PracticeStatus>('idle');
|
||
const sessionId = ref('');
|
||
const roundIndex = ref(0);
|
||
const draft = ref('');
|
||
const message = ref('');
|
||
const turns = ref<PracticeTurn[]>([]);
|
||
const result = ref<PracticeFinishResponse | null>(null);
|
||
const selectedScenarioId = ref(defaultScenarioId);
|
||
const prepCard = ref<PracticePrepCard | null>(null);
|
||
const prepLoading = ref(false);
|
||
const activeScenarioName = ref(defaultScenarioName);
|
||
const helpLoading = ref(false);
|
||
const helpAnswer = ref('');
|
||
const helpRefs = ref<string[]>([]);
|
||
const lastTraineeText = ref('');
|
||
const lastCoachRoundIndex = ref<number | null>(null);
|
||
const adjustingStyle = ref('');
|
||
const emotionScore = ref(0);
|
||
const trustScore = ref(0);
|
||
const redFlagActive = ref(false);
|
||
const assignments = ref<PracticeAssignment[]>([]);
|
||
const drillAnswers = ref<Record<number, string>>({});
|
||
const assignmentLoading = ref(false);
|
||
const answeringId = ref<number | null>(null);
|
||
const activeAssignmentId = ref<number | undefined>();
|
||
const highlightedAssignmentId = ref<number | null>(null);
|
||
const loggedIn = ref(false);
|
||
const recording = ref(false);
|
||
const transcribing = ref(false);
|
||
const draftAudioUrl = ref('');
|
||
const draftAudioOssId = ref<number | undefined>();
|
||
const mediaRecorder = ref<MediaRecorder | null>(null);
|
||
const mediaStream = ref<MediaStream | null>(null);
|
||
const busy = computed(() => status.value === 'starting' || status.value === 'submitting');
|
||
const canStartPractice = computed(() => status.value === 'idle' || status.value === 'finished');
|
||
const hasTraineeReply = computed(() => turns.value.some((item) => item.role === 'trainee'));
|
||
const canFinishPractice = computed(() => hasTraineeReply.value && !redFlagActive.value);
|
||
const hasCoachState = computed(() => emotionScore.value > 0 || trustScore.value > 0 || redFlagActive.value);
|
||
const canAdjustCoach = computed(() => status.value === 'active' && lastCoachRoundIndex.value !== null && turns.value.some((item) => item.role === 'coach'));
|
||
const activeAssignment = computed(() => assignments.value.find((item) => item.id === activeAssignmentId.value));
|
||
const scenarioDescription = computed(() => {
|
||
const points = (prepCard.value?.keyPoints || []).map((item) => item.trim()).filter(Boolean).slice(0, 2);
|
||
if (points.length) return `本场重点:${points.join(';')}`;
|
||
if (activeAssignment.value?.reason) return activeAssignment.value.reason;
|
||
const name = activeScenarioName.value;
|
||
if (/催|缴|费|停车/.test(name)) return '练习承接缴费异议、说明规则依据、给出缴费路径和跟进节点。';
|
||
if (/报修|维修|设施|电梯|漏水/.test(name)) return '练习记录问题、安抚情绪、说明报修流转和反馈时限。';
|
||
if (/突发|应急|安全|火灾|摔|道闸|车库/.test(name)) return '练习先稳现场、同步上报、留痕取证和风险告知。';
|
||
return '练习承接业主情绪、说明处理动作、明确反馈时间和责任边界。';
|
||
});
|
||
const styleOptions = [
|
||
{ value: 'serious', label: '再严肃' },
|
||
{ value: 'gentle', label: '再温柔' },
|
||
{ value: 'detailed', label: '更细致' },
|
||
{ value: 'professional', label: '更专业' }
|
||
];
|
||
|
||
const roleLabel = (role: PracticeRole) => {
|
||
if (role === 'customer') return '业主';
|
||
if (role === 'coach') return '老师傅';
|
||
return '我';
|
||
};
|
||
|
||
const assignmentDesc = (item: PracticeAssignment) => {
|
||
if (item.source === 'daily' && item.status !== '已完成') return '提交后显示反馈与参考答案';
|
||
return item.feedback || item.reason;
|
||
};
|
||
|
||
const goLogin = () => {
|
||
rememberLoginRedirect('/pages/user/practice/index');
|
||
uni.navigateTo({ url: '/pages/auth/login/index' });
|
||
};
|
||
|
||
const refreshAuthState = () => {
|
||
loggedIn.value = isLoggedIn();
|
||
};
|
||
|
||
const clearPracticeData = () => {
|
||
cancelRecording();
|
||
assignments.value = [];
|
||
drillAnswers.value = {};
|
||
prepCard.value = null;
|
||
prepLoading.value = false;
|
||
turns.value = [];
|
||
result.value = null;
|
||
sessionId.value = '';
|
||
roundIndex.value = 0;
|
||
draft.value = '';
|
||
status.value = 'idle';
|
||
selectedScenarioId.value = defaultScenarioId;
|
||
activeScenarioName.value = defaultScenarioName;
|
||
helpLoading.value = false;
|
||
helpAnswer.value = '';
|
||
helpRefs.value = [];
|
||
lastTraineeText.value = '';
|
||
lastCoachRoundIndex.value = null;
|
||
adjustingStyle.value = '';
|
||
emotionScore.value = 0;
|
||
trustScore.value = 0;
|
||
redFlagActive.value = false;
|
||
answeringId.value = null;
|
||
activeAssignmentId.value = undefined;
|
||
highlightedAssignmentId.value = null;
|
||
transcribing.value = false;
|
||
clearDraftAudio();
|
||
};
|
||
|
||
const requireLogin = () => {
|
||
refreshAuthState();
|
||
if (loggedIn.value) return true;
|
||
message.value = '请先登录后再开始训练';
|
||
goLogin();
|
||
return false;
|
||
};
|
||
|
||
const appendTurn = (role: PracticeRole, text?: string) => {
|
||
if (!text) return;
|
||
turns.value.push({ role, text });
|
||
};
|
||
|
||
const clearDraftAudio = () => {
|
||
draftAudioUrl.value = '';
|
||
draftAudioOssId.value = undefined;
|
||
};
|
||
|
||
const stopMediaTracks = () => {
|
||
mediaStream.value?.getTracks().forEach((track) => track.stop());
|
||
mediaStream.value = null;
|
||
};
|
||
|
||
const cancelRecording = () => {
|
||
const recorder = mediaRecorder.value;
|
||
if (recorder && recorder.state !== 'inactive') {
|
||
recorder.ondataavailable = null;
|
||
recorder.onstop = null;
|
||
recorder.stop();
|
||
}
|
||
mediaRecorder.value = null;
|
||
stopMediaTracks();
|
||
recording.value = false;
|
||
};
|
||
|
||
const applyAsrResult = (result: { text?: string; audioUrl?: string; ossId?: number | string }) => {
|
||
const text = (result.text || '').trim();
|
||
if (!text) throw new Error('语音没有识别到文字');
|
||
const current = draft.value.trim();
|
||
draft.value = current ? `${current}\n${text}` : text;
|
||
draftAudioUrl.value = result.audioUrl || '';
|
||
const ossId = Number(result.ossId);
|
||
draftAudioOssId.value = Number.isFinite(ossId) ? ossId : undefined;
|
||
message.value = '语音已转成文字,请确认后提交';
|
||
};
|
||
|
||
const transcribeBlob = async (blob: Blob) => {
|
||
if (transcribing.value) return;
|
||
transcribing.value = true;
|
||
message.value = '语音转写中';
|
||
try {
|
||
applyAsrResult(await transcribeSpeechBlob(blob));
|
||
} catch (error) {
|
||
handleError(error, '语音转写失败');
|
||
} finally {
|
||
transcribing.value = false;
|
||
}
|
||
};
|
||
|
||
const pickAudio = async () => {
|
||
if (busy.value || recording.value || transcribing.value) return;
|
||
if (!requireLogin()) return;
|
||
transcribing.value = true;
|
||
message.value = '语音转写中';
|
||
try {
|
||
const file = await chooseSpeechAudio();
|
||
applyAsrResult(await transcribeSpeechFile(file));
|
||
} catch (error) {
|
||
handleError(error, '语音转写失败');
|
||
} finally {
|
||
transcribing.value = false;
|
||
}
|
||
};
|
||
|
||
const startRecording = async () => {
|
||
if (busy.value || transcribing.value) return;
|
||
if (!requireLogin()) return;
|
||
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
|
||
await pickAudio();
|
||
return;
|
||
}
|
||
try {
|
||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||
const recorder = new MediaRecorder(stream);
|
||
const chunks: Blob[] = [];
|
||
mediaStream.value = stream;
|
||
mediaRecorder.value = recorder;
|
||
recorder.ondataavailable = (event) => {
|
||
if (event.data?.size) chunks.push(event.data);
|
||
};
|
||
recorder.onstop = () => {
|
||
recording.value = false;
|
||
mediaRecorder.value = null;
|
||
stopMediaTracks();
|
||
const blob = new Blob(chunks, { type: recorder.mimeType || 'audio/webm' });
|
||
if (!blob.size) {
|
||
message.value = '未录到音频,请改用文字输入';
|
||
return;
|
||
}
|
||
void transcribeBlob(blob);
|
||
};
|
||
recorder.start();
|
||
recording.value = true;
|
||
message.value = '正在录音';
|
||
} catch (error) {
|
||
await pickAudio();
|
||
}
|
||
};
|
||
|
||
const stopRecording = () => {
|
||
const recorder = mediaRecorder.value;
|
||
if (!recording.value || !recorder || recorder.state === 'inactive') return;
|
||
message.value = '录音结束,正在转写';
|
||
recorder.stop();
|
||
};
|
||
|
||
const toggleRecording = () => {
|
||
if (recording.value) {
|
||
stopRecording();
|
||
return;
|
||
}
|
||
void startRecording();
|
||
};
|
||
|
||
const handleError = (error: unknown, fallback: string) => {
|
||
const text = error instanceof Error ? error.message : fallback;
|
||
refreshAuthState();
|
||
if (!loggedIn.value) {
|
||
clearPracticeData();
|
||
message.value = '登录已过期,请重新登录';
|
||
goLogin();
|
||
return;
|
||
}
|
||
message.value = text;
|
||
if (text.includes('认证') || text.includes('登录') || text.includes('401')) {
|
||
goLogin();
|
||
}
|
||
};
|
||
|
||
const loadAssignments = async () => {
|
||
refreshAuthState();
|
||
if (!loggedIn.value) {
|
||
clearPracticeData();
|
||
return;
|
||
}
|
||
assignmentLoading.value = true;
|
||
try {
|
||
assignments.value = await getPracticeAssignments(true);
|
||
drillAnswers.value = Object.fromEntries(assignments.value.map((item) => [item.id, item.userAnswer || '']));
|
||
consumePendingAssignment();
|
||
} catch (error) {
|
||
handleError(error, '今日任务加载失败');
|
||
} finally {
|
||
assignmentLoading.value = false;
|
||
}
|
||
};
|
||
|
||
const consumePendingAssignment = () => {
|
||
const raw = uni.getStorageSync(pendingAssignmentKey);
|
||
if (!raw) return;
|
||
uni.removeStorageSync(pendingAssignmentKey);
|
||
const id = Number(raw);
|
||
if (!Number.isFinite(id)) return;
|
||
const item = assignments.value.find((row) => row.id === id);
|
||
if (!item) {
|
||
message.value = '首页任务已更新,请从列表选择';
|
||
return;
|
||
}
|
||
highlightedAssignmentId.value = item.id;
|
||
if (item.source !== 'daily' && item.status === '待训练' && canStartPractice.value) {
|
||
startAssignment(item);
|
||
return;
|
||
}
|
||
message.value = item.source === 'daily' ? '已定位首页任务,请填写话术后提交' : '该任务已完成,请选择其他任务';
|
||
};
|
||
|
||
const submitDaily = async (item: PracticeAssignment) => {
|
||
if (answeringId.value) return;
|
||
const answer = (drillAnswers.value[item.id] || '').trim();
|
||
if (!answer) {
|
||
message.value = '请先填写处理话术';
|
||
return;
|
||
}
|
||
answeringId.value = item.id;
|
||
message.value = '';
|
||
try {
|
||
const result = await answerDailyDrill(item.id, answer);
|
||
assignments.value = assignments.value.map((row) =>
|
||
row.id === item.id
|
||
? { ...row, status: result.status, userAnswer: answer, feedback: result.feedback, referenceAnswer: result.referenceAnswer, correct: result.correct }
|
||
: row
|
||
);
|
||
message.value = `每日三题得分 ${result.score}`;
|
||
} catch (error) {
|
||
handleError(error, '每日三题提交失败');
|
||
} finally {
|
||
answeringId.value = null;
|
||
}
|
||
};
|
||
|
||
const startAssignment = (item: PracticeAssignment) => {
|
||
if (!canStartPractice.value) return;
|
||
selectedScenarioId.value = item.scenarioId || defaultScenarioId;
|
||
activeScenarioName.value = item.scenarioName || defaultScenarioName;
|
||
void loadPrepCard(selectedScenarioId.value);
|
||
void start(item.scenarioId || defaultScenarioId, item.id);
|
||
};
|
||
|
||
const loadPrepCard = async (scenarioId = selectedScenarioId.value) => {
|
||
refreshAuthState();
|
||
if (!loggedIn.value) {
|
||
prepCard.value = null;
|
||
return;
|
||
}
|
||
prepLoading.value = true;
|
||
try {
|
||
prepCard.value = await getPracticePrepCard(scenarioId);
|
||
} catch (error) {
|
||
prepCard.value = null;
|
||
handleError(error, '预习卡加载失败');
|
||
} finally {
|
||
prepLoading.value = false;
|
||
}
|
||
};
|
||
|
||
const start = async (scenarioId = defaultScenarioId, assignmentId?: number) => {
|
||
if (!canStartPractice.value) return;
|
||
if (!requireLogin()) return;
|
||
selectedScenarioId.value = scenarioId;
|
||
status.value = 'starting';
|
||
message.value = '';
|
||
result.value = null;
|
||
turns.value = [];
|
||
draft.value = '';
|
||
clearDraftAudio();
|
||
helpAnswer.value = '';
|
||
helpRefs.value = [];
|
||
lastTraineeText.value = '';
|
||
lastCoachRoundIndex.value = null;
|
||
adjustingStyle.value = '';
|
||
emotionScore.value = 0;
|
||
trustScore.value = 0;
|
||
redFlagActive.value = false;
|
||
roundIndex.value = 0;
|
||
activeAssignmentId.value = assignmentId;
|
||
try {
|
||
const data = await startPractice(scenarioId, assignmentId);
|
||
activeScenarioName.value = data.scenarioName || activeScenarioName.value;
|
||
emotionScore.value = data.emotion ?? 0;
|
||
trustScore.value = data.trust ?? 0;
|
||
sessionId.value = data.sessionId;
|
||
appendTurn('customer', data.customerText);
|
||
status.value = 'active';
|
||
} catch (error) {
|
||
status.value = 'idle';
|
||
activeAssignmentId.value = undefined;
|
||
handleError(error, '训练启动失败');
|
||
}
|
||
};
|
||
|
||
const loadHelp = async () => {
|
||
if (helpLoading.value || busy.value) return;
|
||
if (!requireLogin()) return;
|
||
helpLoading.value = true;
|
||
message.value = '';
|
||
try {
|
||
const data = await searchKnowledge(`${activeScenarioName.value} SOP 处理要点`);
|
||
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) {
|
||
handleError(error, '求助失败');
|
||
} finally {
|
||
helpLoading.value = false;
|
||
}
|
||
};
|
||
|
||
const replaceLastCoachHint = (text?: string) => {
|
||
if (!text) return;
|
||
for (let index = turns.value.length - 1; index >= 0; index -= 1) {
|
||
if (turns.value[index].role === 'coach') {
|
||
turns.value[index] = { role: 'coach', text };
|
||
return;
|
||
}
|
||
}
|
||
appendTurn('coach', text);
|
||
};
|
||
|
||
const discardLastCoachAttempt = () => {
|
||
const next = [...turns.value];
|
||
if (next[next.length - 1]?.role === 'coach') next.pop();
|
||
if (next[next.length - 1]?.role === 'trainee') next.pop();
|
||
turns.value = next;
|
||
};
|
||
|
||
const adjustCoachHint = async (style: string) => {
|
||
if (busy.value || adjustingStyle.value) return;
|
||
if (!sessionId.value || lastCoachRoundIndex.value === null || !lastTraineeText.value) return;
|
||
adjustingStyle.value = style;
|
||
message.value = '';
|
||
try {
|
||
const data = await submitPracticeTurn({
|
||
sessionId: sessionId.value,
|
||
roundIndex: lastCoachRoundIndex.value,
|
||
traineeText: lastTraineeText.value,
|
||
regenerate: true,
|
||
style
|
||
});
|
||
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) {
|
||
handleError(error, '建议调整失败');
|
||
} finally {
|
||
adjustingStyle.value = '';
|
||
}
|
||
};
|
||
|
||
const submit = async () => {
|
||
if (busy.value) return;
|
||
const text = draft.value.trim();
|
||
if (!text) {
|
||
message.value = '请先输入回应';
|
||
return;
|
||
}
|
||
status.value = 'submitting';
|
||
message.value = '';
|
||
const audioUrl = draftAudioUrl.value || undefined;
|
||
const audioOssId = draftAudioOssId.value;
|
||
const previousTurns = turns.value.slice();
|
||
draft.value = '';
|
||
clearDraftAudio();
|
||
const retryingRedFlag = redFlagActive.value && lastCoachRoundIndex.value === roundIndex.value;
|
||
if (retryingRedFlag) discardLastCoachAttempt();
|
||
appendTurn('trainee', text);
|
||
try {
|
||
const submittedRound = roundIndex.value;
|
||
const data = await submitPracticeTurn({
|
||
sessionId: sessionId.value,
|
||
roundIndex: submittedRound,
|
||
traineeText: text,
|
||
traineeAudioUrl: audioUrl,
|
||
traineeAudioOssId: audioOssId
|
||
});
|
||
lastTraineeText.value = text;
|
||
lastCoachRoundIndex.value = submittedRound;
|
||
emotionScore.value = data.emotion ?? emotionScore.value;
|
||
trustScore.value = data.trust ?? trustScore.value;
|
||
redFlagActive.value = Boolean(data.redFlag);
|
||
appendTurn('coach', data.redFlag ? data.coachHint || '先承接诉求,再给处理节点。' : data.coachHint);
|
||
if (data.redFlag) {
|
||
message.value = '命中红线,请按老师傅建议重说本轮';
|
||
status.value = 'active';
|
||
return;
|
||
}
|
||
if (data.finished) {
|
||
await finish(true);
|
||
return;
|
||
}
|
||
appendTurn('customer', data.customerText);
|
||
roundIndex.value = data.roundIndex;
|
||
status.value = 'active';
|
||
} catch (error) {
|
||
status.value = 'active';
|
||
turns.value = previousTurns;
|
||
draft.value = text;
|
||
draftAudioUrl.value = audioUrl || '';
|
||
draftAudioOssId.value = audioOssId;
|
||
handleError(error, '提交失败');
|
||
}
|
||
};
|
||
|
||
const finish = async (force = false) => {
|
||
if (!force && busy.value) return;
|
||
if (!sessionId.value) return;
|
||
if (!canFinishPractice.value) {
|
||
message.value = redFlagActive.value ? '请先按老师傅建议重说本轮' : '请先完成至少一轮回应';
|
||
return;
|
||
}
|
||
status.value = 'submitting';
|
||
message.value = '';
|
||
try {
|
||
result.value = await finishPractice(sessionId.value, activeAssignmentId.value);
|
||
status.value = 'finished';
|
||
message.value = '训练完成,主管端待复盘已更新';
|
||
activeAssignmentId.value = undefined;
|
||
await loadAssignments();
|
||
} catch (error) {
|
||
status.value = 'active';
|
||
handleError(error, '评分失败');
|
||
}
|
||
};
|
||
|
||
const handleShow = async () => {
|
||
resetPageScroll();
|
||
refreshAuthState();
|
||
if (loggedIn.value && !(await ensureEmployeePosition('/pages/user/practice/index'))) {
|
||
return;
|
||
}
|
||
void loadAssignments();
|
||
void loadPrepCard();
|
||
};
|
||
|
||
onShow(() => {
|
||
void handleShow();
|
||
});
|
||
</script>
|
||
|
||
<style scoped>
|
||
.practice-page {
|
||
padding: 0 18px 96px;
|
||
background: linear-gradient(180deg, var(--employee-bg-soft) 0%, var(--employee-bg) 100%);
|
||
}
|
||
|
||
.practice-head {
|
||
position: relative;
|
||
margin: 0 -18px;
|
||
padding: 30px 28px 24px;
|
||
overflow: hidden;
|
||
background: linear-gradient(180deg, #eef4ff 0%, #f5f8fd 100%);
|
||
}
|
||
|
||
.practice-head::after {
|
||
position: absolute;
|
||
right: 28px;
|
||
bottom: 24px;
|
||
width: 56px;
|
||
height: 3px;
|
||
background: #e70014;
|
||
content: '';
|
||
}
|
||
|
||
.practice-eyebrow {
|
||
display: inline-flex;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.practice-head .page-title {
|
||
color: #1f2b3d;
|
||
font-size: 31px;
|
||
line-height: 1.12;
|
||
}
|
||
|
||
.practice-head .page-subtitle {
|
||
max-width: 274px;
|
||
margin-top: 10px;
|
||
color: #607086;
|
||
font-size: 14px;
|
||
line-height: 1.55;
|
||
}
|
||
|
||
.practice-tip {
|
||
display: block;
|
||
max-width: 298px;
|
||
margin-top: 14px;
|
||
color: #315b8f;
|
||
font-size: 12px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.login-panel {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 14px;
|
||
margin-top: 22px;
|
||
border-left: 4px solid #e70014;
|
||
box-shadow: var(--employee-card-shadow);
|
||
}
|
||
|
||
.login-panel .muted {
|
||
display: block;
|
||
max-width: 218px;
|
||
margin-top: 6px;
|
||
font-size: 13px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.login-panel .login-button {
|
||
min-width: 84px;
|
||
min-height: 46px;
|
||
padding: 0 12px;
|
||
}
|
||
|
||
.scenario-card,
|
||
.dialogue-card,
|
||
.reply-card,
|
||
.help-card,
|
||
.result-card {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 14px;
|
||
}
|
||
|
||
.scenario-card {
|
||
margin-top: 22px;
|
||
padding: 20px;
|
||
border-color: #d7e5dc;
|
||
border-left: 4px solid #168243;
|
||
background: #ffffff;
|
||
box-shadow: var(--employee-card-shadow);
|
||
}
|
||
|
||
.scenario-headline {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
|
||
.scenario-card .primary-button {
|
||
width: 100%;
|
||
margin-top: 2px;
|
||
background: #168243;
|
||
box-shadow: 0 7px 14px rgba(22, 130, 67, 0.2);
|
||
}
|
||
|
||
.scenario-card .primary-button:active {
|
||
background: #106b35;
|
||
}
|
||
|
||
.assignment-head,
|
||
.daily-answer {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
}
|
||
|
||
.refresh-button,
|
||
.small-button {
|
||
min-width: 82px;
|
||
}
|
||
|
||
.assignment-panel {
|
||
padding: 0;
|
||
border: 0;
|
||
background: transparent;
|
||
box-shadow: none;
|
||
}
|
||
|
||
.assignment-head {
|
||
margin-top: 26px;
|
||
}
|
||
|
||
.assignment-head .panel-title {
|
||
color: #25282d;
|
||
font-size: 20px;
|
||
}
|
||
|
||
.assignment-head .refresh-button {
|
||
min-height: 36px;
|
||
padding: 0 12px;
|
||
border: 1px solid #dedee1;
|
||
border-radius: 18px;
|
||
background: #ffffff;
|
||
color: #66696f;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.assignment-list {
|
||
margin-top: 14px;
|
||
}
|
||
|
||
.assignment-row {
|
||
margin-top: 12px;
|
||
padding: 16px;
|
||
border: 1px solid #deebff;
|
||
border-radius: 20px;
|
||
background: #ffffff;
|
||
box-shadow: 0 10px 26px rgba(47, 107, 255, 0.08);
|
||
}
|
||
|
||
.assignment-row.highlighted {
|
||
border-color: #e70014;
|
||
background: #fff7f7;
|
||
}
|
||
|
||
.assignment-badge {
|
||
display: inline-flex;
|
||
width: fit-content;
|
||
padding: 4px 8px;
|
||
border-radius: 5px;
|
||
background: #fff0f1;
|
||
color: #bd0010;
|
||
font-size: 12px;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.assignment-badge.daily {
|
||
background: #fff5df;
|
||
color: #a86100;
|
||
}
|
||
|
||
.assignment-title {
|
||
display: block;
|
||
margin-top: 8px;
|
||
color: #25282d;
|
||
font-size: 16px;
|
||
font-weight: 900;
|
||
line-height: 1.45;
|
||
}
|
||
|
||
.assignment-desc,
|
||
.assignment-reference {
|
||
display: block;
|
||
margin-top: 6px;
|
||
color: #6d7076;
|
||
font-size: 13px;
|
||
line-height: 1.45;
|
||
}
|
||
|
||
.daily-answer {
|
||
margin-top: 10px;
|
||
align-items: flex-end;
|
||
}
|
||
|
||
.daily-textarea {
|
||
min-height: 82px;
|
||
}
|
||
|
||
.scenario-badge {
|
||
width: fit-content;
|
||
padding: 5px 10px;
|
||
border-radius: 5px;
|
||
background: #edf7f0;
|
||
color: #168243;
|
||
font-size: 13px;
|
||
font-weight: 900;
|
||
}
|
||
|
||
.scenario-title {
|
||
color: #25282d;
|
||
font-size: 25px;
|
||
font-weight: 900;
|
||
}
|
||
|
||
.scenario-desc {
|
||
color: #696c72;
|
||
font-size: 15px;
|
||
line-height: 1.55;
|
||
}
|
||
|
||
.prep-card {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
padding: 14px 0 0;
|
||
border-top: 1px solid #dfe8e2;
|
||
background: transparent;
|
||
}
|
||
|
||
.prep-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
}
|
||
|
||
.prep-head text:first-child,
|
||
.prep-label {
|
||
color: #34373c;
|
||
font-size: 13px;
|
||
font-weight: 900;
|
||
}
|
||
|
||
.prep-head text:last-child {
|
||
color: #898c92;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.prep-block {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 5px;
|
||
}
|
||
|
||
.prep-line {
|
||
color: #5e6167;
|
||
font-size: 13px;
|
||
line-height: 1.45;
|
||
}
|
||
|
||
.dialogue-head,
|
||
.reply-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
|
||
.dialogue-head {
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.dialogue-heading-block {
|
||
display: flex;
|
||
min-width: 0;
|
||
flex: 1;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
.dialogue-caption {
|
||
color: #6d7a8d;
|
||
font-size: 12px;
|
||
line-height: 1.45;
|
||
}
|
||
|
||
.dialogue-card {
|
||
border-color: #e1e2e5;
|
||
box-shadow: var(--employee-card-shadow);
|
||
}
|
||
|
||
.reply-actions {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
|
||
.reply-actions button {
|
||
min-width: 0;
|
||
padding: 0 8px;
|
||
white-space: nowrap;
|
||
word-break: keep-all;
|
||
}
|
||
|
||
.reply-actions .primary-button {
|
||
grid-column: span 2;
|
||
}
|
||
|
||
.voice-state {
|
||
color: #168243;
|
||
font-size: 13px;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.round-label {
|
||
color: #6d7076;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.coach-state {
|
||
display: flex;
|
||
margin-top: 12px;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.state-row {
|
||
display: grid;
|
||
align-items: center;
|
||
grid-template-columns: 72px 1fr;
|
||
gap: 10px;
|
||
color: #5f6268;
|
||
font-size: 13px;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.state-bar {
|
||
height: 8px;
|
||
overflow: hidden;
|
||
border-radius: 4px;
|
||
background: #e3e4e6;
|
||
}
|
||
|
||
.state-fill {
|
||
height: 100%;
|
||
border-radius: 999px;
|
||
transition: width 0.3s ease-in-out;
|
||
}
|
||
|
||
.emotion-fill {
|
||
background: #d98200;
|
||
}
|
||
|
||
.trust-fill {
|
||
background: #168243;
|
||
}
|
||
|
||
.red-flag {
|
||
padding: 9px 10px;
|
||
border-left: 3px solid #e70014;
|
||
border-radius: 4px;
|
||
background: #fff2f3;
|
||
color: #bd0010;
|
||
font-size: 13px;
|
||
font-weight: 800;
|
||
line-height: 1.45;
|
||
}
|
||
|
||
.turn-list {
|
||
display: flex;
|
||
margin-top: 14px;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
|
||
.turn-row {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
padding: 14px;
|
||
border: 1px solid #e5edf7;
|
||
border-left: 3px solid #9ca0a6;
|
||
border-radius: 18px;
|
||
background: #f8fbff;
|
||
}
|
||
|
||
.turn-row.customer {
|
||
border-left-color: #315b8f;
|
||
background: #f0f4f8;
|
||
}
|
||
|
||
.turn-row.trainee {
|
||
border-left-color: #e70014;
|
||
background: #fff6f6;
|
||
}
|
||
|
||
.turn-row.coach {
|
||
border-left-color: #168243;
|
||
background: #edf7f0;
|
||
}
|
||
|
||
.reply-actions button.recording-active {
|
||
border-color: #e70014;
|
||
background: #fff5f5;
|
||
color: #e70014;
|
||
}
|
||
|
||
.turn-role {
|
||
color: #42454b;
|
||
font-size: 13px;
|
||
font-weight: 900;
|
||
}
|
||
|
||
.turn-text {
|
||
color: #282b30;
|
||
font-size: 15px;
|
||
line-height: 1.55;
|
||
}
|
||
|
||
.style-actions {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
margin-top: 12px;
|
||
}
|
||
|
||
.style-chip {
|
||
width: auto;
|
||
border-color: #c9d9ce;
|
||
background: #ffffff;
|
||
color: #168243;
|
||
}
|
||
|
||
.reply-card {
|
||
border-top: 0;
|
||
box-shadow: var(--employee-card-shadow);
|
||
}
|
||
|
||
.practice-textarea {
|
||
min-height: 124px;
|
||
border-color: #d6deea;
|
||
border-radius: 18px;
|
||
background: #f9fbff;
|
||
}
|
||
|
||
.help-answer {
|
||
color: #45484e;
|
||
font-size: 15px;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.help-refs {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
|
||
.help-ref {
|
||
padding: 5px 8px;
|
||
border-radius: 5px;
|
||
background: #edf7f0;
|
||
color: #168243;
|
||
font-size: 12px;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.result-label {
|
||
color: #6d7076;
|
||
font-size: 14px;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.result-score {
|
||
color: #2f6bff;
|
||
font-size: 44px;
|
||
font-weight: 900;
|
||
line-height: 1;
|
||
}
|
||
|
||
.result-comment,
|
||
.mentor-rewrite text:last-child {
|
||
color: #45484e;
|
||
font-size: 15px;
|
||
line-height: 1.55;
|
||
}
|
||
|
||
.mentor-rewrite {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
padding: 12px;
|
||
border-left: 3px solid #d98200;
|
||
border-radius: 6px;
|
||
background: #fff9ed;
|
||
}
|
||
|
||
.mentor-rewrite text:first-child {
|
||
color: #855000;
|
||
font-weight: 900;
|
||
}
|
||
</style>
|