Files
prop-ai-hr/mobile-uni/src/pages/user/practice/index.vue
T
admin ee396d1e7a feat(mobile): 重做员工端首页、问答和训练页视觉
统一员工端 tabBar、共享卡片和三大核心页面的视觉基础,同时保持现有移动端业务流程不变。补充 H5 输入兼容、页面结构和训练回滚行为的回归测试。
2026-07-11 01:13:14 +08:00

1227 lines
33 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>