Files
prop-ai-hr/mobile/src/App.vue
T

3560 lines
137 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<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>