- 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
4024 lines
112 KiB
Vue
4024 lines
112 KiB
Vue
<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>
|