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

3124 lines
84 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">{{ practicePageTitle }}</text>
<text class="page-subtitle">{{ practicePageSubtitle }}</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 && practiceView === 'prep'" class="panel scenario-card employee-card">
<view class="scenario-headline">
<text class="scenario-title">{{ activeScenarioName }}</text>
<text class="scenario-desc">{{ positionLabel }} · 业主服务场景</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 v-if="status === 'idle' || status === 'finished'" class="realtime-voice-picker">
<view class="realtime-voice-heading">
<text class="realtime-voice-title">实时陪练音色</text>
</view>
<picker :range="realtimeVoiceOptions" range-key="label" :value="realtimeVoiceIndex" :disabled="busy" @change="selectRealtimeVoice">
<view class="realtime-voice-select" aria-label="选择实时陪练音色">
<text class="realtime-voice-value">{{ realtimeVoiceLabel }}</text>
<text class="realtime-voice-arrow">›</text>
</view>
</picker>
<text class="realtime-voice-hint">当前模型完整官方音色(56项);下次开练生效</text>
</view>
<button
v-if="status === 'idle' || status === 'finished'"
class="secondary-button realtime-start-button"
:disabled="busy"
@click="startRealtime"
>
实时陪练 Beta
</button>
<text class="realtime-beta-note">自然打断、边听边说;本次体验不计入训练成绩。</text>
</view>
<view v-if="loggedIn && practiceView === 'prep'" 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"><uni-icons :type="item.icon" size="22" color="#e60012" /></view>
<view class="assignment-copy">
<text v-if="item.sourceLabel" class="assignment-badge" :class="item.assignment?.source || 'manual'">
{{ item.sourceLabel }}
</text>
<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 && practiceView === 'prep'" 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>
<button
class="turn-voice-button daily-question-voice"
:disabled="speechState.status === 'loading' && speechState.key !== `daily-${item.id}`"
@click.stop="speakGenerated(`daily-${item.id}`, item.question || item.scenarioName)"
>
{{ speechButtonLabel(`daily-${item.id}`) }}
</button>
</view>
<view class="daily-answer">
<textarea
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>
<view v-if="item.status === '已完成'" class="daily-feedback-card">
<view class="daily-feedback-head">
<text class="daily-feedback-title">点评与参考答案</text>
<button
class="turn-voice-button"
:disabled="speechState.status === 'loading' && speechState.key !== `daily-result-${item.id}`"
@click.stop="speakGenerated(`daily-result-${item.id}`, dailyFeedbackSpeech(item))"
>
{{ speechButtonLabel(`daily-result-${item.id}`) }}
</button>
</view>
<text v-if="item.feedback" class="daily-feedback-copy">点评:{{ item.feedback }}</text>
<text v-if="item.referenceAnswer" class="daily-reference-copy">参考答案:{{ item.referenceAnswer }}</text>
<view class="item-feedback-actions">
<text>这道题对工作有帮助吗?</text>
<button :class="{ active: item.itemFeedback === 'useful' }" :disabled="feedbackSubmittingId === item.id" @click.stop="submitItemFeedback(item.id, 'useful')">有用</button>
<button :class="{ active: item.itemFeedback === 'not_useful' }" :disabled="feedbackSubmittingId === item.id" @click.stop="submitItemFeedback(item.id, 'not_useful')">无用</button>
</view>
</view>
<button class="primary-button small-button" :disabled="Boolean(answeringId) || item.status === '已完成'" @click="submitDaily(item)">
{{ item.status === '已完成' ? '已完成' : '提交' }}
</button>
</view>
</view>
</view>
<view v-else class="daily-empty">
<text>今日暂无题目</text>
<text>完成专项训练后会自动生成每日练习</text>
</view>
</view>
<view v-if="loggedIn && practiceView === 'prep'" 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="practiceView === 'active'" class="practice-chat">
<view class="chat-head">
<view class="chat-head-row">
<text class="chat-title">{{ activeScenarioName }}</text>
<text class="chat-context">{{ realtimeActive ? `实时 · ${realtimeStatusLabel}` : `第 ${Math.min(roundIndex + 1, 3)}/3 轮` }}</text>
</view>
<view v-if="hasCoachState" class="coach-strip">
<view class="coach-metric">
<text class="coach-metric-label">情绪 {{ emotionScore }}</text>
<view class="state-bar"><view class="state-fill emotion-fill" :style="{ width: `${emotionScore}%` }" /></view>
</view>
<view class="coach-metric">
<text class="coach-metric-label">信任 {{ trustScore }}</text>
<view class="state-bar"><view class="state-fill trust-fill" :style="{ width: `${trustScore}%` }" /></view>
</view>
</view>
<text v-if="redFlagActive" class="chat-red-flag">红线纠偏:先承接诉求,避免推责、绝对承诺或情绪对抗。</text>
</view>
<scroll-view scroll-y class="chat-thread" :scroll-top="threadScrollTop" :scroll-with-animation="true">
<view class="thread-inner">
<template v-for="(turn, index) in turns" :key="index">
<view v-if="turn.role === 'customer'" class="bubble-row left">
<view class="avatar customer-avatar">业</view>
<view class="master-col">
<view v-if="turnVoice(index).status !== 'unavailable'" class="voice-line">
<view
class="bubble master voice-bubble"
:class="{ playing: isTurnPlaying(index) }"
@click="playTurnVoice(index, turn.text)"
>
<uni-icons
class="voice-icon"
:class="{ playing: isTurnPlaying(index) }"
type="sound"
size="17"
:color="isTurnPlaying(index) ? '#e70014' : '#5a6270'"
/>
<text class="voice-duration">{{ turnVoiceLabel(index) }}</text>
</view>
<view v-if="turnVoice(index).unread" class="voice-dot" />
<button class="transcript-toggle" @click="toggleTurnTranscript(index)">
{{ turnVoice(index).open ? '收起' : '转文字' }}
</button>
</view>
<view v-if="turnVoice(index).status === 'unavailable' || turnVoice(index).open" class="bubble master">
<text class="bubble-text">{{ turn.text }}</text>
</view>
</view>
</view>
<view v-else-if="turn.role === 'coach'" class="bubble-row left">
<view class="avatar coach-avatar">傅</view>
<view class="bubble coach">
<text class="coach-label">老师傅提示</text>
<text class="bubble-text">{{ turn.text }}</text>
<view class="bubble-actions">
<button
class="bubble-action"
:disabled="speechState.status === 'loading' && speechState.key !== `turn-${index}`"
@click="speakGenerated(`turn-${index}`, turn.text)"
>
{{ speechButtonLabel(`turn-${index}`) }}
</button>
<button class="bubble-action" @click="adoptCoachHint(turn.text)">用这句话回复</button>
</view>
</view>
</view>
<view v-else class="bubble-row right">
<view class="bubble user">
<text class="bubble-text">{{ turn.text }}</text>
</view>
</view>
</template>
<view v-if="helpAnswer" class="bubble-row left">
<view class="avatar coach-avatar">傅</view>
<view class="bubble coach">
<text class="coach-label">SOP 提示</text>
<text class="bubble-text">{{ helpAnswer }}</text>
<view v-if="helpRefs.length" class="help-ref-list">
<text v-for="item in helpRefs" :key="item" class="citation-meta">📄 {{ item }}</text>
</view>
</view>
</view>
<view v-if="status === 'submitting'" class="system-row">
<text class="system-text">业主回应中…</text>
</view>
<view v-if="message" class="system-row">
<text class="system-text">{{ message }}</text>
</view>
<view :style="{ height: '12px' }" />
</view>
</scroll-view>
<view v-if="realtimeActive" class="realtime-practice-panel">
<view>
<text class="realtime-practice-title">实时陪练 Beta</text>
<text class="realtime-practice-copy">直接说话即可;本次内容仅供即时练习,不写入训练成绩或主管复盘。</text>
</view>
<view class="realtime-practice-actions">
<button v-if="realtimeAudioBlocked" class="secondary-button realtime-resume-button" @click="resumeRealtimeAudio">点击播放</button>
<button class="secondary-button realtime-stop-button" @click="stopRealtime">结束体验</button>
</view>
</view>
<view v-if="!realtimeActive && canAdjustCoach" class="style-chip-row">
<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>
<ChatComposer
v-if="!realtimeActive"
ref="composerRef"
:disabled="busy"
:transcribing="transcribing"
placeholder="先承接业主情绪,再说明处理动作和反馈时间。"
:maxlength="500"
:panel-items="practicePanelItems"
@send="handleComposerSend"
@voice-blob="handleSessionVoiceBlob"
@pick-audio="composerPickAudio"
@panel="handlePanelAction"
/>
</view>
<view v-if="result" class="panel result-card employee-card">
<view class="result-title-row">
<text class="result-label">本次评分</text>
<button
class="turn-voice-button result-voice-button"
:disabled="speechState.status === 'loading' && speechState.key !== 'practice-result'"
@click="speakGenerated('practice-result', practiceResultSpeech())"
>
{{ speechButtonLabel('practice-result') }}
</button>
</view>
<text class="result-score">{{ result.totalScore }}</text>
<text class="result-comment">{{ result.aiComment }}</text>
<view v-if="result.mentorRewrite" class="mentor-rewrite">
<text>老师傅建议</text>
<text>{{ result.mentorRewrite }}</text>
</view>
<view v-if="completedAssignmentId" class="item-feedback-actions result-feedback-actions">
<text>这个专项对工作有帮助吗?</text>
<button :class="{ active: completedAssignmentFeedback === 'useful' }" :disabled="feedbackSubmittingId === completedAssignmentId" @click="submitItemFeedback(completedAssignmentId, 'useful')">有用</button>
<button :class="{ active: completedAssignmentFeedback === 'not_useful' }" :disabled="feedbackSubmittingId === completedAssignmentId" @click="submitItemFeedback(completedAssignmentId, 'not_useful')">无用</button>
</view>
</view>
<view v-if="result" class="panel satisfaction-card employee-card">
<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 v-if="result" class="result-actions">
<button class="secondary-button" @click="openPracticeHistory">查看训练记录</button>
<button class="primary-button tabbar-safe-action" @click="start(selectedScenarioId)">再练一次</button>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, nextTick, ref } from 'vue';
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
import ChatComposer from '@/components/chat/ChatComposer.vue';
import type {
PracticeFinishResponse,
PracticePrepCard,
PracticeAssignment,
PracticeRole,
SpeechVoiceProfile,
SpeechPlaybackStatus,
PracticeTurn,
PracticeRecord
} from '@/types/api';
import { answerDailyDrill, finishPractice, getPracticeAssignments, getPracticeHistory, getPracticePrepCard, startPractice, submitPracticeQuestionFeedback, submitPracticeSatisfaction, submitPracticeTurn } from '@/services/practice';
import { getSelectedPosition, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { searchKnowledge } from '@/services/knowledge';
import { resetPageScroll } from '@/services/navigation';
import { ensureEmployeePosition } from '@/services/position';
import { chooseSpeechAudio, createSpeechPlaybackController, customerVoiceProfile, measureAudioDuration, MENTOR_VOICE_PROFILE, normalizeOssId, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech';
import { createPageRequestScope, currentAccountKey, type RequestScopeTicket } from '@/services/request-scope';
import { createRealtimePracticeController, DEFAULT_REALTIME_PRACTICE_VOICE, isRealtimePracticeVoice, REALTIME_PRACTICE_VOICE_OPTIONS, type RealtimePracticeVoice } from '@/services/realtime-practice';
import { stripInternalCodes } from '@/services/text';
type PracticeStatus = 'idle' | 'starting' | 'active' | 'submitting' | 'finished';
type RecentPracticeItem = {
id: string;
title: string;
desc: string;
sourceLabel?: string;
status: string;
icon: string;
assignment?: PracticeAssignment;
};
const defaultScenarioId = 'complaint-water';
const defaultScenarioName = '投诉接待';
const pendingAssignmentKey = 'aihr_pending_assignment_id';
const realtimeVoiceStorageKey = 'aihr_realtime_practice_voice';
const realtimeVoiceOptions = REALTIME_PRACTICE_VOICE_OPTIONS;
const storedRealtimeVoice = uni.getStorageSync(realtimeVoiceStorageKey);
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 positionLabel = ref(getSelectedPosition() || '生活顾问');
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 realtimeActive = ref(false);
const realtimeStatus = ref<'idle' | 'connecting' | 'connected' | 'listening' | 'speaking'>('idle');
const realtimeAudioBlocked = ref(false);
const realtimeVoice = ref<RealtimePracticeVoice>(
isRealtimePracticeVoice(storedRealtimeVoice) ? storedRealtimeVoice : DEFAULT_REALTIME_PRACTICE_VOICE
);
const realtimeVoiceIndex = computed(() => Math.max(0, realtimeVoiceOptions.findIndex((item) => item.value === realtimeVoice.value)));
const realtimeVoiceLabel = computed(() => realtimeVoiceOptions[realtimeVoiceIndex.value].label);
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, string | undefined>>({});
const assignmentLoading = ref(false);
const answeringId = ref<number | null>(null);
const activeAssignmentId = ref<number | undefined>();
const completedAssignmentId = ref<number | undefined>();
const completedAssignmentFeedback = ref<'useful' | 'not_useful' | null>(null);
const feedbackSubmittingId = ref<number | null>(null);
const highlightedAssignmentId = ref<number | null>(null);
const loggedIn = ref(false);
const recording = ref(false);
const recordingStarting = ref(false);
const recordingAssignmentId = ref<number | null>(null);
const transcribing = ref(false);
const draftAudioUrl = ref('');
const draftAudioOssId = ref<string | undefined>();
const speechState = ref<{ key: string; status: SpeechPlaybackStatus }>({ key: '', status: 'idle' });
const mediaRecorder = ref<MediaRecorder | null>(null);
const mediaStream = ref<MediaStream | null>(null);
let recordingCaptureGeneration = 0;
let transcriptionGeneration = 0;
let abortTranscription: (() => void) | null = null;
let visibleAccountKey = '';
const requests = createPageRequestScope();
const speechPlayback = createSpeechPlaybackController(
(state) => { speechState.value = state; },
(text) => {
message.value = text;
uni.showToast({ title: text, icon: 'none' });
},
MENTOR_VOICE_PROFILE
);
const busy = computed(() => status.value === 'starting' || status.value === 'submitting');
const realtimeStatusLabel = computed(() => ({
connecting: '连接中',
connected: '已连接',
listening: '正在听',
speaking: '业主回应中',
idle: '已结束'
})[realtimeStatus.value]);
const practiceView = computed<'prep' | 'active' | 'result'>(() => {
if (result.value) return 'result';
return status.value === 'active' || status.value === 'submitting' ? 'active' : 'prep';
});
const practicePageTitle = computed(() => {
if (practiceView.value === 'result') return '训练完成';
return practiceView.value === 'active' ? '场景对练' : '训练前预习';
});
const practicePageSubtitle = computed(() => {
if (practiceView.value === 'result') return `${activeScenarioName.value} · ${Math.max(roundIndex.value, 1)} 轮对话`;
if (practiceView.value === 'active') return `${activeScenarioName.value} · 第 ${Math.min(roundIndex.value + 1, 3)}/3 轮`;
return '先掌握要点,再开始场景练习';
});
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 === '已完成' ? '已完成' : '待复盘',
sourceLabel: assignmentSourceLabel(item.source),
status: item.status,
icon: item.scenarioName?.includes('催费') ? 'wallet' : item.scenarioName?.includes('报修') ? 'gear' : 'chatbubble',
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('催费') ? 'wallet' : item.scene?.includes('报修') ? 'gear' : 'chatbubble'
}));
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 speechButtonLabel = (key: string) => {
if (speechState.value.key !== key) return '语音播报';
if (speechState.value.status === 'loading') return '生成中';
if (speechState.value.status === 'playing') return '停止播报';
return '语音播报';
};
const speakGenerated = (key: string, text?: string) => {
void speechPlayback.toggle(key, text || '');
};
/* ===== chat-session presentation (WeChat-style, batch 2) ===== */
const composerRef = ref<InstanceType<typeof ChatComposer> | null>(null);
const threadScrollTop = ref(0);
let threadScrollSeq = 0;
const scrollThreadToBottom = () => {
void nextTick(() => {
threadScrollSeq += 200;
threadScrollTop.value = 100000 + threadScrollSeq;
});
};
interface TurnVoice {
status: 'preparing' | 'ready' | 'unavailable';
duration: number | null;
unread: boolean;
open: boolean;
profile: SpeechVoiceProfile;
}
const turnVoices = ref<Record<number, TurnVoice>>({});
const turnVoice = (index: number): TurnVoice =>
turnVoices.value[index] || {
status: 'unavailable',
duration: null,
unread: false,
open: false,
profile: customerVoiceProfile()
};
const patchTurnVoice = (index: number, patch: Partial<TurnVoice>) => {
turnVoices.value = { ...turnVoices.value, [index]: { ...turnVoice(index), ...patch } };
};
/** Customer lines arrive as WeChat-style voice messages: pre-synthesize so the
* bubble shows a duration and an unread dot; fall back to text when TTS is off. */
const prepareTurnVoice = async (index: number, text: string, emotion = 0) => {
const spoken = (text || '').trim();
if (!spoken) return;
const profile = customerVoiceProfile(emotion);
patchTurnVoice(index, { status: 'preparing', duration: null, unread: true, open: false, profile });
const source = await speechPlayback.preload(spoken, profile);
if (!turnVoices.value[index]) return;
if (!source) {
patchTurnVoice(index, { status: 'unavailable', unread: false });
return;
}
const duration = await measureAudioDuration(source);
patchTurnVoice(index, { status: 'ready', duration });
};
const isTurnPlaying = (index: number) =>
speechState.value.key === `turn-${index}` && speechState.value.status === 'playing';
const turnVoiceLabel = (index: number) => {
if (speechState.value.key === `turn-${index}` && speechState.value.status === 'loading') return '…';
const voice = turnVoice(index);
if (voice.status === 'preparing') return '…';
return voice.duration ? `${Math.min(voice.duration, 60)}"` : '语音';
};
const playTurnVoice = (index: number, text: string) => {
if (turnVoice(index).status === 'preparing') {
uni.showToast({ title: '语音生成中,请稍候', icon: 'none' });
return;
}
patchTurnVoice(index, { unread: false });
void speechPlayback.toggle(`turn-${index}`, (text || '').trim(), turnVoice(index).profile);
};
const toggleTurnTranscript = (index: number) => {
patchTurnVoice(index, { open: !turnVoice(index).open });
};
const practicePanelItems = computed(() => [
{ key: 'audio', icon: 'sound', label: '选音频转文字', disabled: transcribing.value },
{ key: 'help', icon: 'help', label: helpLoading.value ? '求助中…' : '求助 SOP', disabled: helpLoading.value },
{ key: 'finish', icon: 'checkmark', label: '结束评分', disabled: !canFinishPractice.value }
]);
const handlePanelAction = (key: string) => {
if (key === 'audio') {
void composerPickAudio();
} else if (key === 'help') {
void loadHelp();
} else if (key === 'finish') {
void finish();
}
};
const handleComposerSend = (text: string) => {
draft.value = text;
void submit();
};
/** 旧 H5「建议回复」逻辑的会话版:把老师傅建议填入输入框,员工可改后发送。 */
const adoptCoachHint = (text: string) => {
const value = (text || '').trim();
if (!value) return;
composerRef.value?.setDraft(value);
uni.showToast({ title: '已填入输入框,可修改后发送', icon: 'none' });
};
const submitTranscribed = async (text: string, audioUrl: string, ossId?: string) => {
draft.value = text;
draftAudioUrl.value = audioUrl;
draftAudioOssId.value = ossId;
await submit();
};
const handleSessionVoiceBlob = async (blob: Blob) => {
if (transcribing.value || busy.value) return;
const generation = ++transcriptionGeneration;
transcribing.value = true;
try {
const asr = await transcribeSpeechBlob(blob, 'practice-turn.webm', (abort) => {
if (generation === transcriptionGeneration) abortTranscription = abort;
});
if (generation !== transcriptionGeneration) return;
const text = (asr.text || '').trim();
if (!text) {
uni.showToast({ title: '语音没有识别到文字,请再说一遍', icon: 'none' });
return;
}
await submitTranscribed(text, asr.audioUrl || '', normalizeOssId(asr.ossId));
} catch (error) {
if (generation !== transcriptionGeneration) return;
uni.showToast({ title: error instanceof Error ? error.message : '语音转写失败,请改用文字输入', icon: 'none' });
} finally {
if (generation === transcriptionGeneration) {
abortTranscription = null;
transcribing.value = false;
}
}
};
const composerPickAudio = async () => {
if (busy.value || transcribing.value) return;
const generation = ++transcriptionGeneration;
transcribing.value = true;
try {
const file = await chooseSpeechAudio();
if (generation !== transcriptionGeneration) return;
const asr = await transcribeSpeechFile(file, (abort) => {
if (generation === transcriptionGeneration) abortTranscription = abort;
});
if (generation !== transcriptionGeneration) return;
const text = (asr.text || '').trim();
if (!text) {
uni.showToast({ title: '语音没有识别到文字,请再试一次', icon: 'none' });
return;
}
await submitTranscribed(text, asr.audioUrl || '', normalizeOssId(asr.ossId));
} catch (error) {
if (generation !== transcriptionGeneration) return;
const text = error instanceof Error ? error.message : '语音转写失败,请改用文字输入';
if (text !== '未选择音频') uni.showToast({ title: text, icon: 'none' });
} finally {
if (generation === transcriptionGeneration) {
abortTranscription = null;
transcribing.value = false;
}
}
};
const dailyFeedbackSpeech = (item: PracticeAssignment) => [
typeof item.score === 'number' ? `本题得分${item.score}分` : '',
item.feedback ? `点评,${item.feedback}` : '',
item.referenceAnswer ? `参考答案,${item.referenceAnswer}` : ''
].filter(Boolean).join('。');
const practiceResultSpeech = () => {
if (!result.value) return '';
return [
`本次评分${result.value.totalScore}分`,
result.value.aiComment ? `点评,${result.value.aiComment}` : '',
result.value.mentorRewrite ? `老师傅建议,${result.value.mentorRewrite}` : ''
].filter(Boolean).join('。');
};
const assignmentDesc = (item: PracticeAssignment) => {
if (item.source === 'daily' && item.status !== '已完成') return '提交后显示反馈与参考答案';
if (item.source === 'daily' && item.status === '已完成') {
const score = typeof item.score === 'number' ? `得分 ${item.score} 分` : '';
const mode = item.scoreMode === 'LLM' ? 'AI语义评估' : '规则兜底';
return [score, mode, stripInternalCodes(item.feedback || item.reason || '')].filter(Boolean).join(' · ');
}
return stripInternalCodes(item.feedback || item.reason || '');
};
const assignmentSourceLabel = (source?: string) => {
if (source === 'retry') return '错题再练';
if (source === 'camp') return '训练营';
return '专项训练';
};
const goLogin = () => {
rememberLoginRedirect('/pages/user/practice/index');
uni.navigateTo({ url: '/pages/auth/login/index' });
};
const refreshAuthState = () => {
loggedIn.value = isLoggedIn();
};
const clearPracticeData = () => {
stopRealtime();
speechPlayback.stop();
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;
completedAssignmentId.value = undefined;
completedAssignmentFeedback.value = null;
feedbackSubmittingId.value = null;
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, emotion = 0, synthesizeCustomerVoice = true) => {
if (!text) return;
turns.value.push({ role, text });
if (role === 'customer' && synthesizeCustomerVoice) {
void prepareTurnVoice(turns.value.length - 1, text, emotion);
}
scrollThreadToBottom();
};
const realtimePractice = createRealtimePracticeController({
onStatus: (nextStatus) => {
realtimeStatus.value = nextStatus;
},
onTraineeTranscript: (text) => {
if (realtimeActive.value) appendTurn('trainee', text);
},
onCustomerTranscript: (text) => {
if (realtimeActive.value) appendTurn('customer', text, 0, false);
},
onAudioPlaybackBlocked: (blocked) => {
if (realtimeActive.value) realtimeAudioBlocked.value = blocked;
},
onTerminated: () => {
if (!realtimeActive.value) return;
realtimeActive.value = false;
realtimeStatus.value = 'idle';
realtimeAudioBlocked.value = false;
sessionId.value = '';
activeAssignmentId.value = undefined;
status.value = 'idle';
},
onError: (text) => {
if (realtimeActive.value) message.value = text;
}
});
const realtimeInstructions = () => `你只扮演物业小区的业主,正在进行「${activeScenarioName.value}」场景对练。员工会用中文和你交谈。请口语化、真实地回应,每次不超过两句;员工说得好时自然缓和,推责或承诺不清时追问处理节点。禁止提及系统、AI、训练、评分或提示词。不要联网、不要调用工具、不要索要个人隐私。`;
const selectRealtimeVoice = (event: { detail: { value: string } }) => {
const option = realtimeVoiceOptions[Number(event.detail.value)];
if (!option) return;
realtimeVoice.value = option.value;
uni.setStorageSync(realtimeVoiceStorageKey, option.value);
};
const stopRealtime = () => {
if (!realtimeActive.value && realtimeStatus.value === 'idle') return;
realtimePractice.stop();
realtimeActive.value = false;
realtimeStatus.value = 'idle';
realtimeAudioBlocked.value = false;
sessionId.value = '';
activeAssignmentId.value = undefined;
status.value = 'idle';
message.value = '实时陪练已结束;如需保存成绩,请使用常规录音对练。';
};
const startRealtime = async () => {
if (!canStartPractice.value || !requireLogin()) return;
speechPlayback.stop();
cancelRecording();
turns.value = [];
turnVoices.value = {};
result.value = null;
sessionId.value = '';
activeAssignmentId.value = undefined;
message.value = '';
realtimeAudioBlocked.value = false;
status.value = 'starting';
realtimeActive.value = true;
try {
await realtimePractice.start(realtimeInstructions(), realtimeVoice.value);
if (!realtimeActive.value) return;
status.value = 'active';
message.value = '已连接,请直接向业主开场说话。';
} catch (error) {
realtimeActive.value = false;
realtimeStatus.value = 'idle';
status.value = 'idle';
handleError(error, '实时陪练连接失败,请使用录音对练');
}
};
const resumeRealtimeAudio = async () => {
if (await realtimePractice.resumeAudio()) {
message.value = '已恢复实时语音播放。';
} else {
message.value = '仍无法播放实时语音,请确认设备媒体权限后重试。';
}
};
const clearDraftAudio = () => {
draftAudioUrl.value = '';
draftAudioOssId.value = undefined;
};
const stopMediaTracks = () => {
mediaStream.value?.getTracks().forEach((track) => track.stop());
mediaStream.value = null;
};
const cancelTranscription = () => {
transcriptionGeneration += 1;
abortTranscription?.();
abortTranscription = null;
transcribing.value = false;
};
const cancelRecording = () => {
recordingCaptureGeneration += 1;
cancelTranscription();
recordingStarting.value = false;
const recorder = mediaRecorder.value;
if (recorder && recorder.state !== 'inactive') {
recorder.ondataavailable = null;
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 || '';
draftAudioOssId.value = normalizeOssId(result.ossId);
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 = normalizeOssId(result.ossId);
dailyAudioOssIds.value = {
...dailyAudioOssIds.value,
[assignmentId]: ossId
};
message.value = '每日一练语音已转成文字,请确认后提交';
};
const transcribeBlob = async (blob: Blob, assignmentId?: number) => {
if (transcribing.value) return;
const generation = ++transcriptionGeneration;
transcribing.value = true;
message.value = '语音转写中';
try {
const result = await transcribeSpeechBlob(blob, 'practice-audio.webm', (abort) => {
if (generation === transcriptionGeneration) abortTranscription = abort;
});
if (generation !== transcriptionGeneration) return;
if (assignmentId === undefined) {
applyAsrResult(result);
} else {
applyDailyAsrResult(assignmentId, result);
}
} catch (error) {
if (generation !== transcriptionGeneration) return;
handleError(error, '语音转写失败');
} finally {
if (generation === transcriptionGeneration) {
abortTranscription = null;
transcribing.value = false;
}
}
};
const pickAudio = async (assignmentId?: number) => {
if (busy.value || recording.value || transcribing.value) return;
if (!requireLogin()) return;
const generation = ++transcriptionGeneration;
transcribing.value = true;
message.value = '语音转写中';
try {
const file = await chooseSpeechAudio();
if (generation !== transcriptionGeneration) return;
const result = await transcribeSpeechFile(file, (abort) => {
if (generation === transcriptionGeneration) abortTranscription = abort;
});
if (generation !== transcriptionGeneration) return;
if (assignmentId === undefined) {
applyAsrResult(result);
} else {
applyDailyAsrResult(assignmentId, result);
}
} catch (error) {
if (generation !== transcriptionGeneration) return;
handleError(error, '语音转写失败');
} finally {
if (generation === transcriptionGeneration) {
abortTranscription = null;
transcribing.value = false;
}
}
};
const startRecording = async (assignmentId?: number) => {
if (busy.value || recordingStarting.value || recording.value || transcribing.value) return;
if (!requireLogin()) return;
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
await pickAudio(assignmentId);
return;
}
const generation = ++recordingCaptureGeneration;
recordingStarting.value = true;
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
if (generation !== recordingCaptureGeneration) {
stream.getTracks().forEach((track) => track.stop());
return;
}
mediaStream.value = stream;
const recorder = new MediaRecorder(stream);
const chunks: Blob[] = [];
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;
stopMediaTracks();
if (generation === recordingCaptureGeneration) await pickAudio(assignmentId);
} finally {
if (generation === recordingCaptureGeneration) recordingStarting.value = false;
}
};
const stopRecording = () => {
const recorder = mediaRecorder.value;
if (!recording.value || !recorder || recorder.state === 'inactive') return;
message.value = '录音结束,正在转写';
recorder.stop();
};
const toggleRecording = (assignmentId?: number) => {
if (recordingStarting.value) return;
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 (request: RequestScopeTicket = requests.begin('assignments')) => {
refreshAuthState();
if (!loggedIn.value) {
clearPracticeData();
return;
}
assignmentLoading.value = true;
try {
const nextAssignments = await getPracticeAssignments(true);
if (!requests.isCurrent(request)) return;
let nextHistory: PracticeRecord[] = [];
try {
nextHistory = await getPracticeHistory();
} catch {
nextHistory = [];
}
if (!requests.isCurrent(request)) return;
assignments.value = nextAssignments;
practiceHistory.value = nextHistory;
drillAnswers.value = Object.fromEntries(nextAssignments.map((item) => [item.id, item.userAnswer || '']));
dailyAudioUrls.value = Object.fromEntries(
nextAssignments.filter((item) => item.audioUrl).map((item) => [item.id, item.audioUrl || ''])
);
dailyAudioOssIds.value = Object.fromEntries(
nextAssignments
.filter((item) => item.audioOssId !== undefined && item.audioOssId !== null)
.map((item) => [item.id, normalizeOssId(item.audioOssId)])
);
consumePendingAssignment();
} catch (error) {
if (!requests.isCurrent(request)) return;
handleError(error, '今日任务加载失败');
} finally {
if (requests.isCurrent(request)) assignmentLoading.value = false;
}
};
const openRecentItem = (item: RecentPracticeItem) => {
if (item.assignment) {
startAssignment(item.assignment);
return;
}
uni.navigateTo({ url: '/pages/user/profile/index' });
};
const openPracticeHistory = () => {
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;
}
const request = requests.begin(`daily-${item.id}`);
answeringId.value = item.id;
message.value = '';
try {
const result = await answerDailyDrill(item.id, answer, audioUrl, audioOssId);
if (!requests.isCurrent(request)) return;
assignments.value = assignments.value.map((row) =>
row.id === item.id
? {
...row,
status: result.status,
userAnswer: answer,
feedback: result.feedback,
referenceAnswer: result.referenceAnswer,
correct: result.correct,
score: result.score,
scoreMode: result.scoreMode,
audioUrl: result.audioUrl || audioUrl,
audioOssId: result.audioOssId || audioOssId
}
: row
);
message.value = `每日三题得分 ${result.score}(${result.scoreMode === 'LLM' ? 'AI语义评估' : '规则兜底'})`;
} catch (error) {
if (!requests.isCurrent(request)) return;
handleError(error, '每日三题提交失败');
} finally {
if (requests.isCurrent(request)) answeringId.value = null;
}
};
const submitItemFeedback = async (assignmentId: number, verdict: 'useful' | 'not_useful') => {
if (feedbackSubmittingId.value) return;
const request = requests.begin(`feedback-${assignmentId}`);
feedbackSubmittingId.value = assignmentId;
try {
await submitPracticeQuestionFeedback(assignmentId, verdict);
if (!requests.isCurrent(request)) return;
assignments.value = assignments.value.map((item) => item.id === assignmentId ? { ...item, itemFeedback: verdict } : item);
if (completedAssignmentId.value === assignmentId) completedAssignmentFeedback.value = verdict;
message.value = verdict === 'useful' ? '已记录为有用' : '已记录为无用,题目会进入优化队列';
} catch (error) {
if (!requests.isCurrent(request)) return;
handleError(error, '题目反馈提交失败');
} finally {
if (requests.isCurrent(request)) feedbackSubmittingId.value = null;
}
};
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,
request: RequestScopeTicket = requests.begin('prep-card')
) => {
refreshAuthState();
if (!loggedIn.value) {
prepCard.value = null;
return;
}
prepLoading.value = true;
try {
const nextPrepCard = await getPracticePrepCard(scenarioId);
if (!requests.isCurrent(request)) return;
prepCard.value = nextPrepCard;
} catch (error) {
if (!requests.isCurrent(request)) return;
prepCard.value = null;
handleError(error, '预习卡加载失败');
} finally {
if (requests.isCurrent(request)) prepLoading.value = false;
}
};
const start = async (scenarioId = defaultScenarioId, assignmentId?: number) => {
if (!canStartPractice.value) return;
if (!requireLogin()) return;
stopRealtime();
const request = requests.begin('session-start');
speechPlayback.stop();
cancelRecording();
selectedScenarioId.value = scenarioId;
status.value = 'starting';
message.value = '';
result.value = null;
satisfactionScore.value = 0;
satisfactionComment.value = '';
satisfactionSubmitted.value = false;
turns.value = [];
turnVoices.value = {};
threadScrollTop.value = 0;
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);
if (!requests.isCurrent(request)) return;
activeScenarioName.value = data.scenarioName || activeScenarioName.value;
emotionScore.value = data.emotion ?? 0;
trustScore.value = data.trust ?? 0;
sessionId.value = data.sessionId;
appendTurn('customer', data.customerText, data.emotion);
status.value = 'active';
} catch (error) {
if (!requests.isCurrent(request)) return;
status.value = 'idle';
activeAssignmentId.value = undefined;
handleError(error, '训练启动失败');
}
};
const loadHelp = async () => {
if (helpLoading.value || busy.value) return;
if (!requireLogin()) return;
const request = requests.begin('help');
helpLoading.value = true;
message.value = '';
try {
const data = await searchKnowledge(`${activeScenarioName.value} SOP 处理要点`);
if (!requests.isCurrent(request)) return;
if (!data.answer) {
throw new Error('未检索到可用 SOP 提示');
}
helpAnswer.value = data.answer;
helpRefs.value = (data.snippets || []).map((item) => item.docTitle).filter(Boolean).slice(0, 3);
} catch (error) {
if (!requests.isCurrent(request)) return;
handleError(error, '求助失败');
} finally {
if (requests.isCurrent(request)) 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;
const request = requests.begin('coach-adjust');
adjustingStyle.value = style;
message.value = '';
try {
const data = await submitPracticeTurn({
sessionId: sessionId.value,
roundIndex: lastCoachRoundIndex.value,
traineeText: lastTraineeText.value,
regenerate: true,
style
});
if (!requests.isCurrent(request)) return;
replaceLastCoachHint(data.coachHint);
emotionScore.value = data.emotion ?? emotionScore.value;
trustScore.value = data.trust ?? trustScore.value;
redFlagActive.value = Boolean(data.redFlag);
if (data.message) message.value = data.message;
} catch (error) {
if (!requests.isCurrent(request)) return;
handleError(error, '建议调整失败');
} finally {
if (requests.isCurrent(request)) adjustingStyle.value = '';
}
};
const submit = async () => {
if (busy.value) return;
const text = draft.value.trim();
if (!text) {
message.value = '请先输入回应';
return;
}
const request = requests.begin('turn-submit');
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
});
if (!requests.isCurrent(request)) return;
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, data.emotion);
roundIndex.value = data.roundIndex;
status.value = 'active';
} catch (error) {
if (!requests.isCurrent(request)) return;
status.value = 'active';
turns.value = previousTurns;
draft.value = text;
draftAudioUrl.value = audioUrl || '';
draftAudioOssId.value = audioOssId;
handleError(error, '提交失败');
uni.showToast({ title: message.value || '提交失败', icon: 'none', duration: 3000 });
}
};
const finish = async (force = false) => {
if (!force && busy.value) return;
if (!sessionId.value) return;
if (!canFinishPractice.value) {
message.value = redFlagActive.value ? '请先按老师傅建议重说本轮' : '请先完成至少一轮回应';
return;
}
const request = requests.begin('finish');
status.value = 'submitting';
message.value = '';
try {
const assignmentId = activeAssignmentId.value;
const nextResult = await finishPractice(sessionId.value, assignmentId);
if (!requests.isCurrent(request)) return;
result.value = nextResult;
resetPageScroll();
status.value = 'finished';
message.value = '训练完成,主管端待复盘已更新';
completedAssignmentId.value = assignmentId;
completedAssignmentFeedback.value = null;
activeAssignmentId.value = undefined;
await loadAssignments();
} catch (error) {
if (!requests.isCurrent(request)) return;
status.value = 'active';
handleError(error, '评分失败');
}
};
const sendSatisfaction = async () => {
if (!sessionId.value || satisfactionScore.value < 1 || satisfactionScore.value > 5 || satisfactionBusy.value) return;
const request = requests.begin('satisfaction');
satisfactionBusy.value = true;
message.value = '';
try {
await submitPracticeSatisfaction(sessionId.value, satisfactionScore.value, satisfactionComment.value.trim());
if (!requests.isCurrent(request)) return;
satisfactionSubmitted.value = true;
} catch (error) {
if (!requests.isCurrent(request)) return;
handleError(error, '体验反馈提交失败');
} finally {
if (requests.isCurrent(request)) satisfactionBusy.value = false;
}
};
const handleShow = async () => {
const pageRequest = requests.begin('page');
resetPageScroll();
refreshAuthState();
if (loggedIn.value && (!(await ensureEmployeePosition('/pages/user/practice/index')) || !requests.isCurrent(pageRequest))) {
return;
}
if (!requests.isCurrent(pageRequest)) return;
positionLabel.value = getSelectedPosition() || '生活顾问';
void loadAssignments(requests.begin('assignments'));
void loadPrepCard(selectedScenarioId.value, requests.begin('prep-card'));
};
const invalidateRequests = () => {
requests.invalidate();
assignmentLoading.value = false;
prepLoading.value = false;
answeringId.value = null;
feedbackSubmittingId.value = null;
helpLoading.value = false;
adjustingStyle.value = '';
satisfactionBusy.value = false;
if (status.value === 'starting') status.value = 'idle';
if (status.value === 'submitting') status.value = sessionId.value ? 'active' : 'idle';
};
onShow(() => {
const nextAccountKey = currentAccountKey();
if (visibleAccountKey && visibleAccountKey !== nextAccountKey) clearPracticeData();
visibleAccountKey = nextAccountKey;
requests.activate();
void handleShow();
});
onHide(() => {
stopRealtime();
invalidateRequests();
speechPlayback.stop();
cancelRecording();
});
onUnload(() => {
stopRealtime();
invalidateRequests();
speechPlayback.destroy();
cancelRecording();
});
</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;
}
.scenario-card .realtime-start-button {
width: 100%;
margin-top: 10px;
border-color: #bed3f2;
background: #f5f9ff;
color: #315b8f;
}
.realtime-voice-picker {
margin-top: 12px;
padding: 12px;
border: 1px solid #dbe8fb;
border-radius: 14px;
background: #f7fbff;
}
.realtime-voice-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.realtime-voice-title,
.realtime-voice-hint,
.realtime-voice-value {
display: block;
}
.realtime-voice-title {
color: #315b8f;
font-size: 13px;
font-weight: 800;
}
.realtime-voice-hint {
margin-top: 8px;
color: #6d7a8d;
font-size: 11px;
}
.realtime-voice-select {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 10px;
width: 100%;
min-height: 44px;
padding: 0 12px;
border-color: #c8d9f2;
border-radius: 10px;
background: #ffffff;
color: #315b8f;
}
.realtime-voice-value {
overflow: hidden;
color: #315b8f;
font-size: 13px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.realtime-voice-arrow {
flex: 0 0 auto;
color: #6d7a8d;
font-size: 22px;
}
.realtime-beta-note {
display: block;
color: #6d7a8d;
font-size: 12px;
line-height: 1.5;
text-align: center;
}
.realtime-practice-panel {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 18px;
border-top: 1px solid #dbe8fb;
background: #f7fbff;
}
.realtime-practice-title,
.realtime-practice-copy {
display: block;
}
.realtime-practice-title {
color: #315b8f;
font-size: 14px;
font-weight: 800;
}
.realtime-practice-copy {
max-width: 220px;
margin-top: 4px;
color: #6d7a8d;
font-size: 12px;
line-height: 1.45;
}
.realtime-practice-actions {
display: flex;
flex: 0 0 auto;
flex-direction: column;
gap: 8px;
}
.realtime-stop-button,
.realtime-resume-button {
flex: 0 0 auto;
min-width: 82px;
padding: 0 10px;
}
.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: #f3dadd;
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: #e60012;
}
.trust-fill {
background: #8c929a;
}
.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: #e60012;
background: #fff0f1;
}
.turn-row.coach {
border-left-color: #ef8992;
background: #fff7f7;
}
.reply-actions button.recording-active {
border-color: #e60012;
background: #fff0f1;
color: #e60012;
}
.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: #f0c7cb;
background: #ffffff;
color: #e60012;
}
.reply-card {
border-top: 0;
box-shadow: var(--employee-card-shadow);
}
.practice-textarea {
min-height: 124px;
border-color: #f3dadd;
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: #fff0f1;
color: #e60012;
font-size: 12px;
font-weight: 800;
}
.result-label {
color: #6d7076;
font-size: 14px;
font-weight: 800;
}
.result-score {
color: #e60012;
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 #e60012;
border-radius: 6px;
background: #fff0f1;
}
.mentor-rewrite text:first-child {
color: #e60012;
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: #e60012;
background: #fff0f1;
color: #e60012;
}
.satisfaction-textarea {
min-height: 76px;
}
.satisfaction-thanks {
color: #168243;
font-size: 14px;
font-weight: 800;
}
.result-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-top: 14px;
}
.result-actions button {
width: 100%;
min-width: 0;
}
/* 练页入口态:与员工端蓝白轻卡片体系保持一致 */
.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: 28px 20px 16px;
border-bottom: 0;
background: #f7f7f7;
text-align: center;
}
.practice-head::after,
.practice-eyebrow,
.practice-tip {
display: none;
}
.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: 12px;
line-height: 1.3;
}
.scenario-card .primary-button {
margin-top: 2px;
background: var(--employee-primary);
box-shadow: 0 8px 16px rgba(230, 0, 18, 0.18);
}
.login-panel {
border-left-color: var(--employee-primary);
}
.assignment-row {
border-color: #f3dadd;
}
.assignment-row.highlighted {
border-color: #ef8992;
background: #fff7f7;
}
.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);
line-height: 1;
}
.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 {
display: flex;
width: 100%;
align-items: stretch;
flex-direction: column;
margin-top: 8px;
}
.daily-row .small-button {
width: 100%;
margin-top: 10px;
background: linear-gradient(135deg, #ed1f28, #ff4b4d);
box-shadow: 0 6px 12px rgba(230, 0, 18, 0.16);
}
.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: 12px;
}
.practice-head .page-title {
color: #1f2024;
font-size: 30px;
letter-spacing: 0.5px;
}
.practice-head .page-subtitle {
color: #8a8d93;
}
.scenario-card {
padding: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.scenario-headline {
max-width: none;
min-height: 42px;
flex-direction: row;
align-items: center;
gap: 6px;
padding: 0 14px;
border: 1px solid #ffd1d5;
border-radius: 22px;
background: #fff7f7;
}
.scenario-title {
color: var(--employee-primary);
font-size: 14px;
}
.scenario-desc {
color: var(--employee-primary);
font-size: 13px;
}
.scenario-persona {
display: flex;
align-items: center;
gap: 12px;
margin-top: 14px;
padding: 14px;
border: 0;
border-radius: 20px;
background: #ffffff;
}
.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: 14px;
}
.prep-section {
display: flex;
flex-direction: column;
gap: 7px;
padding: 15px 16px;
border: 0;
border-radius: 20px;
background: #ffffff;
}
.prep-section-title {
color: #25282d;
font-size: 19px;
font-weight: 900;
}
.prep-section-redline {
background: #ffffff;
}
.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.42;
}
.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);
}
.turn-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.turn-voice-button {
display: flex;
width: auto;
min-width: 76px;
height: 32px;
align-items: center;
justify-content: center;
padding: 0 10px;
border: 1px solid #f2b9bf;
border-radius: 999px;
background: #ffffff;
color: #e60012;
font-size: 12px;
font-weight: 800;
line-height: 1.2;
}
.daily-question-voice {
margin: 8px 0 0;
}
.daily-feedback-card {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 10px;
padding: 12px;
border: 1px solid #f4c9cd;
border-radius: 14px;
background: #fff8f8;
}
.daily-feedback-head,
.result-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.daily-feedback-title {
color: var(--employee-text);
font-size: 13px;
font-weight: 800;
}
.daily-feedback-copy,
.daily-reference-copy {
color: var(--employee-text);
font-size: 13px;
line-height: 1.65;
}
.item-feedback-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin-top: 4px;
color: var(--employee-muted);
font-size: 12px;
}
.item-feedback-actions button {
min-height: 34px;
padding: 0 12px;
border: 1px solid #d4dce6;
border-radius: 999px;
background: #fff;
color: #4f5c70;
font-size: 12px;
}
.item-feedback-actions button.active {
border-color: #ef9da5;
background: var(--employee-primary-soft);
color: var(--employee-primary);
font-weight: 900;
}
.result-feedback-actions {
padding-top: 12px;
border-top: 1px solid var(--employee-line);
}
.daily-reference-copy {
color: var(--employee-muted);
}
.result-voice-button {
flex: none;
}
/* ===== chat session (WeChat-style, batch 2) ===== */
.practice-chat {
position: fixed;
top: var(--window-top, 0px);
right: 0;
bottom: var(--window-bottom, 0px);
left: 0;
display: flex;
flex-direction: column;
min-height: 0;
padding: 0;
background: #f2f3f5;
overflow: hidden;
z-index: 10;
}
.practice-chat .chat-head {
flex-shrink: 0;
padding: 8px 12px 8px;
background: #ffffff;
border-bottom: 1px solid #ebedf0;
}
.chat-head-row {
display: flex;
align-items: baseline;
justify-content: space-between;
}
.chat-title {
color: #18202b;
font-size: 16px;
font-weight: 800;
}
.chat-context {
color: #8a93a0;
font-size: 12px;
}
.coach-strip {
display: flex;
gap: 14px;
margin-top: 8px;
}
.coach-metric {
flex: 1;
}
.coach-metric-label {
display: block;
margin-bottom: 3px;
color: #5a6270;
font-size: 11px;
}
.chat-red-flag {
display: block;
margin-top: 7px;
color: #bd0010;
font-size: 12px;
}
.chat-thread {
flex: 1;
min-height: 0;
}
.thread-inner {
display: flex;
flex-direction: column;
gap: 12px;
padding: 14px 12px 6px;
}
.bubble-row {
display: flex;
align-items: flex-start;
gap: 8px;
}
.bubble-row.right {
justify-content: flex-end;
}
.avatar {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 8px;
font-size: 15px;
font-weight: 700;
}
.customer-avatar {
background: #e70014;
color: #ffffff;
}
.coach-avatar {
background: #1f5c9d;
color: #ffffff;
}
.bubble {
max-width: 82%;
padding: 10px 12px;
border-radius: 12px;
word-break: break-word;
}
.bubble.master {
background: #ffffff;
border-top-left-radius: 4px;
box-shadow: 0 1px 3px rgba(20, 24, 31, 0.06);
}
.bubble.coach {
background: #eef4fb;
border-top-left-radius: 4px;
}
.bubble.user {
background: #ffd8dc;
border-top-right-radius: 4px;
}
.coach-label {
display: block;
margin-bottom: 4px;
color: #1f5c9d;
font-size: 11px;
font-weight: 700;
}
.bubble-text {
display: block;
color: #232a33;
font-size: 15px;
line-height: 1.55;
white-space: pre-wrap;
}
.master-col {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
max-width: 82%;
}
.master-col .bubble {
max-width: 100%;
}
.voice-line {
display: flex;
align-items: center;
gap: 8px;
}
.voice-bubble {
display: flex;
align-items: center;
gap: 8px;
min-width: 96px;
padding: 11px 14px;
cursor: pointer;
}
.voice-bubble.playing {
background: #ffe9eb;
}
.voice-icon {
line-height: 1;
}
.voice-icon.playing {
animation: voice-icon-pulse 1s ease-in-out infinite;
}
@keyframes voice-icon-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.voice-duration {
color: #232a33;
font-size: 15px;
font-weight: 600;
}
.voice-dot {
flex-shrink: 0;
width: 9px;
height: 9px;
border-radius: 50%;
background: #f43b30;
}
.transcript-toggle {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
min-height: 28px;
margin: 0;
padding: 0 12px;
border: none;
border-radius: 8px;
background: rgba(20, 24, 31, 0.06);
color: #6b7480;
font-size: 12px;
line-height: 1;
}
.bubble-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.bubble-action {
display: flex;
align-items: center;
justify-content: center;
min-height: 28px;
margin: 0;
padding: 0 12px;
border: 1px solid #d9e4f0;
border-radius: 999px;
background: #ffffff;
color: #38424e;
font-size: 12px;
line-height: 1;
}
.help-ref-list {
display: flex;
flex-direction: column;
gap: 3px;
margin-top: 6px;
}
.citation-meta {
color: #6b7480;
font-size: 11px;
}
.system-row {
display: flex;
justify-content: center;
padding: 2px 0;
}
.system-text {
max-width: 86%;
padding: 4px 12px;
border-radius: 999px;
background: rgba(20, 24, 31, 0.06);
color: #6b7480;
font-size: 12px;
text-align: center;
line-height: 1.5;
}
.style-chip-row {
display: flex;
gap: 8px;
padding: 8px 10px 0;
background: #f7f8fa;
border-top: 1px solid #ebedf0;
overflow-x: auto;
}
.style-chip-row .chip {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
min-height: 30px;
margin: 0;
padding: 0 12px;
border: 1px solid #cddcec;
border-radius: 999px;
background: #ffffff;
color: #1f5c9d;
font-size: 13px;
line-height: 1;
}
.style-chip-row + .chat-composer-wrap,
.practice-chat .chat-composer-wrap {
border-top: none;
}
</style>