3560 lines
137 KiB
Vue
3560 lines
137 KiB
Vue
<template>
|
||
<main class="mobile-aihr">
|
||
<section v-if="!isAuthenticated" class="page auth-page">
|
||
<header class="top auth-top">
|
||
<div class="top-row">
|
||
<h1>帮道</h1>
|
||
<div class="avatar avatar-woman" aria-hidden="true">
|
||
<span></span>
|
||
</div>
|
||
</div>
|
||
<h2>手机号登录</h2>
|
||
<p class="place">
|
||
<Icon name="map-pin" size="22" />
|
||
星河湾一期
|
||
</p>
|
||
</header>
|
||
|
||
<form class="login-panel" @submit.prevent="loginBySms">
|
||
<label>
|
||
<span>手机号</span>
|
||
<input v-model.trim="phone" inputmode="tel" maxlength="11" placeholder="请输入手机号" autocomplete="tel" />
|
||
</label>
|
||
<label>
|
||
<span>验证码</span>
|
||
<div class="code-row">
|
||
<input v-model.trim="smsCode" inputmode="numeric" maxlength="6" placeholder="短信验证码" autocomplete="one-time-code" />
|
||
<button type="button" :disabled="sendingCode" @click="sendSmsCode">{{ sendingCode ? '发送中' : '获取验证码' }}</button>
|
||
</div>
|
||
</label>
|
||
<p v-if="authMessage" class="auth-message">{{ authMessage }}</p>
|
||
<button class="login-submit" type="submit" :disabled="loggingIn">{{ loggingIn ? '登录中' : '登录' }}</button>
|
||
</form>
|
||
</section>
|
||
|
||
<section
|
||
v-else
|
||
class="page"
|
||
:class="{
|
||
'user-redesign': roleKey === 'user',
|
||
'role-redesign': roleKey !== 'user',
|
||
'candidate-redesign': roleKey === 'candidate',
|
||
'supervisor-redesign': roleKey === 'supervisor'
|
||
}"
|
||
>
|
||
<header v-if="!(roleKey === 'user' && isHomeTab)" class="top">
|
||
<div class="top-row">
|
||
<h1>帮道</h1>
|
||
<button class="role-pill" type="button" @click="cycleRole">
|
||
{{ role.roleLabel }}
|
||
<Icon name="chevron-down" size="16" />
|
||
</button>
|
||
<img v-if="roleAvatarSrc" class="avatar avatar-img" :src="roleAvatarSrc" alt="" />
|
||
<div v-else class="avatar" :class="role.avatarTone" aria-hidden="true">
|
||
<span></span>
|
||
</div>
|
||
</div>
|
||
|
||
<h2>{{ role.greeting }} <span>👋</span></h2>
|
||
<p class="place">
|
||
<Icon name="map-pin" size="22" />
|
||
{{ role.location }}
|
||
</p>
|
||
</header>
|
||
|
||
<template v-if="roleKey === 'supervisor'">
|
||
<template v-if="isHomeTab">
|
||
<section class="supervisor-dashboard">
|
||
<div class="supervisor-dashboard-head">
|
||
<h2>团队训练驾驶舱</h2>
|
||
<button type="button" @click="openSupervisorAction('team')">全部 ›</button>
|
||
</div>
|
||
<div class="supervisor-stat-row">
|
||
<button v-for="item in supervisorDashboardStats" :key="item.title" type="button" :class="item.tone" @click="openSupervisorAction(item.target)">
|
||
<small>{{ item.title }}</small>
|
||
<b>{{ item.value }}</b>
|
||
<span>{{ item.desc }}</span>
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="home-card dynamics-card">
|
||
<div class="home-card-head">
|
||
<h2>团队今日动态</h2>
|
||
<button type="button" @click="openSupervisorAction('reviews')">复盘列表 ›</button>
|
||
</div>
|
||
<div class="activity-timeline">
|
||
<button v-for="item in practiceReviews.slice(0, 2)" :key="item.id || `${item.time}-${item.trainee}-${item.scene}`" type="button" @click="openPracticeReview(item)">
|
||
<em>{{ item.score }}</em>
|
||
<span>
|
||
<b>{{ item.trainee }} 完成 {{ item.scene }}</b>
|
||
<small>{{ item.time }} · {{ item.summary }}</small>
|
||
</span>
|
||
<i>{{ item.status }}</i>
|
||
</button>
|
||
<template v-if="!practiceReviews.length">
|
||
<button v-for="item in supervisorRole.todos" :key="item.title" type="button" @click="openSupervisorTodo(item)">
|
||
<em>{{ item.count }}</em>
|
||
<span>
|
||
<b>{{ item.title }}</b>
|
||
<small>{{ item.desc }}</small>
|
||
</span>
|
||
<i>待办</i>
|
||
</button>
|
||
</template>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="home-card weakness-card">
|
||
<div class="home-card-head">
|
||
<h2>团队短板热力图</h2>
|
||
<button type="button" @click="openSupervisorAction('weakness')">看短板 ›</button>
|
||
</div>
|
||
<div class="weakness-grid">
|
||
<button v-for="item in supervisorHeatmapItems" :key="item.title" type="button" :class="item.tone" @click="openSupervisorAction(item.target)">
|
||
<small>{{ item.title }}</small>
|
||
<b>{{ item.value }}</b>
|
||
<span>{{ item.desc }}</span>
|
||
</button>
|
||
</div>
|
||
<button class="suggestion supervisor-suggestion" type="button" @click="openSupervisorAction('assign')">
|
||
<IconBubble :icon="supervisorRole.suggestion.icon" :tone="supervisorRole.suggestion.tone" />
|
||
<span>
|
||
<b>{{ supervisorRole.suggestion.title }}</b>
|
||
<small>{{ supervisorRole.suggestion.desc }}</small>
|
||
</span>
|
||
<Icon name="chevron-right" size="26" />
|
||
</button>
|
||
</section>
|
||
</template>
|
||
|
||
<Card v-if="activeSupervisorPanel && !isHomeTab" :title="supervisorPanelTitle">
|
||
<div class="user-panel">
|
||
<template v-if="activeSupervisorPanel === 'team' || activeSupervisorPanel === 'weakness'">
|
||
<div class="progress-grid">
|
||
<ProgressTile
|
||
v-for="item in supervisorTeamTiles"
|
||
:key="item.title"
|
||
:item="item"
|
||
/>
|
||
</div>
|
||
<div class="result-list">
|
||
<h3>{{ activeSupervisorPanel === 'weakness' ? '团队短板' : '团队动作' }}</h3>
|
||
<p v-for="item in supervisorRole.todos" :key="item.title">
|
||
<b>{{ item.title }}</b>
|
||
<small>{{ item.desc }} · {{ item.count }}项</small>
|
||
</p>
|
||
</div>
|
||
</template>
|
||
|
||
<template v-else-if="activeSupervisorPanel === 'reviews' || activeSupervisorPanel === 'assign'">
|
||
<div v-if="practiceReviews.length && !selectedReview" class="record-list">
|
||
<h3>{{ activeSupervisorPanel === 'assign' ? '建议指派对象' : '待复盘对练' }}</h3>
|
||
<button v-for="item in practiceReviews" :key="item.id || `${item.time}-${item.trainee}-${item.scene}`" type="button" @click="openPracticeReview(item)">
|
||
<span>
|
||
<b>{{ item.trainee }} · {{ item.score }}分</b>
|
||
<small>{{ item.time }} · {{ item.scene }} · {{ item.summary }}</small>
|
||
</span>
|
||
<em>{{ item.status }}</em>
|
||
</button>
|
||
</div>
|
||
<em v-else-if="!selectedReview" class="panel-message">暂无待复盘记录</em>
|
||
<em v-if="reviewMessage && !selectedReview" class="review-message">{{ reviewMessage }}</em>
|
||
<div v-if="selectedReview" class="review-detail">
|
||
<button class="detail-back" type="button" @click="closeSupervisorReviewDetail">返回列表</button>
|
||
<div class="review-detail-head">
|
||
<span>
|
||
<b>{{ selectedReview.trainee }} · {{ selectedReview.score }}分</b>
|
||
<small>{{ selectedReview.time }} · {{ selectedReview.scene }}</small>
|
||
</span>
|
||
<em>{{ selectedReview.status }}</em>
|
||
</div>
|
||
|
||
<div class="review-score-grid">
|
||
<p v-for="item in selectedReview.scoreItems" :key="item.label">
|
||
<span>{{ item.label }}</span>
|
||
<b>{{ item.value }}</b>
|
||
<small>{{ item.note }}</small>
|
||
</p>
|
||
</div>
|
||
|
||
<div class="practice-dialogue review-dialogue">
|
||
<p v-for="(turn, index) in selectedReview.dialogue" :key="`${turn.role}-${index}`" :class="turn.role">
|
||
<span>{{ turn.label }}</span>
|
||
{{ turn.text }}
|
||
<audio v-if="turn.audioUrl" controls :src="turn.audioUrl"></audio>
|
||
</p>
|
||
</div>
|
||
|
||
<div v-if="selectedReview.annotations.length" class="review-annotations">
|
||
<b>逐句标注</b>
|
||
<p v-for="item in selectedReview.annotations" :key="`${item.turnIndex}-${item.sourceText}`">
|
||
<span>第 {{ item.turnIndex }} 句</span>
|
||
<small>{{ item.sourceText }}</small>
|
||
<em>{{ item.issue }}</em>
|
||
<strong>{{ item.rewrite }}</strong>
|
||
<i>{{ item.sopPoint }}</i>
|
||
</p>
|
||
</div>
|
||
|
||
<div class="review-summary">
|
||
<b>导师改写</b>
|
||
<p>{{ selectedReview.mentorRewrite }}</p>
|
||
<b>AI点评</b>
|
||
<p>{{ selectedReview.aiComment }}</p>
|
||
</div>
|
||
|
||
<div class="review-advice-box">
|
||
<b>复盘建议</b>
|
||
<p v-if="selectedReview.reviewAdvice">{{ selectedReview.reviewAdvice }}</p>
|
||
<textarea v-model.trim="reviewAdviceDraft" :disabled="selectedReview.status !== '待复盘'" rows="3" placeholder="写给员工的下一步带教建议" />
|
||
<label class="review-score-field">
|
||
<span>贡献度加分</span>
|
||
<input v-model.number="reviewIncentiveDraft" :disabled="selectedReview.status !== '待复盘'" type="number" min="0" max="100" inputmode="numeric" placeholder="0-100" />
|
||
</label>
|
||
<label>
|
||
<input v-model="reviewAssignNext" :disabled="selectedReview.status !== '待复盘'" type="checkbox" />
|
||
<span>同步派发催费沟通再练</span>
|
||
</label>
|
||
</div>
|
||
|
||
<button type="button" :disabled="markingReviewed || selectedReview.status !== '待复盘'" @click="markReviewDone">
|
||
{{ selectedReview.status === '待复盘' ? (markingReviewed ? '处理中' : '标记已复盘') : '已复盘' }}
|
||
</button>
|
||
<em v-if="reviewMessage">{{ reviewMessage }}</em>
|
||
</div>
|
||
<div v-if="!selectedReview" class="result-list">
|
||
<h3>专项建议</h3>
|
||
<p v-for="item in practiceAlerts" :key="`${item.type}-${item.title}`">
|
||
<b>{{ item.title }}</b>
|
||
<small>{{ item.desc }}</small>
|
||
</p>
|
||
<p>
|
||
<b>催费沟通专项</b>
|
||
<small>优先给低分或待复盘员工指派,复盘后再更新训练安排。</small>
|
||
</p>
|
||
<button v-if="activeSupervisorPanel === 'assign'" class="panel-primary assign-button" type="button" :disabled="assigningPractice" @click="dispatchPracticeAssignment">
|
||
{{ assigningPractice ? '派发中' : '派发催费专项' }}
|
||
</button>
|
||
<p v-for="item in practiceAssignments" :key="item.id || `${item.extPartyId}-${item.scenarioId}`">
|
||
<b>{{ item.traineeName }} · {{ item.scenarioName }}</b>
|
||
<small>{{ item.createTime }} · {{ item.reason }}</small>
|
||
</p>
|
||
</div>
|
||
</template>
|
||
|
||
<template v-else-if="activeSupervisorPanel === 'sop'">
|
||
<form class="knowledge-search" @submit.prevent="searchKnowledge(knowledgeQuery, 'sop')">
|
||
<input v-model.trim="knowledgeQuery" placeholder="搜索SOP关键词" />
|
||
<button type="submit" :disabled="knowledgeLoading">{{ knowledgeLoading ? '查询中' : '查询' }}</button>
|
||
</form>
|
||
<em v-if="knowledgeMessage" class="panel-message">{{ knowledgeMessage }}</em>
|
||
<div v-if="knowledgeResult" class="knowledge-result">
|
||
<div class="digest-card">
|
||
<strong>{{ knowledgeResult.answer }}</strong>
|
||
<ol v-if="knowledgeResult.keyPoints?.length" class="digest-points">
|
||
<li v-for="point in knowledgeResult.keyPoints" :key="point">{{ point }}</li>
|
||
</ol>
|
||
<div v-if="knowledgeResult.cautions?.length" class="digest-cautions">
|
||
<p v-for="item in knowledgeResult.cautions" :key="item">⚠ {{ item }}</p>
|
||
</div>
|
||
<p v-if="knowledgeResult.reference" class="digest-reference">{{ knowledgeResult.reference }}</p>
|
||
</div>
|
||
<div v-if="knowledgeResult.snippets?.length" class="snippet-list">
|
||
<button class="snippet-toggle" type="button" @click="knowledgeSnippetsOpen = !knowledgeSnippetsOpen">
|
||
{{ knowledgeSnippetsOpen ? '收起依据' : `查看依据(${knowledgeResult.snippets.length} 条原文片段)` }}
|
||
</button>
|
||
<template v-if="knowledgeSnippetsOpen">
|
||
<blockquote v-for="item in knowledgeResult.snippets" :key="`${item.title}-${item.text}`">
|
||
<b>{{ item.title }}</b>
|
||
<span>{{ item.text }}</span>
|
||
</blockquote>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<template v-else-if="activeSupervisorPanel === 'case'">
|
||
<label class="file-picker">
|
||
<span>{{ materialFile?.name || '选择语音素材' }}</span>
|
||
<small>支持 mp3 / wav / m4a / webm / ogg,上传后自动转写并整理案例</small>
|
||
<input type="file" accept="audio/*,.mp3,.wav,.m4a,.webm,.ogg" @change="handleMaterialFile" />
|
||
</label>
|
||
<button class="panel-primary" type="button" :disabled="materialUploading" @click="submitMaterial">
|
||
{{ materialUploading ? '提交中' : '上传并整理' }}
|
||
</button>
|
||
<em v-if="materialMessage" class="panel-message">{{ materialMessage }}</em>
|
||
<div v-if="materialResult" class="case-result">
|
||
<div>
|
||
<h3>ASR转写</h3>
|
||
<p>{{ materialResult.upload.transcript }}</p>
|
||
</div>
|
||
<div>
|
||
<h3>案例整理</h3>
|
||
<dl>
|
||
<template v-for="item in materialResult.organize.summary" :key="item.label">
|
||
<dt>{{ item.label }}</dt>
|
||
<dd>{{ item.text }}</dd>
|
||
</template>
|
||
</dl>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<template v-else-if="activeSupervisorPanel === 'profile'">
|
||
<div class="result-list">
|
||
<h3>主管工作台</h3>
|
||
<p>
|
||
<b>{{ supervisorRole.team.title }}</b>
|
||
<small>{{ supervisorRole.team.progressText }} · 平均能力 {{ supervisorRole.team.stats[1]?.value || '-' }}</small>
|
||
</p>
|
||
<p>
|
||
<b>待办概况</b>
|
||
<small>{{ supervisorRole.todos.map((item) => `${item.title}${item.count}项`).join(',') }}</small>
|
||
</p>
|
||
</div>
|
||
</template>
|
||
|
||
</div>
|
||
</Card>
|
||
</template>
|
||
|
||
<template v-else>
|
||
<template v-if="roleKey === 'user' && isHomeTab">
|
||
<section v-if="!userBuildingOpen" class="mentor-hero">
|
||
<div class="mentor-topline">
|
||
<span>生活顾问客服管家</span>
|
||
<button class="role-pill mentor-role-pill" type="button" @click="cycleRole">
|
||
{{ role.roleLabel }}
|
||
<Icon name="chevron-down" size="16" />
|
||
</button>
|
||
</div>
|
||
<div class="mentor-main">
|
||
<div class="mentor-avatar" :class="`is-${mentorState}`" aria-hidden="true">
|
||
<img :src="mentorImageSrc" alt="" />
|
||
<span class="mentor-eyelid mentor-eyelid-left"></span>
|
||
<span class="mentor-eyelid mentor-eyelid-right"></span>
|
||
<span class="mentor-thinking" aria-hidden="true"><i></i><i></i><i></i></span>
|
||
<span class="mentor-waves" aria-hidden="true"><i></i><i></i><i></i><i></i></span>
|
||
</div>
|
||
<div class="mentor-copy">
|
||
<span class="mentor-greeting">早上好,王敏</span>
|
||
<p>{{ mentorBubbleText }}</p>
|
||
</div>
|
||
</div>
|
||
<div class="mentor-actions">
|
||
<button
|
||
class="mentor-talk"
|
||
type="button"
|
||
@pointerdown="startMentorVoicePress"
|
||
@touchstart="startMentorVoicePress"
|
||
>
|
||
<span>🎙</span>
|
||
按住说话
|
||
</button>
|
||
<button class="mentor-type" type="button" @click="openMentorTextAsk">⌨️ 打字问</button>
|
||
</div>
|
||
</section>
|
||
|
||
<div v-if="!userBuildingOpen" class="mentor-chip-row" aria-label="快捷提问">
|
||
<button v-for="chip in mentorQuickChips" :key="chip.label" type="button" @click="askMentorChip(chip.query)">
|
||
<span></span>
|
||
{{ chip.label }}
|
||
<b>›</b>
|
||
</button>
|
||
</div>
|
||
|
||
<section v-if="userBuildingOpen" class="building-page">
|
||
<div class="building-icon">🚧</div>
|
||
<h2>建设中</h2>
|
||
<p>该功能正在加紧建设中,敬请期待上线</p>
|
||
<button type="button" @click="closeBuildingPage">返回首页</button>
|
||
</section>
|
||
|
||
<template v-else>
|
||
<section class="home-card agenda-card">
|
||
<div class="home-card-head">
|
||
<h2>📅 今日安排</h2>
|
||
<button type="button" @click="openUserAction('tasks')">查看全部 ›</button>
|
||
</div>
|
||
<div class="agenda-list">
|
||
<div class="agenda-row">
|
||
<em class="agenda-index blue">1</em>
|
||
<span>
|
||
<b>{{ todayTrainingTask.title }}</b>
|
||
<small>{{ todayTrainingTask.desc }}</small>
|
||
</span>
|
||
<button class="agenda-action blue" type="button" @click="startAgendaPractice">开始</button>
|
||
</div>
|
||
<div class="agenda-row">
|
||
<em class="agenda-index orange">2</em>
|
||
<span>
|
||
<b>{{ reviewAgendaTitle }}</b>
|
||
<small>{{ reviewAgendaDesc }}</small>
|
||
</span>
|
||
<button class="agenda-action orange" type="button" :disabled="!pendingPracticeReview" @click="openPendingReview">去复盘</button>
|
||
</div>
|
||
<div v-if="dailyDrillAssignments.length" class="agenda-daily">
|
||
<button v-for="item in dailyDrillAssignments" :key="item.id || item.question || item.reason" type="button" @click="openUserPracticePanel">
|
||
<em>{{ item.status === '已完成' ? '✓' : '题' }}</em>
|
||
<span>
|
||
<b>{{ item.question || item.scenarioName }}</b>
|
||
<small>{{ item.status === '已完成' ? item.feedback || '已完成' : '今日微题 · 即答即评' }}</small>
|
||
</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="home-card map-card">
|
||
<div class="home-card-head">
|
||
<h2>🧭 我的地图</h2>
|
||
</div>
|
||
<div class="map-grid">
|
||
<button
|
||
v-for="item in mentorMapActions"
|
||
:key="item.key"
|
||
type="button"
|
||
:class="`tone-${item.tone}`"
|
||
@click="openMentorMapAction(item.key)"
|
||
>
|
||
<img :src="item.icon" alt="" />
|
||
<span>{{ item.label }}</span>
|
||
<small>{{ item.desc }}</small>
|
||
<b>›</b>
|
||
</button>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
</template>
|
||
|
||
<template v-else-if="roleKey === 'candidate' && isHomeTab">
|
||
<section class="candidate-progress-head">
|
||
<h2>应聘进度</h2>
|
||
<div class="candidate-step-rail">
|
||
<button v-for="item in candidateProgressItems" :key="item.title" type="button" :class="item.state" @click="openCandidateAction(item.target)">
|
||
<em>{{ item.mark }}</em>
|
||
<span>{{ item.title }}</span>
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="home-card candidate-material-card">
|
||
<div class="home-card-head">
|
||
<h2>当前步骤:{{ candidateCurrentStep.title }}</h2>
|
||
<button type="button" @click="openCandidateAction(candidateCurrentStep.target)">去处理 ›</button>
|
||
</div>
|
||
<p class="candidate-step-note">{{ candidateCurrentStep.desc }}</p>
|
||
<div class="candidate-doc-list">
|
||
<button v-for="item in candidateMaterialChecklist" :key="item.title" type="button" @click="openCandidateAction('submit')">
|
||
<Icon :name="item.icon" size="24" />
|
||
<span>
|
||
<b>{{ item.title }}</b>
|
||
<small>{{ item.hint }}</small>
|
||
</span>
|
||
<em :class="item.state">{{ item.status }}</em>
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="home-card candidate-approval-card">
|
||
<div class="home-card-head">
|
||
<h2>审核进度</h2>
|
||
<button type="button" @click="openCandidateAction('profile')">看详情 ›</button>
|
||
</div>
|
||
<div class="candidate-doc-list compact">
|
||
<button v-for="item in candidateApprovalItems" :key="item.title" type="button" @click="openCandidateAction(item.target)">
|
||
<Icon :name="item.icon" size="22" />
|
||
<span>
|
||
<b>{{ item.title }}</b>
|
||
<small>{{ item.desc }}</small>
|
||
</span>
|
||
<em :class="item.state">{{ item.status }}</em>
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
<button class="candidate-submit-button" type="button" @click="openCandidateAction(candidateCurrentStep.target)">
|
||
{{ candidateCurrentStep.cta }}
|
||
</button>
|
||
</template>
|
||
|
||
<Card v-if="roleKey !== 'supervisor' && activeUserPanel && !isHomeTab" :title="userPanelTitle">
|
||
<div class="user-panel">
|
||
<template v-if="activeUserPanel === 'tasks'">
|
||
<template v-if="roleKey === 'user'">
|
||
<section class="scenario-shell">
|
||
<p>选择一个场景开始练</p>
|
||
<div class="scenario-tabs" aria-label="训练场景">
|
||
<button
|
||
v-for="item in practiceScenarios"
|
||
:key="item.id"
|
||
type="button"
|
||
:class="{ active: item.id === selectedPracticeScenarioId }"
|
||
@click="selectPracticeScenario(item.id)"
|
||
>
|
||
{{ item.title }}
|
||
</button>
|
||
</div>
|
||
|
||
<div class="scenario-card">
|
||
<img :src="practiceAvatarSrc('customer')" alt="" />
|
||
<span>
|
||
<b>{{ selectedPracticeScenario.title }}</b>
|
||
<small>{{ selectedPracticeScenario.desc }}</small>
|
||
</span>
|
||
<em>{{ selectedPracticeScenario.badge }}</em>
|
||
<div class="scenario-card-progress">
|
||
<small>进度</small>
|
||
<b>{{ selectedPracticeScenario.progressText }}</b>
|
||
<i><span :style="{ width: selectedPracticeScenario.progress }"></span></i>
|
||
</div>
|
||
<button type="button" @click="handlePrimaryAction">开始练习</button>
|
||
</div>
|
||
|
||
<div class="recent-practice">
|
||
<div class="recent-practice-head">
|
||
<b>最近练过</b>
|
||
<button type="button" @click="openUserAction('history')">查看全部 ›</button>
|
||
</div>
|
||
<button v-for="item in practiceRecentItems" :key="`${item.title}-${item.time}`" type="button" @click="item.record ? openUserHistory(item.record) : selectPracticeScenario(item.scenarioId)">
|
||
<em>✓</em>
|
||
<span>
|
||
<b>{{ item.title }}</b>
|
||
<small>练习时间:{{ item.time }}</small>
|
||
</span>
|
||
<i>
|
||
<small>本轮评分</small>
|
||
<b>{{ item.score }}</b>
|
||
<span>{{ scoreStars(item.score) }}</span>
|
||
</i>
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
<section v-if="dailyDrillAssignments.length" class="daily-drills">
|
||
<div class="daily-drills-head">
|
||
<span>
|
||
<b>新员工每日三题</b>
|
||
<small>{{ dailyDrillCompletedCount }}/{{ dailyDrillAssignments.length }} 已完成</small>
|
||
</span>
|
||
<em>即答即评</em>
|
||
</div>
|
||
<article v-for="item in dailyDrillAssignments" :key="item.id || item.question || item.reason" class="daily-drill-card" :class="{ done: item.status === '已完成' }">
|
||
<div class="daily-drill-title">
|
||
<b>{{ item.question || item.scenarioName }}</b>
|
||
<small>{{ item.scenarioName }}</small>
|
||
</div>
|
||
<textarea
|
||
v-model="dailyDrillAnswers[item.id || 0]"
|
||
:disabled="item.status === '已完成' || !!dailyDrillSubmitting[item.id || 0]"
|
||
rows="3"
|
||
placeholder="写下你的第一句话或处理要点"
|
||
></textarea>
|
||
<div class="daily-drill-actions">
|
||
<button type="button" :disabled="item.status === '已完成' || !!dailyDrillSubmitting[item.id || 0]" @click="submitDailyDrill(item)">
|
||
{{ item.status === '已完成' ? '已完成' : dailyDrillSubmitting[item.id || 0] ? '提交中' : '提交作答' }}
|
||
</button>
|
||
<small v-if="item.correct !== undefined">{{ item.correct ? '方向正确' : '待补关键点' }}</small>
|
||
</div>
|
||
<p v-if="item.feedback" class="daily-drill-feedback">{{ item.feedback }}</p>
|
||
</article>
|
||
<p v-if="dailyDrillMessage" class="daily-drill-message">{{ dailyDrillMessage }}</p>
|
||
</section>
|
||
</template>
|
||
<template v-else>
|
||
<button class="panel-row" type="button" @click="handlePrimaryAction">
|
||
<IconBubble :icon="workerRole.primary.icon" tone="teal" />
|
||
<span>
|
||
<b>{{ workerRole.primary.title }}</b>
|
||
<small>{{ workerRole.primary.desc }}</small>
|
||
</span>
|
||
<Icon name="chevron-right" size="24" />
|
||
</button>
|
||
<button v-if="roleKey === 'candidate' && interviewStatus === 'idle'" class="panel-primary" type="button" @click="handlePrimaryAction">
|
||
{{ workerRole.primary.cta }}
|
||
</button>
|
||
<button class="panel-row" type="button" @click="openUserAction('sop')">
|
||
<IconBubble icon="document" tone="amber" />
|
||
<span>
|
||
<b>{{ workerRole.review.title }}</b>
|
||
<small>{{ workerRole.review.desc }}</small>
|
||
</span>
|
||
<Icon name="chevron-right" size="24" />
|
||
</button>
|
||
<button class="panel-row" type="button" @click="openUserAction('cases')">
|
||
<IconBubble icon="folder" tone="orange" />
|
||
<span>
|
||
<b>案例学习</b>
|
||
<small>查看优秀服务案例和可复用话术</small>
|
||
</span>
|
||
<Icon name="chevron-right" size="24" />
|
||
</button>
|
||
|
||
<div v-if="roleKey === 'candidate' && interviewStatus !== 'idle'" class="practice-panel">
|
||
<div class="practice-panel-head">
|
||
<span>AI结构化面试</span>
|
||
<b v-if="interviewResult">{{ interviewResult.totalScore }}分</b>
|
||
<small v-else>{{ interviewStatus === 'starting' ? '生成题目中' : '作答中' }}</small>
|
||
</div>
|
||
|
||
<div v-if="interviewResult" class="review-detail">
|
||
<div class="review-detail-head">
|
||
<span>
|
||
<b>{{ interviewResult.result }}</b>
|
||
<small>{{ interviewResult.aiSummary }}</small>
|
||
</span>
|
||
<em>{{ interviewResult.source }}</em>
|
||
</div>
|
||
<div class="review-score-grid">
|
||
<p v-for="item in interviewResult.dimensions" :key="item.label">
|
||
<span>{{ item.label }}</span>
|
||
<b>{{ item.value }}</b>
|
||
<small>{{ item.note }}</small>
|
||
</p>
|
||
</div>
|
||
<div class="review-summary">
|
||
<b>建议</b>
|
||
<p>{{ interviewResult.suggestion }}</p>
|
||
<b>依据</b>
|
||
<p>{{ interviewResult.evidence }}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else class="interview-questions">
|
||
<label v-for="item in interviewQuestions" :key="item.questionId" class="interview-question">
|
||
<span>{{ item.seq }}. {{ item.questionText }}</span>
|
||
<small>{{ item.point }}</small>
|
||
<textarea v-model.trim="interviewAnswers[item.questionId]" rows="3" placeholder="输入你的回答" />
|
||
</label>
|
||
<button class="panel-primary" type="button" :disabled="interviewStatus === 'starting' || interviewStatus === 'submitting'" @click="finishCandidateInterview">
|
||
{{ interviewStatus === 'submitting' ? '提交中' : '提交面试' }}
|
||
</button>
|
||
</div>
|
||
<em v-if="interviewMessage">{{ interviewMessage }}</em>
|
||
</div>
|
||
</template>
|
||
</template>
|
||
|
||
<template v-else-if="activeUserPanel === 'practice'">
|
||
<div class="practice-panel scene-practice">
|
||
<div class="practice-panel-head scene-head">
|
||
<button class="detail-back" type="button" @click="closePracticeScene">返回</button>
|
||
<span>
|
||
<b>场景模拟</b>
|
||
<small>第 {{ practiceRoundLabel }} 轮对话(共 {{ practiceTotalRounds }} 轮)</small>
|
||
</span>
|
||
<button v-if="!practiceResult && practiceStatus !== 'idle'" type="button" :disabled="practiceStatus === 'starting' || practiceStatus === 'submitting'" @click="finishMobilePractice">
|
||
结束
|
||
</button>
|
||
</div>
|
||
|
||
<div class="scene-progress">
|
||
<span :style="{ width: `${(practiceRoundLabel / practiceTotalRounds) * 100}%` }"></span>
|
||
<b>{{ practiceRoundLabel }}/{{ practiceTotalRounds }}</b>
|
||
</div>
|
||
|
||
<div v-if="practiceStatus !== 'idle'" class="scene-progress emotion-progress">
|
||
<span :style="{ width: clampPercent(practiceEmotionScore) }"></span>
|
||
<b>情绪 {{ practiceEmotionScore }}%</b>
|
||
</div>
|
||
|
||
<div v-if="practiceResult" class="practice-result">
|
||
<strong>{{ practiceResult.aiComment }}</strong>
|
||
<p>{{ practiceResult.mentorRewrite }}</p>
|
||
<div class="scene-rating" aria-label="本轮评分">
|
||
<span>★ ★ ★ ★ ☆</span>
|
||
<b>{{ practiceResult.totalScore }}分</b>
|
||
</div>
|
||
<div class="practice-result-actions">
|
||
<button type="button" @click="startMobilePractice">再练一次</button>
|
||
<button type="button" @click="goSupervisor">查看主管端</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else class="practice-live">
|
||
<div v-if="practicePrepCard" class="practice-prep-card">
|
||
<b>预习卡</b>
|
||
<small>要点:{{ practicePrepCard.keyPoints.join(' / ') }}</small>
|
||
<small>红线:{{ practicePrepCard.redLines.join(' / ') }}</small>
|
||
<em>{{ practicePrepCard.scripts[0] }}</em>
|
||
</div>
|
||
<div class="practice-dialogue" aria-label="场景对话">
|
||
<div v-for="(turn, index) in practiceTurns" :key="`${turn.role}-${index}`" class="practice-message" :class="turn.role">
|
||
<img :src="practiceAvatarSrc(turn.role)" alt="" />
|
||
<p>
|
||
<span>{{ turn.label }}</span>
|
||
{{ turn.text }}
|
||
<small class="practice-wave">
|
||
<i v-for="(height, barIndex) in waveBars" :key="`${turn.role}-${index}-${barIndex}`" :style="{ height: `${height}px` }"></i>
|
||
<b>{{ practiceTurnDuration(turn.role, index) }}</b>
|
||
</small>
|
||
</p>
|
||
<div v-if="turn.role === 'coach' && turn.roundIndex !== undefined" class="coach-style-actions">
|
||
<small v-if="turn.styleNote" class="coach-style-note">{{ turn.styleNote }}</small>
|
||
<button
|
||
v-for="option in practiceStyleOptions"
|
||
:key="option.key"
|
||
type="button"
|
||
:disabled="Boolean(practiceRegeneratingKey)"
|
||
@click="regeneratePracticeCoach(index, option)"
|
||
>
|
||
{{ practiceRegeneratingKey === `${index}:${option.key}` ? '调整中' : option.label }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="dialogue-memory">
|
||
<b>对话记忆</b>
|
||
<small>已记录 {{ practiceTurns.length }} 条上下文,下一轮会继续带入</small>
|
||
</div>
|
||
<div v-if="latestCoachFeedback" class="round-rating">
|
||
<b>本轮评分(师傅评价)</b>
|
||
<span aria-label="4星评分">★ ★ ★ ★ ☆</span>
|
||
<small>{{ latestCoachFeedback.text }}</small>
|
||
<em>4.0</em>
|
||
</div>
|
||
<textarea v-model.trim="practiceDraft" rows="3" placeholder="输入你的回应,也可以用语音输入" />
|
||
<div class="practice-actions">
|
||
<button type="button" :disabled="asrBusy" @click="toggleRecording">
|
||
{{ recording ? '停止录音' : asrBusy ? '识别中' : useAudioFileFallback ? '选择语音' : '语音输入' }}
|
||
</button>
|
||
<input ref="practiceAudioInput" class="practice-audio-input" type="file" accept="audio/*,.mp3,.wav,.m4a,.webm,.ogg" capture @change="handlePracticeAudioFile" />
|
||
<button type="button" :disabled="practiceHelpLoading" @click="askPracticeHelp">
|
||
{{ practiceHelpLoading ? '查询中' : '求助SOP' }}
|
||
</button>
|
||
<button type="button" @click="fillPracticeReply">建议回复</button>
|
||
<button type="button" :disabled="practiceStatus === 'starting' || practiceStatus === 'submitting'" @click="submitMobilePractice">
|
||
{{ practiceStatus === 'submitting' ? '提交中' : '下一轮' }}
|
||
</button>
|
||
</div>
|
||
<div v-if="practiceHelp" class="practice-help-card">
|
||
<b>SOP提示</b>
|
||
<small>{{ practiceHelp.answer }}</small>
|
||
<em v-if="practiceHelp.reference">{{ practiceHelp.reference }}</em>
|
||
</div>
|
||
</div>
|
||
<em v-if="practiceMessage">{{ practiceMessage }}</em>
|
||
</div>
|
||
</template>
|
||
|
||
<template v-else-if="activeUserPanel === 'sop' || activeUserPanel === 'cases'">
|
||
<div v-if="roleKey === 'candidate'" class="knowledge-tabs">
|
||
<button type="button" :class="{ active: activeUserPanel === 'sop' }" @click="switchKnowledgePanel('sop')">SOP</button>
|
||
<button type="button" :class="{ active: activeUserPanel === 'cases' }" @click="switchKnowledgePanel('cases')">案例</button>
|
||
</div>
|
||
<form class="knowledge-search" @submit.prevent="searchKnowledge()">
|
||
<input v-model.trim="knowledgeQuery" :placeholder="activeUserPanel === 'cases' ? '搜索案例关键词' : '搜索SOP关键词'" />
|
||
<button type="submit" :disabled="knowledgeLoading">{{ knowledgeLoading ? '查询中' : '查询' }}</button>
|
||
</form>
|
||
<em v-if="knowledgeMessage" class="panel-message">{{ knowledgeMessage }}</em>
|
||
|
||
<div v-if="knowledgeResult" class="knowledge-result">
|
||
<strong>{{ knowledgeResult.answer }}</strong>
|
||
<p v-if="knowledgeResult.reference">{{ knowledgeResult.reference }}</p>
|
||
|
||
<div v-if="knowledgeResult.training?.length" class="panel-tags">
|
||
<span v-for="item in knowledgeResult.training" :key="item">{{ item }}</span>
|
||
</div>
|
||
|
||
<div v-if="knowledgeResult.docs?.length" class="result-list">
|
||
<h3>命中文档</h3>
|
||
<p v-for="doc in knowledgeResult.docs" :key="`${doc.title}-${doc.hit}`">
|
||
<b>{{ doc.title }}</b>
|
||
<small>{{ doc.desc }} · {{ doc.hit }}</small>
|
||
</p>
|
||
</div>
|
||
|
||
<div v-if="knowledgeResult.snippets?.length" class="snippet-list">
|
||
<h3>引用片段</h3>
|
||
<blockquote v-for="item in knowledgeResult.snippets" :key="`${item.title}-${item.text}`">
|
||
<b>{{ item.title }}</b>
|
||
<span>{{ item.text }}</span>
|
||
</blockquote>
|
||
</div>
|
||
|
||
<div v-if="knowledgeResult.records?.length" class="result-list">
|
||
<h3>{{ activeUserPanel === 'cases' ? '可学习案例' : '知识记录' }}</h3>
|
||
<p v-for="record in knowledgeResult.records" :key="`${record.title}-${record.version}`">
|
||
<b>{{ record.title }}</b>
|
||
<small>{{ record.summary }} · {{ record.status }}</small>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<template v-else-if="activeUserPanel === 'submit'">
|
||
<label class="file-picker">
|
||
<span>{{ materialFile?.name || (roleKey === 'candidate' ? '选择补充资料' : '选择语音素材') }}</span>
|
||
<small>{{ roleKey === 'candidate' ? '支持 PDF / Word / 图片,当前用于候选资料预览' : '支持 mp3 / wav / m4a / webm / ogg,上传后自动转写并整理案例' }}</small>
|
||
<input :accept="roleKey === 'candidate' ? '.pdf,.doc,.docx,image/*' : 'audio/*,.mp3,.wav,.m4a,.webm,.ogg'" type="file" @change="handleMaterialFile" />
|
||
</label>
|
||
<button class="panel-primary" type="button" :disabled="materialUploading" @click="submitMaterial">
|
||
{{ materialUploading ? '提交中' : roleKey === 'candidate' ? '确认补充' : '上传并整理' }}
|
||
</button>
|
||
<em v-if="materialMessage" class="panel-message">{{ materialMessage }}</em>
|
||
|
||
<div v-if="roleKey === 'candidate' && candidateMaterials.length" class="result-list">
|
||
<h3>已提交资料</h3>
|
||
<p v-for="item in candidateMaterials" :key="item.id">
|
||
<b>{{ item.fileName }}</b>
|
||
<small>#{{ item.id }} · OSS {{ item.ossId }} · {{ item.materialType }} · {{ item.status }} · {{ item.time }}</small>
|
||
</p>
|
||
</div>
|
||
|
||
<div v-if="materialResult" class="case-result">
|
||
<div v-if="materialResult.upload">
|
||
<h3>ASR转写</h3>
|
||
<p>{{ materialResult.upload.transcript }}</p>
|
||
<div class="panel-tags">
|
||
<span v-for="tag in materialResult.upload.tags" :key="tag.text">{{ tag.text }}</span>
|
||
</div>
|
||
</div>
|
||
<div v-if="materialResult.organize">
|
||
<h3>案例整理</h3>
|
||
<p v-if="materialResult.organize.aiSummary">{{ materialResult.organize.aiSummary }}</p>
|
||
<dl>
|
||
<template v-for="item in materialResult.organize.summary" :key="item.label">
|
||
<dt>{{ item.label }}</dt>
|
||
<dd>{{ item.text }}</dd>
|
||
</template>
|
||
</dl>
|
||
</div>
|
||
<div v-if="materialResult.curate?.records?.length">
|
||
<h3>入库记录</h3>
|
||
<p v-for="record in materialResult.curate.records" :key="record.title">
|
||
<b>{{ record.title }}</b>
|
||
<small>{{ record.summary }}</small>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<template v-else-if="activeUserPanel === 'profile'">
|
||
<div v-if="roleKey === 'candidate'" class="profile-card panel-profile">
|
||
<span v-for="item in workerRole.metrics" :key="item.title">
|
||
<small>{{ item.title }}</small>
|
||
<b>{{ item.value }}</b>
|
||
<small>{{ item.desc }}</small>
|
||
</span>
|
||
</div>
|
||
<div v-if="roleKey === 'candidate' && candidateMaterials.length" class="result-list">
|
||
<h3>资料记录</h3>
|
||
<p v-for="item in candidateMaterials" :key="item.id">
|
||
<b>{{ item.fileName }}</b>
|
||
<small>#{{ item.id }} · OSS {{ item.ossId }} · {{ item.status }} · {{ item.time }}</small>
|
||
</p>
|
||
</div>
|
||
<div v-else-if="competencyProfile" class="profile-card panel-profile">
|
||
<span>
|
||
<small>综合能力</small>
|
||
<b>{{ competencyProfile.score }}</b>
|
||
</span>
|
||
<span>
|
||
<small>训练历史</small>
|
||
<b>{{ competencyProfile.completed }}</b>
|
||
</span>
|
||
<span>
|
||
<small>待复盘</small>
|
||
<b>{{ competencyProfile.pendingReview }}</b>
|
||
</span>
|
||
<span>
|
||
<small>训练时长</small>
|
||
<b>{{ competencyProfile.trainingMinutes ?? 0 }}分</b>
|
||
</span>
|
||
<span>
|
||
<small>贡献度</small>
|
||
<b>{{ competencyProfile.contribution ?? 0 }}</b>
|
||
</span>
|
||
<span>
|
||
<small>AI等级</small>
|
||
<b>{{ competencyProfile.aiLevel || '初级' }}</b>
|
||
</span>
|
||
<div class="profile-dims">
|
||
<p v-for="item in competencyProfile.dimensions" :key="item.label">
|
||
<span>{{ item.label }}</span>
|
||
<b>{{ item.value }}</b>
|
||
<small>{{ item.note }}</small>
|
||
</p>
|
||
</div>
|
||
<div v-if="competencyProfile.growthPath?.length" class="growth-path-list">
|
||
<p v-for="item in competencyProfile.growthPath" :key="item.level">
|
||
<span>{{ item.level }} · {{ item.status }}</span>
|
||
<b>{{ item.progress }}%</b>
|
||
<small>{{ item.evidence }}</small>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div v-if="practiceHistory.length" class="record-list">
|
||
<h3>训练历史</h3>
|
||
<button v-for="item in practiceHistory" :key="`${item.time}-${item.scene}`" type="button" @click="openUserHistory(item)">
|
||
<span>
|
||
<b>{{ item.scene }} · {{ item.score }}分</b>
|
||
<small>{{ item.time }} · {{ item.summary }}</small>
|
||
</span>
|
||
<em>{{ item.status }}</em>
|
||
</button>
|
||
</div>
|
||
</template>
|
||
|
||
<template v-else-if="activeUserPanel === 'history'">
|
||
<div v-if="practiceHistory.length && !userReviewDetail" class="record-list">
|
||
<h3>训练历史</h3>
|
||
<button v-for="item in practiceHistory" :key="`${item.time}-${item.scene}`" type="button" @click="openUserHistory(item)">
|
||
<span>
|
||
<b>{{ item.scene }} · {{ item.score }}分</b>
|
||
<small>{{ item.time }} · {{ item.summary }}</small>
|
||
</span>
|
||
<em>{{ item.status }}</em>
|
||
</button>
|
||
</div>
|
||
<em v-if="userReviewMessage" class="panel-message">{{ userReviewMessage }}</em>
|
||
<div v-if="userReviewDetail" class="review-detail">
|
||
<div class="review-detail-head">
|
||
<span>
|
||
<b>{{ userReviewDetail.scene }} · {{ userReviewDetail.score }}分</b>
|
||
<small>{{ userReviewDetail.time }} · {{ userReviewDetail.status }}</small>
|
||
</span>
|
||
<em>{{ userReviewDetail.status }}</em>
|
||
</div>
|
||
<div class="review-score-grid">
|
||
<p v-for="item in userReviewDetail.scoreItems" :key="item.label">
|
||
<span>{{ item.label }}</span>
|
||
<b>{{ item.value }}</b>
|
||
<small>{{ item.note }}</small>
|
||
</p>
|
||
</div>
|
||
<div class="practice-dialogue review-dialogue">
|
||
<p v-for="(turn, index) in userReviewDetail.dialogue" :key="`${turn.role}-${index}`" :class="turn.role">
|
||
<span>{{ turn.label }}</span>
|
||
{{ turn.text }}
|
||
<audio v-if="turn.audioUrl" controls :src="turn.audioUrl"></audio>
|
||
</p>
|
||
</div>
|
||
<div v-if="userReviewDetail.annotations.length" class="review-annotations">
|
||
<b>逐句标注</b>
|
||
<p v-for="item in userReviewDetail.annotations" :key="`${item.turnIndex}-${item.sourceText}`">
|
||
<span>第 {{ item.turnIndex }} 句</span>
|
||
<small>{{ item.sourceText }}</small>
|
||
<em>{{ item.issue }}</em>
|
||
<strong>{{ item.rewrite }}</strong>
|
||
<i>{{ item.sopPoint }}</i>
|
||
</p>
|
||
</div>
|
||
<div class="review-summary">
|
||
<b>导师改写</b>
|
||
<p>{{ userReviewDetail.mentorRewrite }}</p>
|
||
<b>AI点评</b>
|
||
<p>{{ userReviewDetail.aiComment }}</p>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
</div>
|
||
</Card>
|
||
|
||
<section v-if="roleKey === 'user' && isHomeTab && !userBuildingOpen" class="scenario-section growth-section">
|
||
<div class="scenario-title">
|
||
<h2>我的成长</h2>
|
||
<p>这里只看摘要,完整记录去我的页</p>
|
||
</div>
|
||
|
||
<Card title="我的进度" action="查看全部" compact-action @action="openUserAction('profile')">
|
||
<div class="profile-card growth-summary">
|
||
<span>
|
||
<small>综合能力</small>
|
||
<b>{{ competencyProfile?.score || workerRole.metrics[1]?.value || '-' }}</b>
|
||
</span>
|
||
<span>
|
||
<small>今日完成</small>
|
||
<b>{{ todayPracticeCompleted }}</b>
|
||
</span>
|
||
<span>
|
||
<small>待复盘</small>
|
||
<b>{{ competencyProfile?.pendingReview ?? workerRole.metrics[2]?.value ?? '-' }}</b>
|
||
</span>
|
||
<span>
|
||
<small>AI等级</small>
|
||
<b>{{ competencyProfile?.aiLevel || '初级' }}</b>
|
||
</span>
|
||
</div>
|
||
</Card>
|
||
</section>
|
||
|
||
</template>
|
||
|
||
<nav v-if="showTabbar" class="tabbar" aria-label="底部导航">
|
||
<button v-for="tab in visibleTabs" :key="tab.label" type="button" :class="{ active: isTabActive(tab.label) }" @click="openTab(tab.label)">
|
||
<Icon :name="tab.icon" size="28" />
|
||
<span>{{ tab.label }}</span>
|
||
</button>
|
||
</nav>
|
||
|
||
<button v-if="mentorFloatVisible" class="mentor-float mentor-avatar" :class="`is-${mentorState}`" type="button" @click="restoreMentorHero" aria-label="回到师傅区">
|
||
<img :src="mentorImageSrc" alt="" />
|
||
<span class="mentor-eyelid mentor-eyelid-left"></span>
|
||
<span class="mentor-eyelid mentor-eyelid-right"></span>
|
||
<span class="mentor-thinking" aria-hidden="true"><i></i><i></i><i></i></span>
|
||
<span class="mentor-waves" aria-hidden="true"><i></i><i></i><i></i><i></i></span>
|
||
</button>
|
||
</section>
|
||
|
||
<section v-if="sopPanelOpen" class="sop-overlay" aria-label="查 SOP">
|
||
<div class="sop-panel">
|
||
<header class="sop-head">
|
||
<button class="sop-back" type="button" @click="closeSopPanel">返回</button>
|
||
<h2>查 SOP</h2>
|
||
</header>
|
||
|
||
<form class="sop-form" @submit.prevent="searchSopKnowledge">
|
||
<input ref="sopTextInput" v-model.trim="sopQuery" placeholder="输入或语音提问,如:投诉首次反馈时限是多久" />
|
||
<div class="sop-actions">
|
||
<button class="sop-voice" type="button" :disabled="sopAsrBusy || sopLoading" @click="toggleSopRecording">
|
||
{{ sopRecording ? '停止录音' : sopAsrBusy ? '识别中' : sopUseAudioFileFallback ? '选择语音' : '语音提问' }}
|
||
</button>
|
||
<input ref="sopAudioInput" class="practice-audio-input" type="file" accept="audio/*,.mp3,.wav,.m4a,.webm,.ogg" capture @change="handleSopAudioFile" />
|
||
<button class="sop-submit" type="submit" :disabled="sopLoading">{{ sopLoading ? '总结中' : '搜索' }}</button>
|
||
</div>
|
||
</form>
|
||
|
||
<em v-if="sopMessage" class="panel-message">{{ sopMessage }}</em>
|
||
<div v-if="sopResult" class="knowledge-result sop-result">
|
||
<div class="digest-card">
|
||
<div class="digest-head">
|
||
<strong>{{ sopResult.answer }}</strong>
|
||
<button class="digest-speak" type="button" :disabled="sopSpeakLoading" @click="toggleSopSpeak">
|
||
{{ sopSpeaking ? '停止' : sopSpeakLoading ? '合成中' : '听一遍' }}
|
||
</button>
|
||
</div>
|
||
<ol v-if="sopResult.keyPoints?.length" class="digest-points">
|
||
<li v-for="point in sopResult.keyPoints" :key="point">{{ point }}</li>
|
||
</ol>
|
||
<div class="answer-feedback-actions">
|
||
<button
|
||
type="button"
|
||
aria-label="这条答案有帮助"
|
||
:class="{ active: sopFeedbackVerdict === 'up' }"
|
||
:disabled="sopFeedbackSubmitting || !!sopFeedbackVerdict"
|
||
@click="submitSopFeedback('up')"
|
||
>
|
||
👍
|
||
</button>
|
||
<button
|
||
type="button"
|
||
aria-label="这条答案不准确"
|
||
:class="{ active: sopFeedbackVerdict === 'down' }"
|
||
:disabled="sopFeedbackSubmitting || !!sopFeedbackVerdict"
|
||
@click="submitSopFeedback('down')"
|
||
>
|
||
👎
|
||
</button>
|
||
</div>
|
||
<div v-if="sopResult.cautions?.length" class="digest-cautions">
|
||
<p v-for="item in sopResult.cautions" :key="item">⚠ {{ item }}</p>
|
||
</div>
|
||
<p v-if="sopResult.reference" class="digest-reference">{{ sopResult.reference }}</p>
|
||
<div class="summary-card-entry">
|
||
<button class="summary-card-trigger" type="button" :disabled="summaryCardLoading" @click="generateSummaryCard">
|
||
<span>{{ summaryCardLoading ? '生成中…' : '✨ 生成总结卡' }}</span>
|
||
<small>约10秒生成</small>
|
||
</button>
|
||
<em v-if="summaryCardMessage">{{ summaryCardMessage }}</em>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="sopResult.snippets?.length" class="snippet-list">
|
||
<button class="snippet-toggle" type="button" @click="sopSnippetsOpen = !sopSnippetsOpen">
|
||
{{ sopSnippetsOpen ? '收起依据' : `查看依据(${sopResult.snippets.length} 条原文片段)` }}
|
||
</button>
|
||
<template v-if="sopSnippetsOpen">
|
||
<blockquote
|
||
v-for="(item, index) in sopResult.snippets"
|
||
:key="`${item.title}-${index}`"
|
||
:class="{ expanded: expandedSnippets.has(index) }"
|
||
@click="toggleSnippet(index)"
|
||
>
|
||
<b>{{ item.title }}</b>
|
||
<span>{{ item.text }}</span>
|
||
<em v-if="!expandedSnippets.has(index)">点击展开全文</em>
|
||
</blockquote>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section v-if="summaryCardOpen && summaryCard" class="summary-card-mask" aria-label="总结卡弹层" @click.self="closeSummaryCard">
|
||
<article class="summary-card-sheet" role="dialog" aria-modal="true">
|
||
<span class="summary-card-handle" aria-hidden="true"></span>
|
||
<button class="summary-card-close" type="button" aria-label="关闭总结卡" @click="closeSummaryCard">×</button>
|
||
<header class="summary-card-head">
|
||
<h2>{{ summaryCard.title }}</h2>
|
||
<span>由AI生成</span>
|
||
</header>
|
||
|
||
<div class="summary-card-grid">
|
||
<section class="summary-card-block is-steps">
|
||
<h3>沟通四步法</h3>
|
||
<ol>
|
||
<li v-for="(step, index) in summaryCard.steps" :key="`${step.label}-${index}`">
|
||
<em>{{ index + 1 }}</em>
|
||
<span>
|
||
<b>{{ step.label }}</b>
|
||
<small>{{ step.desc }}</small>
|
||
</span>
|
||
</li>
|
||
</ol>
|
||
</section>
|
||
|
||
<section class="summary-card-block is-objections">
|
||
<h3>高频异议应对</h3>
|
||
<div class="objection-list">
|
||
<p v-for="(item, index) in summaryCard.objections" :key="`${item.concern}-${index}`">
|
||
<b>{{ item.concern }}</b>
|
||
<span>{{ item.response }}</span>
|
||
</p>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="summary-card-block is-scripts">
|
||
<h3>开口话术(可直接使用)</h3>
|
||
<blockquote v-for="(script, index) in summaryCard.scripts" :key="`${script}-${index}`">{{ script }}</blockquote>
|
||
</section>
|
||
|
||
<section class="summary-card-block is-reminders">
|
||
<h3>关键提醒</h3>
|
||
<ul>
|
||
<li v-for="(item, index) in summaryCard.reminders" :key="`${item}-${index}`">{{ item }}</li>
|
||
</ul>
|
||
</section>
|
||
</div>
|
||
|
||
<p v-if="summaryCardMessage" class="summary-card-inline-message">{{ summaryCardMessage }}</p>
|
||
|
||
<footer class="summary-card-footer">
|
||
<div class="summary-card-footer-actions">
|
||
<button class="listen" type="button" :disabled="cardSpeakLoading" @click="toggleSummaryCardSpeak">
|
||
{{ cardSpeaking ? '停止播放' : cardSpeakLoading ? '合成中' : '🔊 听一遍' }}
|
||
</button>
|
||
<button class="practice" type="button" @click="startPracticeFromSummaryCard">🎤 开始跟读</button>
|
||
</div>
|
||
<p>总结卡由 AI 基于检索结果生成,请结合实际情况灵活使用</p>
|
||
</footer>
|
||
</article>
|
||
</section>
|
||
|
||
<div v-if="toastMessage" class="build-toast" role="status" aria-live="polite">{{ toastMessage }}</div>
|
||
</main>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import type { PropType } from 'vue';
|
||
import { computed, defineComponent, h, nextTick, onBeforeUnmount, ref, watch } from 'vue';
|
||
import {
|
||
clientIdKey,
|
||
phoneKey,
|
||
phonePattern,
|
||
tokenKey
|
||
} from './data/auth';
|
||
import { employeeTabs, fallbackHomes } from './data/home';
|
||
import { iconPaths } from './data/icons';
|
||
import { mentorImageSrc, mentorMapActions, mentorQuickChips } from './data/mentor';
|
||
import { rolePaths } from './data/navigation';
|
||
import {
|
||
practiceRecentFallback,
|
||
practiceScenarios,
|
||
practiceStyleOptions,
|
||
practiceTotalRounds,
|
||
todayPracticeTarget,
|
||
waveBars
|
||
} from './data/practice';
|
||
import { supervisorHeatmapItems } from './data/supervisor';
|
||
import type {
|
||
AnswerFeedbackVerdict,
|
||
ApiPayload,
|
||
CandidateMaterialResponse,
|
||
CompetencyDimension,
|
||
CompetencyProfile,
|
||
DailyDrillAnswerResponse,
|
||
IconName,
|
||
InterviewFinishResponse,
|
||
InterviewQuestion,
|
||
InterviewRecord,
|
||
InterviewStartResponse,
|
||
KnowledgeSearchResponse,
|
||
LoginResult,
|
||
MaterialResult,
|
||
MentorState,
|
||
MetricItem,
|
||
PracticeAlert,
|
||
PracticeAssignment,
|
||
PracticeFinishResponse,
|
||
PracticePrepCardResponse,
|
||
PracticeRecentItem,
|
||
PracticeRecord,
|
||
PracticeReviewDetail,
|
||
PracticeRole,
|
||
PracticeScenario,
|
||
PracticeStartResponse,
|
||
PracticeStatus,
|
||
PracticeStyleKey,
|
||
PracticeTurn,
|
||
PracticeTurnResponse,
|
||
PrimaryTask,
|
||
RoleHome,
|
||
RoleKey,
|
||
SupervisorPanelKey,
|
||
TabItem,
|
||
TodoItem,
|
||
ToolItem,
|
||
Tone,
|
||
UserPanelKey
|
||
} from './types';
|
||
import {
|
||
candidatePanelPath,
|
||
clampPercent,
|
||
formatPracticeScore,
|
||
practiceAvatarSrc,
|
||
practiceTurnDuration,
|
||
readRole,
|
||
readRoute,
|
||
readStoredCredential,
|
||
ringStyle,
|
||
scoreStars,
|
||
supervisorPanelPath,
|
||
userPanelPath
|
||
} from './utils';
|
||
|
||
const initialAuthToken = readStoredCredential(tokenKey);
|
||
const initialClientId = readStoredCredential(clientIdKey);
|
||
const hasIncompleteStoredAuth = Boolean(initialAuthToken || initialClientId) && !(initialAuthToken && initialClientId);
|
||
if (hasIncompleteStoredAuth) {
|
||
localStorage.removeItem(tokenKey);
|
||
localStorage.removeItem(clientIdKey);
|
||
}
|
||
|
||
const roleKey = ref<RoleKey>(readRole());
|
||
const activeTabLabel = ref('今日');
|
||
const isHomeTab = computed(() => activeTabLabel.value === '首页' || activeTabLabel.value === '今日');
|
||
const tapped = ref('');
|
||
const toastMessage = ref('');
|
||
const phone = ref(localStorage.getItem(phoneKey) || '');
|
||
const smsCode = ref('');
|
||
const authToken = ref(hasIncompleteStoredAuth ? '' : initialAuthToken);
|
||
const clientId = ref(hasIncompleteStoredAuth ? '' : initialClientId);
|
||
const authMessage = ref(hasIncompleteStoredAuth ? '登录已过期,请重新登录' : '');
|
||
const isAuthenticated = computed(() => Boolean(authToken.value && clientId.value));
|
||
const sendingCode = ref(false);
|
||
const loggingIn = ref(false);
|
||
const practiceStatus = ref<PracticeStatus>('idle');
|
||
const practiceSessionId = ref('');
|
||
const practiceRoundIndex = ref(0);
|
||
|
||
const practiceRoundLabel = computed(() => Math.max(1, Math.min(practiceRoundIndex.value + 1, practiceTotalRounds)));
|
||
const practiceEmotion = ref<number | null>(null);
|
||
const practiceEmotionScore = computed(() => practiceEmotion.value ?? Math.max(45, 76 - practiceRoundIndex.value * 8));
|
||
|
||
const practiceDraft = ref('');
|
||
const practiceTurns = ref<PracticeTurn[]>([]);
|
||
const practiceResult = ref<PracticeFinishResponse | null>(null);
|
||
const practiceMessage = ref('');
|
||
const practiceRegeneratingKey = ref('');
|
||
const latestPracticeAudioUrl = ref('');
|
||
const latestPracticeAudioOssId = ref<number | null>(null);
|
||
const practicePrepCard = ref<PracticePrepCardResponse | null>(null);
|
||
const practiceHelp = ref<KnowledgeSearchResponse | null>(null);
|
||
const practiceHelpLoading = ref(false);
|
||
const interviewStatus = ref<InterviewStatus>('idle');
|
||
const interviewSessionId = ref('');
|
||
const interviewQuestions = ref<InterviewQuestion[]>([]);
|
||
const interviewAnswers = ref<Record<string, string>>({});
|
||
const interviewResult = ref<InterviewFinishResponse | null>(null);
|
||
const interviewMessage = ref('');
|
||
const practiceHistory = ref<PracticeRecord[]>([]);
|
||
const selectedPracticeScenarioId = ref(practiceScenarios[0].id);
|
||
const practiceReviews = ref<PracticeRecord[]>([]);
|
||
const practiceAssignments = ref<PracticeAssignment[]>([]);
|
||
const dailyDrillAnswers = ref<Record<number, string>>({});
|
||
const dailyDrillSubmitting = ref<Record<number, boolean>>({});
|
||
const dailyDrillMessage = ref('');
|
||
const practiceAlerts = ref<PracticeAlert[]>([]);
|
||
const selectedReview = ref<PracticeReviewDetail | null>(null);
|
||
const userReviewDetail = ref<PracticeReviewDetail | null>(null);
|
||
const userReviewMessage = ref('');
|
||
const reviewMessage = ref('');
|
||
const markingReviewed = ref(false);
|
||
const assigningPractice = ref(false);
|
||
const reviewAdviceDraft = ref('');
|
||
const reviewIncentiveDraft = ref<number | null>(null);
|
||
const reviewAssignNext = ref(false);
|
||
const competencyProfile = ref<CompetencyProfile | null>(null);
|
||
const practiceDataReady = ref(false);
|
||
const activeUserPanel = ref<UserPanelKey>('');
|
||
const activeSupervisorPanel = ref<SupervisorPanelKey>('');
|
||
const knowledgeQuery = ref('住宅投诉处理流程');
|
||
const knowledgeResult = ref<KnowledgeSearchResponse | null>(null);
|
||
const knowledgeSnippetsOpen = ref(false);
|
||
const knowledgeLoading = ref(false);
|
||
const knowledgeMessage = ref('');
|
||
const sopPanelOpen = ref(false);
|
||
const sopReturnTabLabel = ref('首页');
|
||
const sopReturnPath = ref(rolePaths[roleKey.value]);
|
||
const sopQuery = ref('');
|
||
const sopResult = ref<KnowledgeSearchResponse | null>(null);
|
||
const sopLoading = ref(false);
|
||
const sopMessage = ref('');
|
||
const sopFeedbackVerdict = ref<AnswerFeedbackVerdict | ''>('');
|
||
const sopFeedbackSubmitting = ref(false);
|
||
const sopRecording = ref(false);
|
||
const sopAsrBusy = ref(false);
|
||
const sopUseAudioFileFallback = ref(false);
|
||
const sopAudioInput = ref<HTMLInputElement>();
|
||
const sopTextInput = ref<HTMLInputElement>();
|
||
const sopSnippetsOpen = ref(false);
|
||
const expandedSnippets = ref(new Set<number>());
|
||
const sopSpeaking = ref(false);
|
||
const sopSpeakLoading = ref(false);
|
||
const summaryCard = ref<SummaryCardResponse | null>(null);
|
||
const summaryCardOpen = ref(false);
|
||
const summaryCardLoading = ref(false);
|
||
const summaryCardMessage = ref('');
|
||
const cardSpeaking = ref(false);
|
||
const cardSpeakLoading = ref(false);
|
||
let sopAudio: HTMLAudioElement | null = null;
|
||
let cardAudio: HTMLAudioElement | null = null;
|
||
const materialFile = ref<File | null>(null);
|
||
const materialUploading = ref(false);
|
||
const materialMessage = ref('');
|
||
const materialResult = ref<MaterialResult | null>(null);
|
||
const candidateMaterials = ref<CandidateMaterialResponse[]>([]);
|
||
const voiceEnabled = ref(true);
|
||
const recording = ref(false);
|
||
const asrBusy = ref(false);
|
||
const useAudioFileFallback = ref(false);
|
||
const practiceAudioInput = ref<HTMLInputElement>();
|
||
const userBuildingOpen = ref(false);
|
||
const mentorCollapsed = ref(false);
|
||
let toastTimer: number | undefined;
|
||
let mediaRecorder: MediaRecorder | null = null;
|
||
let recordedChunks: Blob[] = [];
|
||
let sopMediaRecorder: MediaRecorder | null = null;
|
||
let sopRecordedChunks: Blob[] = [];
|
||
let customerAudio: HTMLAudioElement | null = null;
|
||
let applyingRoute = false;
|
||
let mentorVoicePressing = false;
|
||
let sopAutoSearchAfterTranscribe = false;
|
||
let mentorScrollRaf = 0;
|
||
let mentorScrollListening = false;
|
||
|
||
const showToast = (message: string) => {
|
||
toastMessage.value = message;
|
||
if (toastTimer) window.clearTimeout(toastTimer);
|
||
toastTimer = window.setTimeout(() => {
|
||
toastMessage.value = '';
|
||
}, 2200);
|
||
};
|
||
|
||
const roleAvatarSrc = computed(() => {
|
||
if (roleKey.value === 'candidate') return `${import.meta.env.BASE_URL}candidate-avatar.png`;
|
||
if (roleKey.value === 'supervisor') return `${import.meta.env.BASE_URL}supervisor-avatar.png`;
|
||
return '';
|
||
});
|
||
|
||
const selectedPracticeScenario = computed<PracticeScenario>(() => {
|
||
const preset = practiceScenarios.find((item) => item.id === selectedPracticeScenarioId.value);
|
||
if (preset) return preset;
|
||
const assignment = practiceAssignments.value.find((item) => item.scenarioId === selectedPracticeScenarioId.value);
|
||
if (!assignment) return practiceScenarios[0];
|
||
return {
|
||
id: assignment.scenarioId,
|
||
title: assignment.scenarioName || '专项训练',
|
||
badge: assignment.source === 'retry' ? '重练' : '专项',
|
||
desc: assignment.reason || '主管派发训练',
|
||
progressText: assignment.status === '已完成' ? '已完成' : '0/1',
|
||
progress: assignment.status === '已完成' ? '100%' : '0%'
|
||
};
|
||
});
|
||
|
||
const practiceRecentItems = computed<PracticeRecentItem[]>(() => {
|
||
if (!practiceHistory.value.length) return practiceRecentFallback;
|
||
return practiceHistory.value.slice(0, 2).map((record) => ({
|
||
scenarioId: selectedPracticeScenarioId.value,
|
||
title: `${record.scene}${record.summary ? ` · ${record.summary}` : ''}`,
|
||
time: record.time,
|
||
score: formatPracticeScore(record.score),
|
||
record
|
||
}));
|
||
});
|
||
const latestCoachFeedback = computed(() => {
|
||
const coachTurns = practiceTurns.value.filter((item) => item.role === 'coach');
|
||
return coachTurns.length ? coachTurns[coachTurns.length - 1] : null;
|
||
});
|
||
|
||
const navigatePath = (path: string, replace = false) => {
|
||
if (window.location.pathname === path) return;
|
||
window.history[replace ? 'replaceState' : 'pushState']({}, '', path);
|
||
};
|
||
const syncPath = () => navigatePath(rolePaths[roleKey.value]);
|
||
|
||
const scrollToTop = () => {
|
||
window.requestAnimationFrame(() => window.scrollTo({ top: 0 }));
|
||
};
|
||
const homeTabLabel = () => (roleKey.value === 'user' ? '今日' : '首页');
|
||
const closeBuildingPage = () => {
|
||
userBuildingOpen.value = false;
|
||
activeTabLabel.value = '今日';
|
||
activeUserPanel.value = '';
|
||
syncPath();
|
||
scrollToTop();
|
||
};
|
||
const openBuildingPage = (source = '建设中') => {
|
||
if (roleKey.value !== 'user') {
|
||
tapped.value = source;
|
||
toastMessage.value = `「${source}」本期演示未开放,正在建设中`;
|
||
if (toastTimer) window.clearTimeout(toastTimer);
|
||
toastTimer = window.setTimeout(() => {
|
||
toastMessage.value = '';
|
||
}, 2000);
|
||
return;
|
||
}
|
||
userBuildingOpen.value = true;
|
||
activeTabLabel.value = '今日';
|
||
activeUserPanel.value = '';
|
||
scrollToTop();
|
||
};
|
||
const tap = (name: string) => {
|
||
if (roleKey.value === 'user') {
|
||
openBuildingPage(name);
|
||
return;
|
||
}
|
||
tapped.value = name;
|
||
toastMessage.value = `「${name}」本期演示未开放,正在建设中`;
|
||
if (toastTimer) window.clearTimeout(toastTimer);
|
||
toastTimer = window.setTimeout(() => {
|
||
toastMessage.value = '';
|
||
}, 2000);
|
||
};
|
||
const cycleRole = () => {
|
||
const order: RoleKey[] = ['user', 'candidate', 'supervisor'];
|
||
roleKey.value = order[(order.indexOf(roleKey.value) + 1) % order.length];
|
||
activeTabLabel.value = homeTabLabel();
|
||
userBuildingOpen.value = false;
|
||
syncPath();
|
||
};
|
||
|
||
const userPanelTitle = computed(() => {
|
||
const titles: Record<Exclude<UserPanelKey, ''>, string> =
|
||
roleKey.value === 'candidate'
|
||
? {
|
||
tasks: '面试任务',
|
||
practice: '面试练习',
|
||
sop: '岗位SOP',
|
||
cases: '案例预习',
|
||
submit: '补充资料',
|
||
profile: '候选进度',
|
||
history: '面试记录',
|
||
recommend: '推荐预习'
|
||
}
|
||
: {
|
||
tasks: '场景模拟',
|
||
practice: '场景对练',
|
||
sop: 'SOP查询',
|
||
cases: '案例学习',
|
||
submit: '提交素材',
|
||
profile: '我的能力',
|
||
history: '训练复盘',
|
||
recommend: '专项推荐'
|
||
};
|
||
return activeUserPanel.value ? titles[activeUserPanel.value] : '员工工具';
|
||
});
|
||
|
||
const supervisorPanelTitle = computed(() => {
|
||
const titles: Record<Exclude<SupervisorPanelKey, ''>, string> = {
|
||
team: '团队详情',
|
||
reviews: '复盘处理',
|
||
assign: '指派训练',
|
||
weakness: '团队短板',
|
||
case: '案例提交',
|
||
sop: 'SOP查询',
|
||
profile: '我的主管台'
|
||
};
|
||
return activeSupervisorPanel.value ? titles[activeSupervisorPanel.value] : '主管工具';
|
||
});
|
||
|
||
const roleFlowSteps = computed<FlowStep[]>(() => {
|
||
if (roleKey.value === 'supervisor') {
|
||
return [
|
||
{ phase: '看', title: '团队进度', desc: '先看完训率、低分项和待跟进员工', target: 'team' },
|
||
{ phase: '评', title: '处理复盘', desc: '点开对练记录,查看评分和导师改写', target: 'reviews' },
|
||
{ phase: '派', title: '指派专项', desc: '把短板员工放进下一轮训练任务', target: 'assign' },
|
||
{ phase: '沉', title: '提交案例', desc: '把本周典型服务场景沉淀成案例', target: 'case' }
|
||
];
|
||
}
|
||
if (roleKey.value === 'candidate') {
|
||
return [
|
||
{ phase: '1', title: '面试', desc: '8分钟结构化问答,完成后生成评分', target: 'tasks' },
|
||
{ phase: '2', title: '补资料', desc: '上传证书、履历或图片材料', target: 'submit' },
|
||
{ phase: '3', title: '岗前学习', desc: '预习客服接待和投诉处理标准', target: 'sop' },
|
||
{ phase: '4', title: '等通知', desc: '查看资料审核和面试结果', target: 'profile' }
|
||
];
|
||
}
|
||
return [
|
||
{ phase: '学', title: '先学今日SOP', desc: '上岗前复习投诉处理流程', target: 'sop' },
|
||
{ phase: '练', title: '完成三角色对练', desc: '语音或文字回应,提交后自动评分', target: 'tasks' },
|
||
{ phase: '用', title: '工作中查答案', desc: '遇到投诉、催费、安抚先查SOP和案例', target: 'cases' },
|
||
{ phase: '改', title: '看复盘改话术', desc: '查看历史评分和主管复盘建议', target: 'history' }
|
||
];
|
||
});
|
||
|
||
const clearAuth = (message: string) => {
|
||
authToken.value = '';
|
||
clientId.value = '';
|
||
localStorage.removeItem(tokenKey);
|
||
localStorage.removeItem(clientIdKey);
|
||
activeUserPanel.value = '';
|
||
activeSupervisorPanel.value = '';
|
||
candidateMaterials.value = [];
|
||
authMessage.value = message;
|
||
};
|
||
|
||
const readApi = async <T,>(response: Response): Promise<T> => {
|
||
const payload = (await response.json()) as ApiPayload<T>;
|
||
if (!response.ok || (payload.code !== undefined && payload.code !== 200)) {
|
||
const message = payload.msg || `HTTP ${response.status}`;
|
||
if (response.status === 401 || response.status === 403 || payload.code === 401 || payload.code === 403) {
|
||
clearAuth('登录已过期,请重新登录');
|
||
}
|
||
throw new Error(message);
|
||
}
|
||
return payload.data as T;
|
||
};
|
||
|
||
const apiHeaders = () => ({
|
||
'Content-Type': 'application/json',
|
||
...authOnlyHeaders()
|
||
});
|
||
|
||
// multipart 上传不能手动设置 Content-Type,浏览器需自动带 boundary
|
||
const authOnlyHeaders = () => ({
|
||
...(authToken.value ? { Authorization: `Bearer ${authToken.value}` } : {}),
|
||
...(clientId.value ? { clientid: clientId.value } : {})
|
||
});
|
||
|
||
const playCustomerVoice = async (text: string) => {
|
||
if (!voiceEnabled.value || !text) return;
|
||
try {
|
||
const data = await readApi<{ audioUrl: string }>(
|
||
await fetch('/dev-api/api/ai/tts', {
|
||
method: 'POST',
|
||
headers: apiHeaders(),
|
||
body: JSON.stringify({ text, voice: '' })
|
||
})
|
||
);
|
||
customerAudio?.pause();
|
||
customerAudio = new Audio(data.audioUrl);
|
||
void customerAudio.play().catch(() => undefined);
|
||
} catch {
|
||
// TTS 未配置或失败时静默降级为纯文本
|
||
}
|
||
};
|
||
|
||
const toggleVoice = () => {
|
||
voiceEnabled.value = !voiceEnabled.value;
|
||
if (!voiceEnabled.value) customerAudio?.pause();
|
||
};
|
||
|
||
const toggleRecording = async () => {
|
||
if (recording.value) {
|
||
mediaRecorder?.stop();
|
||
return;
|
||
}
|
||
if (useAudioFileFallback.value || !navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
|
||
useAudioFileFallback.value = true;
|
||
practiceAudioInput.value?.click();
|
||
return;
|
||
}
|
||
try {
|
||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||
recordedChunks = [];
|
||
mediaRecorder = new MediaRecorder(stream);
|
||
mediaRecorder.ondataavailable = (event) => {
|
||
if (event.data.size > 0) recordedChunks.push(event.data);
|
||
};
|
||
mediaRecorder.onstop = () => {
|
||
stream.getTracks().forEach((track) => track.stop());
|
||
recording.value = false;
|
||
void transcribeRecording(new Blob(recordedChunks, { type: mediaRecorder?.mimeType || 'audio/webm' }));
|
||
};
|
||
mediaRecorder.start();
|
||
recording.value = true;
|
||
practiceMessage.value = '';
|
||
} catch {
|
||
useAudioFileFallback.value = true;
|
||
practiceAudioInput.value?.click();
|
||
practiceMessage.value = '无法访问麦克风,请选择语音文件';
|
||
}
|
||
};
|
||
|
||
const handlePracticeAudioFile = (event: Event) => {
|
||
const input = event.target as HTMLInputElement;
|
||
const file = input.files?.[0];
|
||
input.value = '';
|
||
if (file) void transcribeRecording(file, file.name || 'practice-audio');
|
||
};
|
||
|
||
const transcribeRecording = async (blob: Blob, filename = 'practice.webm') => {
|
||
asrBusy.value = true;
|
||
try {
|
||
const form = new FormData();
|
||
form.append('file', blob, filename);
|
||
const data = await readApi<{ text: string; audioUrl?: string; ossId?: number }>(
|
||
await fetch('/dev-api/api/ai/asr', { method: 'POST', headers: authOnlyHeaders(), body: form })
|
||
);
|
||
practiceDraft.value = data.text;
|
||
latestPracticeAudioUrl.value = data.audioUrl || '';
|
||
latestPracticeAudioOssId.value = data.ossId ?? null;
|
||
} catch (error) {
|
||
latestPracticeAudioUrl.value = '';
|
||
latestPracticeAudioOssId.value = null;
|
||
practiceMessage.value = error instanceof Error ? error.message : '语音识别失败,请改用文字输入';
|
||
} finally {
|
||
asrBusy.value = false;
|
||
}
|
||
};
|
||
|
||
const openSopPanel = () => {
|
||
if (roleKey.value === 'user') {
|
||
userBuildingOpen.value = false;
|
||
}
|
||
sopReturnTabLabel.value = activeTabLabel.value === '问' ? '今日' : activeTabLabel.value;
|
||
sopReturnPath.value = window.location.pathname.endsWith('/sop') ? rolePaths[roleKey.value] : window.location.pathname;
|
||
sopPanelOpen.value = true;
|
||
sopQuery.value = '';
|
||
sopResult.value = null;
|
||
sopMessage.value = '';
|
||
sopFeedbackVerdict.value = '';
|
||
sopFeedbackSubmitting.value = false;
|
||
summaryCard.value = null;
|
||
summaryCardMessage.value = '';
|
||
summaryCardOpen.value = false;
|
||
navigatePath(`${rolePaths[roleKey.value]}/sop`);
|
||
};
|
||
|
||
const stopSummaryCardSpeak = () => {
|
||
cardAudio?.pause();
|
||
cardAudio = null;
|
||
cardSpeaking.value = false;
|
||
};
|
||
|
||
const closeSummaryCard = () => {
|
||
stopSummaryCardSpeak();
|
||
summaryCardOpen.value = false;
|
||
};
|
||
|
||
const closeSopPanel = () => {
|
||
if (sopRecording.value) {
|
||
sopAutoSearchAfterTranscribe = false;
|
||
sopMediaRecorder?.stop();
|
||
}
|
||
stopSopSpeak();
|
||
closeSummaryCard();
|
||
sopPanelOpen.value = false;
|
||
activeTabLabel.value = sopReturnTabLabel.value;
|
||
navigatePath(sopReturnPath.value || rolePaths[roleKey.value]);
|
||
applyRouteFromLocation();
|
||
};
|
||
|
||
const toggleSopRecording = async () => {
|
||
if (sopRecording.value) {
|
||
sopAutoSearchAfterTranscribe = false;
|
||
sopMediaRecorder?.stop();
|
||
return;
|
||
}
|
||
if (sopUseAudioFileFallback.value || !navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
|
||
sopUseAudioFileFallback.value = true;
|
||
sopAudioInput.value?.click();
|
||
return;
|
||
}
|
||
try {
|
||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||
sopRecordedChunks = [];
|
||
sopMediaRecorder = new MediaRecorder(stream);
|
||
sopMediaRecorder.ondataavailable = (event) => {
|
||
if (event.data.size > 0) sopRecordedChunks.push(event.data);
|
||
};
|
||
sopMediaRecorder.onstop = () => {
|
||
stream.getTracks().forEach((track) => track.stop());
|
||
sopRecording.value = false;
|
||
void transcribeSopRecording(new Blob(sopRecordedChunks, { type: sopMediaRecorder?.mimeType || 'audio/webm' }));
|
||
};
|
||
sopMediaRecorder.start();
|
||
sopRecording.value = true;
|
||
sopMessage.value = '';
|
||
} catch {
|
||
sopUseAudioFileFallback.value = true;
|
||
sopAudioInput.value?.click();
|
||
}
|
||
};
|
||
|
||
const handleSopAudioFile = (event: Event) => {
|
||
const input = event.target as HTMLInputElement;
|
||
const file = input.files?.[0];
|
||
input.value = '';
|
||
if (file) void transcribeSopRecording(file, file.name || 'sop-audio');
|
||
};
|
||
|
||
const transcribeSopRecording = async (blob: Blob, filename = 'sop.webm') => {
|
||
sopAsrBusy.value = true;
|
||
sopMessage.value = '';
|
||
let shouldSearch = false;
|
||
try {
|
||
const form = new FormData();
|
||
form.append('file', blob, filename);
|
||
const data = await readApi<{ text: string }>(
|
||
await fetch('/dev-api/api/ai/asr', { method: 'POST', headers: authOnlyHeaders(), body: form })
|
||
);
|
||
sopQuery.value = data.text?.trim() || '';
|
||
if (!sopQuery.value) {
|
||
sopMessage.value = '语音识别失败,请改用文字输入';
|
||
} else {
|
||
shouldSearch = sopAutoSearchAfterTranscribe;
|
||
}
|
||
} catch {
|
||
sopMessage.value = '语音识别失败,请改用文字输入';
|
||
} finally {
|
||
sopAutoSearchAfterTranscribe = false;
|
||
sopAsrBusy.value = false;
|
||
}
|
||
if (shouldSearch) void searchSopKnowledge();
|
||
};
|
||
|
||
const openUserPracticePanel = () => {
|
||
activeTabLabel.value = '练';
|
||
userBuildingOpen.value = false;
|
||
activeUserPanel.value = 'tasks';
|
||
userReviewDetail.value = null;
|
||
userReviewMessage.value = '';
|
||
navigatePath(userPanelPath('tasks'));
|
||
scrollToTop();
|
||
};
|
||
|
||
const openPracticeScenePage = () => {
|
||
activeTabLabel.value = '练';
|
||
userBuildingOpen.value = false;
|
||
activeUserPanel.value = 'practice';
|
||
userReviewDetail.value = null;
|
||
userReviewMessage.value = '';
|
||
navigatePath(userPanelPath('practice'));
|
||
scrollToTop();
|
||
};
|
||
|
||
const resetPracticeConversation = () => {
|
||
practiceStatus.value = 'idle';
|
||
practiceSessionId.value = '';
|
||
practiceRoundIndex.value = 0;
|
||
practiceDraft.value = '';
|
||
practiceTurns.value = [];
|
||
practiceResult.value = null;
|
||
practiceMessage.value = '';
|
||
practicePrepCard.value = null;
|
||
practiceHelp.value = null;
|
||
practiceHelpLoading.value = false;
|
||
practiceEmotion.value = null;
|
||
};
|
||
|
||
const selectPracticeScenario = (id: string) => {
|
||
selectedPracticeScenarioId.value = id;
|
||
resetPracticeConversation();
|
||
};
|
||
|
||
const closePracticeScene = () => {
|
||
resetPracticeConversation();
|
||
openUserPracticePanel();
|
||
};
|
||
|
||
const handlePrimaryAction = () => {
|
||
if (roleKey.value === 'candidate') {
|
||
void startCandidateInterview();
|
||
return;
|
||
}
|
||
if (roleKey.value !== 'user') {
|
||
tap(workerRole.value.primary.cta);
|
||
return;
|
||
}
|
||
openPracticeScenePage();
|
||
void startMobilePractice();
|
||
};
|
||
|
||
const toggleSnippet = (index: number) => {
|
||
const next = new Set(expandedSnippets.value);
|
||
if (next.has(index)) {
|
||
next.delete(index);
|
||
} else {
|
||
next.add(index);
|
||
}
|
||
expandedSnippets.value = next;
|
||
};
|
||
|
||
const stopSopSpeak = () => {
|
||
sopAudio?.pause();
|
||
sopAudio = null;
|
||
sopSpeaking.value = false;
|
||
};
|
||
|
||
const toggleSopSpeak = async () => {
|
||
if (sopSpeaking.value) {
|
||
stopSopSpeak();
|
||
return;
|
||
}
|
||
const result = sopResult.value;
|
||
if (!result) return;
|
||
// 播报文本 = 直接回答 + 要点,TTS 上限 300 字,留余量截 280
|
||
const speech = [result.answer, ...(result.keyPoints || []).map((point, i) => `${i + 1}、${point}`)].join('。').slice(0, 280);
|
||
sopSpeakLoading.value = true;
|
||
try {
|
||
const data = await readApi<{ audioUrl: string }>(
|
||
await fetch('/dev-api/api/ai/tts', {
|
||
method: 'POST',
|
||
headers: apiHeaders(),
|
||
body: JSON.stringify({ text: speech, voice: '' })
|
||
})
|
||
);
|
||
sopAudio = new Audio(data.audioUrl);
|
||
sopAudio.onended = () => stopSopSpeak();
|
||
sopSpeaking.value = true;
|
||
void sopAudio.play().catch(() => stopSopSpeak());
|
||
} catch {
|
||
sopMessage.value = '语音播报暂不可用(需启用 tts 模型),可直接阅读上方概要';
|
||
} finally {
|
||
sopSpeakLoading.value = false;
|
||
}
|
||
};
|
||
|
||
const generateSummaryCard = async () => {
|
||
const queryText = (sopQuery.value || sopResult.value?.queryText || '').trim();
|
||
if (!sopResult.value || !queryText) return;
|
||
summaryCardLoading.value = true;
|
||
summaryCardMessage.value = '';
|
||
closeSummaryCard();
|
||
const controller = new AbortController();
|
||
const timeout = window.setTimeout(() => controller.abort(), 35000);
|
||
try {
|
||
summaryCard.value = await readApi<SummaryCardResponse>(
|
||
await fetch('/dev-api/api/knowledge/summary-card', {
|
||
method: 'POST',
|
||
headers: apiHeaders(),
|
||
body: JSON.stringify({ queryText, category: 'sop' }),
|
||
signal: controller.signal
|
||
})
|
||
);
|
||
summaryCardOpen.value = true;
|
||
} catch (error) {
|
||
summaryCardMessage.value =
|
||
error instanceof DOMException && error.name === 'AbortError'
|
||
? '总结卡生成超时,请稍后重试'
|
||
: error instanceof Error
|
||
? error.message
|
||
: '总结卡生成失败,请稍后重试';
|
||
} finally {
|
||
window.clearTimeout(timeout);
|
||
summaryCardLoading.value = false;
|
||
}
|
||
};
|
||
|
||
const toggleSummaryCardSpeak = async () => {
|
||
if (cardSpeaking.value) {
|
||
stopSummaryCardSpeak();
|
||
return;
|
||
}
|
||
const card = summaryCard.value;
|
||
if (!card) return;
|
||
const speech = [
|
||
card.title,
|
||
...card.steps.map((step, index) => `${index + 1}、${step.label},${step.desc}`),
|
||
...card.reminders.map((item) => `提醒,${item}`)
|
||
].join('。').slice(0, 280);
|
||
cardSpeakLoading.value = true;
|
||
try {
|
||
const data = await readApi<{ audioUrl: string }>(
|
||
await fetch('/dev-api/api/ai/tts', {
|
||
method: 'POST',
|
||
headers: apiHeaders(),
|
||
body: JSON.stringify({ text: speech, voice: '' })
|
||
})
|
||
);
|
||
if (!summaryCardOpen.value) return;
|
||
cardAudio = new Audio(data.audioUrl);
|
||
cardAudio.onended = () => stopSummaryCardSpeak();
|
||
cardSpeaking.value = true;
|
||
void cardAudio.play().catch(() => stopSummaryCardSpeak());
|
||
} catch {
|
||
summaryCardMessage.value = '语音播报暂不可用(需启用 tts 模型)';
|
||
} finally {
|
||
cardSpeakLoading.value = false;
|
||
}
|
||
};
|
||
|
||
const startPracticeFromSummaryCard = () => {
|
||
closeSummaryCard();
|
||
closeSopPanel();
|
||
navigatePath(userPanelPath('tasks'));
|
||
applyRouteFromLocation();
|
||
scrollToTop();
|
||
};
|
||
|
||
const searchSopKnowledge = async () => {
|
||
const cleaned = sopQuery.value.trim();
|
||
if (!cleaned) {
|
||
sopMessage.value = '请先输入问题';
|
||
return;
|
||
}
|
||
sopQuery.value = cleaned;
|
||
sopLoading.value = true;
|
||
sopMessage.value = '';
|
||
sopResult.value = null;
|
||
sopFeedbackVerdict.value = '';
|
||
sopFeedbackSubmitting.value = false;
|
||
sopSnippetsOpen.value = false;
|
||
expandedSnippets.value = new Set();
|
||
summaryCard.value = null;
|
||
summaryCardMessage.value = '';
|
||
summaryCardOpen.value = false;
|
||
stopSopSpeak();
|
||
stopSummaryCardSpeak();
|
||
const controller = new AbortController();
|
||
// 概要由 chat 模型生成,比纯检索多几秒,超时放宽到 25s
|
||
const timeout = window.setTimeout(() => controller.abort(), 25000);
|
||
try {
|
||
sopResult.value = await readApi<KnowledgeSearchResponse>(
|
||
await fetch('/dev-api/api/knowledge/search', {
|
||
method: 'POST',
|
||
headers: apiHeaders(),
|
||
body: JSON.stringify({ queryText: cleaned, category: 'sop', limit: 5 }),
|
||
signal: controller.signal
|
||
})
|
||
);
|
||
if (!sopResult.value?.answer && !sopResult.value?.snippets?.length) {
|
||
sopMessage.value = '未检索到可引用内容';
|
||
}
|
||
} catch {
|
||
sopMessage.value = '网络不佳或服务不可用,请稍后重试';
|
||
} finally {
|
||
window.clearTimeout(timeout);
|
||
sopLoading.value = false;
|
||
}
|
||
};
|
||
|
||
const submitSopFeedback = async (verdict: AnswerFeedbackVerdict) => {
|
||
if (sopFeedbackSubmitting.value || sopFeedbackVerdict.value || !sopResult.value) return;
|
||
const fragmentIds = Array.from(new Set(
|
||
(sopResult.value.snippets || [])
|
||
.map((item) => item.fragmentId)
|
||
.filter((id): id is number => typeof id === 'number' && id > 0)
|
||
));
|
||
if (!fragmentIds.length) {
|
||
showToast('暂无可反馈依据');
|
||
return;
|
||
}
|
||
sopFeedbackSubmitting.value = true;
|
||
try {
|
||
await readApi<{ saved: number }>(
|
||
await fetch('/dev-api/api/knowledge/answer-feedback', {
|
||
method: 'POST',
|
||
headers: apiHeaders(),
|
||
body: JSON.stringify({
|
||
queryText: sopResult.value.queryText || sopQuery.value,
|
||
fragmentIds,
|
||
verdict,
|
||
category: sopResult.value.category || 'sop',
|
||
position: '生活顾问',
|
||
source: 'mobile_sop'
|
||
})
|
||
})
|
||
);
|
||
sopFeedbackVerdict.value = verdict;
|
||
showToast(verdict === 'down' ? '已记录,该答案将进入复核' : '感谢反馈');
|
||
} catch (error) {
|
||
showToast(error instanceof Error ? error.message : '反馈提交失败');
|
||
} finally {
|
||
sopFeedbackSubmitting.value = false;
|
||
}
|
||
};
|
||
|
||
const askMentorChip = (query: string) => {
|
||
openSopPanel();
|
||
sopQuery.value = query;
|
||
void searchSopKnowledge();
|
||
};
|
||
|
||
const openMentorTextAsk = () => {
|
||
openSopPanel();
|
||
void nextTick(() => sopTextInput.value?.focus());
|
||
};
|
||
|
||
const supportsSopVoiceRecording = () => !sopUseAudioFileFallback.value && !!navigator.mediaDevices?.getUserMedia && typeof MediaRecorder !== 'undefined';
|
||
|
||
const cleanupMentorVoicePress = () => {
|
||
mentorVoicePressing = false;
|
||
window.removeEventListener('pointerup', stopMentorVoicePress);
|
||
window.removeEventListener('pointercancel', stopMentorVoicePress);
|
||
window.removeEventListener('touchend', stopMentorVoicePress);
|
||
window.removeEventListener('touchcancel', stopMentorVoicePress);
|
||
};
|
||
|
||
const stopMentorVoicePress = (event?: Event) => {
|
||
event?.preventDefault();
|
||
if (!mentorVoicePressing) return;
|
||
cleanupMentorVoicePress();
|
||
if (sopUseAudioFileFallback.value) return;
|
||
if (sopRecording.value) {
|
||
sopAutoSearchAfterTranscribe = true;
|
||
sopMediaRecorder?.stop();
|
||
}
|
||
};
|
||
|
||
const startMentorVoicePress = async (event: Event) => {
|
||
if (event.type === 'touchstart' && 'PointerEvent' in window) return;
|
||
event.preventDefault();
|
||
if (mentorVoicePressing || sopAsrBusy.value || sopLoading.value) return;
|
||
mentorVoicePressing = true;
|
||
window.addEventListener('pointerup', stopMentorVoicePress);
|
||
window.addEventListener('pointercancel', stopMentorVoicePress);
|
||
window.addEventListener('touchend', stopMentorVoicePress);
|
||
window.addEventListener('touchcancel', stopMentorVoicePress);
|
||
openSopPanel();
|
||
await nextTick();
|
||
if (!supportsSopVoiceRecording()) {
|
||
cleanupMentorVoicePress();
|
||
sopUseAudioFileFallback.value = true;
|
||
sopAudioInput.value?.click();
|
||
return;
|
||
}
|
||
await toggleSopRecording();
|
||
if (!sopRecording.value) cleanupMentorVoicePress();
|
||
};
|
||
|
||
const startAgendaPractice = () => {
|
||
if (assignedPracticeTask.value?.scenarioId) {
|
||
selectedPracticeScenarioId.value = assignedPracticeTask.value.scenarioId;
|
||
}
|
||
openPracticeScenePage();
|
||
void startMobilePractice();
|
||
};
|
||
|
||
const openPendingReview = () => {
|
||
if (pendingPracticeReview.value) void openUserHistory(pendingPracticeReview.value);
|
||
};
|
||
|
||
const openMentorMapAction = (key: MentorMapActionKey) => {
|
||
if (key === 'ask') {
|
||
openSopPanel();
|
||
return;
|
||
}
|
||
if (key === 'practice') {
|
||
openUserPracticePanel();
|
||
return;
|
||
}
|
||
if (key === 'learn') {
|
||
openBuildingPage('岗前课程');
|
||
return;
|
||
}
|
||
openUserAction('submit');
|
||
};
|
||
|
||
const searchKnowledge = async (query = knowledgeQuery.value, category = activeUserPanel.value === 'cases' ? 'case' : 'sop') => {
|
||
const cleaned = query.trim();
|
||
if (!cleaned) {
|
||
knowledgeMessage.value = '请输入关键词';
|
||
return;
|
||
}
|
||
knowledgeQuery.value = cleaned;
|
||
knowledgeLoading.value = true;
|
||
knowledgeMessage.value = '';
|
||
try {
|
||
knowledgeResult.value = await readApi<KnowledgeSearchResponse>(
|
||
await fetch('/dev-api/api/knowledge/search', {
|
||
method: 'POST',
|
||
headers: apiHeaders(),
|
||
body: JSON.stringify({ queryText: cleaned, category, limit: 4 })
|
||
})
|
||
);
|
||
knowledgeMessage.value = '已从知识库检索';
|
||
} catch (error) {
|
||
knowledgeResult.value = null;
|
||
knowledgeMessage.value = error instanceof Error ? error.message : '知识库查询失败';
|
||
} finally {
|
||
knowledgeLoading.value = false;
|
||
}
|
||
};
|
||
|
||
const switchKnowledgePanel = (panel: Extract<UserPanelKey, 'sop' | 'cases'>) => {
|
||
activeTabLabel.value = roleKey.value === 'user' ? '问' : '知识';
|
||
activeUserPanel.value = panel;
|
||
navigatePath(roleKey.value === 'candidate' ? candidatePanelPath(panel) : userPanelPath(panel));
|
||
knowledgeQuery.value =
|
||
panel === 'cases' ? '投诉接待优秀案例' : roleKey.value === 'candidate' ? '客服接待基础流程' : '住宅投诉处理流程';
|
||
void searchKnowledge(knowledgeQuery.value, panel === 'cases' ? 'case' : 'sop');
|
||
};
|
||
|
||
const openFlowStep = (item: FlowStep) => {
|
||
if (roleKey.value === 'supervisor') {
|
||
openSupervisorAction(item.target as SupervisorPanelKey);
|
||
return;
|
||
}
|
||
if (roleKey.value === 'candidate') {
|
||
openCandidateAction(item.target as UserPanelKey);
|
||
return;
|
||
}
|
||
openUserAction(item.target as UserPanelKey);
|
||
};
|
||
|
||
const isFlowActive = (item: FlowStep) =>
|
||
roleKey.value === 'supervisor'
|
||
? activeSupervisorPanel.value === item.target
|
||
: activeUserPanel.value === item.target;
|
||
|
||
const openCandidateAction = (panel: UserPanelKey) => {
|
||
activeTabLabel.value =
|
||
({ tasks: '面试', practice: '面试', sop: '知识', cases: '知识', submit: '我的', profile: '我的', history: '我的', recommend: '知识' } as Partial<Record<UserPanelKey, string>>)[panel] || '首页';
|
||
activeUserPanel.value = panel;
|
||
userReviewDetail.value = null;
|
||
userReviewMessage.value = '';
|
||
navigatePath(candidatePanelPath(panel));
|
||
scrollToTop();
|
||
|
||
if (panel === 'sop') {
|
||
knowledgeQuery.value = '客服接待基础流程';
|
||
void searchKnowledge(knowledgeQuery.value, 'sop');
|
||
return;
|
||
}
|
||
|
||
if (panel === 'cases') {
|
||
knowledgeQuery.value = '投诉接待优秀案例';
|
||
void searchKnowledge(knowledgeQuery.value, 'case');
|
||
return;
|
||
}
|
||
|
||
if (panel === 'recommend') {
|
||
activeUserPanel.value = 'sop';
|
||
knowledgeQuery.value = '投诉接待基础';
|
||
void searchKnowledge(knowledgeQuery.value, 'sop');
|
||
}
|
||
|
||
if (panel === 'submit' || panel === 'profile') {
|
||
void fetchCandidateMaterials();
|
||
}
|
||
};
|
||
|
||
const openUserAction = (panel: UserPanelKey) => {
|
||
if (roleKey.value === 'candidate') {
|
||
openCandidateAction(panel);
|
||
return;
|
||
}
|
||
|
||
if (roleKey.value !== 'user') {
|
||
tap(panel || roleKey.value);
|
||
return;
|
||
}
|
||
|
||
if (panel === 'sop') {
|
||
openSopPanel();
|
||
return;
|
||
}
|
||
|
||
activeTabLabel.value =
|
||
({ tasks: '练', practice: '练', sop: '问', cases: '问', submit: '我', profile: '我', history: '我', recommend: '问' } as Partial<Record<UserPanelKey, string>>)[panel] || '今日';
|
||
userBuildingOpen.value = false;
|
||
activeUserPanel.value = panel;
|
||
userReviewDetail.value = null;
|
||
userReviewMessage.value = '';
|
||
navigatePath(userPanelPath(panel));
|
||
scrollToTop();
|
||
|
||
if (panel === 'cases') {
|
||
knowledgeQuery.value = '投诉接待优秀案例';
|
||
void searchKnowledge(knowledgeQuery.value, 'case');
|
||
return;
|
||
}
|
||
|
||
if (panel === 'recommend') {
|
||
activeUserPanel.value = 'sop';
|
||
knowledgeQuery.value = '催费沟通专项';
|
||
void searchKnowledge(knowledgeQuery.value, 'sop');
|
||
return;
|
||
}
|
||
|
||
if (panel === 'profile' || panel === 'history') {
|
||
void fetchPracticeData();
|
||
}
|
||
};
|
||
|
||
const openUserTool = (item: ToolItem) => {
|
||
if (roleKey.value === 'candidate') {
|
||
if (item.title.includes('面试')) {
|
||
void startCandidateInterview();
|
||
return;
|
||
}
|
||
if (item.title.includes('SOP')) {
|
||
openCandidateAction('sop');
|
||
return;
|
||
}
|
||
if (item.title.includes('案例')) {
|
||
openCandidateAction('cases');
|
||
return;
|
||
}
|
||
if (item.title.includes('资料')) {
|
||
openCandidateAction('submit');
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (roleKey.value !== 'user') {
|
||
tap(item.title);
|
||
return;
|
||
}
|
||
|
||
if (item.title === '每日一练') {
|
||
openPracticeScenePage();
|
||
void startMobilePractice();
|
||
return;
|
||
}
|
||
if (item.title === '查SOP') {
|
||
openUserAction('sop');
|
||
return;
|
||
}
|
||
if (item.title === '案例学习') {
|
||
openUserAction('cases');
|
||
return;
|
||
}
|
||
if (item.title === '提交素材') {
|
||
openUserAction('submit');
|
||
return;
|
||
}
|
||
tap(item.title);
|
||
};
|
||
|
||
const openSupervisorAction = (panel: SupervisorPanelKey) => {
|
||
if (roleKey.value !== 'supervisor') {
|
||
tap(panel || roleKey.value);
|
||
return;
|
||
}
|
||
if (panel === 'sop') {
|
||
openSopPanel();
|
||
return;
|
||
}
|
||
activeTabLabel.value =
|
||
({ team: '团队', reviews: '团队', assign: '团队', weakness: '团队', case: '案例', sop: '案例', profile: '我的' } as Partial<Record<SupervisorPanelKey, string>>)[panel] || '首页';
|
||
activeSupervisorPanel.value = panel;
|
||
activeUserPanel.value = '';
|
||
navigatePath(supervisorPanelPath(panel));
|
||
scrollToTop();
|
||
if (panel === 'reviews' || panel === 'assign') {
|
||
void fetchPracticeData();
|
||
}
|
||
};
|
||
|
||
const openSupervisorTodo = (item: TodoItem) => {
|
||
if (item.title.includes('复盘')) {
|
||
openSupervisorAction('reviews');
|
||
return;
|
||
}
|
||
if (item.title.includes('案例')) {
|
||
openSupervisorAction('case');
|
||
return;
|
||
}
|
||
openSupervisorAction('assign');
|
||
};
|
||
|
||
const openSupervisorTool = (item: ToolItem) => {
|
||
if (item.title.includes('指派')) {
|
||
openSupervisorAction('assign');
|
||
return;
|
||
}
|
||
if (item.title.includes('短板')) {
|
||
openSupervisorAction('weakness');
|
||
return;
|
||
}
|
||
if (item.title.includes('案例')) {
|
||
openSupervisorAction('case');
|
||
return;
|
||
}
|
||
if (item.title.includes('SOP')) {
|
||
openSupervisorAction('sop');
|
||
return;
|
||
}
|
||
tap(item.title);
|
||
};
|
||
|
||
const isTabActive = (label: string) => {
|
||
if (roleKey.value !== 'user') return activeTabLabel.value === label;
|
||
if (label === '今日') return activeTabLabel.value === '今日' || activeTabLabel.value === '首页';
|
||
if (label === '练') return activeTabLabel.value === '练' || activeTabLabel.value === '训练';
|
||
if (label === '问') return sopPanelOpen.value || activeTabLabel.value === '问' || activeTabLabel.value === '知识';
|
||
if (label === '我') return activeTabLabel.value === '我' || activeTabLabel.value === '我的';
|
||
return activeTabLabel.value === label;
|
||
};
|
||
|
||
const openTab = (label: string) => {
|
||
scrollToTop();
|
||
activeTabLabel.value = label;
|
||
if (roleKey.value === 'supervisor') {
|
||
if (label === '首页') {
|
||
activeSupervisorPanel.value = '';
|
||
activeUserPanel.value = '';
|
||
selectedReview.value = null;
|
||
syncPath();
|
||
return;
|
||
}
|
||
if (label === '团队') {
|
||
openSupervisorAction('team');
|
||
return;
|
||
}
|
||
if (label === '案例') {
|
||
openSupervisorAction('case');
|
||
return;
|
||
}
|
||
if (label === '我的') {
|
||
openSupervisorAction('profile');
|
||
return;
|
||
}
|
||
}
|
||
if (roleKey.value !== 'user') {
|
||
if (roleKey.value === 'candidate') {
|
||
if (label === '首页') {
|
||
activeUserPanel.value = '';
|
||
userReviewDetail.value = null;
|
||
userReviewMessage.value = '';
|
||
syncPath();
|
||
return;
|
||
}
|
||
if (label === '面试') {
|
||
openCandidateAction('tasks');
|
||
return;
|
||
}
|
||
if (label === '知识') {
|
||
openCandidateAction('sop');
|
||
return;
|
||
}
|
||
if (label === '我的') {
|
||
openCandidateAction('profile');
|
||
return;
|
||
}
|
||
} else {
|
||
tap(label);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (label === '首页' || label === '今日') {
|
||
activeUserPanel.value = '';
|
||
userReviewDetail.value = null;
|
||
userReviewMessage.value = '';
|
||
userBuildingOpen.value = false;
|
||
syncPath();
|
||
return;
|
||
}
|
||
if (label === '训练' || label === '练') {
|
||
openUserAction('tasks');
|
||
return;
|
||
}
|
||
if (label === '知识' || label === '问') {
|
||
openSopPanel();
|
||
return;
|
||
}
|
||
if (label === '我的' || label === '我') {
|
||
openUserAction('profile');
|
||
return;
|
||
}
|
||
tap(label);
|
||
};
|
||
|
||
const loadUserReviewDetail = async (id: number) => {
|
||
userReviewDetail.value = null;
|
||
userReviewMessage.value = '';
|
||
try {
|
||
userReviewDetail.value = await readApi<PracticeReviewDetail>(
|
||
await fetch(`/dev-api/api/aihr/mobile/practice/reviews/${id}`, { headers: apiHeaders() })
|
||
);
|
||
} catch (error) {
|
||
userReviewMessage.value = error instanceof Error ? error.message : '训练复盘加载失败';
|
||
}
|
||
};
|
||
|
||
const reviewDetailFromRecord = (item: PracticeRecord): PracticeReviewDetail => ({
|
||
...item,
|
||
mentorRewrite: `建议复盘时围绕这条记录继续追问:${item.summary}`,
|
||
aiComment: item.summary,
|
||
reviewAdvice: '',
|
||
scoreItems: [
|
||
{ label: '总分', value: item.score, note: '本条对练综合评分' }
|
||
],
|
||
dialogue: [
|
||
{ role: 'coach', label: 'AI教练', text: item.summary }
|
||
],
|
||
annotations: []
|
||
});
|
||
|
||
const reviewDetailPlaceholder = (id: number): PracticeReviewDetail => ({
|
||
id,
|
||
time: '',
|
||
trainee: '复盘记录',
|
||
scene: '训练复盘',
|
||
score: 0,
|
||
status: '加载中',
|
||
summary: '正在加载复盘详情',
|
||
mentorRewrite: '正在加载导师改写',
|
||
aiComment: '正在加载AI点评',
|
||
reviewAdvice: '',
|
||
scoreItems: [],
|
||
dialogue: [],
|
||
annotations: []
|
||
});
|
||
|
||
const openUserHistory = async (item: PracticeRecord) => {
|
||
activeTabLabel.value = '我';
|
||
userBuildingOpen.value = false;
|
||
if (!item.id) {
|
||
activeUserPanel.value = 'history';
|
||
userReviewMessage.value = '这条训练记录暂无复盘详情';
|
||
userReviewDetail.value = null;
|
||
navigatePath(userPanelPath('history'));
|
||
scrollToTop();
|
||
return;
|
||
}
|
||
|
||
activeUserPanel.value = 'history';
|
||
userReviewDetail.value = null;
|
||
userReviewMessage.value = '';
|
||
navigatePath(`${userPanelPath('history')}/${item.id}`);
|
||
scrollToTop();
|
||
await loadUserReviewDetail(item.id);
|
||
};
|
||
|
||
const handleMaterialFile = (event: Event) => {
|
||
const input = event.target as HTMLInputElement;
|
||
materialFile.value = input.files?.[0] || null;
|
||
materialResult.value = null;
|
||
materialMessage.value = materialFile.value ? (roleKey.value === 'candidate' ? '已选择补充资料' : '已选择语音素材') : '';
|
||
};
|
||
|
||
const candidateId = () => phone.value || 'mobile-candidate';
|
||
|
||
const fetchCandidateMaterials = async () => {
|
||
if (roleKey.value !== 'candidate' || !authToken.value) {
|
||
candidateMaterials.value = [];
|
||
return;
|
||
}
|
||
try {
|
||
candidateMaterials.value =
|
||
(await readApi<CandidateMaterialResponse[]>(
|
||
await fetch(`/dev-api/api/aihr/mobile/candidate/materials?candidateId=${encodeURIComponent(candidateId())}`, { headers: authOnlyHeaders() })
|
||
)) || [];
|
||
} catch {
|
||
candidateMaterials.value = [];
|
||
}
|
||
};
|
||
|
||
const submitMaterial = async () => {
|
||
if (!materialFile.value) {
|
||
materialMessage.value = roleKey.value === 'candidate' ? '请先选择补充资料' : '请先选择语音素材';
|
||
return;
|
||
}
|
||
|
||
if (roleKey.value === 'candidate') {
|
||
materialUploading.value = true;
|
||
materialMessage.value = '';
|
||
try {
|
||
const form = new FormData();
|
||
form.append('file', materialFile.value);
|
||
form.append('candidateId', candidateId());
|
||
form.append('candidateName', '李娜');
|
||
const saved = await readApi<CandidateMaterialResponse>(
|
||
await fetch('/dev-api/api/aihr/mobile/candidate/materials', {
|
||
method: 'POST',
|
||
headers: authOnlyHeaders(),
|
||
body: form
|
||
})
|
||
);
|
||
materialMessage.value = `资料已上传入库:#${saved.id} / OSS ${saved.ossId}`;
|
||
materialFile.value = null;
|
||
await fetchCandidateMaterials();
|
||
} catch (error) {
|
||
materialMessage.value = error instanceof Error ? error.message : '资料上传失败';
|
||
} finally {
|
||
materialUploading.value = false;
|
||
}
|
||
return;
|
||
}
|
||
|
||
materialUploading.value = true;
|
||
materialMessage.value = '';
|
||
materialResult.value = null;
|
||
try {
|
||
const form = new FormData();
|
||
form.append('file', materialFile.value);
|
||
form.append('projectExtOrgId', phone.value || 'mobile-user');
|
||
const upload = await readApi<CaseUploadResponse>(
|
||
await fetch('/dev-api/api/knowledge/case/upload', {
|
||
method: 'POST',
|
||
headers: authOnlyHeaders(),
|
||
body: form
|
||
})
|
||
);
|
||
const organize = await readApi<CaseOrganizeResponse>(
|
||
await fetch('/dev-api/api/knowledge/case/organize', {
|
||
method: 'POST',
|
||
headers: apiHeaders(),
|
||
body: JSON.stringify({ caseId: upload.caseId })
|
||
})
|
||
);
|
||
const curate = await readApi<CaseCurateResponse>(
|
||
await fetch('/dev-api/api/knowledge/case/curate', {
|
||
method: 'POST',
|
||
headers: apiHeaders(),
|
||
body: JSON.stringify({ caseId: upload.caseId, criteria: '员工端提交素材', limit: 1 })
|
||
})
|
||
);
|
||
materialResult.value = { upload, organize, curate };
|
||
materialMessage.value = '素材已完成转写、整理和入库';
|
||
} catch (error) {
|
||
materialMessage.value = error instanceof Error ? error.message : '素材提交失败';
|
||
} finally {
|
||
materialUploading.value = false;
|
||
}
|
||
};
|
||
|
||
const appendPracticeTurn = (role: PracticeRole, text: string, meta: Partial<PracticeTurn> = {}) => {
|
||
const labels: Record<PracticeRole, string> = {
|
||
customer: 'AI业主',
|
||
trainee: '我的回复',
|
||
coach: 'AI教练'
|
||
};
|
||
practiceTurns.value.push({ role, label: labels[role], text, ...meta });
|
||
if (role === 'customer') {
|
||
void playCustomerVoice(text);
|
||
}
|
||
};
|
||
|
||
const fillPracticeReply = () => {
|
||
const replies = [
|
||
'您先别着急,我马上帮您确认责任人和到场时间,10分钟内给您第一次反馈。',
|
||
'如果今天无法处理完,我会同步主管给您替代方案,并约定下一次反馈时间。'
|
||
];
|
||
practiceDraft.value = replies[Math.min(practiceRoundIndex.value, replies.length - 1)];
|
||
latestPracticeAudioUrl.value = '';
|
||
latestPracticeAudioOssId.value = null;
|
||
};
|
||
|
||
const loadPracticePrepCard = async () => {
|
||
try {
|
||
practicePrepCard.value = await readApi<PracticePrepCardResponse>(
|
||
await fetch(`/dev-api/api/train/practice/scenarios/${selectedPracticeScenarioId.value}/prep-card`, {
|
||
headers: apiHeaders()
|
||
})
|
||
);
|
||
} catch {
|
||
practicePrepCard.value = null;
|
||
}
|
||
};
|
||
|
||
const askPracticeHelp = async () => {
|
||
practiceHelpLoading.value = true;
|
||
practiceMessage.value = '';
|
||
practiceHelp.value = null;
|
||
try {
|
||
const queryText = `${selectedPracticeScenario.value.title} ${selectedPracticeScenario.value.desc}`;
|
||
const data = await readApi<KnowledgeSearchResponse>(
|
||
await fetch('/dev-api/api/knowledge/search', {
|
||
method: 'POST',
|
||
headers: apiHeaders(),
|
||
body: JSON.stringify({ queryText, category: 'sop', position: '生活顾问', source: 'practice_help', limit: 3 })
|
||
})
|
||
);
|
||
if (!data.answer && !data.snippets?.length) {
|
||
throw new Error('未检索到可引用 SOP');
|
||
}
|
||
practiceHelp.value = data;
|
||
} catch (error) {
|
||
practiceMessage.value = error instanceof Error ? error.message : 'SOP 求助失败,请稍后重试';
|
||
} finally {
|
||
practiceHelpLoading.value = false;
|
||
}
|
||
};
|
||
|
||
const startMobilePractice = async () => {
|
||
practiceStatus.value = 'starting';
|
||
practiceMessage.value = '';
|
||
practiceResult.value = null;
|
||
practiceDraft.value = '';
|
||
latestPracticeAudioUrl.value = '';
|
||
latestPracticeAudioOssId.value = null;
|
||
practiceRegeneratingKey.value = '';
|
||
practiceTurns.value = [];
|
||
practiceRoundIndex.value = 0;
|
||
practiceHelp.value = null;
|
||
await loadPracticePrepCard();
|
||
try {
|
||
const data = await readApi<PracticeStartResponse>(
|
||
await fetch('/dev-api/api/train/practice/start', {
|
||
method: 'POST',
|
||
headers: apiHeaders(),
|
||
body: JSON.stringify({ extPartyId: phone.value || 'mobile-user', scenarioId: selectedPracticeScenarioId.value, mode: 'mobile' })
|
||
})
|
||
);
|
||
practiceSessionId.value = data.sessionId;
|
||
practiceEmotion.value = data.emotion ?? null;
|
||
appendPracticeTurn('customer', data.customerText);
|
||
practiceStatus.value = 'active';
|
||
} catch (error) {
|
||
practiceStatus.value = 'idle';
|
||
practiceMessage.value = error instanceof Error ? error.message : '训练启动失败';
|
||
}
|
||
};
|
||
|
||
const finishMobilePractice = async () => {
|
||
const data = await readApi<PracticeFinishResponse>(
|
||
await fetch('/dev-api/api/train/practice/finish', {
|
||
method: 'POST',
|
||
headers: apiHeaders(),
|
||
body: JSON.stringify({ sessionId: practiceSessionId.value })
|
||
})
|
||
);
|
||
practiceResult.value = data;
|
||
practiceStatus.value = 'finished';
|
||
practiceMessage.value = '训练完成,主管端待复盘已更新';
|
||
void fetchMobileHome();
|
||
void fetchPracticeData();
|
||
};
|
||
|
||
const submitMobilePractice = async () => {
|
||
if (!practiceDraft.value) {
|
||
practiceMessage.value = '请先输入回复';
|
||
return;
|
||
}
|
||
const traineeText = practiceDraft.value;
|
||
const traineeAudioUrl = latestPracticeAudioUrl.value;
|
||
const traineeAudioOssId = latestPracticeAudioOssId.value;
|
||
const submittedRoundIndex = practiceRoundIndex.value;
|
||
practiceDraft.value = '';
|
||
practiceMessage.value = '';
|
||
practiceStatus.value = 'submitting';
|
||
appendPracticeTurn('trainee', traineeText);
|
||
try {
|
||
const data = await readApi<PracticeTurnResponse>(
|
||
await fetch('/dev-api/api/train/practice/turn', {
|
||
method: 'POST',
|
||
headers: apiHeaders(),
|
||
body: JSON.stringify({ sessionId: practiceSessionId.value, roundIndex: submittedRoundIndex, traineeText, traineeAudioUrl, traineeAudioOssId })
|
||
})
|
||
);
|
||
latestPracticeAudioUrl.value = '';
|
||
latestPracticeAudioOssId.value = null;
|
||
practiceEmotion.value = data.emotion ?? practiceEmotion.value;
|
||
const coachHint = data.redFlag ? data.coachHint || '红线纠偏:先承接诉求,再给责任人与反馈节点。' : data.coachHint;
|
||
if (coachHint) {
|
||
appendPracticeTurn('coach', coachHint, { roundIndex: submittedRoundIndex });
|
||
}
|
||
if (data.finished) {
|
||
await finishMobilePractice();
|
||
return;
|
||
}
|
||
appendPracticeTurn('customer', data.customerText);
|
||
practiceRoundIndex.value = data.roundIndex;
|
||
practiceStatus.value = 'active';
|
||
} catch (error) {
|
||
practiceStatus.value = 'active';
|
||
practiceMessage.value = error instanceof Error ? error.message : '提交失败';
|
||
}
|
||
};
|
||
|
||
const regeneratePracticeCoach = async (index: number, option: { key: PracticeStyleKey; label: string }) => {
|
||
const turn = practiceTurns.value[index];
|
||
if (!turn || turn.role !== 'coach' || turn.roundIndex === undefined || practiceRegeneratingKey.value) return;
|
||
const key = `${index}:${option.key}`;
|
||
practiceRegeneratingKey.value = key;
|
||
practiceMessage.value = '';
|
||
try {
|
||
const data = await readApi<PracticeTurnResponse>(
|
||
await fetch('/dev-api/api/train/practice/turn', {
|
||
method: 'POST',
|
||
headers: apiHeaders(),
|
||
body: JSON.stringify({ sessionId: practiceSessionId.value, roundIndex: turn.roundIndex, regenerate: true, style: option.key })
|
||
})
|
||
);
|
||
if (data.message) {
|
||
throw new Error(data.message);
|
||
}
|
||
const coachHint = data.redFlag ? data.coachHint || '红线纠偏:先承接诉求,再给责任人与反馈节点。' : data.coachHint;
|
||
if (!coachHint) {
|
||
throw new Error('教练建议重生成失败');
|
||
}
|
||
practiceTurns.value[index] = { ...turn, text: coachHint, styleNote: `已按“${option.label}”调整` };
|
||
} catch (error) {
|
||
toastMessage.value = error instanceof Error ? error.message : '教练建议调整失败';
|
||
if (toastTimer) window.clearTimeout(toastTimer);
|
||
toastTimer = window.setTimeout(() => {
|
||
toastMessage.value = '';
|
||
}, 1800);
|
||
} finally {
|
||
if (practiceRegeneratingKey.value === key) {
|
||
practiceRegeneratingKey.value = '';
|
||
}
|
||
}
|
||
};
|
||
|
||
const startCandidateInterview = async () => {
|
||
activeUserPanel.value = 'tasks';
|
||
activeTabLabel.value = '面试';
|
||
navigatePath(candidatePanelPath('tasks'));
|
||
scrollToTop();
|
||
interviewStatus.value = 'starting';
|
||
interviewSessionId.value = '';
|
||
interviewQuestions.value = [];
|
||
interviewAnswers.value = {};
|
||
interviewResult.value = null;
|
||
interviewMessage.value = '';
|
||
try {
|
||
const data = await readApi<InterviewStartResponse>(
|
||
await fetch('/dev-api/api/recruit/interview/start', {
|
||
method: 'POST',
|
||
headers: apiHeaders(),
|
||
body: JSON.stringify({ candidateId: 'li-na', positionCode: 'customer-service', mode: 'mobile' })
|
||
})
|
||
);
|
||
interviewSessionId.value = data.sessionId;
|
||
interviewQuestions.value = data.questions || [];
|
||
interviewAnswers.value = Object.fromEntries(interviewQuestions.value.map((item) => [item.questionId, ''])) as Record<string, string>;
|
||
interviewStatus.value = 'active';
|
||
interviewMessage.value = `已生成 ${interviewQuestions.value.length} 道题`;
|
||
} catch (error) {
|
||
interviewStatus.value = 'idle';
|
||
interviewMessage.value = error instanceof Error ? error.message : '面试启动失败';
|
||
}
|
||
};
|
||
|
||
const finishCandidateInterview = async () => {
|
||
const answers = Object.fromEntries(Object.entries(interviewAnswers.value).map(([key, value]) => [key, value.trim()])) as Record<string, string>;
|
||
if (!Object.values(answers).some(Boolean)) {
|
||
interviewMessage.value = '请至少填写一题回答';
|
||
return;
|
||
}
|
||
|
||
interviewStatus.value = 'submitting';
|
||
interviewMessage.value = '';
|
||
try {
|
||
for (const [questionId, answerText] of Object.entries(answers)) {
|
||
if (!answerText) continue;
|
||
await readApi(
|
||
await fetch('/dev-api/api/recruit/interview/answer', {
|
||
method: 'POST',
|
||
headers: apiHeaders(),
|
||
body: JSON.stringify({ sessionId: interviewSessionId.value, questionId, answerText })
|
||
})
|
||
);
|
||
}
|
||
interviewResult.value = await readApi<InterviewFinishResponse>(
|
||
await fetch('/dev-api/api/recruit/interview/finish', {
|
||
method: 'POST',
|
||
headers: apiHeaders(),
|
||
body: JSON.stringify({ sessionId: interviewSessionId.value, answers })
|
||
})
|
||
);
|
||
interviewStatus.value = 'finished';
|
||
interviewMessage.value = '面试完成,已生成面试记录';
|
||
} catch (error) {
|
||
interviewStatus.value = 'active';
|
||
interviewMessage.value = error instanceof Error ? error.message : '面试提交失败';
|
||
}
|
||
};
|
||
|
||
const goSupervisor = () => {
|
||
roleKey.value = 'supervisor';
|
||
activeTabLabel.value = '首页';
|
||
activeSupervisorPanel.value = '';
|
||
activeUserPanel.value = '';
|
||
syncPath();
|
||
void fetchMobileHome();
|
||
};
|
||
|
||
const loadSupervisorReviewDetail = async (id: number) => {
|
||
reviewMessage.value = '';
|
||
try {
|
||
selectedReview.value = await readApi<PracticeReviewDetail>(
|
||
await fetch(`/dev-api/api/aihr/mobile/practice/reviews/${id}`, { headers: apiHeaders() })
|
||
);
|
||
reviewAdviceDraft.value = selectedReview.value.reviewAdvice || '';
|
||
reviewIncentiveDraft.value = selectedReview.value.incentivePoint ?? null;
|
||
} catch (error) {
|
||
reviewMessage.value = error instanceof Error ? error.message : '复盘详情加载失败';
|
||
}
|
||
};
|
||
|
||
const openPracticeReview = async (item: PracticeRecord) => {
|
||
if (!item.id) {
|
||
tap(item.trainee);
|
||
return;
|
||
}
|
||
activeTabLabel.value = '团队';
|
||
activeSupervisorPanel.value = 'reviews';
|
||
activeUserPanel.value = '';
|
||
selectedReview.value = reviewDetailFromRecord(item);
|
||
reviewMessage.value = '';
|
||
reviewAdviceDraft.value = '';
|
||
reviewIncentiveDraft.value = null;
|
||
reviewAssignNext.value = false;
|
||
navigatePath(`${supervisorPanelPath('reviews')}/${item.id}`);
|
||
scrollToTop();
|
||
await loadSupervisorReviewDetail(item.id);
|
||
};
|
||
|
||
const closeSupervisorReviewDetail = () => {
|
||
selectedReview.value = null;
|
||
reviewMessage.value = '';
|
||
reviewAdviceDraft.value = '';
|
||
reviewIncentiveDraft.value = null;
|
||
reviewAssignNext.value = false;
|
||
navigatePath(supervisorPanelPath('reviews'));
|
||
scrollToTop();
|
||
};
|
||
|
||
const normalizeReviewIncentive = () => {
|
||
const raw = reviewIncentiveDraft.value as number | string | null;
|
||
if (raw === null || raw === '') {
|
||
return null;
|
||
}
|
||
const value = Number(raw);
|
||
if (!Number.isFinite(value)) {
|
||
return null;
|
||
}
|
||
return Math.max(0, Math.min(100, Math.round(value)));
|
||
};
|
||
|
||
const markReviewDone = async () => {
|
||
if (!selectedReview.value?.id) {
|
||
return;
|
||
}
|
||
markingReviewed.value = true;
|
||
reviewMessage.value = '';
|
||
const reviewId = selectedReview.value.id;
|
||
const advice = reviewAdviceDraft.value || '已完成复盘,下一轮重点补齐责任人、反馈节点和SOP依据。';
|
||
const incentivePoint = normalizeReviewIncentive();
|
||
try {
|
||
await readApi<void>(
|
||
await fetch(`/dev-api/api/aihr/mobile/practice/reviews/${reviewId}/reviewed`, {
|
||
method: 'POST',
|
||
headers: apiHeaders(),
|
||
body: JSON.stringify({
|
||
reviewer: '主管',
|
||
advice,
|
||
incentivePoint,
|
||
nextScenarioId: reviewAssignNext.value ? 'fee-parking' : '',
|
||
nextReason: reviewAssignNext.value ? '复盘建议:催费沟通再练' : ''
|
||
})
|
||
})
|
||
);
|
||
selectedReview.value = { ...selectedReview.value, status: '已复盘', reviewAdvice: advice, incentivePoint: incentivePoint ?? selectedReview.value.incentivePoint };
|
||
reviewMessage.value = '已标记复盘';
|
||
await fetchPracticeData();
|
||
await fetchMobileHome();
|
||
} catch (error) {
|
||
reviewMessage.value = error instanceof Error ? error.message : '标记失败';
|
||
} finally {
|
||
markingReviewed.value = false;
|
||
}
|
||
};
|
||
|
||
const dispatchPracticeAssignment = async () => {
|
||
assigningPractice.value = true;
|
||
reviewMessage.value = '';
|
||
try {
|
||
const target = practiceReviews.value[0];
|
||
await readApi<PracticeAssignment>(
|
||
await fetch('/dev-api/api/aihr/mobile/practice/assignments', {
|
||
method: 'POST',
|
||
headers: apiHeaders(),
|
||
body: JSON.stringify({
|
||
extPartyId: phone.value || 'mobile-user',
|
||
traineeName: target?.trainee || '管家(生活顾问)',
|
||
scenarioId: 'fee-parking',
|
||
scenarioName: '停车费催缴',
|
||
source: 'manual',
|
||
reason: '主管派发:催费沟通专项'
|
||
})
|
||
})
|
||
);
|
||
reviewMessage.value = '已派发催费专项';
|
||
await fetchPracticeData();
|
||
await fetchMobileHome();
|
||
} catch (error) {
|
||
reviewMessage.value = error instanceof Error ? error.message : '派发失败';
|
||
} finally {
|
||
assigningPractice.value = false;
|
||
}
|
||
};
|
||
|
||
const syncDailyDrillAnswers = (assignments: PracticeAssignment[]) => {
|
||
const next = { ...dailyDrillAnswers.value };
|
||
assignments.forEach((item) => {
|
||
if (item.source === 'daily' && item.id && item.userAnswer && !next[item.id]) {
|
||
next[item.id] = item.userAnswer;
|
||
}
|
||
});
|
||
dailyDrillAnswers.value = next;
|
||
};
|
||
|
||
const submitDailyDrill = async (item: PracticeAssignment) => {
|
||
if (!item.id) {
|
||
dailyDrillMessage.value = '每日三题缺少任务编号';
|
||
return;
|
||
}
|
||
const answer = (dailyDrillAnswers.value[item.id] || '').trim();
|
||
if (!answer) {
|
||
dailyDrillMessage.value = '请先填写作答内容';
|
||
return;
|
||
}
|
||
dailyDrillMessage.value = '';
|
||
dailyDrillSubmitting.value = { ...dailyDrillSubmitting.value, [item.id]: true };
|
||
try {
|
||
const data = await readApi<DailyDrillAnswerResponse>(
|
||
await fetch(`/dev-api/api/aihr/mobile/practice/assignments/${item.id}/answer`, {
|
||
method: 'POST',
|
||
headers: apiHeaders(),
|
||
body: JSON.stringify({ answer })
|
||
})
|
||
);
|
||
practiceAssignments.value = practiceAssignments.value.map((current) =>
|
||
current.id === item.id
|
||
? {
|
||
...current,
|
||
status: data.status,
|
||
correct: data.correct,
|
||
userAnswer: answer,
|
||
feedback: data.feedback,
|
||
referenceAnswer: data.referenceAnswer
|
||
}
|
||
: current
|
||
);
|
||
dailyDrillMessage.value = data.correct ? '回答方向正确' : '已给出补充建议';
|
||
} catch (error) {
|
||
dailyDrillMessage.value = error instanceof Error ? error.message : '提交失败';
|
||
} finally {
|
||
dailyDrillSubmitting.value = { ...dailyDrillSubmitting.value, [item.id]: false };
|
||
}
|
||
};
|
||
|
||
const validatePhone = () => {
|
||
if (!phonePattern.test(phone.value)) {
|
||
authMessage.value = '请输入正确手机号';
|
||
return false;
|
||
}
|
||
return true;
|
||
};
|
||
|
||
const sendSmsCode = async () => {
|
||
if (!validatePhone()) {
|
||
return;
|
||
}
|
||
sendingCode.value = true;
|
||
authMessage.value = '';
|
||
try {
|
||
await readApi<void>(await fetch(`/dev-api/resource/sms/code?phonenumber=${encodeURIComponent(phone.value)}`));
|
||
authMessage.value = '验证码已发送';
|
||
} catch (error) {
|
||
authMessage.value = error instanceof Error ? error.message : '验证码发送失败';
|
||
} finally {
|
||
sendingCode.value = false;
|
||
}
|
||
};
|
||
|
||
const loginBySms = async () => {
|
||
if (!validatePhone()) {
|
||
return;
|
||
}
|
||
if (!smsCode.value) {
|
||
authMessage.value = '请输入短信验证码';
|
||
return;
|
||
}
|
||
loggingIn.value = true;
|
||
authMessage.value = '';
|
||
try {
|
||
const data = await readApi<LoginResult>(
|
||
await fetch('/dev-api/auth/mobile/sms-login', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ phonenumber: phone.value, smsCode: smsCode.value, tenantId: '000000' })
|
||
})
|
||
);
|
||
const nextToken = data.access_token?.trim() || '';
|
||
const nextClientId = data.client_id?.trim() || '';
|
||
if (!nextToken || !nextClientId) {
|
||
throw new Error('登录响应缺少认证信息,请重试');
|
||
}
|
||
authToken.value = nextToken;
|
||
clientId.value = nextClientId;
|
||
localStorage.setItem(tokenKey, nextToken);
|
||
localStorage.setItem(clientIdKey, nextClientId);
|
||
localStorage.setItem(phoneKey, phone.value);
|
||
smsCode.value = '';
|
||
void fetchMobileHome();
|
||
void fetchPracticeData();
|
||
} catch (error) {
|
||
authMessage.value = error instanceof Error ? error.message : '登录失败';
|
||
} finally {
|
||
loggingIn.value = false;
|
||
}
|
||
};
|
||
|
||
const apiHome = ref<RoleHome | null>(null);
|
||
|
||
const fetchMobileHome = async () => {
|
||
const currentRole = roleKey.value;
|
||
|
||
try {
|
||
const response = await fetch(`/dev-api/api/aihr/mobile/home/${currentRole}`, {
|
||
headers:
|
||
authToken.value && clientId.value
|
||
? { Accept: 'application/json', Authorization: `Bearer ${authToken.value}`, clientid: clientId.value }
|
||
: { Accept: 'application/json' }
|
||
});
|
||
const data = await readApi<RoleHome>(response);
|
||
if (!data) {
|
||
throw new Error('Invalid mobile home payload');
|
||
}
|
||
if (roleKey.value === currentRole) {
|
||
apiHome.value = data;
|
||
}
|
||
} catch {
|
||
if (roleKey.value === currentRole) {
|
||
apiHome.value = null;
|
||
}
|
||
}
|
||
};
|
||
|
||
const fetchPracticeData = async () => {
|
||
if (!authToken.value || roleKey.value === 'candidate') {
|
||
practiceDataReady.value = false;
|
||
practiceHistory.value = [];
|
||
practiceReviews.value = [];
|
||
practiceAssignments.value = [];
|
||
dailyDrillAnswers.value = {};
|
||
dailyDrillSubmitting.value = {};
|
||
dailyDrillMessage.value = '';
|
||
practiceAlerts.value = [];
|
||
selectedReview.value = null;
|
||
userReviewDetail.value = null;
|
||
competencyProfile.value = null;
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const extPartyId = encodeURIComponent(phone.value || 'mobile-user');
|
||
if (roleKey.value === 'user') {
|
||
const [history, profile, assignments] = await Promise.all([
|
||
readApi<PracticeRecord[]>(await fetch(`/dev-api/api/aihr/mobile/practice/history?extPartyId=${extPartyId}`, { headers: apiHeaders() })),
|
||
readApi<CompetencyProfile>(await fetch(`/dev-api/api/aihr/mobile/profile?extPartyId=${extPartyId}`, { headers: apiHeaders() })),
|
||
readApi<PracticeAssignment[]>(await fetch(`/dev-api/api/aihr/mobile/practice/assignments?extPartyId=${extPartyId}`, { headers: apiHeaders() }))
|
||
]);
|
||
practiceHistory.value = history || [];
|
||
competencyProfile.value = profile || null;
|
||
practiceAssignments.value = assignments || [];
|
||
syncDailyDrillAnswers(practiceAssignments.value);
|
||
practiceAlerts.value = [];
|
||
practiceDataReady.value = true;
|
||
practiceReviews.value = [];
|
||
selectedReview.value = null;
|
||
return;
|
||
}
|
||
|
||
practiceDataReady.value = false;
|
||
const [reviews, alerts, assignments] = await Promise.all([
|
||
readApi<PracticeRecord[]>(await fetch('/dev-api/api/aihr/mobile/practice/reviews', { headers: apiHeaders() })),
|
||
readApi<PracticeAlert[]>(await fetch('/dev-api/api/aihr/mobile/practice/alerts', { headers: apiHeaders() })),
|
||
readApi<PracticeAssignment[]>(await fetch('/dev-api/api/aihr/mobile/practice/assignments', { headers: apiHeaders() }))
|
||
]);
|
||
practiceReviews.value = reviews || [];
|
||
practiceAlerts.value = alerts || [];
|
||
practiceAssignments.value = assignments || [];
|
||
syncDailyDrillAnswers(practiceAssignments.value);
|
||
practiceHistory.value = [];
|
||
competencyProfile.value = null;
|
||
} catch {
|
||
practiceDataReady.value = false;
|
||
practiceHistory.value = [];
|
||
practiceReviews.value = [];
|
||
practiceAssignments.value = [];
|
||
dailyDrillSubmitting.value = {};
|
||
practiceAlerts.value = [];
|
||
selectedReview.value = null;
|
||
userReviewDetail.value = null;
|
||
competencyProfile.value = null;
|
||
}
|
||
};
|
||
|
||
const applyRouteFromLocation = () => {
|
||
const route = readRoute();
|
||
applyingRoute = true;
|
||
roleKey.value = route.role;
|
||
activeTabLabel.value = homeTabLabel();
|
||
activeUserPanel.value = '';
|
||
activeSupervisorPanel.value = '';
|
||
userBuildingOpen.value = false;
|
||
selectedReview.value = null;
|
||
userReviewDetail.value = null;
|
||
reviewMessage.value = '';
|
||
userReviewMessage.value = '';
|
||
sopPanelOpen.value = false;
|
||
closeSummaryCard();
|
||
|
||
if (route.role === 'user') {
|
||
if (route.page === 'training') {
|
||
activeTabLabel.value = '练';
|
||
activeUserPanel.value = 'tasks';
|
||
void fetchPracticeData();
|
||
} else if (route.page === 'practice') {
|
||
activeTabLabel.value = '练';
|
||
activeUserPanel.value = 'practice';
|
||
void fetchPracticeData();
|
||
if (practiceStatus.value === 'idle' && !practiceResult.value) void startMobilePractice();
|
||
} else if (route.page === 'knowledge') {
|
||
activeTabLabel.value = '今日';
|
||
sopReturnTabLabel.value = '今日';
|
||
sopReturnPath.value = rolePaths.user;
|
||
sopPanelOpen.value = true;
|
||
} else if (route.page === 'cases') {
|
||
activeTabLabel.value = '问';
|
||
activeUserPanel.value = 'cases';
|
||
knowledgeQuery.value = '投诉接待优秀案例';
|
||
void searchKnowledge(knowledgeQuery.value, 'case');
|
||
} else if (route.page === 'submit') {
|
||
activeTabLabel.value = '我';
|
||
activeUserPanel.value = 'submit';
|
||
} else if (route.page === 'profile') {
|
||
activeTabLabel.value = '我';
|
||
activeUserPanel.value = 'profile';
|
||
void fetchPracticeData();
|
||
} else if (route.page === 'history') {
|
||
activeTabLabel.value = '我';
|
||
activeUserPanel.value = 'history';
|
||
void fetchPracticeData();
|
||
const id = Number(route.detail);
|
||
if (Number.isFinite(id) && id > 0) void loadUserReviewDetail(id);
|
||
} else if (route.page === 'recommend') {
|
||
activeTabLabel.value = '今日';
|
||
sopReturnTabLabel.value = '今日';
|
||
sopReturnPath.value = rolePaths.user;
|
||
sopQuery.value = '催费沟通专项';
|
||
sopPanelOpen.value = true;
|
||
} else if (route.page === 'sop') {
|
||
sopReturnTabLabel.value = '今日';
|
||
sopReturnPath.value = rolePaths.user;
|
||
sopPanelOpen.value = true;
|
||
}
|
||
} else if (route.role === 'candidate') {
|
||
if (route.page === 'interview') {
|
||
activeTabLabel.value = '面试';
|
||
activeUserPanel.value = 'tasks';
|
||
} else if (route.page === 'sop') {
|
||
activeTabLabel.value = '知识';
|
||
activeUserPanel.value = 'sop';
|
||
knowledgeQuery.value = '客服接待基础流程';
|
||
void searchKnowledge(knowledgeQuery.value, 'sop');
|
||
} else if (route.page === 'cases') {
|
||
activeTabLabel.value = '知识';
|
||
activeUserPanel.value = 'cases';
|
||
knowledgeQuery.value = '投诉接待优秀案例';
|
||
void searchKnowledge(knowledgeQuery.value, 'case');
|
||
} else if (route.page === 'materials') {
|
||
activeTabLabel.value = '我的';
|
||
activeUserPanel.value = 'submit';
|
||
void fetchCandidateMaterials();
|
||
} else if (route.page === 'profile') {
|
||
activeTabLabel.value = '我的';
|
||
activeUserPanel.value = 'profile';
|
||
void fetchCandidateMaterials();
|
||
} else if (route.page === 'recommend') {
|
||
activeTabLabel.value = '知识';
|
||
activeUserPanel.value = 'sop';
|
||
knowledgeQuery.value = '投诉接待基础';
|
||
void searchKnowledge(knowledgeQuery.value, 'sop');
|
||
}
|
||
} else if (route.role === 'supervisor') {
|
||
if (route.page === 'team') {
|
||
activeTabLabel.value = '团队';
|
||
activeSupervisorPanel.value = 'team';
|
||
} else if (route.page === 'reviews') {
|
||
activeTabLabel.value = '团队';
|
||
activeSupervisorPanel.value = 'reviews';
|
||
void fetchPracticeData();
|
||
const id = Number(route.detail);
|
||
if (Number.isFinite(id) && id > 0) {
|
||
selectedReview.value = reviewDetailPlaceholder(id);
|
||
void loadSupervisorReviewDetail(id);
|
||
}
|
||
} else if (route.page === 'assign') {
|
||
activeTabLabel.value = '团队';
|
||
activeSupervisorPanel.value = 'assign';
|
||
void fetchPracticeData();
|
||
} else if (route.page === 'weakness') {
|
||
activeTabLabel.value = '团队';
|
||
activeSupervisorPanel.value = 'weakness';
|
||
} else if (route.page === 'cases') {
|
||
activeTabLabel.value = '案例';
|
||
activeSupervisorPanel.value = 'case';
|
||
} else if (route.page === 'profile') {
|
||
activeTabLabel.value = '我的';
|
||
activeSupervisorPanel.value = 'profile';
|
||
} else if (route.page === 'sop') {
|
||
sopReturnTabLabel.value = '案例';
|
||
sopReturnPath.value = rolePaths.supervisor;
|
||
sopPanelOpen.value = true;
|
||
}
|
||
}
|
||
|
||
window.setTimeout(() => {
|
||
applyingRoute = false;
|
||
});
|
||
void fetchMobileHome();
|
||
void fetchPracticeData();
|
||
void fetchCandidateMaterials();
|
||
};
|
||
|
||
window.addEventListener('popstate', applyRouteFromLocation);
|
||
|
||
watch(
|
||
roleKey,
|
||
() => {
|
||
if (applyingRoute) return;
|
||
apiHome.value = null;
|
||
reviewMessage.value = '';
|
||
userReviewMessage.value = '';
|
||
activeTabLabel.value = homeTabLabel();
|
||
sopPanelOpen.value = false;
|
||
userBuildingOpen.value = false;
|
||
if (roleKey.value !== 'user') {
|
||
activeUserPanel.value = '';
|
||
userReviewDetail.value = null;
|
||
}
|
||
if (roleKey.value !== 'supervisor') {
|
||
activeSupervisorPanel.value = '';
|
||
}
|
||
if (roleKey.value !== 'candidate') {
|
||
interviewStatus.value = 'idle';
|
||
interviewResult.value = null;
|
||
interviewQuestions.value = [];
|
||
candidateMaterials.value = [];
|
||
}
|
||
void fetchMobileHome();
|
||
void fetchPracticeData();
|
||
void fetchCandidateMaterials();
|
||
},
|
||
{ immediate: true }
|
||
);
|
||
|
||
applyRouteFromLocation();
|
||
|
||
const role = computed(() => (apiHome.value?.role === roleKey.value ? apiHome.value : fallbackHomes[roleKey.value]));
|
||
// 员工端 tab 由 P1 设计钉死(今日/练/问/我),主管端沿用驾驶舱导航;候选人端按文档使用线性漏斗,不显示 tab。
|
||
const visibleTabs = computed<TabItem[]>(() => (roleKey.value === 'user' ? employeeTabs : role.value.tabs));
|
||
const showTabbar = computed(() => roleKey.value !== 'candidate');
|
||
const workerRole = computed(
|
||
() =>
|
||
role.value as RoleHome & {
|
||
toolTitle: string;
|
||
recommend: string;
|
||
primary: PrimaryTask;
|
||
review: ReviewTask;
|
||
metrics: MetricItem[];
|
||
}
|
||
);
|
||
const supervisorRole = computed(
|
||
() =>
|
||
role.value as RoleHome & {
|
||
toolTitle: string;
|
||
team: TeamOverview;
|
||
todos: TodoItem[];
|
||
suggestion: SuggestionItem;
|
||
}
|
||
);
|
||
const supervisorTeamTiles = computed<MetricItem[]>(() =>
|
||
supervisorRole.value.team.stats.map((item, index) => ({
|
||
icon: item.icon,
|
||
title: item.title,
|
||
value: item.value,
|
||
desc: index === 0 ? '今日团队进度' : index === 1 ? '训练评分均值' : '待改进主题',
|
||
tone: item.tone,
|
||
progress: Number.parseInt(item.value, 10) || 66
|
||
}))
|
||
);
|
||
const candidateCurrentStep = computed(() => {
|
||
if (!interviewResult.value && interviewStatus.value === 'idle') {
|
||
return { title: '面试', desc: '完成结构化问答,生成面试评分', cta: '开始面试', target: 'tasks' as UserPanelKey };
|
||
}
|
||
if (!candidateMaterials.value.length) {
|
||
return { title: '补充资料', desc: '请完善资料,提交后 HR 会尽快审核', cta: '提交资料', target: 'submit' as UserPanelKey };
|
||
}
|
||
return { title: '等通知', desc: '资料已提交,等待 HR 审核结果同步', cta: '查看进度', target: 'profile' as UserPanelKey };
|
||
});
|
||
const candidateProgressItems = computed(() =>
|
||
[
|
||
{ title: '面试', mark: interviewResult.value ? '✓' : '1', target: 'tasks' as UserPanelKey },
|
||
{ title: '补资料', mark: candidateMaterials.value.length ? '✓' : '2', target: 'submit' as UserPanelKey },
|
||
{ title: '岗前学习', mark: '3', target: 'sop' as UserPanelKey },
|
||
{ title: '等通知', mark: '4', target: 'profile' as UserPanelKey }
|
||
].map((item) => ({
|
||
...item,
|
||
state: item.target === candidateCurrentStep.value.target ? 'active' : item.mark === '✓' ? 'done' : 'todo'
|
||
}))
|
||
);
|
||
const candidateMaterialChecklist = computed(() =>
|
||
['身份证(正反面)', '学历证明', '健康证', '无犯罪记录证明'].map((title, index) => {
|
||
const material = candidateMaterials.value[index];
|
||
return {
|
||
title,
|
||
hint: material?.fileName || '支持 JPG / PNG / PDF',
|
||
icon: (index === 0 ? 'document' : index === 1 ? 'graduation' : 'upload') as IconName,
|
||
status: material?.status || '待上传',
|
||
state: material ? 'done' : 'pending'
|
||
};
|
||
})
|
||
);
|
||
const candidateApprovalItems = computed(() =>
|
||
candidateMaterialChecklist.value.map((item) => ({
|
||
...item,
|
||
desc: item.hint,
|
||
target: item.state === 'done' ? ('profile' as UserPanelKey) : ('submit' as UserPanelKey),
|
||
status: item.state === 'done' ? item.status : '未提交'
|
||
}))
|
||
);
|
||
const supervisorPendingReviewCount = computed(() => practiceReviews.value.length || supervisorRole.value.todos[0]?.count || 0);
|
||
const supervisorDashboardStats = computed(() => [
|
||
{ title: '待复盘', value: `${supervisorPendingReviewCount.value}`, desc: '较昨日 +3', tone: 'is-alert', target: 'reviews' as SupervisorPanelKey },
|
||
{ title: '今日完训率', value: `${supervisorRole.value.team.progress}%`, desc: '较昨日 +8%', tone: 'is-good', target: 'team' as SupervisorPanelKey },
|
||
{ title: '团队短板', value: '收费异议处理', desc: '正确率 62%', tone: 'is-risk', target: 'weakness' as SupervisorPanelKey }
|
||
]);
|
||
|
||
const todayPrefix = () => {
|
||
const date = new Date();
|
||
return `${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||
};
|
||
const countTodayPracticeCompletions = () => {
|
||
const prefix = todayPrefix();
|
||
return practiceHistory.value.filter((item) => item.time.startsWith(prefix)).length;
|
||
};
|
||
const todayPracticeCompleted = computed(() => (roleKey.value === 'user' && practiceDataReady.value ? countTodayPracticeCompletions() : 0));
|
||
const userPrimaryTask = computed<PrimaryTask>(() => {
|
||
const base = workerRole.value.primary;
|
||
if (roleKey.value !== 'user' || !practiceDataReady.value) return base;
|
||
|
||
const completed = countTodayPracticeCompletions();
|
||
const shownCompleted = Math.min(completed, todayPracticeTarget);
|
||
return {
|
||
...base,
|
||
progressText: `${shownCompleted}/${todayPracticeTarget}`,
|
||
progress: Math.round((shownCompleted / todayPracticeTarget) * 100),
|
||
cta: completed >= todayPracticeTarget ? '再练一次' : '开始训练'
|
||
};
|
||
});
|
||
const latestPracticeReview = computed(() => practiceHistory.value[0] || null);
|
||
const pendingPracticeReview = computed(() => practiceHistory.value.find((item) => item.status === '待复盘') || latestPracticeReview.value || null);
|
||
const dailyDrillAssignments = computed(() => practiceAssignments.value.filter((item) => item.source === 'daily'));
|
||
const dailyDrillCompletedCount = computed(() => dailyDrillAssignments.value.filter((item) => item.status === '已完成').length);
|
||
const assignedPracticeTask = computed(() => practiceAssignments.value.find((item) => item.source !== 'daily' && item.status === '待训练') || null);
|
||
const todayTrainingTask = computed(() => ({
|
||
title: assignedPracticeTask.value
|
||
? `待训:${assignedPracticeTask.value.scenarioName}`
|
||
: dailyDrillAssignments.value.length
|
||
? `每日三题:${dailyDrillCompletedCount.value}/${dailyDrillAssignments.value.length}`
|
||
: practiceDataReady.value && userPrimaryTask.value.title
|
||
? userPrimaryTask.value.title
|
||
: '每日小训:催费沟通 8分钟',
|
||
desc: assignedPracticeTask.value
|
||
? assignedPracticeTask.value.reason
|
||
: dailyDrillAssignments.value.length
|
||
? '新员工微题,作答后即时反馈'
|
||
: practiceDataReady.value && userPrimaryTask.value.desc
|
||
? userPrimaryTask.value.desc
|
||
: '学一学更专业,说一说更有效'
|
||
}));
|
||
const reviewAgendaTitle = computed(() => (pendingPracticeReview.value ? `待复盘:${pendingPracticeReview.value.scene || '昨天的投诉对练'}` : '待复盘:昨天的投诉对练'));
|
||
const reviewAgendaDesc = computed(() =>
|
||
pendingPracticeReview.value ? pendingPracticeReview.value.summary || '复盘提升能力,服务更有底气' : '练完第一场,师傅在这里等你复盘'
|
||
);
|
||
const mentorAgendaCount = computed(() => Math.max(1, 1 + (pendingPracticeReview.value ? 1 : 0)));
|
||
const mentorBubbleText = computed(() => (userPrimaryTask.value?.title ? `王敏,今天先做 ${mentorAgendaCount.value} 件事` : '王敏,今天想练点什么?'));
|
||
const mentorState = computed<MentorState>(() => {
|
||
if (sopRecording.value) return 'listening';
|
||
if (sopLoading.value) return 'thinking';
|
||
if (sopSpeaking.value) return 'speaking';
|
||
return 'idle';
|
||
});
|
||
const mentorFloatVisible = computed(() => roleKey.value === 'user' && isHomeTab.value && mentorCollapsed.value && !sopPanelOpen.value && !userBuildingOpen.value);
|
||
const restoreMentorHero = () => {
|
||
mentorCollapsed.value = false;
|
||
scrollToTop();
|
||
};
|
||
const updateMentorCollapse = () => {
|
||
mentorScrollRaf = 0;
|
||
mentorCollapsed.value = roleKey.value === 'user' && isHomeTab.value && !sopPanelOpen.value && !userBuildingOpen.value && window.scrollY > 120;
|
||
};
|
||
const onMentorScroll = () => {
|
||
if (mentorScrollRaf) return;
|
||
mentorScrollRaf = window.requestAnimationFrame(updateMentorCollapse);
|
||
};
|
||
const setMentorScrollListening = (enabled: boolean) => {
|
||
if (enabled && !mentorScrollListening) {
|
||
window.addEventListener('scroll', onMentorScroll, { passive: true });
|
||
mentorScrollListening = true;
|
||
updateMentorCollapse();
|
||
return;
|
||
}
|
||
if (!enabled && mentorScrollListening) {
|
||
window.removeEventListener('scroll', onMentorScroll);
|
||
mentorScrollListening = false;
|
||
}
|
||
if (!enabled) mentorCollapsed.value = false;
|
||
if (!enabled && mentorScrollRaf) {
|
||
window.cancelAnimationFrame(mentorScrollRaf);
|
||
mentorScrollRaf = 0;
|
||
}
|
||
};
|
||
watch(
|
||
() => roleKey.value === 'user' && isHomeTab.value && !sopPanelOpen.value && !userBuildingOpen.value,
|
||
(enabled) => setMentorScrollListening(enabled),
|
||
{ immediate: true }
|
||
);
|
||
onBeforeUnmount(() => {
|
||
setMentorScrollListening(false);
|
||
cleanupMentorVoicePress();
|
||
stopSopSpeak();
|
||
stopSummaryCardSpeak();
|
||
window.removeEventListener('popstate', applyRouteFromLocation);
|
||
});
|
||
|
||
const Icon = defineComponent({
|
||
props: {
|
||
name: { type: String as PropType<IconName>, required: true },
|
||
size: { type: [String, Number], default: 24 },
|
||
stroke: { type: [String, Number], default: 2 }
|
||
},
|
||
setup(props) {
|
||
return () =>
|
||
h(
|
||
'svg',
|
||
{
|
||
class: 'icon',
|
||
width: props.size,
|
||
height: props.size,
|
||
viewBox: '0 0 24 24',
|
||
fill: 'none',
|
||
stroke: 'currentColor',
|
||
'stroke-width': props.stroke,
|
||
'stroke-linecap': 'round',
|
||
'stroke-linejoin': 'round',
|
||
'aria-hidden': 'true'
|
||
},
|
||
iconPaths[props.name].map(([tag, attrs]) => h(tag, attrs || {}))
|
||
);
|
||
}
|
||
});
|
||
|
||
const Card = defineComponent({
|
||
props: {
|
||
title: { type: String, required: true },
|
||
action: { type: String, default: '' },
|
||
compactAction: { type: Boolean, default: false }
|
||
},
|
||
emits: ['action'],
|
||
setup(props, { emit, slots }) {
|
||
return () =>
|
||
h('section', { class: ['section-card', { 'has-action': props.action }] }, [
|
||
h('div', { class: ['section-title', { 'compact-action': props.compactAction }] }, [
|
||
h('h2', props.title),
|
||
props.action
|
||
? h('button', { type: 'button', onClick: () => emit('action') }, [
|
||
props.compactAction ? h(Icon, { name: 'megaphone', size: 17 }) : null,
|
||
h('span', props.action),
|
||
h(Icon, { name: 'chevron-right', size: 20 })
|
||
])
|
||
: null
|
||
]),
|
||
slots.default?.()
|
||
]);
|
||
}
|
||
});
|
||
|
||
const ProgressBar = defineComponent({
|
||
props: {
|
||
value: { type: Number, required: true }
|
||
},
|
||
setup(props) {
|
||
return () => h('div', { class: 'bar' }, h('span', { style: { width: clampPercent(props.value) } }));
|
||
}
|
||
});
|
||
|
||
const IconBubble = defineComponent({
|
||
props: {
|
||
icon: { type: String as PropType<IconName>, required: true },
|
||
tone: { type: String as PropType<Tone>, default: 'teal' },
|
||
size: { type: Number, default: 31 }
|
||
},
|
||
setup(props) {
|
||
return () => h('span', { class: ['icon-bubble', `tone-${props.tone}`] }, h(Icon, { name: props.icon, size: props.size }));
|
||
}
|
||
});
|
||
|
||
const ToolGrid = defineComponent({
|
||
props: {
|
||
items: { type: Array as PropType<ToolItem[]>, required: true },
|
||
handler: { type: Function as PropType<(item: ToolItem) => void>, default: undefined }
|
||
},
|
||
setup(props) {
|
||
return () =>
|
||
h(
|
||
'div',
|
||
{ class: 'tool-grid' },
|
||
props.items.map((item) =>
|
||
h('button', { type: 'button', class: `tone-${item.tone}`, onClick: () => (props.handler ? props.handler(item) : tap(item.title)) }, [
|
||
h(IconBubble, { icon: item.icon, tone: item.tone }),
|
||
h('span', [h('b', item.title), h('small', item.desc)]),
|
||
h(Icon, { name: 'chevron-right', size: 24 })
|
||
])
|
||
)
|
||
);
|
||
}
|
||
});
|
||
|
||
const MiniStat = defineComponent({
|
||
props: {
|
||
icon: { type: String as PropType<IconName>, required: true },
|
||
title: { type: String, required: true },
|
||
value: { type: String, required: true },
|
||
tone: { type: String as PropType<Tone>, required: true }
|
||
},
|
||
setup(props) {
|
||
return () =>
|
||
h('div', { class: 'mini-stat' }, [
|
||
h(IconBubble, { icon: props.icon, tone: props.tone, size: 24 }),
|
||
h('span', [h('small', props.title), h('b', props.value)])
|
||
]);
|
||
}
|
||
});
|
||
|
||
const ProgressTile = defineComponent({
|
||
props: {
|
||
item: { type: Object as PropType<MetricItem>, required: true }
|
||
},
|
||
setup(props) {
|
||
return () =>
|
||
h('div', { class: ['progress-tile', `tone-${props.item.tone}`] }, [
|
||
h(Icon, { name: props.item.icon, size: 38 }),
|
||
h('small', props.item.title),
|
||
h('b', props.item.value),
|
||
h('em', props.item.desc),
|
||
h(ProgressBar, { value: props.item.progress })
|
||
]);
|
||
}
|
||
});
|
||
</script>
|
||
|
||
<style src="./styles/app.css"></style>
|