Files
prop-ai-hr/mobile-uni/src/pages/user/practice/index.vue
T
admin 6b8c89bcdb feat(mobile): owner identity selector on realtime practice
- default persona falls back to owner-calm; picker below persona cards
  (compact single row), default own community via defaultProfileId,
  storage persistence incl. explicit none, hidden on empty/failure
- profileId passed to realtime session creation when selected
2026-08-05 03:18:50 +08:00

4024 lines
112 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" :style="{ paddingTop: safeTopPadding(12) }">
<text class="page-title">{{ practicePageTitle }}</text>
<text class="page-subtitle">{{ practicePageSubtitle }}</text>
</view>
<view v-if="message && (!loggedIn || practiceView !== 'prep')" 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 realtime-entry-card employee-card">
<view class="realtime-entry-head">
<view class="realtime-title-row">
<text class="scenario-title">实时陪练</text>
<text class="realtime-beta-badge">Beta</text>
</view>
<text class="scenario-desc">选一个角色,直接开口练;无需等待场景发布。</text>
</view>
<view v-if="realtimePersonaOptions.length" class="realtime-persona-picker">
<text class="realtime-persona-title">选择陪练角色</text>
<scroll-view class="scene-option-scroll realtime-persona-scroll" scroll-x>
<view class="scene-option-list">
<button
v-for="item in realtimePersonaOptions"
:key="item.id"
class="scene-option realtime-persona-option"
:class="{ selected: selectedRealtimePersonaId === item.id }"
:disabled="busy"
@click="selectRealtimePersona(item)"
>
<text class="scene-option-name">{{ item.name }}</text>
<text class="scene-option-capability">{{ item.hasTools ? '可查询知识库' : '纯对练' }}</text>
</button>
</view>
</scroll-view>
</view>
<view v-if="showOwnerProfilePicker" class="owner-profile-row">
<text class="owner-profile-label">业主身份</text>
<picker
class="owner-profile-picker"
mode="selector"
:range="ownerProfilePickerOptions"
:value="ownerProfilePickerIndex"
:disabled="busy"
@change="handleOwnerProfileChange"
>
<view class="owner-profile-value">
<text class="owner-profile-value-text">{{ ownerProfilePickerOptions[ownerProfilePickerIndex] }}</text>
<text class="owner-profile-arrow" aria-hidden="true">›</text>
</view>
</picker>
</view>
<button
v-if="status === 'idle' || status === 'finished'"
class="primary-button realtime-start-button"
:disabled="!canStartRealtime || busy"
@click="startRealtime"
>
开始实时陪练
</button>
<text class="realtime-beta-note">不计入成绩,可随时结束</text>
</view>
<view v-if="message && loggedIn && practiceView === 'prep'" class="status-message status-message-compact">{{ message }}</view>
<view v-if="loggedIn && practiceView === 'prep' && (curriculumLoading || availableScenarios.length)" class="panel scene-picker-card employee-card">
<view class="scene-picker-head">
<view>
<text class="panel-title">场景训练</text>
<text class="scene-picker-subtitle">选择已发布、已启用的 {{ positionLabel }} 场景</text>
</view>
<text v-if="curriculumLoading" class="scene-picker-loading">加载中</text>
</view>
<scroll-view v-if="availableScenarios.length" class="scene-option-scroll" scroll-x>
<view class="scene-option-list">
<button
v-for="item in availableScenarios"
:key="item.id"
class="scene-option"
:class="{ selected: selectedScenarioId === item.id }"
:disabled="busy"
@click="selectPracticeScenario(item)"
>
<text class="scene-option-level">{{ item.growthLevel || '基础' }}</text>
<text class="scene-option-name">{{ item.name }}</text>
<text class="scene-option-capability">{{ item.competencyCode || '待运营归类' }}</text>
</button>
</view>
</scroll-view>
<text v-else-if="!curriculumLoading" class="scene-picker-empty">当前岗位暂无已发布场景,请联系内容运营完成审核发布。</text>
</view>
<view v-else-if="loggedIn && practiceView === 'prep'" class="panel scene-empty-card employee-card">
<view>
<text class="panel-title">常规场景</text>
<text class="scene-picker-subtitle">发布后可录音对练并保存成绩</text>
</view>
<text class="scene-empty-state">暂未开放</text>
</view>
<view v-if="loggedIn && practiceView === 'prep' && availableScenarios.length" class="panel scenario-card practice-scenario-card employee-card">
<view class="scenario-headline">
<text class="scenario-title">{{ activeScenarioName }}</text>
<text class="scenario-desc">{{ selectedScenarioDescription }}</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="!canStartPractice || busy"
@click="start(selectedScenarioId)"
>
{{ status === 'finished' ? '再练一次' : '我已准备好,开始练习' }}
</button>
</view>
<view v-if="loggedIn && practiceView === 'prep'" class="panel growth-card employee-card">
<view class="growth-card-head">
<view>
<text class="panel-title">我的成长</text>
<text class="growth-meta">住宅物业 · {{ positionLabel }}</text>
</view>
<text class="growth-level-badge">{{ currentGrowthLevel }}</text>
</view>
<view class="growth-summary">
<view class="growth-summary-copy">
<text>当前进度</text>
<text>{{ currentGrowthProgress }}%</text>
</view>
<view class="growth-summary-track" aria-hidden="true">
<view class="growth-summary-fill" :style="{ width: `${currentGrowthProgress}%` }" />
</view>
</view>
<button class="growth-toggle" :aria-expanded="growthDetailsExpanded" @click="growthDetailsExpanded = !growthDetailsExpanded">
{{ growthDetailsExpanded ? '收起成长规则' : '查看成长规则' }}
<text aria-hidden="true">{{ growthDetailsExpanded ? '⌃' : '⌄' }}</text>
</button>
<view v-if="growthDetailsExpanded" class="growth-details">
<text v-if="growthConfirmation" class="growth-confirmation">
主管已确认可进入{{ growthConfirmation.confirmedGrowthLevel }}{{ growthConfirmation.confirmedTime ? ` · ${growthConfirmation.confirmedTime}` : '' }}
</text>
<view v-if="growthPath.length" class="growth-path-list">
<view v-for="item in growthPath" :key="item.level" class="growth-path-row" :class="{ reached: item.status === '已达成' }">
<view class="growth-path-copy">
<text class="growth-path-level">{{ item.level }}</text>
<text class="growth-path-evidence">{{ item.evidence }}</text>
</view>
<text class="growth-path-status">{{ item.status }} · {{ item.progress }}%</text>
</view>
</view>
<view v-if="capabilityProgress.length" class="capability-progress-list">
<text class="capability-progress-title">已覆盖能力项</text>
<view v-for="item in capabilityProgress.slice(0, 3)" :key="`${item.growthLevel}-${item.competencyCode}`" class="capability-progress-row">
<text>{{ item.growthLevel }} · {{ item.competencyCode }}</text>
<text>{{ item.completed }} 次 · 均分 {{ item.averageScore }}</text>
</view>
</view>
<text v-else class="growth-empty">完成训练后,这里会显示岗位、层级和能力项证据。</text>
<text class="growth-note">成长记录仅作训练建议,不自动改变职级、薪酬或晋升资格。</text>
</view>
</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" :style="{ paddingTop: safeTopPadding(8) }">
<view class="chat-head-row">
<text class="chat-title">{{ activeScenarioName }}</text>
<text class="chat-context">{{ realtimeActive ? `实时 · ${realtimeStatusLabel}` : `第 ${Math.min(roundIndex + 1, activeRoundCount)}/${activeRoundCount} 轮` }}</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">
<view v-if="realtimeActive && !turns.length" class="realtime-listening-empty">
<text class="realtime-listening-label">{{ realtimeStatusLabel }}</text>
<text class="realtime-listening-title">直接开口说话</text>
<text class="realtime-listening-copy">实时对话会显示在这里</text>
</view>
<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"
:disabled="replySuggestionLoading !== null || busy"
@click="adoptCoachHint(turn, index)"
>
{{ replySuggestionLoading === index ? '生成中…' : '用这句话回复' }}
</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-capture="handleSessionVoiceCapture"
@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" :disabled="!canStartPractice || busy" @click="start(selectedScenarioId)">再练一次</button>
</view>
<!-- #ifdef APP-PLUS -->
<RealtimePracticeAppBridge
ref="realtimeAppBridge"
@status="handleRealtimeStatus"
@trainee-transcript="handleRealtimeTraineeTranscript"
@customer-transcript="handleRealtimeCustomerTranscript"
@audio-playback-blocked="handleRealtimeAudioPlaybackBlocked"
@terminated="handleRealtimeTerminated"
@error="handleRealtimeError"
/>
<!-- #endif -->
</view>
</template>
<script setup lang="ts">
import { computed, nextTick, ref } from 'vue';
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
import { isPlatformNativeActivityActive } from '@/services/file-selection-lifecycle';
import ChatComposer from '@/components/chat/ChatComposer.vue';
import RealtimePracticeAppBridge from '@/components/practice/RealtimePracticeAppBridge.vue';
import type {
PracticeFinishResponse,
PracticePrepCard,
PracticeAssignment,
PracticeRole,
PracticeTtsContext,
SpeechVoiceProfile,
SpeechPlaybackStatus,
PracticeTurn,
PracticeRecord,
PracticeScenarioOption,
CompetencyProfile,
GrowthStage,
CapabilityProgress
} from '@/types/api';
import { answerDailyDrill, finishPractice, getCompetencyProfile, getPracticeAssignments, getPracticeHistory, getPracticePrepCard, getPracticeScenarios, recordPracticeHelp, 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, transcribeSpeechCapture, transcribeSpeechFile } from '@/services/speech';
import { isNativeAppRuntime, startSpeechCapture } from '@/services/speech-capture';
import type { SpeechCapture, SpeechCaptureSession } from '@/services/speech-capture';
import { createPageRequestScope, currentAccountKey, type RequestScopeTicket } from '@/services/request-scope';
import { createRealtimePracticeController, createRealtimeSession, fetchOwnerProfiles, fetchRealtimePersonas, invokeRealtimeTool, type OwnerProfileOption, type RealtimePersonaOption } from '@/services/realtime-practice';
import { stripInternalCodes } from '@/services/text';
import { describeMicrophoneError, microphoneDeniedMessage, requestMicrophonePermission } from '@/utils/microphone';
import { isFileSelectionCancelled } from '@/utils/file-selection';
import { safeTopPadding } from '@/utils/safe-area';
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 realtimePersonaStorageKey = 'aihr_realtime_practice_persona';
const storedRealtimePersona = uni.getStorageSync(realtimePersonaStorageKey);
// 业主身份选择持久化:存 profileId;用户显式选「不指定」时存 NONE 标记,
// 以便与「从未选择」(用服务端 defaultProfileId 作首次默认)区分。
const ownerProfileStorageKey = 'aihr_realtime_owner_profile';
const ownerProfileNoneMarker = 'NONE';
const status = ref<PracticeStatus>('idle');
const sessionId = ref('');
const roundIndex = ref(0);
const activeRoundCount = ref(3);
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 availableScenarios = ref<PracticeScenarioOption[]>([]);
const competencyProfile = ref<CompetencyProfile | null>(null);
const curriculumLoading = ref(false);
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 replySuggestionLoading = ref<number | null>(null);
const emotionScore = ref(0);
const trustScore = ref(0);
const redFlagActive = ref(false);
const realtimeActive = ref(false);
const realtimeStatus = ref<'idle' | 'connecting' | 'connected' | 'listening' | 'speaking' | 'calling-tool'>('idle');
const realtimeAudioBlocked = ref(false);
const realtimeSessionId = ref('');
const realtimePersonaOptions = ref<RealtimePersonaOption[]>([]);
const selectedRealtimePersonaId = ref(typeof storedRealtimePersona === 'string' ? storedRealtimePersona : '');
const ownerProfiles = ref<OwnerProfileOption[]>([]);
const selectedOwnerProfileId = ref('');
const realtimeAppBridge = ref<{
start: (sessionUpdate: Record<string, unknown>, sessionId?: string) => Promise<void>;
stop: () => void;
resumeAudio: () => Promise<boolean>;
} | null>(null);
const growthDetailsExpanded = 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, 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' });
let recordingCapture: SpeechCaptureSession | 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: '回应中',
'calling-tool': '正在查知识库…',
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' ? '场景对练' : 'AI 陪练';
});
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, activeRoundCount.value)}/${activeRoundCount.value} 轮`;
return '选一种方式,马上开始';
});
const selectedScenario = computed(() =>
availableScenarios.value.find((item) => item.id === selectedScenarioId.value)
);
const canStartPractice = computed(() =>
!curriculumLoading.value
&& (status.value === 'idle' || status.value === 'finished')
&& Boolean(selectedScenario.value?.id)
);
const canStartRealtime = computed(() =>
status.value === 'idle' || status.value === 'finished'
);
// 业主身份选择器仅在选中 owner 类人设且服务端返回了档案时展示;
// 档案为空或接口失败时整体隐藏,静默降级为无画像对练。
const showOwnerProfilePicker = computed(() =>
selectedRealtimePersonaId.value.startsWith('owner') && ownerProfiles.value.length > 0
);
const ownerProfilePickerOptions = computed(() => ['不指定', ...ownerProfiles.value.map((item) => item.label)]);
const ownerProfilePickerIndex = computed(() => {
const index = ownerProfiles.value.findIndex((item) => item.profileId === selectedOwnerProfileId.value);
return index < 0 ? 0 : index + 1;
});
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 selectedScenarioDescription = computed(() => {
const item = selectedScenario.value;
return [positionLabel.value, item?.growthLevel || '基础训练', item?.competencyCode || '业主服务场景']
.filter(Boolean)
.join(' · ');
});
const growthConfirmation = computed(() =>
(competencyProfile.value?.growthConfirmations || [])
.find((item) => item.position === positionLabel.value)
);
const currentGrowthLevel = computed(() => {
const confirmed = growthConfirmation.value?.confirmedGrowthLevel || '';
if (['初级', '中级', '高级'].includes(confirmed)) return confirmed;
const level = competencyProfile.value?.aiLevel || '';
return ['初级', '中级', '高级'].includes(level) ? level : '初级';
});
const growthPath = computed<GrowthStage[]>(() => competencyProfile.value?.growthPath || []);
const currentGrowthProgress = computed(() =>
growthPath.value.find((item) => item.level === currentGrowthLevel.value)?.progress || 0
);
const capabilityProgress = computed<CapabilityProgress[]>(() =>
(competencyProfile.value?.capabilityProgress || [])
.filter((item) => !item.position || item.position === positionLabel.value)
);
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;
practiceContext?: PracticeTtsContext;
requestKey?: string;
}
const turnVoices = ref<Record<number, TurnVoice>>({});
let turnVoiceGeneration = 0;
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, customerTurnIndex?: number) => {
const spoken = (text || '').trim();
if (!spoken) return;
const generation = turnVoiceGeneration;
const profile = customerVoiceProfile(emotion);
const practiceContext: PracticeTtsContext | undefined = sessionId.value && Number.isInteger(customerTurnIndex)
? { sessionId: sessionId.value, turnIndex: Number(customerTurnIndex), role: 'customer' }
: undefined;
const requestKey = `${generation}:${index}:${spoken}:${practiceContext?.sessionId || ''}:${practiceContext?.turnIndex ?? ''}`;
const isCurrentVoiceRequest = () =>
turnVoiceGeneration === generation && turnVoices.value[index]?.requestKey === requestKey;
patchTurnVoice(index, { status: 'preparing', duration: null, unread: true, open: false, profile, practiceContext, requestKey });
const source = await speechPlayback.preload(spoken, profile, practiceContext);
if (!isCurrentVoiceRequest()) return;
if (!source) {
patchTurnVoice(index, { status: 'unavailable', unread: false });
return;
}
const duration = await measureAudioDuration(source);
if (!isCurrentVoiceRequest()) return;
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, turnVoice(index).practiceContext);
};
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();
};
const adoptCoachHint = async (turn: PracticeTurn, index: number) => {
if (busy.value || replySuggestionLoading.value !== null || !sessionId.value || !Number.isInteger(turn.roundIndex)) return;
const request = requests.begin('reply-suggestion');
replySuggestionLoading.value = index;
message.value = '';
try {
const data = await submitPracticeTurn({
sessionId: sessionId.value,
roundIndex: turn.roundIndex as number,
traineeText: '',
suggestReply: true
});
if (!requests.isCurrent(request)) return;
const value = (data.suggestedReply || '').trim();
if (!value) throw new Error(data.message || '未生成可用回复');
composerRef.value?.setDraft(value);
uni.showToast({ title: '已生成回复,可修改后发送', icon: 'none' });
} catch (error) {
if (!requests.isCurrent(request)) return;
handleError(error, '回复生成失败');
uni.showToast({ title: message.value || '回复生成失败', icon: 'none' });
} finally {
if (requests.isCurrent(request)) replySuggestionLoading.value = null;
}
};
const submitTranscribed = async (text: string, audioUrl: string, ossId?: string) => {
draft.value = text;
draftAudioUrl.value = audioUrl;
draftAudioOssId.value = ossId;
await submit();
};
const handleSessionVoiceCapture = async (capture: SpeechCapture) => {
if (transcribing.value || busy.value) return;
const generation = ++transcriptionGeneration;
transcribing.value = true;
try {
const asr = await transcribeSpeechCapture(capture, '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;
if (!isFileSelectionCancelled(error)) {
const text = error instanceof Error ? error.message : '语音转写失败,请改用文字输入';
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 = () => {
turnVoiceGeneration += 1;
stopRealtime();
speechPlayback.stop();
cancelRecording();
assignments.value = [];
practiceHistory.value = [];
availableScenarios.value = [];
competencyProfile.value = null;
curriculumLoading.value = false;
drillAnswers.value = {};
dailyAudioUrls.value = {};
dailyAudioOssIds.value = {};
prepCard.value = null;
prepLoading.value = false;
turns.value = [];
turnVoices.value = {};
result.value = null;
satisfactionScore.value = 0;
satisfactionComment.value = '';
satisfactionBusy.value = false;
satisfactionSubmitted.value = false;
sessionId.value = '';
roundIndex.value = 0;
activeRoundCount.value = 3;
draft.value = '';
status.value = 'idle';
selectedScenarioId.value = defaultScenarioId;
activeScenarioName.value = defaultScenarioName;
ownerProfiles.value = [];
selectedOwnerProfileId.value = '';
helpLoading.value = false;
helpAnswer.value = '';
helpRefs.value = [];
lastTraineeText.value = '';
lastCoachRoundIndex.value = null;
adjustingStyle.value = '';
replySuggestionLoading.value = null;
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, customerTurnIndex?: number, coachRoundIndex?: number) => {
if (!text) return;
turns.value.push({ role, text, roundIndex: coachRoundIndex });
if (role === 'customer' && synthesizeCustomerVoice) {
void prepareTurnVoice(turns.value.length - 1, text, emotion, customerTurnIndex);
}
scrollThreadToBottom();
};
const handleRealtimeStatus = (nextStatus: typeof realtimeStatus.value) => {
realtimeStatus.value = nextStatus;
};
const handleRealtimeTraineeTranscript = (text: string) => {
if (realtimeActive.value) appendTurn('trainee', text);
};
const handleRealtimeCustomerTranscript = (text: string) => {
if (realtimeActive.value) appendTurn('customer', text, 0, false);
};
const handleRealtimeAudioPlaybackBlocked = (blocked: boolean) => {
if (realtimeActive.value) realtimeAudioBlocked.value = blocked;
};
const handleRealtimeTerminated = () => {
if (!realtimeActive.value) return;
realtimeActive.value = false;
realtimeStatus.value = 'idle';
realtimeAudioBlocked.value = false;
realtimeSessionId.value = '';
sessionId.value = '';
activeAssignmentId.value = undefined;
status.value = 'idle';
};
const handleRealtimeError = (text: string) => {
// 会话过程中的底层错误同样可能是 Chromium 英文原文,统一翻译后再展示。
if (realtimeActive.value) message.value = describeMicrophoneError(text, '实时语音出现异常,请结束后重试');
};
const realtimePractice = createRealtimePracticeController({
onStatus: handleRealtimeStatus,
onTraineeTranscript: handleRealtimeTraineeTranscript,
onCustomerTranscript: handleRealtimeCustomerTranscript,
onAudioPlaybackBlocked: handleRealtimeAudioPlaybackBlocked,
onToolCall: (callId, name, argsJson) =>
invokeRealtimeTool(realtimeSessionId.value, callId, name, argsJson).then((res) => res.output),
onTerminated: handleRealtimeTerminated,
onError: handleRealtimeError
});
const selectRealtimePersona = (item: RealtimePersonaOption) => {
if (busy.value) return;
selectedRealtimePersonaId.value = item.id;
uni.setStorageSync(realtimePersonaStorageKey, item.id);
};
const loadRealtimePersonas = async (request: RequestScopeTicket = requests.begin('realtime-personas')) => {
try {
const options = await fetchRealtimePersonas();
if (!requests.isCurrent(request)) return;
realtimePersonaOptions.value = options;
if (!options.some((item) => item.id === selectedRealtimePersonaId.value)) {
// 「业主·急躁」下线后默认选中「业主」(owner-calm),其余情况回落首项
selectedRealtimePersonaId.value = options.find((item) => item.id === 'owner-calm')?.id || options[0]?.id || '';
}
} catch {
// 人设列表拉取失败不阻塞页面:会话创建接口对空白 personaId 回落默认人设
if (requests.isCurrent(request)) realtimePersonaOptions.value = [];
}
};
const loadOwnerProfiles = async (request: RequestScopeTicket = requests.begin('owner-profiles')) => {
try {
const data = await fetchOwnerProfiles();
if (!requests.isCurrent(request)) return;
const profiles = Array.isArray(data?.profiles) ? data.profiles : [];
ownerProfiles.value = profiles;
const stored = uni.getStorageSync(ownerProfileStorageKey);
if (stored === ownerProfileNoneMarker) {
selectedOwnerProfileId.value = '';
} else if (typeof stored === 'string' && stored && profiles.some((item) => item.profileId === stored)) {
selectedOwnerProfileId.value = stored;
} else if (data?.defaultProfileId && profiles.some((item) => item.profileId === data.defaultProfileId)) {
selectedOwnerProfileId.value = data.defaultProfileId;
} else {
selectedOwnerProfileId.value = '';
}
} catch {
// 业主档案拉取失败不阻塞页面:选择器整体隐藏,降级为无画像对练
if (requests.isCurrent(request)) ownerProfiles.value = [];
}
};
const handleOwnerProfileChange = (event: { detail: { value: number | string } }) => {
const index = Number(event.detail.value);
if (!Number.isInteger(index) || index < 0 || index > ownerProfiles.value.length) return;
selectedOwnerProfileId.value = index === 0 ? '' : ownerProfiles.value[index - 1].profileId;
uni.setStorageSync(ownerProfileStorageKey, selectedOwnerProfileId.value || ownerProfileNoneMarker);
};
const stopRealtime = () => {
if (!realtimeActive.value && realtimeStatus.value === 'idle') return;
if (isNativeAppRuntime()) realtimeAppBridge.value?.stop();
else realtimePractice.stop();
realtimeActive.value = false;
realtimeStatus.value = 'idle';
realtimeAudioBlocked.value = false;
realtimeSessionId.value = '';
sessionId.value = '';
activeAssignmentId.value = undefined;
status.value = 'idle';
message.value = '实时陪练已结束;如需保存成绩,请使用常规录音对练。';
};
const startRealtime = async () => {
if (!canStartRealtime.value || !requireLogin()) return;
speechPlayback.stop();
cancelRecording();
turnVoiceGeneration += 1;
turns.value = [];
turnVoices.value = {};
result.value = null;
sessionId.value = '';
realtimeSessionId.value = '';
activeAssignmentId.value = undefined;
message.value = '';
realtimeAudioBlocked.value = false;
status.value = 'starting';
realtimeActive.value = true;
try {
// 先申请麦克风权限:App WebView 的 getUserMedia 不会自行弹出系统授权框,
// 若跳过这一步,权限缺失时用户只会看到 Chromium 英文报错且没有授权入口。
// 放在建会话之前,避免留下一个无法使用的孤儿会话。
const permission = await requestMicrophonePermission();
if (permission === 'denied' || permission === 'denied-always') {
throw new Error(microphoneDeniedMessage(permission));
}
if (!realtimeActive.value) return;
const session = await createRealtimeSession(
selectedRealtimePersonaId.value || undefined,
selectedScenarioId.value || undefined,
showOwnerProfilePicker.value && selectedOwnerProfileId.value ? selectedOwnerProfileId.value : undefined
);
if (!realtimeActive.value) return;
realtimeSessionId.value = session.sessionId;
activeScenarioName.value = session.personaName || '实时陪练';
const controller = isNativeAppRuntime() ? realtimeAppBridge.value : realtimePractice;
if (!controller) throw new Error('实时语音模块尚未就绪,请稍后重试');
await controller.start(session.sessionUpdate, session.sessionId);
if (!realtimeActive.value) return;
status.value = 'active';
message.value = `已连接「${session.personaName}」,请直接开口说话。`;
} catch (error) {
realtimeActive.value = false;
realtimeStatus.value = 'idle';
realtimeSessionId.value = '';
status.value = 'idle';
// 底层音频/WebRTC 错误为 Chromium 英文原文,必须翻译后再展示。
handleError(
new Error(describeMicrophoneError(error, '实时陪练连接失败,请使用录音对练')),
'实时陪练连接失败,请使用录音对练'
);
}
};
const resumeRealtimeAudio = async () => {
const controller = isNativeAppRuntime() ? realtimeAppBridge.value : realtimePractice;
if (controller && await controller.resumeAudio()) {
message.value = '已恢复实时语音播放。';
} else {
message.value = '仍无法播放实时语音,请确认设备媒体权限后重试。';
}
};
const clearDraftAudio = () => {
draftAudioUrl.value = '';
draftAudioOssId.value = undefined;
};
const cancelTranscription = () => {
transcriptionGeneration += 1;
abortTranscription?.();
abortTranscription = null;
transcribing.value = false;
};
const cancelRecording = () => {
recordingCaptureGeneration += 1;
cancelTranscription();
recordingStarting.value = false;
recordingCapture?.cancel();
recordingCapture = null;
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 transcribeCapture = async (capture: SpeechCapture, assignmentId?: number) => {
if (transcribing.value) return;
const generation = ++transcriptionGeneration;
transcribing.value = true;
message.value = '语音转写中';
try {
const result = await transcribeSpeechCapture(capture, '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;
if (isFileSelectionCancelled(error)) {
message.value = '';
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;
const generation = ++recordingCaptureGeneration;
recordingStarting.value = true;
let fallbackRequested = false;
try {
const session = await startSpeechCapture({
onCapture: (capture) => {
if (generation !== recordingCaptureGeneration) return;
recordingCapture = null;
recording.value = false;
const targetAssignmentId = assignmentId;
recordingAssignmentId.value = null;
void transcribeCapture(capture, targetAssignmentId);
},
onError: () => {
if (generation !== recordingCaptureGeneration) return;
fallbackRequested = true;
recordingCapture = null;
recording.value = false;
recordingAssignmentId.value = null;
void pickAudio(assignmentId);
}
});
if (!session || generation !== recordingCaptureGeneration) {
session?.cancel();
if (generation === recordingCaptureGeneration && !fallbackRequested) await pickAudio(assignmentId);
return;
}
recordingCapture = session;
recordingAssignmentId.value = assignmentId ?? null;
recording.value = true;
message.value = '正在录音';
} catch (error) {
recordingAssignmentId.value = null;
recordingCapture = null;
if (generation === recordingCaptureGeneration) await pickAudio(assignmentId);
} finally {
if (generation === recordingCaptureGeneration) recordingStarting.value = false;
}
};
const stopRecording = () => {
if (!recording.value || !recordingCapture) return;
message.value = '录音结束,正在转写';
recordingCapture.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;
consumePendingAssignment();
}
}
};
const loadCurriculum = async (request: RequestScopeTicket = requests.begin('curriculum')) => {
refreshAuthState();
if (!loggedIn.value) {
availableScenarios.value = [];
competencyProfile.value = null;
return;
}
curriculumLoading.value = true;
try {
const [profileResult, scenarioResult] = await Promise.allSettled([
getCompetencyProfile(),
getPracticeScenarios(positionLabel.value)
]);
if (!requests.isCurrent(request)) return;
if (profileResult.status === 'fulfilled') {
competencyProfile.value = profileResult.value;
}
if (scenarioResult.status === 'fulfilled') {
const nextScenarios = (scenarioResult.value || []).filter((item) =>
item.enabled !== false && (!item.reviewStatus || item.reviewStatus === '已发布')
);
availableScenarios.value = nextScenarios;
const activeScenario = nextScenarios.find((item) => item.id === selectedScenarioId.value) || nextScenarios[0];
if (activeScenario) {
selectedScenarioId.value = activeScenario.id;
activeScenarioName.value = activeScenario.name || defaultScenarioName;
void loadPrepCard(activeScenario.id, requests.begin('prep-card'));
} else {
selectedScenarioId.value = '';
activeScenarioName.value = '暂无可用训练场景';
prepCard.value = null;
}
} else {
availableScenarios.value = [];
selectedScenarioId.value = '';
activeScenarioName.value = '暂无可用训练场景';
prepCard.value = null;
handleError(scenarioResult.reason, '训练场景加载失败');
}
} finally {
if (requests.isCurrent(request)) {
curriculumLoading.value = false;
consumePendingAssignment();
}
}
};
const selectPracticeScenario = (item: PracticeScenarioOption) => {
if (busy.value || !item?.id) return;
selectedScenarioId.value = item.id;
activeScenarioName.value = item.name || defaultScenarioName;
prepCard.value = null;
void loadPrepCard(item.id, requests.begin('prep-card'));
};
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;
const id = Number(raw);
if (!Number.isFinite(id)) {
uni.removeStorageSync(pendingAssignmentKey);
return;
}
if (assignmentLoading.value || curriculumLoading.value) return;
const item = assignments.value.find((row) => row.id === id);
if (!item) {
uni.removeStorageSync(pendingAssignmentKey);
message.value = '首页任务已更新,请从列表选择';
return;
}
highlightedAssignmentId.value = item.id;
if (item.source === 'daily') {
uni.removeStorageSync(pendingAssignmentKey);
message.value = '已定位首页任务,请填写话术后提交';
return;
}
if (item.status !== '待训练') {
uni.removeStorageSync(pendingAssignmentKey);
message.value = item.status === '已完成' ? '该任务已完成,请选择其他任务' : `该任务当前为${item.status || '不可训练'},请从列表查看`;
return;
}
startAssignment(item);
uni.removeStorageSync(pendingAssignmentKey);
};
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 (curriculumLoading.value) {
message.value = '训练场景加载中,请稍候';
return false;
}
const scenarioId = (item.scenarioId || '').trim();
const scenario = availableScenarios.value.find((entry) => entry.id === scenarioId);
if (!scenario) {
message.value = '本次专项关联的场景已下线或待发布,请联系内容运营后再练习';
return false;
}
if (!canStartPractice.value) return false;
selectedScenarioId.value = scenario.id;
activeScenarioName.value = scenario.name || item.scenarioName || defaultScenarioName;
void loadPrepCard(selectedScenarioId.value);
void start(scenario.id, item.id);
return true;
};
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 = selectedScenarioId.value, 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;
turnVoiceGeneration += 1;
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;
activeRoundCount.value = 3;
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;
activeRoundCount.value = Math.max(2, Math.min(6, data.roundCount || 3));
sessionId.value = data.sessionId;
appendTurn('customer', data.customerText, data.emotion, true, 0);
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;
if (!sessionId.value) {
message.value = '请先开始训练后再求助';
return;
}
const request = requests.begin('help');
helpLoading.value = true;
message.value = '';
try {
await recordPracticeHelp(sessionId.value, roundIndex.value);
if (!requests.isCurrent(request)) return;
const data = await searchKnowledge(`${activeScenarioName.value} SOP 处理要点`, positionLabel.value, undefined, undefined, 'practice_help');
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] = { ...turns.value[index], 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, 0, true, undefined, submittedRound);
if (data.redFlag) {
message.value = '命中红线,请按老师傅建议重说本轮';
status.value = 'active';
return;
}
if (data.finished) {
roundIndex.value = data.roundIndex;
await finish(true);
return;
}
appendTurn('customer', data.customerText, data.emotion, true, data.roundIndex);
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();
void loadCurriculum(requests.begin('curriculum'));
} 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 loadCurriculum(requests.begin('curriculum'));
void loadRealtimePersonas(requests.begin('realtime-personas'));
void loadOwnerProfiles(requests.begin('owner-profiles'));
};
const invalidateRequests = () => {
requests.invalidate();
assignmentLoading.value = false;
prepLoading.value = false;
curriculumLoading.value = false;
answeringId.value = null;
feedbackSubmittingId.value = null;
helpLoading.value = false;
adjustingStyle.value = '';
replySuggestionLoading.value = null;
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();
if (!isPlatformNativeActivityActive()) 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-top 由模板内联绑定(状态栏高度 + 12),见 utils/safe-area.ts;
此处 30px 仅为 H5 观感基线,App 端会被内联值覆盖。 */
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;
}
.growth-card,
.scene-picker-card {
margin-top: 16px;
border-color: #dbe6f5;
background: #ffffff;
box-shadow: var(--employee-card-shadow);
}
.growth-card {
padding: 18px;
}
.growth-card-head,
.scene-picker-head,
.growth-path-row,
.capability-progress-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.growth-meta,
.growth-note,
.growth-empty,
.scene-picker-subtitle,
.scene-picker-note,
.scene-picker-empty,
.growth-path-evidence,
.capability-progress-title,
.scene-option-level,
.scene-option-capability {
display: block;
}
.growth-meta {
margin-top: 5px;
color: #66758a;
font-size: 12px;
line-height: 1.45;
}
.growth-confirmation {
display: block;
margin-top: 10px;
color: #0f766e;
font-size: 12px;
font-weight: 700;
line-height: 1.45;
}
.growth-level-badge {
flex: 0 0 auto;
padding: 5px 10px;
border-radius: 999px;
background: #eef5ff;
color: #315b8f;
font-size: 12px;
font-weight: 800;
}
.growth-path-list {
display: grid;
gap: 8px;
margin-top: 14px;
}
.growth-path-row {
align-items: flex-start;
padding: 9px 0;
border-top: 1px solid #eef1f5;
}
.growth-path-row.reached .growth-path-level,
.growth-path-row.reached .growth-path-status {
color: #168243;
}
.growth-path-copy {
min-width: 0;
}
.growth-path-level {
display: block;
color: #25282d;
font-size: 13px;
font-weight: 800;
}
.growth-path-evidence {
margin-top: 3px;
color: #7b8491;
font-size: 11px;
line-height: 1.45;
}
.growth-path-status {
flex: 0 0 auto;
padding-top: 1px;
color: #54667d;
font-size: 11px;
white-space: nowrap;
}
.capability-progress-list {
display: grid;
gap: 7px;
margin-top: 12px;
padding: 11px 12px;
border-radius: 12px;
background: #f7faff;
}
.capability-progress-title {
color: #315b8f;
font-size: 12px;
font-weight: 800;
}
.capability-progress-row {
color: #566577;
font-size: 11px;
line-height: 1.45;
}
.growth-empty,
.growth-note {
color: #778395;
font-size: 11px;
line-height: 1.5;
}
.growth-empty {
margin-top: 12px;
}
.growth-note {
margin-top: 10px;
color: #315b8f;
}
.scene-picker-card {
padding: 18px 0 14px;
overflow: hidden;
}
.scene-picker-head {
padding: 0 18px;
}
.scene-picker-subtitle {
margin-top: 5px;
color: #69798d;
font-size: 12px;
line-height: 1.45;
}
.scene-picker-loading {
color: #708198;
font-size: 12px;
}
.scene-option-scroll {
margin-top: 12px;
white-space: nowrap;
}
.scene-option-list {
display: inline-flex;
gap: 10px;
padding: 0 18px;
}
.scene-option {
display: inline-flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
width: 144px;
min-height: 100px;
padding: 13px;
border: 1px solid #dfe7f1;
border-radius: 14px;
background: #f8fafc;
color: #25282d;
text-align: left;
vertical-align: top;
white-space: normal;
}
.scene-option.selected {
border-color: #e70014;
background: #fff7f7;
box-shadow: 0 6px 16px rgba(231, 0, 20, 0.08);
}
.scene-option:disabled {
opacity: 0.62;
}
.scene-option-level {
color: #d51b2b;
font-size: 11px;
font-weight: 800;
}
.scene-option-name {
display: block;
min-height: 38px;
margin-top: 7px;
overflow: hidden;
color: #25282d;
font-size: 14px;
font-weight: 900;
line-height: 1.4;
text-overflow: ellipsis;
}
.scene-option-capability {
margin-top: 4px;
overflow: hidden;
color: #778395;
font-size: 11px;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.scene-picker-note,
.scene-picker-empty {
padding: 0 18px;
color: #778395;
font-size: 11px;
line-height: 1.5;
}
.scene-picker-note {
margin-top: 12px;
}
.scene-picker-empty {
margin-top: 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-persona-picker {
margin-top: 12px;
padding: 12px;
border: 1px solid #dbe8fb;
border-radius: 14px;
background: #f7fbff;
}
.realtime-persona-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.realtime-persona-title {
display: block;
color: #315b8f;
font-size: 13px;
font-weight: 800;
}
.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;
/* .practice-chat 为 fixed 定位,不受页面内边距影响,头部需自行避让状态栏。 */
/* padding-top 由模板内联绑定(状态栏高度 + 8),见 utils/safe-area.ts */
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;
}
/* 首屏只承载开始训练;规则和运营说明按需展开。 */
.practice-head {
padding: 18px 20px 12px;
text-align: left;
}
.practice-head .page-title {
font-size: 24px;
letter-spacing: 0;
line-height: 1.25;
}
.practice-head .page-subtitle {
margin-top: 3px;
font-size: 13px;
line-height: 1.45;
}
.realtime-entry-card {
gap: 12px;
margin-top: 12px;
padding: 18px;
border: 1px solid #f2d9dc;
border-radius: 20px;
background: linear-gradient(150deg, #ffffff 0%, #fff7f7 100%);
box-shadow: 0 8px 22px rgba(41, 29, 31, 0.06);
}
.realtime-entry-head {
display: flex;
flex-direction: column;
gap: 5px;
}
.realtime-title-row {
display: flex;
align-items: center;
gap: 8px;
}
.realtime-entry-card .scenario-title {
color: #25282d;
font-size: 20px;
line-height: 1.35;
}
.realtime-entry-card .scenario-desc {
color: #6f7784;
font-size: 13px;
line-height: 1.45;
}
.realtime-beta-badge {
padding: 3px 7px;
border-radius: 999px;
background: #fff0f1;
color: var(--employee-primary);
font-size: 11px;
font-weight: 800;
}
.realtime-persona-picker {
margin-top: 0;
padding: 10px 0 0;
border: 0;
border-top: 1px solid #f1e3e4;
border-radius: 0;
background: transparent;
}
.realtime-persona-title {
color: #5e6672;
font-size: 12px;
}
.realtime-persona-scroll {
margin: 8px -18px 0;
}
.realtime-persona-option {
width: 124px;
min-height: 66px;
padding: 10px 12px;
border-radius: 13px;
background: #ffffff;
}
.realtime-persona-option .scene-option-name {
min-height: 0;
margin-top: 0;
font-size: 14px;
}
.realtime-persona-option .scene-option-capability {
width: 100%;
margin-top: 5px;
}
.scenario-card .realtime-start-button {
min-height: 48px;
margin-top: 0;
border: 0;
background: linear-gradient(135deg, #ed1f28, #ff4b4d);
color: #ffffff;
box-shadow: 0 8px 16px rgba(231, 0, 20, 0.18);
}
.realtime-beta-note {
font-size: 11px;
line-height: 1.35;
}
.owner-profile-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 8px 12px;
border: 1px solid #f1e3e4;
border-radius: 12px;
background: #fffafa;
}
.owner-profile-label {
flex-shrink: 0;
color: #5e6672;
font-size: 12px;
}
.owner-profile-picker {
flex: 1;
min-width: 0;
}
.owner-profile-value {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
}
.owner-profile-value-text {
overflow: hidden;
color: #25282d;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.owner-profile-arrow {
flex-shrink: 0;
color: #b6bcc6;
font-size: 14px;
}
.status-message {
display: block;
margin-top: 12px;
padding: 10px 12px;
border-radius: 12px;
background: #fff8e8;
color: #875300;
font-size: 12px;
line-height: 1.5;
}
.status-message-compact {
margin-bottom: 0;
}
.scene-picker-card,
.scene-empty-card,
.growth-card {
margin-top: 12px;
}
.scene-empty-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
}
.scene-empty-state {
flex: 0 0 auto;
color: #8a919d;
font-size: 12px;
}
.growth-card {
padding: 16px;
}
.growth-summary {
margin-top: 14px;
}
.growth-summary-copy {
display: flex;
align-items: center;
justify-content: space-between;
color: #697488;
font-size: 12px;
}
.growth-summary-track {
height: 6px;
margin-top: 7px;
overflow: hidden;
border-radius: 999px;
background: #edf1f5;
}
.growth-summary-fill {
height: 100%;
border-radius: inherit;
background: var(--employee-primary);
}
.growth-toggle {
display: flex;
width: 100%;
min-height: 44px;
align-items: center;
justify-content: space-between;
margin: 10px 0 0;
padding: 0;
border: 0;
border-top: 1px solid #eef1f4;
border-radius: 0;
background: transparent;
color: #4e5d70;
font-size: 13px;
text-align: left;
}
.growth-toggle::after {
border: 0;
}
.growth-details {
border-top: 1px solid #eef1f4;
}
.growth-details .growth-path-evidence,
.growth-details .growth-path-status,
.growth-details .growth-empty,
.growth-details .growth-note {
font-size: 12px;
}
.realtime-listening-empty {
display: flex;
min-height: 280px;
align-items: center;
justify-content: center;
flex-direction: column;
color: #687487;
text-align: center;
}
.realtime-listening-label {
padding: 5px 10px;
border-radius: 999px;
background: #edf8f2;
color: #168243;
font-size: 12px;
font-weight: 800;
}
.realtime-listening-title {
margin-top: 14px;
color: #25282d;
font-size: 20px;
font-weight: 900;
}
.realtime-listening-copy {
margin-top: 6px;
font-size: 13px;
}
</style>