Files
prop-ai-hr/mobile-uni/src/pages/user/practice/index.vue
T

1963 lines
49 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="page-title">训练前预习</text>
<text class="page-subtitle">先掌握要点,再开始场景练习</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-title">{{ activeScenarioName }}</text>
<text class="scenario-desc">生活顾问 · 业主服务场景</text>
</view>
<view class="scenario-persona">
<view class="persona-avatar">业</view>
<view class="persona-copy">
<text class="persona-title">待处理业主</text>
<text class="persona-desc">先听清诉求,再回应</text>
</view>
<text class="persona-state">待进入</text>
</view>
<view v-if="prepCard" class="prep-sections">
<view class="prep-section prep-section-points">
<text class="prep-section-title">要点</text>
<text v-for="item in prepCard.keyPoints.slice(0, 3)" :key="`point-${item}`" class="prep-line"><text class="prep-mark">✓</text>{{ item }}</text>
</view>
<view class="prep-section prep-section-redline">
<text class="prep-section-title">红线</text>
<text v-for="item in prepCard.redLines.slice(0, 3)" :key="`red-${item}`" class="prep-line"><text class="prep-mark">!</text>{{ item }}</text>
</view>
<view class="prep-section prep-section-script">
<text class="prep-section-title">可说话术</text>
<text v-for="item in prepCard.scripts.slice(0, 3)" :key="`script-${item}`" class="prep-line"><text class="prep-mark">✓</text>{{ 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 recent-panel">
<view class="assignment-head">
<text class="panel-title">最近练过</text>
<text class="recent-link">全部 ›</text>
</view>
<view v-if="recentPracticeItems.length" class="assignment-list">
<view v-for="item in recentPracticeItems" :key="item.id" class="assignment-row recent-row" :class="{ highlighted: item.assignment && highlightedAssignmentId === item.assignment.id }" @click="openRecentItem(item)">
<view class="recent-icon" aria-hidden="true">{{ item.icon }}</view>
<view class="assignment-copy">
<text class="assignment-title">{{ item.title }}</text>
<text class="assignment-desc">{{ item.desc }}</text>
</view>
<text class="recent-arrow">›</text>
</view>
</view>
<view v-else class="empty">暂无最近练习</view>
</view>
<view v-if="loggedIn" class="panel assignment-panel daily-panel">
<view class="assignment-head">
<view><text class="panel-title">每日三题</text><text class="daily-subtitle">今日安排</text></view>
<text class="daily-action-label">去作答 ›</text>
</view>
<view v-if="dailyAssignments.length" class="assignment-list">
<view v-for="item in dailyAssignments" :key="item.id" class="assignment-row daily-row">
<view class="assignment-copy">
<text class="assignment-title">{{ item.question || item.scenarioName }}</text>
<text v-if="assignmentDesc(item)" class="assignment-desc">{{ assignmentDesc(item) }}</text>
</view>
<view class="daily-answer">
<textarea
v-model="drillAnswers[item.id]"
class="textarea daily-textarea"
maxlength="220"
:disabled="item.status === '已完成'"
placeholder="写一句你的处理话术"
/>
<view class="daily-voice-actions">
<button
class="secondary-button"
:class="{ 'recording-active': recording && recordingAssignmentId === item.id }"
:disabled="Boolean(answeringId) || transcribing || (recording && recordingAssignmentId !== item.id) || item.status === '已完成'"
@click="() => toggleRecording(item.id)"
>
{{ recording && recordingAssignmentId === item.id ? '停止录音' : '录音转写' }}
</button>
<button
class="ghost-button"
:disabled="Boolean(answeringId) || recording || transcribing || item.status === '已完成'"
@click="() => pickAudio(item.id)"
>
{{ transcribing ? '转写中' : '选音频' }}
</button>
</view>
<text v-if="dailyAudioUrls[item.id]" class="voice-state">已附带语音回放</text>
<button class="primary-button small-button" :disabled="Boolean(answeringId) || item.status === '已完成'" @click="submitDaily(item)">
{{ item.status === '已完成' ? '已完成' : '提交' }}
</button>
</view>
</view>
</view>
<view v-else class="daily-empty">
<text>今日暂无题目</text>
<text>完成专项训练后会自动生成每日练习</text>
</view>
</view>
<view v-if="loggedIn" class="capability-strip">
<view><text>情绪</text><text>识别与安抚</text></view>
<view><text>信任</text><text>建立与维护</text></view>
<view><text>红线纠偏</text><text>识别与规避</text></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 v-if="result" class="panel satisfaction-card employee-card">
<view class="satisfaction-head">
<text class="panel-title">本次训练体验如何?</text>
<text class="muted">帮助我们改进训练内容</text>
</view>
<view v-if="!satisfactionSubmitted" class="satisfaction-options">
<button
v-for="score in satisfactionOptions"
:key="score"
class="satisfaction-option"
:class="{ selected: satisfactionScore === score }"
@click="satisfactionScore = score"
>
{{ score }}
</button>
</view>
<textarea
v-if="!satisfactionSubmitted"
v-model="satisfactionComment"
class="textarea satisfaction-textarea"
maxlength="500"
placeholder="可选:写下你希望改进的地方"
/>
<button
v-if="!satisfactionSubmitted"
class="primary-button small-button"
:disabled="satisfactionBusy || satisfactionScore === 0"
@click="sendSatisfaction"
>
{{ satisfactionBusy ? '提交中' : '提交体验反馈' }}
</button>
<text v-else class="satisfaction-thanks">感谢反馈,已记录本次训练体验。</text>
</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,
PracticeRecord
} from '@/types/api';
import { answerDailyDrill, finishPractice, getPracticeAssignments, getPracticeHistory, getPracticePrepCard, startPractice, submitPracticeSatisfaction, 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';
type RecentPracticeItem = {
id: string;
title: string;
desc: string;
status: string;
icon: string;
assignment?: PracticeAssignment;
};
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 satisfactionOptions = [1, 2, 3, 4, 5];
const satisfactionScore = ref(0);
const satisfactionComment = ref('');
const satisfactionBusy = ref(false);
const satisfactionSubmitted = ref(false);
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 practiceHistory = ref<PracticeRecord[]>([]);
const drillAnswers = ref<Record<number, string>>({});
const dailyAudioUrls = ref<Record<number, string>>({});
const dailyAudioOssIds = ref<Record<number, number | undefined>>({});
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 recordingAssignmentId = ref<number | null>(null);
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 recentPracticeItems = computed<RecentPracticeItem[]>(() => {
const assignmentItems: RecentPracticeItem[] = assignments.value
.filter((item) => item.source !== 'daily')
.map((item) => ({
id: `assignment-${item.id}`,
title: item.question || item.scenarioName || '专项训练',
desc: item.status === '已完成' ? '已完成' : '待复盘',
status: item.status,
icon: item.scenarioName?.includes('催费') ? '⌕' : item.scenarioName?.includes('报修') ? '⌁' : '◉',
assignment: item
}));
const historyItems: RecentPracticeItem[] = practiceHistory.value.map((item) => ({
id: `history-${item.id || item.sessionId || item.time}`,
title: item.scene || '场景训练',
desc: `${item.score || 0} 分 · ${item.status || '已完成'}`,
status: item.status,
icon: item.scene?.includes('催费') ? '⌕' : item.scene?.includes('报修') ? '⌁' : '◉'
}));
return [...assignmentItems, ...historyItems].slice(0, 3);
});
const dailyAssignments = computed(() => assignments.value.filter((item) => item.source === 'daily'));
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 = [];
practiceHistory.value = [];
drillAnswers.value = {};
dailyAudioUrls.value = {};
dailyAudioOssIds.value = {};
prepCard.value = null;
prepLoading.value = false;
turns.value = [];
result.value = null;
satisfactionScore.value = 0;
satisfactionComment.value = '';
satisfactionBusy.value = false;
satisfactionSubmitted.value = false;
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;
recordingAssignmentId.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;
recordingAssignmentId.value = null;
};
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 applyDailyAsrResult = (assignmentId: number, result: { text?: string; audioUrl?: string; ossId?: number | string }) => {
const text = (result.text || '').trim();
if (!text) throw new Error('语音没有识别到文字');
const current = (drillAnswers.value[assignmentId] || '').trim();
drillAnswers.value = {
...drillAnswers.value,
[assignmentId]: current ? `${current}\n${text}` : text
};
dailyAudioUrls.value = {
...dailyAudioUrls.value,
[assignmentId]: result.audioUrl || ''
};
const ossId = Number(result.ossId);
dailyAudioOssIds.value = {
...dailyAudioOssIds.value,
[assignmentId]: Number.isFinite(ossId) ? ossId : undefined
};
message.value = '每日一练语音已转成文字,请确认后提交';
};
const transcribeBlob = async (blob: Blob, assignmentId?: number) => {
if (transcribing.value) return;
transcribing.value = true;
message.value = '语音转写中';
try {
const result = await transcribeSpeechBlob(blob);
if (assignmentId === undefined) {
applyAsrResult(result);
} else {
applyDailyAsrResult(assignmentId, result);
}
} catch (error) {
handleError(error, '语音转写失败');
} finally {
transcribing.value = false;
}
};
const pickAudio = async (assignmentId?: number) => {
if (busy.value || recording.value || transcribing.value) return;
if (!requireLogin()) return;
transcribing.value = true;
message.value = '语音转写中';
try {
const file = await chooseSpeechAudio();
const result = await transcribeSpeechFile(file);
if (assignmentId === undefined) {
applyAsrResult(result);
} else {
applyDailyAsrResult(assignmentId, result);
}
} catch (error) {
handleError(error, '语音转写失败');
} finally {
transcribing.value = false;
}
};
const startRecording = async (assignmentId?: number) => {
if (busy.value || transcribing.value) return;
if (!requireLogin()) return;
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
await pickAudio(assignmentId);
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const recorder = new MediaRecorder(stream);
const chunks: Blob[] = [];
mediaStream.value = stream;
mediaRecorder.value = recorder;
recordingAssignmentId.value = assignmentId ?? null;
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;
}
const targetAssignmentId = assignmentId;
recordingAssignmentId.value = null;
void transcribeBlob(blob, targetAssignmentId);
};
recorder.start();
recording.value = true;
message.value = '正在录音';
} catch (error) {
recordingAssignmentId.value = null;
await pickAudio(assignmentId);
}
};
const stopRecording = () => {
const recorder = mediaRecorder.value;
if (!recording.value || !recorder || recorder.state === 'inactive') return;
message.value = '录音结束,正在转写';
recorder.stop();
};
const toggleRecording = (assignmentId?: number) => {
if (recording.value) {
stopRecording();
return;
}
void startRecording(assignmentId);
};
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);
try {
practiceHistory.value = await getPracticeHistory();
} catch {
practiceHistory.value = [];
}
drillAnswers.value = Object.fromEntries(assignments.value.map((item) => [item.id, item.userAnswer || '']));
dailyAudioUrls.value = Object.fromEntries(
assignments.value.filter((item) => item.audioUrl).map((item) => [item.id, item.audioUrl || ''])
);
dailyAudioOssIds.value = Object.fromEntries(
assignments.value
.filter((item) => item.audioOssId !== undefined && item.audioOssId !== null)
.map((item) => [item.id, Number(item.audioOssId)])
);
consumePendingAssignment();
} catch (error) {
handleError(error, '今日任务加载失败');
} finally {
assignmentLoading.value = false;
}
};
const openRecentItem = (item: RecentPracticeItem) => {
if (item.assignment) {
startAssignment(item.assignment);
return;
}
uni.navigateTo({ url: '/pages/user/profile/index' });
};
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();
const audioUrl = dailyAudioUrls.value[item.id] || undefined;
const audioOssId = dailyAudioOssIds.value[item.id];
if (!answer && !audioUrl) {
message.value = '请先输入或录制处理话术';
return;
}
answeringId.value = item.id;
message.value = '';
try {
const result = await answerDailyDrill(item.id, answer, audioUrl, audioOssId);
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,
audioUrl: result.audioUrl || audioUrl,
audioOssId: result.audioOssId || audioOssId
}
: 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;
satisfactionScore.value = 0;
satisfactionComment.value = '';
satisfactionSubmitted.value = false;
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 sendSatisfaction = async () => {
if (!sessionId.value || satisfactionScore.value < 1 || satisfactionScore.value > 5 || satisfactionBusy.value) return;
satisfactionBusy.value = true;
message.value = '';
try {
await submitPracticeSatisfaction(sessionId.value, satisfactionScore.value, satisfactionComment.value.trim());
satisfactionSubmitted.value = true;
} catch (error) {
handleError(error, '体验反馈提交失败');
} finally {
satisfactionBusy.value = false;
}
};
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: 20px;
bottom: 20px;
width: 42px;
height: 4px;
border-radius: 4px;
background: var(--employee-primary);
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,
.satisfaction-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 #f1dfe0;
border-radius: 20px;
background: #ffffff;
box-shadow: 0 8px 22px rgba(24, 28, 36, 0.05);
}
.assignment-row.highlighted {
border-color: #e70014;
background: #fff7f7;
}
.assignment-badge {
display: inline-flex;
width: fit-content;
padding: 4px 8px;
border-radius: 5px;
background: #fff1f2;
color: #d51b2b;
font-size: 12px;
font-weight: 800;
}
.assignment-badge.daily {
background: #fff1f2;
color: #d51b2b;
}
.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-voice-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin-top: 8px;
}
.daily-voice-actions button {
min-width: 0;
padding: 0 8px;
white-space: nowrap;
}
.daily-textarea {
min-height: 82px;
}
.scenario-badge {
width: fit-content;
padding: 5px 10px;
border-radius: 5px;
background: #fff1f2;
color: #d51b2b;
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: #dbe7ff;
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: #6f9cff;
}
.trust-fill {
background: #2f6bff;
}
.red-flag {
padding: 9px 10px;
border-left: 3px solid #f59e0b;
border-radius: 4px;
background: #fff7e6;
color: #a15c00;
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: #b8bdc5;
background: #f7f7f8;
}
.turn-row.trainee {
border-left-color: #2f6bff;
background: #eef4ff;
}
.turn-row.coach {
border-left-color: #8bb1ff;
background: #f4f7ff;
}
.reply-actions button.recording-active {
border-color: #2f6bff;
background: #eef4ff;
color: #2f6bff;
}
.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: #c9d9ff;
background: #ffffff;
color: #2f6bff;
}
.reply-card {
border-top: 0;
box-shadow: var(--employee-card-shadow);
}
.practice-textarea {
min-height: 124px;
border-color: #dbe7ff;
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: #eef4ff;
color: #2f6bff;
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 #2f6bff;
border-radius: 6px;
background: #eef4ff;
}
.mentor-rewrite text:first-child {
color: #2f6bff;
font-weight: 900;
}
.satisfaction-head {
display: flex;
flex-direction: column;
gap: 5px;
}
.satisfaction-options {
display: flex;
gap: 8px;
}
.satisfaction-option {
flex: 1;
min-height: 40px;
padding: 0;
border: 1px solid #d8e1ef;
border-radius: 8px;
background: #ffffff;
color: #5a6270;
font-size: 15px;
font-weight: 800;
}
.satisfaction-option.selected {
border-color: #2f6bff;
background: #eef4ff;
color: #2f6bff;
}
.satisfaction-textarea {
min-height: 76px;
}
.satisfaction-thanks {
color: #168243;
font-size: 14px;
font-weight: 800;
}
/* 练页入口态:与员工端蓝白轻卡片体系保持一致 */
.practice-page {
padding: 0 16px 108px;
background: linear-gradient(180deg, var(--employee-bg-soft) 0%, var(--employee-bg) 100%);
}
.practice-head {
margin: 0 -16px;
padding: 30px 20px 24px;
border-bottom: 1px solid var(--employee-line);
background: linear-gradient(180deg, #eef4ff 0%, #f5f8fd 100%);
text-align: left;
}
.practice-head::after,
.practice-eyebrow,
.practice-tip {
display: block;
}
.practice-head .page-title {
color: var(--employee-text);
font-size: 30px;
font-weight: 900;
}
.practice-head .page-subtitle {
max-width: none;
margin-top: 8px;
color: var(--employee-muted);
font-size: 14px;
}
.scenario-card {
position: relative;
overflow: hidden;
margin-top: 16px;
padding: 22px 18px 18px;
border: 0;
border-radius: var(--employee-card-radius);
border-left: 0;
background: #ffffff;
box-shadow: 0 8px 24px rgba(24, 28, 36, 0.06);
}
.scenario-headline {
position: relative;
z-index: 1;
max-width: 58%;
gap: 8px;
}
.scenario-title {
color: var(--employee-text);
font-size: 30px;
font-weight: 900;
}
.scenario-desc {
color: var(--employee-muted);
font-size: 17px;
}
.scenario-visual {
position: absolute;
top: 24px;
right: 18px;
width: 42%;
height: 108px;
border-radius: 50%;
background: var(--employee-primary-soft);
opacity: 0.9;
}
.visual-sheet {
position: absolute;
top: 18px;
right: 24px;
width: 92px;
height: 76px;
border-radius: 12px;
background: #ffffff;
box-shadow: 0 8px 16px rgba(47, 107, 255, 0.08);
transform: rotate(7deg);
}
.visual-sheet::before,
.visual-sheet::after {
position: absolute;
left: 14px;
width: 48px;
height: 6px;
border-radius: 6px;
background: #dce8ff;
content: '';
}
.visual-sheet::before {
top: 20px;
}
.visual-sheet::after {
top: 36px;
width: 30px;
}
.visual-bubble {
position: absolute;
right: 34px;
bottom: 16px;
display: flex;
width: 72px;
height: 42px;
align-items: center;
justify-content: center;
border-radius: 14px;
background: #7ba4ff;
color: #ffffff;
font-size: 19px;
letter-spacing: 3px;
box-shadow: 0 8px 14px rgba(47, 107, 255, 0.16);
}
.visual-bubble::after {
position: absolute;
right: 10px;
bottom: -7px;
width: 14px;
height: 14px;
background: #7ba4ff;
content: '';
transform: rotate(35deg);
}
.prep-card {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin-top: 16px;
padding: 0;
border: 0;
background: transparent;
}
.prep-mini {
display: flex;
min-height: 68px;
flex-direction: column;
justify-content: center;
gap: 4px;
padding: 8px;
border: 1px solid #dce8ff;
border-radius: 12px;
background: #f8fbff;
}
.prep-mini .prep-label {
color: var(--employee-primary);
font-size: 15px;
font-weight: 800;
}
.prep-mini text:last-child {
color: var(--employee-muted);
font-size: 11px;
line-height: 1.3;
}
.scenario-card .primary-button {
margin-top: 18px;
background: linear-gradient(135deg, #2f6bff, #5a8cff);
box-shadow: 0 8px 16px rgba(47, 107, 255, 0.18);
}
.login-panel {
border-left-color: var(--employee-primary);
}
.assignment-row {
border-color: #dbe7ff;
}
.assignment-row.highlighted {
border-color: #9bbaff;
background: #f4f7ff;
}
.assignment-badge,
.assignment-badge.daily,
.scenario-badge {
background: var(--employee-primary-soft);
color: var(--employee-primary);
}
.recent-panel,
.daily-panel {
margin-top: 14px;
padding: 18px;
border: 0;
border-radius: var(--employee-card-radius);
background: #ffffff;
box-shadow: 0 8px 24px rgba(24, 28, 36, 0.05);
}
.recent-link,
.daily-action-label {
color: var(--employee-muted);
font-size: 14px;
}
.recent-row {
display: flex;
min-height: 64px;
align-items: center;
margin-top: 0;
padding: 10px 0;
border: 0;
border-bottom: 1px solid #f0f1f3;
border-radius: 0;
box-shadow: none;
}
.recent-icon {
display: flex;
width: 42px;
height: 42px;
flex: 0 0 42px;
align-items: center;
justify-content: center;
margin-right: 12px;
border-radius: 50%;
background: var(--employee-primary-soft);
color: var(--employee-primary);
font-size: 24px;
font-weight: 800;
}
.recent-row .assignment-copy {
min-width: 0;
flex: 1;
}
.recent-row:last-child {
border-bottom: 0;
}
.recent-row .assignment-title {
font-size: 17px;
}
.recent-row .assignment-desc {
margin-top: 4px;
color: var(--employee-muted);
}
.recent-arrow {
color: #8c9098;
font-size: 25px;
}
.daily-panel .assignment-head {
margin-top: 0;
}
.daily-subtitle {
display: block;
margin-top: 4px;
color: var(--employee-muted);
font-size: 14px;
}
.daily-empty {
display: flex;
min-height: 72px;
flex-direction: column;
justify-content: center;
gap: 5px;
margin-top: 14px;
padding: 0 14px;
border: 1px dashed #cfe0ff;
border-radius: 14px;
background: #f8fbff;
color: var(--employee-muted);
font-size: 13px;
line-height: 1.45;
text-align: center;
}
.daily-empty text:first-child {
color: var(--employee-primary);
font-size: 15px;
font-weight: 800;
}
.daily-row {
display: block;
margin-top: 12px;
border-color: #f0f1f3;
border-radius: 14px;
box-shadow: none;
}
.daily-row .daily-answer {
margin-top: 8px;
}
.daily-row .small-button {
background: linear-gradient(135deg, #2f6bff, #5a8cff);
box-shadow: 0 6px 12px rgba(47, 107, 255, 0.16);
}
.capability-strip {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0;
margin-top: 14px;
padding: 16px 8px;
border-radius: var(--employee-card-radius);
background: #ffffff;
box-shadow: 0 8px 24px rgba(24, 28, 36, 0.05);
}
.capability-strip view {
display: flex;
flex-direction: column;
gap: 4px;
align-items: center;
border-right: 1px solid #ececee;
}
.capability-strip view:last-child {
border-right: 0;
}
.capability-strip text:first-child {
color: var(--employee-text);
font-size: 16px;
font-weight: 800;
}
.capability-strip text:last-child {
color: var(--employee-muted);
font-size: 11px;
}
.practice-head .page-title {
color: #1f2024;
font-size: 29px;
letter-spacing: 0.5px;
}
.practice-head .page-subtitle {
color: #8a8d93;
}
.scenario-card {
padding: 20px 16px 16px;
border-radius: 24px;
background: #ffffff;
box-shadow: 0 10px 26px rgba(24, 28, 36, 0.06);
}
.scenario-headline {
max-width: none;
gap: 6px;
}
.scenario-title {
color: #25282d;
font-size: 27px;
}
.scenario-desc {
color: #8a8d93;
font-size: 14px;
}
.scenario-persona {
display: flex;
align-items: center;
gap: 12px;
margin-top: 2px;
padding: 14px;
border: 1px solid #ffd9dc;
border-radius: 16px;
background: #fff8f8;
}
.persona-avatar {
display: flex;
width: 48px;
height: 48px;
flex: 0 0 48px;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #ffe1e4;
color: #e70014;
font-size: 19px;
font-weight: 900;
}
.persona-copy {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 4px;
}
.persona-title {
color: #25282d;
font-size: 16px;
font-weight: 900;
}
.persona-desc {
color: #8a8d93;
font-size: 12px;
}
.persona-state {
padding: 6px 9px;
border-radius: 12px;
background: #fff0f1;
color: #e70014;
font-size: 12px;
font-weight: 800;
}
.prep-sections {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 2px;
}
.prep-section {
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px;
border: 1px solid #f0f1f3;
border-radius: 20px;
background: #ffffff;
}
.prep-section-title {
color: #25282d;
font-size: 19px;
font-weight: 900;
}
.prep-section-redline {
border-color: #ffe0e2;
background: #fffafa;
}
.prep-section-redline .prep-section-title,
.prep-section-redline .prep-mark {
color: #e70014;
}
.prep-line {
display: block;
margin-top: 0;
color: #858990;
font-size: 13px;
line-height: 1.5;
}
.prep-mark {
display: inline-block;
width: 17px;
color: #8c929a;
font-weight: 900;
}
.scenario-card .primary-button {
margin-top: 4px;
background: linear-gradient(135deg, #ed1f28, #ff4b4d);
box-shadow: 0 8px 16px rgba(231, 0, 20, 0.18);
}
</style>