Files
prop-ai-hr/mobile/src/App.vue
T
admin 3dfb98ff1b feat(mobile): restructure worker home into scenario-based task flow
员工端首页从功能陈列改为情境任务流,回应甲方「学的时候怎么用、
工作的时候怎么用、碰到问题怎么用」反馈:五区依次为「今天要学」
(训练卡进度接真数据:practiceHistory 按今日 MM-dd 统计,完成后 CTA
变再练一次,接口失败保持静态兜底)、「练完要复盘」(最近一条评分+
导师改写摘要,点击进复盘详情,含空态引导)、「工作中要用」(查SOP
升级为通栏大按钮卡)、「碰到问题要报」(素材上报)、「我的成长」
(进度+能力画像移至底部)。员工端 ToolGrid 移除,候选人/主管端零
改动,demo-check 14 条标记保留。
2026-07-04 14:33:42 +08:00

4713 lines
130 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<main class="mobile-aihr">
<section v-if="!authToken" class="page auth-page">
<header class="top auth-top">
<div class="top-row">
<h1>AI人力助手</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">
<header class="top">
<div class="top-row">
<h1>AI人力助手</h1>
<button class="role-pill" type="button" @click="cycleRole">
{{ role.roleLabel }}
<Icon name="chevron-down" size="16" />
</button>
<div 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>
<Card v-if="roleKey !== 'user'" :title="roleKey === 'supervisor' ? '今日处理流程' : '今日使用流程'">
<div class="flow-steps">
<button v-for="item in roleFlowSteps" :key="`${item.phase}-${item.title}`" type="button" :class="{ active: isFlowActive(item) }" @click="openFlowStep(item)">
<em>{{ item.phase }}</em>
<span>
<b>{{ item.title }}</b>
<small>{{ item.desc }}</small>
</span>
<Icon name="chevron-right" size="22" />
</button>
</div>
</Card>
<template v-if="roleKey === 'supervisor'">
<Card title="团队今日概况">
<div class="team-card">
<div class="team-copy">
<h3>{{ supervisorRole.team.title }}</h3>
<p>{{ supervisorRole.team.progressText }}</p>
<ProgressBar :value="supervisorRole.team.progress" />
<span>{{ supervisorRole.team.completionLabel }} <b>{{ supervisorRole.team.progress }}%</b></span>
</div>
<div class="ring-box">
<div class="ring" :style="ringStyle(supervisorRole.team.progress)">
<b>{{ supervisorRole.team.progress }}%</b>
<small>{{ supervisorRole.team.completionLabel }}</small>
</div>
<button type="button" @click="openSupervisorAction('team')">{{ supervisorRole.team.detailCta }}</button>
</div>
<div class="mini-stats">
<MiniStat
v-for="item in supervisorRole.team.stats"
:key="item.title"
:icon="item.icon"
:title="item.title"
:value="item.value"
:tone="item.tone"
/>
</div>
</div>
</Card>
<Card title="待处理">
<div class="todo-list">
<button v-for="item in supervisorRole.todos" :key="item.title" type="button" @click="openSupervisorTodo(item)">
<IconBubble :icon="item.icon" :tone="item.tone" />
<span>
<b>{{ item.title }}</b>
<small>{{ item.desc }}</small>
</span>
<em>{{ item.count }}</em>
<Icon name="chevron-right" size="24" />
</button>
</div>
<div v-if="practiceReviews.length" class="record-list">
<h3>待复盘列表</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 }}</small>
</span>
<em>{{ item.status }}</em>
</button>
</div>
<em v-if="reviewMessage && !selectedReview" class="review-message">{{ reviewMessage }}</em>
<div v-if="selectedReview" class="review-detail">
<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 }}
</p>
</div>
<div class="review-summary">
<b>导师改写</b>
<p>{{ selectedReview.mentorRewrite }}</p>
<b>AI点评</b>
<p>{{ selectedReview.aiComment }}</p>
</div>
<button type="button" :disabled="markingReviewed || selectedReview.status !== '待复盘'" @click="markReviewDone">
{{ selectedReview.status === '待复盘' ? (markingReviewed ? '处理中' : '标记已复盘') : '已复盘' }}
</button>
<em v-if="reviewMessage">{{ reviewMessage }}</em>
</div>
</Card>
<Card :title="supervisorRole.toolTitle">
<ToolGrid :items="supervisorRole.tools" :handler="openSupervisorTool" />
</Card>
<button class="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>
<Card v-if="activeSupervisorPanel" :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" 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 class="panel-message">暂无待复盘记录</em>
<div class="result-list">
<h3>专项建议</h3>
<p>
<b>催费沟通专项</b>
<small>优先给低分或待复盘员工指派,复盘后再更新训练安排。</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">
<strong>{{ knowledgeResult.answer }}</strong>
<p v-if="knowledgeResult.reference">{{ knowledgeResult.reference }}</p>
<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>
</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 class="panel-actions">
<button type="button" @click="activeSupervisorPanel = ''">收起</button>
</div>
</div>
</Card>
</template>
<template v-else>
<template v-if="roleKey === 'user'">
<section class="scenario-section">
<div class="scenario-title">
<h2>今天要学</h2>
<p>跟着任务走,学完自动记录</p>
</div>
<div class="scenario-card-stack">
<div class="task-card">
<div class="task-copy">
<strong>{{ userPrimaryTask.badge }}</strong>
<h3>{{ userPrimaryTask.title }}</h3>
<p>{{ userPrimaryTask.desc }}</p>
<span>进度 {{ userPrimaryTask.progressText }}</span>
<ProgressBar :value="userPrimaryTask.progress" />
</div>
<div class="headset-art">
<Icon :name="userPrimaryTask.icon" size="104" stroke="1.7" />
</div>
<button type="button" @click="handlePrimaryAction">{{ userPrimaryTask.cta }}</button>
</div>
<button class="review-card" 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="26" />
</button>
</div>
<div v-if="practiceStatus !== 'idle'" class="practice-panel">
<div class="practice-panel-head">
<span>三角色对练</span>
<b v-if="practiceResult">{{ practiceResult.totalScore }}分</b>
<small v-else>{{ practiceStatus === 'starting' ? '准备中' : '进行中' }}</small>
</div>
<div v-if="practiceResult" class="practice-result">
<strong>{{ practiceResult.aiComment }}</strong>
<p>{{ practiceResult.mentorRewrite }}</p>
<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 class="practice-dialogue">
<p v-for="(turn, index) in practiceTurns" :key="`${turn.role}-${index}`" :class="turn.role">
<span>{{ turn.label }}</span>
{{ turn.text }}
</p>
</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" @click="toggleVoice">{{ voiceEnabled ? '播报:开' : '播报:关' }}</button>
<button type="button" @click="fillPracticeReply">填入建议回复</button>
<button type="button" :disabled="practiceStatus === 'starting' || practiceStatus === 'submitting'" @click="submitMobilePractice">
{{ practiceStatus === 'submitting' ? '提交中' : '提交本轮' }}
</button>
</div>
</div>
<em v-if="practiceMessage">{{ practiceMessage }}</em>
</div>
</section>
<section class="scenario-section">
<div class="scenario-title">
<h2>练完要复盘</h2>
<p>看评分和导师改写,把话术改到位</p>
</div>
<button v-if="latestPracticeReview" class="review-summary-card" type="button" @click="openUserHistory(latestPracticeReview)">
<IconBubble icon="target" tone="blue" />
<span>
<b>{{ latestPracticeReview.scene }} · {{ latestPracticeReview.score }}分</b>
<small>导师改写摘要:{{ latestPracticeReview.summary }}</small>
</span>
<Icon name="chevron-right" size="26" />
</button>
<div v-else class="review-summary-card empty">
<IconBubble icon="target" tone="blue" />
<span>
<b>我的复盘</b>
<small>完成第一次训练后,这里会出现你的评分和导师改写</small>
</span>
</div>
</section>
<section class="scenario-section">
<div class="scenario-title">
<h2>工作中要用</h2>
<p>遇到不确定的现场问题,先查标准做法</p>
</div>
<button class="sop-action-card" type="button" @click="openUserAction('sop')">
<IconBubble icon="book-search" tone="teal" />
<span>
<b>遇到问题,问一句</b>
<small>输入或语音提问,30秒拿到带出处的标准做法</small>
</span>
<Icon name="chevron-right" size="28" />
</button>
</section>
<section class="scenario-section">
<div class="scenario-title">
<h2>碰到问题要报</h2>
<p>现场情况拍下来说出来,沉淀成案例</p>
</div>
<button class="report-card" type="button" @click="openUserAction('submit')">
<IconBubble icon="pen" tone="purple" />
<span>
<b>提交素材 / 案例上报</b>
<small>记录工作场景,上传后自动转写并整理案例</small>
</span>
<Icon name="chevron-right" size="26" />
</button>
</section>
</template>
<template v-else>
<Card title="今日任务" action="查看全部" @action="openUserAction('tasks')">
<div class="task-card">
<div class="task-copy">
<strong>{{ workerRole.primary.badge }}</strong>
<h3>{{ workerRole.primary.title }}</h3>
<p>{{ workerRole.primary.desc }}</p>
<span>进度 {{ workerRole.primary.progressText }}</span>
<ProgressBar :value="workerRole.primary.progress" />
</div>
<div class="headset-art">
<Icon :name="workerRole.primary.icon" size="104" stroke="1.7" />
</div>
<button type="button" @click="handlePrimaryAction">{{ workerRole.primary.cta }}</button>
</div>
<button class="review-card" 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="26" />
</button>
</Card>
<Card :title="workerRole.toolTitle">
<ToolGrid :items="workerRole.tools" :handler="openUserTool" />
</Card>
</template>
<Card v-if="roleKey !== 'supervisor' && activeUserPanel" :title="userPanelTitle">
<div class="user-panel">
<template v-if="activeUserPanel === 'tasks'">
<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 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 v-else-if="activeUserPanel === 'sop' || activeUserPanel === 'cases'">
<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>
<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>
<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 }}
</p>
</div>
<div class="review-summary">
<b>导师改写</b>
<p>{{ userReviewDetail.mentorRewrite }}</p>
<b>AI点评</b>
<p>{{ userReviewDetail.aiComment }}</p>
</div>
</div>
</template>
<div class="panel-actions">
<button type="button" @click="activeUserPanel = ''">收起</button>
</div>
</div>
</Card>
<section v-if="roleKey === 'user'" class="scenario-section growth-section">
<div class="scenario-title">
<h2>我的成长</h2>
<p>训练、复盘和能力画像集中在这里看</p>
</div>
<Card title="我的进度" :action="workerRole.recommend" compact-action @action="openUserAction('recommend')">
<div class="progress-grid">
<ProgressTile v-for="item in workerRole.metrics" :key="item.title" :item="item" />
</div>
<div v-if="competencyProfile" class="profile-card">
<span>
<small>综合能力</small>
<b>{{ competencyProfile.score }}</b>
</span>
<span>
<small>训练历史</small>
<b>{{ competencyProfile.completed }}</b>
</span>
<span>
<small>待复盘</small>
<b>{{ competencyProfile.pendingReview }}</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>
<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>
</Card>
</section>
<Card v-else title="我的进度" :action="workerRole.recommend" compact-action @action="openUserAction('recommend')">
<div class="progress-grid">
<ProgressTile v-for="item in workerRole.metrics" :key="item.title" :item="item" />
</div>
<div v-if="competencyProfile" class="profile-card">
<span>
<small>综合能力</small>
<b>{{ competencyProfile.score }}</b>
</span>
<span>
<small>训练历史</small>
<b>{{ competencyProfile.completed }}</b>
</span>
<span>
<small>待复盘</small>
<b>{{ competencyProfile.pendingReview }}</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>
<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>
</Card>
</template>
<nav class="tabbar" aria-label="底部导航">
<button v-for="tab in role.tabs" :key="tab.label" type="button" :class="{ active: activeTabLabel === tab.label }" @click="openTab(tab.label)">
<Icon :name="tab.icon" size="28" />
<span>{{ tab.label }}</span>
</button>
</nav>
</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 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">
<strong>{{ sopResult.answer }}</strong>
<p v-if="sopResult.reference">{{ sopResult.reference }}</p>
<div v-if="sopResult.snippets?.length" class="snippet-list">
<h3>引用片段</h3>
<blockquote v-for="item in sopResult.snippets" :key="`${item.title}-${item.text}`">
<b>{{ item.title }}</b>
<span>{{ item.text }}</span>
</blockquote>
</div>
</div>
</div>
</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, ref, watch } from 'vue';
type RoleKey = 'user' | 'candidate' | 'supervisor';
type Tone = 'teal' | 'blue' | 'orange' | 'purple' | 'amber';
type UserPanelKey = '' | 'tasks' | 'sop' | 'cases' | 'submit' | 'profile' | 'history' | 'recommend';
type SupervisorPanelKey = '' | 'team' | 'reviews' | 'assign' | 'weakness' | 'case' | 'sop' | 'profile';
type FlowTarget = UserPanelKey | SupervisorPanelKey;
type IconName =
| 'bars'
| 'book'
| 'book-search'
| 'briefcase'
| 'bulb'
| 'chevron-down'
| 'chevron-right'
| 'clipboard'
| 'document'
| 'folder'
| 'graduation'
| 'headset'
| 'home'
| 'map-pin'
| 'megaphone'
| 'mic'
| 'pen'
| 'presentation'
| 'target'
| 'upload'
| 'user'
| 'users'
| 'warning';
interface ToolItem {
icon: IconName;
title: string;
desc: string;
tone: Tone;
}
interface MetricItem {
icon: IconName;
title: string;
value: string;
desc: string;
tone: Tone;
progress: number;
}
interface PrimaryTask {
badge: string;
title: string;
desc: string;
progressText: string;
progress: number;
cta: string;
icon: IconName;
}
interface ReviewTask {
title: string;
desc: string;
}
interface TabItem {
label: string;
icon: IconName;
active: boolean;
}
interface FlowStep {
phase: string;
title: string;
desc: string;
target: FlowTarget;
}
interface TeamStat {
icon: IconName;
title: string;
value: string;
tone: Tone;
}
interface TeamOverview {
title: string;
progressText: string;
progress: number;
completionLabel: string;
detailCta: string;
stats: TeamStat[];
}
interface TodoItem extends ToolItem {
count: number;
}
interface SuggestionItem {
icon: IconName;
title: string;
desc: string;
tone: Tone;
}
interface RoleHome {
role: RoleKey;
roleLabel: string;
avatarTone: string;
greeting: string;
location: string;
toolTitle?: string;
recommend?: string;
primary?: PrimaryTask;
review?: ReviewTask;
tools: ToolItem[];
metrics?: MetricItem[];
tabs: TabItem[];
team?: TeamOverview;
todos?: TodoItem[];
suggestion?: SuggestionItem;
}
type PracticeStatus = 'idle' | 'starting' | 'active' | 'submitting' | 'finished';
type InterviewStatus = PracticeStatus;
type PracticeRole = 'customer' | 'trainee' | 'coach';
interface PracticeTurn {
role: PracticeRole;
label: string;
text: string;
}
interface PracticeStartResponse {
sessionId: string;
customerText: string;
}
interface PracticeTurnResponse {
customerText: string;
coachHint: string;
roundIndex: number;
finished: boolean;
}
interface PracticeFinishResponse {
totalScore: number;
mentorRewrite: string;
aiComment: string;
}
interface PracticeRecord {
id?: number;
sessionId?: string;
time: string;
trainee: string;
scene: string;
score: number;
status: string;
summary: string;
}
interface PracticeDialogue {
role: PracticeRole;
label: string;
text: string;
}
interface PracticeReviewDetail extends PracticeRecord {
mentorRewrite: string;
aiComment: string;
scoreItems: CompetencyDimension[];
dialogue: PracticeDialogue[];
}
interface InterviewQuestion {
questionId: string;
seq: number;
questionText: string;
point: string;
scenario: string;
sampleAnswer: string;
}
interface InterviewStartResponse {
sessionId: string;
candidateId: string;
candidateName: string;
positionCode: string;
questions: InterviewQuestion[];
source: string;
}
interface InterviewRecord {
time: string;
candidate: string;
position: string;
score: number;
result: string;
summary: string;
}
interface InterviewFinishResponse {
totalScore: number;
suggestion: string;
result: string;
aiSummary: string;
evidence: string;
dimensions: CompetencyDimension[];
records: InterviewRecord[];
source: string;
}
interface CompetencyDimension {
label: string;
value: number | string;
note: string;
}
interface CompetencyProfile {
score: number;
completed: number;
pendingReview: number;
dimensions: CompetencyDimension[];
}
interface KnowledgeDoc {
title: string;
desc: string;
hit: string;
type: string;
}
interface KnowledgeSnippet {
title: string;
text: string;
}
interface KnowledgeRecord {
title: string;
category: string;
version: string;
status: string;
owner: string;
summary: string;
}
interface KnowledgeSearchResponse {
queryText: string;
category: string;
answer: string;
reference: string;
docs: KnowledgeDoc[];
snippets: KnowledgeSnippet[];
training: string[];
records: KnowledgeRecord[];
}
interface CaseTag {
text: string;
type: string;
}
interface CaseSummaryItem {
label: string;
text: string;
}
interface CaseUploadResponse {
caseId: string;
fileName: string;
transcript: string;
tags: CaseTag[];
source: string;
}
interface CaseOrganizeResponse {
caseId: string;
summary: CaseSummaryItem[];
tags: CaseTag[];
aiSummary: string;
source: string;
}
interface CaseRecord {
title: string;
type: string;
status: string;
time: string;
owner: string;
summary: string;
}
interface CaseCurateResponse {
selectedCaseIds: string[];
records: CaseRecord[];
sampleHint: string;
}
interface MaterialResult {
upload: CaseUploadResponse;
organize: CaseOrganizeResponse;
curate: CaseCurateResponse;
}
interface CandidateMaterialResponse {
id: number | string;
ossId: number | string;
fileName: string;
materialType: string;
fileSize: number;
status: string;
time: string;
}
const aliases: Record<string, RoleKey> = {
h5: 'user',
user: 'user',
employee: 'user',
candidate: 'candidate',
supervisor: 'supervisor',
admin: 'supervisor',
manager: 'supervisor'
};
const rolePaths: Record<RoleKey, string> = {
user: '/h5/user',
candidate: '/h5/candidate',
supervisor: '/h5/supervisor'
};
const readRole = (): RoleKey => {
const last = window.location.pathname.split('/').filter(Boolean).at(-1) || 'user';
return aliases[last] || 'user';
};
const clampPercent = (value: number) => `${Math.max(0, Math.min(value, 100))}%`;
const tokenKey = 'aihr_mobile_access_token';
const clientIdKey = 'aihr_mobile_client_id';
const phoneKey = 'aihr_mobile_phone';
const phonePattern = /^1[3-9]\d{9}$/;
const roleKey = ref<RoleKey>(readRole());
const activeTabLabel = ref('首页');
const tapped = ref('');
const toastMessage = ref('');
const phone = ref(localStorage.getItem(phoneKey) || '');
const smsCode = ref('');
const authToken = ref(localStorage.getItem(tokenKey) || '');
const clientId = ref(localStorage.getItem(clientIdKey) || '');
const authMessage = ref('');
const sendingCode = ref(false);
const loggingIn = ref(false);
const practiceStatus = ref<PracticeStatus>('idle');
const practiceSessionId = ref('');
const practiceRoundIndex = ref(0);
const practiceDraft = ref('');
const practiceTurns = ref<PracticeTurn[]>([]);
const practiceResult = ref<PracticeFinishResponse | null>(null);
const practiceMessage = ref('');
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 practiceReviews = ref<PracticeRecord[]>([]);
const selectedReview = ref<PracticeReviewDetail | null>(null);
const userReviewDetail = ref<PracticeReviewDetail | null>(null);
const userReviewMessage = ref('');
const reviewMessage = ref('');
const markingReviewed = 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 knowledgeLoading = ref(false);
const knowledgeMessage = ref('');
const sopPanelOpen = ref(false);
const sopQuery = ref('');
const sopResult = ref<KnowledgeSearchResponse | null>(null);
const sopLoading = ref(false);
const sopMessage = ref('');
const sopRecording = ref(false);
const sopAsrBusy = ref(false);
const sopUseAudioFileFallback = ref(false);
const sopAudioInput = ref<HTMLInputElement>();
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>();
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;
const syncPath = () => window.history.pushState({}, '', rolePaths[roleKey.value]);
const tap = (name: string) => {
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 = '首页';
syncPath();
};
const userPanelTitle = computed(() => {
const titles: Record<Exclude<UserPanelKey, ''>, string> =
roleKey.value === 'candidate'
? {
tasks: '面试任务',
sop: '岗位SOP',
cases: '案例预习',
submit: '补充资料',
profile: '候选进度',
history: '面试记录',
recommend: '推荐预习'
}
: {
tasks: '今日任务',
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: '学', title: '预习岗位SOP', desc: '先看客服接待和投诉处理标准', target: 'sop' },
{ phase: '练', title: '开始面试练习', desc: '按题作答,生成面试评分', target: 'tasks' },
{ phase: '补', title: '提交补充资料', desc: '上传证书、履历或图片材料', target: 'submit' },
{ phase: '看', 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' }
];
});
interface ApiPayload<T> {
code?: number;
msg?: string;
data?: T;
}
interface LoginResult {
access_token: string;
expire_in: number;
client_id: string;
}
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 }>(
await fetch('/dev-api/api/ai/asr', { method: 'POST', headers: authOnlyHeaders(), body: form })
);
practiceDraft.value = data.text;
} catch (error) {
practiceMessage.value = error instanceof Error ? error.message : '语音识别失败,请改用文字输入';
} finally {
asrBusy.value = false;
}
};
const openSopPanel = () => {
activeTabLabel.value = roleKey.value === 'supervisor' ? '案例' : '知识';
activeUserPanel.value = '';
activeSupervisorPanel.value = '';
sopPanelOpen.value = true;
sopQuery.value = '';
sopResult.value = null;
sopMessage.value = '';
};
const closeSopPanel = () => {
if (sopRecording.value) {
sopMediaRecorder?.stop();
}
sopPanelOpen.value = false;
};
const toggleSopRecording = async () => {
if (sopRecording.value) {
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 = '';
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 = '语音识别失败,请改用文字输入';
}
} catch {
sopMessage.value = '语音识别失败,请改用文字输入';
} finally {
sopAsrBusy.value = false;
}
};
const handlePrimaryAction = () => {
if (roleKey.value === 'candidate') {
void startCandidateInterview();
return;
}
if (roleKey.value !== 'user') {
tap(workerRole.value.primary.cta);
return;
}
void startMobilePractice();
};
const searchSopKnowledge = async () => {
const cleaned = sopQuery.value.trim();
if (!cleaned) {
sopMessage.value = '请先输入问题';
return;
}
sopQuery.value = cleaned;
sopLoading.value = true;
sopMessage.value = '';
sopResult.value = null;
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), 15000);
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 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 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: '面试', sop: '知识', cases: '知识', submit: '我的', profile: '我的', history: '我的', recommend: '知识' } as Partial<Record<UserPanelKey, string>>)[panel] || '首页';
activeUserPanel.value = panel;
userReviewDetail.value = null;
userReviewMessage.value = '';
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: '首页', sop: '知识', cases: '知识', submit: '知识', profile: '我的', history: '我的', recommend: '知识' } as Partial<Record<UserPanelKey, string>>)[panel] || '首页';
activeUserPanel.value = panel;
userReviewDetail.value = null;
userReviewMessage.value = '';
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 === '每日一练') {
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 = '';
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 openTab = (label: string) => {
activeTabLabel.value = label;
if (roleKey.value === 'supervisor') {
if (label === '首页') {
activeSupervisorPanel.value = '';
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 = '';
return;
}
if (label === '面试') {
openCandidateAction('tasks');
return;
}
if (label === '知识') {
openCandidateAction('sop');
return;
}
if (label === '我的') {
openCandidateAction('profile');
return;
}
} else {
tap(label);
}
return;
}
if (label === '首页') {
activeUserPanel.value = '';
return;
}
if (label === '训练') {
void startMobilePractice();
return;
}
if (label === '知识') {
openUserAction('sop');
return;
}
if (label === '我的') {
openUserAction('profile');
return;
}
tap(label);
};
const openUserHistory = async (item: PracticeRecord) => {
if (!item.id) {
activeUserPanel.value = 'history';
userReviewMessage.value = '这条训练记录暂无复盘详情';
userReviewDetail.value = null;
return;
}
activeUserPanel.value = 'history';
userReviewDetail.value = null;
userReviewMessage.value = '';
try {
userReviewDetail.value = await readApi<PracticeReviewDetail>(
await fetch(`/dev-api/api/aihr/mobile/practice/reviews/${item.id}`, { headers: apiHeaders() })
);
} catch (error) {
userReviewMessage.value = error instanceof Error ? error.message : '训练复盘加载失败';
}
};
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) => {
const labels: Record<PracticeRole, string> = {
customer: 'AI业主',
trainee: '我的回复',
coach: 'AI教练'
};
practiceTurns.value.push({ role, label: labels[role], text });
if (role === 'customer') {
void playCustomerVoice(text);
}
};
const fillPracticeReply = () => {
const replies = [
'您先别着急,我马上帮您确认责任人和到场时间,10分钟内给您第一次反馈。',
'如果今天无法处理完,我会同步主管给您替代方案,并约定下一次反馈时间。'
];
practiceDraft.value = replies[Math.min(practiceRoundIndex.value, replies.length - 1)];
};
const startMobilePractice = async () => {
practiceStatus.value = 'starting';
practiceMessage.value = '';
practiceResult.value = null;
practiceDraft.value = '';
practiceTurns.value = [];
practiceRoundIndex.value = 0;
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: 'complaint-water', mode: 'mobile' })
})
);
practiceSessionId.value = data.sessionId;
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;
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: practiceRoundIndex.value, traineeText })
})
);
if (data.coachHint) {
appendPracticeTurn('coach', data.coachHint);
}
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 startCandidateInterview = async () => {
activeUserPanel.value = 'tasks';
activeTabLabel.value = '面试';
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';
syncPath();
void fetchMobileHome();
};
const openPracticeReview = async (item: PracticeRecord) => {
if (!item.id) {
tap(item.trainee);
return;
}
reviewMessage.value = '';
try {
selectedReview.value = await readApi<PracticeReviewDetail>(
await fetch(`/dev-api/api/aihr/mobile/practice/reviews/${item.id}`, { headers: apiHeaders() })
);
} catch (error) {
reviewMessage.value = error instanceof Error ? error.message : '复盘详情加载失败';
}
};
const markReviewDone = async () => {
if (!selectedReview.value?.id) {
return;
}
markingReviewed.value = true;
reviewMessage.value = '';
const reviewId = selectedReview.value.id;
try {
await readApi<void>(
await fetch(`/dev-api/api/aihr/mobile/practice/reviews/${reviewId}/reviewed`, {
method: 'POST',
headers: apiHeaders()
})
);
selectedReview.value = { ...selectedReview.value, status: '已复盘' };
reviewMessage.value = '已标记复盘';
await fetchPracticeData();
await fetchMobileHome();
} catch (error) {
reviewMessage.value = error instanceof Error ? error.message : '标记失败';
} finally {
markingReviewed.value = 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' })
})
);
authToken.value = data.access_token;
clientId.value = data.client_id;
localStorage.setItem(tokenKey, data.access_token);
localStorage.setItem(clientIdKey, data.client_id);
localStorage.setItem(phoneKey, phone.value);
smsCode.value = '';
void fetchMobileHome();
void fetchPracticeData();
} catch (error) {
authMessage.value = error instanceof Error ? error.message : '登录失败';
} finally {
loggingIn.value = false;
}
};
window.addEventListener('popstate', () => {
roleKey.value = readRole();
});
const employeeTabs: TabItem[] = [
{ label: '首页', icon: 'home', active: true },
{ label: '训练', icon: 'graduation', active: false },
{ label: '知识', icon: 'book', active: false },
{ label: '我的', icon: 'user', active: false }
];
const fallbackHomes: Record<RoleKey, RoleHome> = {
user: {
role: 'user',
roleLabel: '员工端',
avatarTone: 'avatar-woman',
greeting: '早上好,王敏',
location: '星河湾一期 · 客服管家',
toolTitle: '学习与工具',
recommend: '本周推荐:催费沟通专项',
primary: {
badge: '重点训练',
title: '投诉接待三角色对练',
desc: '3分钟语音练习 · 完成后自动评分',
progressText: '1/1',
progress: 100,
cta: '开始训练',
icon: 'headset'
},
review: {
title: '岗前SOP复习',
desc: '今日需完成 · 住宅投诉处理流程'
},
tools: [
{ icon: 'mic', title: '每日一练', desc: '每日3分钟\n提升服务能力', tone: 'teal' },
{ icon: 'book-search', title: '查SOP', desc: '随时查找\n标准流程', tone: 'blue' },
{ icon: 'folder', title: '案例学习', desc: '优秀案例\n对标提升', tone: 'orange' },
{ icon: 'pen', title: '提交素材', desc: '记录工作场景\n供审核入库', tone: 'purple' }
],
metrics: [
{ icon: 'bars', title: '完成', value: '6/10', desc: '今日任务进度', tone: 'teal', progress: 60 },
{ icon: 'target', title: '能力', value: '82', desc: '综合能力分', tone: 'blue', progress: 82 },
{ icon: 'clipboard', title: '待复盘', value: '2', desc: '待复盘任务', tone: 'orange', progress: 48 }
],
tabs: [...employeeTabs]
},
candidate: {
role: 'candidate',
roleLabel: '候选人端',
avatarTone: 'avatar-candidate',
greeting: '早上好,李娜',
location: '星河湾一期 · 客服管家候选人',
toolTitle: '面试准备',
recommend: '本周推荐:投诉接待基础',
primary: {
badge: '面试任务',
title: 'AI结构化面试',
desc: '8分钟问答 · 完成后生成面试记录',
progressText: '0/1',
progress: 22,
cta: '开始面试',
icon: 'briefcase'
},
review: {
title: '岗位SOP预习',
desc: '今日需完成 · 客服接待基础流程'
},
tools: [
{ icon: 'mic', title: '面试练习', desc: '模拟问答\n熟悉岗位', tone: 'teal' },
{ icon: 'book-search', title: '查岗位SOP', desc: '快速了解\n标准流程', tone: 'blue' },
{ icon: 'folder', title: '案例学习', desc: '典型场景\n提前预习', tone: 'orange' },
{ icon: 'upload', title: '补充资料', desc: '提交证书\n与经历', tone: 'purple' }
],
metrics: [
{ icon: 'bars', title: '进度', value: '2/4', desc: '候选任务', tone: 'teal', progress: 50 },
{ icon: 'target', title: '匹配度', value: '86', desc: '岗位画像分', tone: 'blue', progress: 86 },
{ icon: 'clipboard', title: '待补充', value: '1', desc: '资料项', tone: 'orange', progress: 35 }
],
tabs: [
{ label: '首页', icon: 'home', active: true },
{ label: '面试', icon: 'briefcase', active: false },
{ label: '知识', icon: 'book', active: false },
{ label: '我的', icon: 'user', active: false }
]
},
supervisor: {
role: 'supervisor',
roleLabel: '主管端',
avatarTone: 'avatar-man',
greeting: '早上好,李主管',
location: '星河湾一期 · 项目主管',
toolTitle: '快捷操作',
tools: [
{ icon: 'presentation', title: '指派训练', desc: '为员工分配训练任务', tone: 'teal' },
{ icon: 'target', title: '团队短板', desc: '查看短板分布\n与改进建议', tone: 'blue' },
{ icon: 'upload', title: '提交案例素材', desc: '上传案例素材\n供审核与沉淀', tone: 'orange' },
{ icon: 'book-search', title: '查SOP', desc: '快速搜索制度与\n标准流程', tone: 'purple' }
],
tabs: [
{ label: '首页', icon: 'home', active: true },
{ label: '团队', icon: 'users', active: false },
{ label: '案例', icon: 'folder', active: false },
{ label: '我的', icon: 'user', active: false }
],
team: {
title: '客服团队训练进度',
progressText: '12人已完成 · 3人待跟进',
progress: 80,
completionLabel: '完训率',
detailCta: '查看详情',
stats: [
{ icon: 'users', title: '完训率', value: '80%', tone: 'teal' },
{ icon: 'bars', title: '平均能力', value: '78', tone: 'teal' },
{ icon: 'warning', title: '高频短板', value: '3', tone: 'orange' }
]
},
todos: [
{ icon: 'presentation', title: '待复盘对练', desc: '查看对练记录,辅导与点评', count: 3, tone: 'teal' },
{ icon: 'folder', title: '待提交本周案例', desc: '提交优质案例素材,供审核入库', count: 1, tone: 'orange' },
{ icon: 'clipboard', title: '待指派专项', desc: '为低分或薄弱项员工指派训练专项', count: 2, tone: 'blue' }
],
suggestion: {
icon: 'bulb',
title: '建议:给催费沟通低分员工指派专项',
desc: '基于团队短板分析,建议开展「催费沟通技巧」专项训练',
tone: 'amber'
}
}
};
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 = [];
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] = 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() }))
]);
practiceHistory.value = history || [];
competencyProfile.value = profile || null;
practiceDataReady.value = true;
practiceReviews.value = [];
selectedReview.value = null;
return;
}
practiceDataReady.value = false;
practiceReviews.value =
(await readApi<PracticeRecord[]>(await fetch('/dev-api/api/aihr/mobile/practice/reviews', { headers: apiHeaders() }))) || [];
practiceHistory.value = [];
competencyProfile.value = null;
} catch {
practiceDataReady.value = false;
practiceHistory.value = [];
practiceReviews.value = [];
selectedReview.value = null;
userReviewDetail.value = null;
competencyProfile.value = null;
}
};
watch(
roleKey,
() => {
apiHome.value = null;
reviewMessage.value = '';
userReviewMessage.value = '';
activeTabLabel.value = '首页';
sopPanelOpen.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 }
);
const role = computed(() => (apiHome.value?.role === roleKey.value ? apiHome.value : fallbackHomes[roleKey.value]));
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 todayPracticeTarget = 1;
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 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 ringStyle = (value: number) => ({ '--value': value } as Record<string, string | number>);
const iconPaths: Record<IconName, Array<[string, Record<string, string | number>?]>> = {
bars: [
['rect', { x: 5, y: 14, width: 3, height: 5, rx: 1 }],
['rect', { x: 11, y: 10, width: 3, height: 9, rx: 1 }],
['rect', { x: 17, y: 5, width: 3, height: 14, rx: 1 }]
],
book: [
['path', { d: 'M4 6.5A3.5 3.5 0 0 1 7.5 3H20v16H7.5A3.5 3.5 0 0 0 4 22V6.5Z' }],
['path', { d: 'M4 19a3.5 3.5 0 0 1 3.5-3.5H20' }]
],
'book-search': [
['path', { d: 'M4 6.5A3.5 3.5 0 0 1 7.5 3H18v10' }],
['path', { d: 'M4 19a3.5 3.5 0 0 1 3.5-3.5H13' }],
['circle', { cx: 17, cy: 17, r: 3 }],
['path', { d: 'm19.5 19.5 2 2' }]
],
briefcase: [
['path', { d: 'M9 6V5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v1' }],
['rect', { x: 4, y: 6, width: 16, height: 13, rx: 2 }],
['path', { d: 'M4 11h16M10 11v2h4v-2' }]
],
bulb: [
['path', { d: 'M9 18h6M10 22h4M8 14a6 6 0 1 1 8 0c-.8.7-1.2 1.6-1.3 2.5H9.3C9.2 15.6 8.8 14.7 8 14Z' }]
],
'chevron-down': [['path', { d: 'm6 9 6 6 6-6' }]],
'chevron-right': [['path', { d: 'm9 5 7 7-7 7' }]],
clipboard: [
['rect', { x: 6, y: 5, width: 12, height: 16, rx: 2 }],
['path', { d: 'M9 5a3 3 0 0 1 6 0M9 11h6M9 15h6' }]
],
document: [
['path', { d: 'M7 3h7l4 4v14H7V3Z' }],
['path', { d: 'M14 3v5h5M10 12h5M10 16h5' }]
],
folder: [
['path', { d: 'M3 7h7l2 2h9v9.5A2.5 2.5 0 0 1 18.5 21h-13A2.5 2.5 0 0 1 3 18.5V7Z' }],
['path', { d: 'M3 9.5h18' }]
],
graduation: [
['path', { d: 'm3 8 9-4 9 4-9 4-9-4Z' }],
['path', { d: 'M7 10.5V15c2.5 2 7.5 2 10 0v-4.5M21 8v5' }]
],
headset: [
['path', { d: 'M4 14a8 8 0 0 1 16 0' }],
['rect', { x: 3, y: 13, width: 4, height: 7, rx: 2 }],
['rect', { x: 17, y: 13, width: 4, height: 7, rx: 2 }],
['path', { d: 'M19 20c0 2-2 3-5 3h-2M13 23v-2h3' }]
],
home: [
['path', { d: 'm3 11 9-8 9 8' }],
['path', { d: 'M5 10.5V21h5v-6h4v6h5V10.5' }]
],
'map-pin': [
['path', { d: 'M12 22s7-6.1 7-12a7 7 0 0 0-14 0c0 5.9 7 12 7 12Z' }],
['circle', { cx: 12, cy: 10, r: 2.5 }]
],
megaphone: [
['path', { d: 'M4 12h4l10-5v10L8 12H4Z' }],
['path', { d: 'M8 12v6a2 2 0 0 0 2 2h1' }]
],
mic: [
['rect', { x: 9, y: 3, width: 6, height: 11, rx: 3 }],
['path', { d: 'M5 11a7 7 0 0 0 14 0M12 18v4M8 22h8' }]
],
pen: [
['path', { d: 'M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16v4Z' }],
['path', { d: 'm13.5 6.5 4 4' }]
],
presentation: [
['rect', { x: 4, y: 4, width: 16, height: 12, rx: 2 }],
['path', { d: 'M8 21h8M12 16v5M9 11l2 2 4-5' }]
],
target: [
['circle', { cx: 12, cy: 12, r: 8 }],
['circle', { cx: 12, cy: 12, r: 3 }],
['path', { d: 'm16 8 4-4M20 4v4M20 4h-4' }]
],
upload: [
['path', { d: 'M12 16V4M7 9l5-5 5 5' }],
['path', { d: 'M5 16v4h14v-4' }]
],
user: [
['circle', { cx: 12, cy: 8, r: 4 }],
['path', { d: 'M4 21a8 8 0 0 1 16 0' }]
],
users: [
['circle', { cx: 9, cy: 8, r: 3 }],
['circle', { cx: 17, cy: 10, r: 3 }],
['path', { d: 'M3 21a6 6 0 0 1 12 0M13 21a5 5 0 0 1 8 0' }]
],
warning: [
['path', { d: 'M12 3 22 20H2L12 3Z' }],
['path', { d: 'M12 9v5M12 17h.01' }]
]
};
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>
:root {
--primary-teal: #0faea6;
--primary-teal-hover: #16c0b7;
--primary-teal-active: #0c948e;
--primary-teal-light: rgba(15, 174, 166, 0.08);
--primary-teal-glow: rgba(15, 174, 166, 0.2);
--accent-orange: #f07b28;
--accent-orange-light: rgba(244, 164, 80, 0.08);
--accent-orange-glow: rgba(244, 164, 80, 0.15);
--accent-blue: #2c7ded;
--accent-blue-light: rgba(87, 154, 255, 0.08);
--accent-blue-glow: rgba(87, 154, 255, 0.15);
--accent-purple: #7367df;
--accent-purple-light: rgba(144, 131, 245, 0.08);
--accent-amber: #edaa14;
--accent-amber-light: rgba(238, 179, 54, 0.08);
--text-primary: #090f1d;
--text-secondary: #4f5e76;
--text-muted: #8c9ba5;
--glass-bg: rgba(255, 255, 255, 0.85);
--glass-bg-hover: rgba(255, 255, 255, 0.95);
--glass-border: rgba(220, 228, 235, 0.6);
--glass-border-light: rgba(255, 255, 255, 0.5);
--shadow-sm: 0 2px 8px rgba(18, 31, 49, 0.03);
--shadow-md: 0 8px 24px rgba(18, 31, 49, 0.06);
--shadow-lg: 0 16px 36px rgba(18, 31, 49, 0.09);
--transition-ease: all 0.25s ease;
--transition-spring: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
--transition-spring-fast: all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
}
:global(html),
:global(body),
:global(#app) {
min-height: 100%;
margin: 0;
}
:global(body) {
overflow-x: hidden;
}
* {
box-sizing: border-box;
}
button {
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
font: inherit;
transition: var(--transition-spring-fast);
}
button:active {
transform: scale(0.97);
}
.mobile-aihr {
min-height: 100vh;
background:
radial-gradient(circle at 10% 20%, rgba(15, 174, 166, 0.07) 0%, transparent 40%),
radial-gradient(circle at 90% 10%, rgba(56, 132, 255, 0.07) 0%, transparent 35%),
radial-gradient(circle at 50% 80%, rgba(240, 123, 40, 0.03) 0%, transparent 45%),
linear-gradient(180deg, #f5f8fa 0%, #fcfdfe 100%);
color: var(--text-primary);
display: flex;
justify-content: flex-start;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.page {
width: min(100vw, 426px);
min-height: 100vh;
padding: 12px 14px 110px;
position: relative;
}
.top {
padding: 0 8px;
margin-bottom: 20px;
}
.top-row {
display: grid;
grid-template-columns: 1fr auto auto;
gap: 10px;
align-items: center;
}
.top h1,
.top h2,
.section-title h2,
.task-card h3,
.team-card h3,
p {
margin: 0;
}
.top h1 {
font-size: 14px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--text-muted);
}
.role-pill {
height: 30px;
padding: 0 12px;
border: 1px solid var(--glass-border);
border-radius: 99px;
color: var(--primary-teal);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
font-size: 13px;
font-weight: 700;
background: rgba(255, 255, 255, 0.75);
backdrop-filter: blur(8px);
box-shadow: var(--shadow-sm);
}
.role-pill:active {
transform: scale(0.95);
background: rgba(255, 255, 255, 0.9);
}
.avatar {
width: 44px;
height: 44px;
border-radius: 999px;
background: #eef3f7;
overflow: hidden;
position: relative;
box-shadow: 0 4px 12px rgba(18, 32, 50, 0.08);
border: 2px solid #ffffff;
}
.avatar::before {
content: '';
position: absolute;
left: 50%;
top: 11px;
width: 17px;
height: 17px;
border-radius: 50%;
transform: translateX(-50%);
background: #f4c7af;
box-shadow: 0 23px 0 8px #d8edf8;
}
.avatar::after {
content: '';
position: absolute;
left: 13px;
top: 7px;
width: 20px;
height: 16px;
border-radius: 50% 50% 45% 45%;
background: #1f2731;
}
.avatar-man::after {
left: 14px;
top: 6px;
width: 19px;
height: 12px;
background: #111821;
}
.avatar-man::before {
box-shadow: 0 23px 0 8px #dce9f2;
}
.avatar-candidate::before {
box-shadow: 0 23px 0 8px #d8edf8;
}
.top h2 {
margin-top: 12px;
font-size: 28px;
line-height: 1.2;
font-weight: 800;
letter-spacing: -0.02em;
}
.top h2 span {
font-size: 24px;
}
.place {
margin-top: 8px;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 600;
}
.section-card {
margin-top: 16px;
padding: 18px 16px 20px;
border: 1px solid var(--glass-border);
border-top: 1px solid var(--glass-border-light);
border-radius: 20px;
background: var(--glass-bg);
backdrop-filter: blur(16px);
box-shadow: var(--shadow-md);
transition: var(--transition-ease);
}
.section-card.has-action {
position: relative;
}
.section-card.has-action > .section-title {
padding-right: 92px;
}
.section-title {
min-height: 28px;
margin-bottom: 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.section-title h2 {
position: relative;
padding-left: 12px;
font-size: 17px;
line-height: 1.2;
font-weight: 800;
letter-spacing: -0.01em;
}
.section-title h2::before {
content: '';
position: absolute;
left: 0;
top: 3px;
width: 4px;
height: 16px;
border-radius: 99px;
background: var(--primary-teal);
}
.section-title button {
position: absolute;
right: 16px;
top: 18px;
color: var(--text-secondary);
display: inline-flex;
align-items: center;
gap: 2px;
white-space: nowrap;
font-size: 13px;
font-weight: 700;
transition: var(--transition-spring-fast);
}
.section-title button:active {
transform: scale(0.95);
}
.section-title.compact-action button {
right: 16px;
top: 18px;
height: 28px;
border-radius: 99px;
padding: 0 12px;
color: var(--primary-teal);
background: var(--primary-teal-light);
border: 1px solid rgba(15, 174, 166, 0.12);
}
.task-card {
min-height: 164px;
padding: 20px 18px;
border: 1px solid rgba(15, 174, 166, 0.15);
border-radius: 16px;
background: linear-gradient(135deg, #10a8a1 0%, #06817b 100%);
display: grid;
grid-template-columns: 1fr 120px;
gap: 6px 10px;
position: relative;
overflow: hidden;
box-shadow: 0 10px 24px rgba(15, 174, 166, 0.15);
}
.task-copy {
min-width: 0;
position: relative;
z-index: 1;
}
.task-copy strong {
display: inline-flex;
min-height: 24px;
align-items: center;
border-radius: 6px;
padding: 0 8px;
color: #ffffff;
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.15);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.02em;
}
.task-copy h3 {
margin-top: 16px;
font-size: 22px;
line-height: 1.2;
font-weight: 800;
color: #ffffff;
letter-spacing: -0.01em;
}
.task-copy p {
margin-top: 8px;
color: rgba(255, 255, 255, 0.85);
font-size: 13px;
font-weight: 600;
}
.task-copy span {
display: block;
margin-top: 20px;
color: rgba(255, 255, 255, 0.9);
font-size: 13px;
font-weight: 700;
}
.headset-art {
height: 115px;
color: rgba(255, 255, 255, 0.85);
display: grid;
place-items: center;
position: relative;
z-index: 0;
}
.headset-art::before,
.headset-art::after {
content: '';
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
}
.headset-art::before {
width: 90px;
height: 90px;
right: 8px;
bottom: 8px;
}
.headset-art::after {
width: 32px;
height: 32px;
left: 4px;
top: 32px;
}
.task-card > button {
position: absolute;
right: 18px;
bottom: 18px;
min-width: 116px;
height: 44px;
border-radius: 10px;
color: var(--primary-teal-active);
background: #ffffff;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.1);
font-size: 15px;
font-weight: 800;
transition: var(--transition-spring);
}
.task-card > button:active {
transform: scale(0.95);
background: #f4ffff;
}
.auth-page {
padding-top: 30px;
}
.auth-top {
margin-bottom: 24px;
}
.login-panel {
margin: 0 8px;
padding: 24px 20px;
border: 1px solid var(--glass-border);
border-top: 1px solid var(--glass-border-light);
border-radius: 20px;
background: var(--glass-bg);
backdrop-filter: blur(16px);
box-shadow: var(--shadow-lg);
display: grid;
gap: 16px;
}
.login-panel label {
display: grid;
gap: 6px;
color: var(--text-secondary);
font-size: 13px;
font-weight: 700;
}
.login-panel input {
width: 100%;
height: 46px;
border: 1px solid rgba(15, 174, 166, 0.2);
border-radius: 11px;
padding: 0 14px;
color: var(--text-primary);
background: #ffffff;
font: inherit;
font-size: 14px;
font-weight: 600;
outline: none;
transition: var(--transition-ease);
}
.login-panel input:focus {
border-color: var(--primary-teal);
box-shadow: 0 0 0 3px var(--primary-teal-glow);
}
.code-row {
display: grid;
grid-template-columns: 1fr 112px;
gap: 8px;
}
.code-row button,
.login-submit {
height: 46px;
border-radius: 11px;
color: #ffffff;
background: linear-gradient(135deg, #1cbdb4, #07948e);
box-shadow: 0 4px 12px rgba(9, 151, 144, 0.18);
font-weight: 800;
transition: var(--transition-spring);
}
.code-row button:disabled,
.login-submit:disabled {
opacity: 0.55;
transform: none;
}
.auth-message {
min-height: 18px;
color: #ef6a2e;
font-size: 13px;
font-weight: 700;
}
.bar {
height: 6px;
margin-top: 8px;
border-radius: 999px;
background: rgba(15, 174, 166, 0.12);
overflow: hidden;
}
.bar span {
display: block;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #19b8b0, #0a9a94);
}
.review-card,
.suggestion {
width: 100%;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 13px;
text-align: left;
}
.review-card {
min-height: 76px;
margin-top: 14px;
padding: 14px;
border: 1px solid rgba(238, 179, 54, 0.25);
border-radius: 14px;
background: linear-gradient(135deg, rgba(255, 251, 242, 0.7), rgba(255, 255, 255, 0.95));
box-shadow: var(--shadow-sm);
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 12px;
text-align: left;
transition: var(--transition-spring);
}
.review-card:active {
transform: scale(0.98);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.02);
}
.review-card b,
.suggestion b,
.todo-list b,
.tool-grid b {
display: block;
color: var(--text-primary);
font-size: 15px;
line-height: 1.2;
font-weight: 750;
}
.review-card small,
.suggestion small,
.todo-list small,
.tool-grid small {
display: block;
margin-top: 4px;
color: var(--text-secondary);
font-size: 12px;
line-height: 1.35;
white-space: pre-line;
font-weight: 600;
}
.scenario-section {
margin-top: 18px;
display: grid;
gap: 12px;
}
.scenario-title {
display: grid;
grid-template-columns: 4px minmax(0, 1fr);
column-gap: 10px;
align-items: center;
padding: 0 4px;
}
.scenario-title::before {
content: '';
width: 4px;
height: 34px;
border-radius: 99px;
background: var(--primary-teal);
grid-row: 1 / span 2;
}
.scenario-title h2 {
margin: 0;
color: var(--text-primary);
font-size: 18px;
line-height: 1.2;
font-weight: 800;
}
.scenario-title p {
margin-top: 3px;
color: var(--text-secondary);
font-size: 13px;
line-height: 1.35;
font-weight: 650;
}
.scenario-card-stack {
display: grid;
gap: 12px;
}
.review-summary-card,
.sop-action-card,
.report-card {
width: 100%;
border: 1px solid var(--glass-border);
border-radius: 18px;
padding: 16px;
background: #ffffff;
box-shadow: var(--shadow-sm);
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 13px;
text-align: left;
transition: var(--transition-spring);
}
.review-summary-card:active,
.sop-action-card:active,
.report-card:active {
transform: scale(0.98);
}
.review-summary-card span,
.sop-action-card span,
.report-card span {
min-width: 0;
}
.review-summary-card b,
.sop-action-card b,
.report-card b {
display: block;
color: var(--text-primary);
font-size: 16px;
line-height: 1.25;
font-weight: 800;
}
.review-summary-card small,
.sop-action-card small,
.report-card small {
display: block;
margin-top: 5px;
color: var(--text-secondary);
font-size: 13px;
line-height: 1.4;
font-weight: 600;
}
.review-summary-card.empty {
grid-template-columns: auto minmax(0, 1fr);
}
.sop-action-card {
min-height: 112px;
border-color: rgba(15, 174, 166, 0.28);
background: linear-gradient(135deg, #ecfffd 0%, #ffffff 72%);
box-shadow: 0 12px 28px rgba(9, 151, 144, 0.1);
}
.sop-action-card .icon-bubble {
width: 54px;
height: 54px;
}
.sop-action-card b {
font-size: 19px;
}
.report-card {
border-color: rgba(144, 131, 245, 0.18);
background: linear-gradient(135deg, #fbf7ff 0%, #ffffff 76%);
}
.growth-section .section-card {
margin-top: 0;
}
.practice-panel {
margin-top: 14px;
padding: 16px;
border: 1px solid rgba(15, 174, 166, 0.18);
border-radius: 16px;
background: rgba(240, 253, 252, 0.5);
display: grid;
gap: 12px;
}
.practice-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.practice-panel-head span,
.practice-panel-head b {
color: var(--text-primary);
font-size: 15px;
font-weight: 800;
}
.practice-panel-head small {
color: var(--primary-teal-active);
font-size: 13px;
font-weight: 800;
}
.practice-dialogue {
display: grid;
gap: 10px;
}
.practice-dialogue p {
padding: 10px 14px;
border-radius: 14px;
color: var(--text-primary);
background: #ffffff;
border: 1px solid rgba(213, 225, 233, 0.5);
font-size: 14px;
line-height: 1.45;
font-weight: 600;
box-shadow: 0 2px 6px rgba(18, 31, 49, 0.02);
}
.practice-dialogue p.trainee {
border-color: rgba(15, 174, 166, 0.18);
background: #effffd;
border-bottom-right-radius: 4px;
}
.practice-dialogue p.coach {
border-color: rgba(87, 154, 255, 0.15);
background: #f3f8ff;
border-bottom-left-radius: 4px;
}
.practice-dialogue span {
display: block;
margin-bottom: 4px;
color: var(--primary-teal);
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.practice-live {
display: grid;
gap: 12px;
}
.practice-live textarea {
width: 100%;
resize: vertical;
min-height: 76px;
border: 1px solid rgba(15, 174, 166, 0.22);
border-radius: 12px;
padding: 12px;
color: var(--text-primary);
background: #ffffff;
font: inherit;
font-size: 14px;
font-weight: 600;
outline: none;
transition: var(--transition-ease);
}
.practice-live textarea:focus {
border-color: var(--primary-teal);
box-shadow: 0 0 0 3px var(--primary-teal-glow);
}
.interview-questions {
display: grid;
gap: 12px;
}
.interview-question {
display: grid;
gap: 6px;
}
.interview-question span {
color: var(--text-primary);
font-size: 14px;
line-height: 1.4;
font-weight: 800;
}
.interview-question small {
color: var(--text-secondary);
font-size: 12px;
line-height: 1.35;
font-weight: 650;
}
.interview-question textarea {
width: 100%;
min-height: 76px;
resize: vertical;
border: 1px solid rgba(15, 174, 166, 0.22);
border-radius: 12px;
padding: 12px;
color: var(--text-primary);
background: #ffffff;
font: inherit;
font-size: 14px;
font-weight: 600;
outline: none;
}
.interview-question textarea:focus {
border-color: var(--primary-teal);
box-shadow: 0 0 0 3px var(--primary-teal-glow);
}
.practice-actions,
.practice-result-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 9px;
}
.practice-actions button,
.practice-result-actions button {
min-height: 42px;
border-radius: 10px;
color: var(--primary-teal-active);
background: var(--primary-teal-light);
font-size: 13px;
font-weight: 800;
transition: var(--transition-spring);
border: 1px solid rgba(15, 174, 166, 0.1);
}
.practice-actions button:last-child,
.practice-result-actions button:first-child {
color: #ffffff;
background: linear-gradient(135deg, #1cbdb4, #07948e);
box-shadow: 0 4px 12px rgba(9, 151, 144, 0.2);
border: none;
}
.practice-actions button:disabled {
opacity: 0.55;
transform: none;
}
.practice-audio-input {
display: none;
}
.practice-result {
display: grid;
gap: 9px;
}
.practice-result strong {
color: var(--text-primary);
font-size: 15px;
line-height: 1.35;
font-weight: 800;
}
.practice-result p {
color: var(--text-secondary);
font-size: 13px;
line-height: 1.45;
font-weight: 600;
}
.practice-panel > em {
color: var(--primary-teal-active);
font-size: 13px;
font-style: normal;
font-weight: 800;
}
.user-panel {
display: grid;
gap: 12px;
}
.flow-steps {
display: grid;
gap: 9px;
}
.flow-steps button {
width: 100%;
min-height: 64px;
padding: 10px 12px;
border: 1px solid rgba(213, 225, 233, 0.62);
border-radius: 13px;
background: #ffffff;
box-shadow: var(--shadow-sm);
display: grid;
grid-template-columns: 34px minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
text-align: left;
transition: var(--transition-spring);
}
.flow-steps button:active {
transform: scale(0.985);
}
.flow-steps button.active {
border-color: rgba(15, 174, 166, 0.48);
background: linear-gradient(135deg, rgba(240, 253, 252, 0.98), #ffffff);
}
.flow-steps em {
width: 34px;
height: 34px;
border-radius: 10px;
background: var(--primary-teal-light);
color: var(--primary-teal-active);
display: grid;
place-items: center;
font-style: normal;
font-size: 15px;
font-weight: 900;
}
.flow-steps b {
display: block;
color: var(--text-primary);
font-size: 14px;
line-height: 1.25;
font-weight: 850;
}
.flow-steps small {
display: block;
margin-top: 3px;
color: var(--text-secondary);
font-size: 12px;
line-height: 1.35;
font-weight: 650;
}
.panel-row {
width: 100%;
min-height: 66px;
padding: 12px;
border: 1px solid rgba(213, 225, 233, 0.6);
border-radius: 14px;
background: #ffffff;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
text-align: left;
transition: var(--transition-spring);
box-shadow: var(--shadow-sm);
}
.panel-row:active {
transform: scale(0.98);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.02);
}
.panel-row b,
.knowledge-result strong,
.case-result h3,
.result-list h3,
.snippet-list h3 {
color: var(--text-primary);
font-weight: 800;
}
.panel-row b {
display: block;
font-size: 15px;
line-height: 1.25;
}
.panel-row small {
display: block;
margin-top: 4px;
color: var(--text-secondary);
font-size: 12px;
line-height: 1.35;
font-weight: 650;
}
.knowledge-search {
display: grid;
grid-template-columns: minmax(0, 1fr) 78px;
gap: 8px;
}
.knowledge-search input {
min-width: 0;
height: 42px;
border: 1px solid rgba(15, 174, 166, 0.2);
border-radius: 10px;
padding: 0 12px;
color: var(--text-primary);
background: #ffffff;
font: inherit;
font-size: 14px;
font-weight: 700;
outline: none;
transition: var(--transition-ease);
}
.knowledge-search input:focus {
border-color: var(--primary-teal);
box-shadow: 0 0 0 3px var(--primary-teal-glow);
}
.knowledge-search button,
.panel-primary,
.panel-actions button {
min-height: 42px;
border-radius: 10px;
color: #ffffff;
background: linear-gradient(135deg, #1cbdb4, #07948e);
box-shadow: 0 4px 12px rgba(9, 151, 144, 0.15);
font-size: 14px;
font-weight: 800;
transition: var(--transition-spring);
}
.knowledge-search button:disabled,
.panel-primary:disabled {
opacity: 0.55;
transform: none;
}
.panel-message {
color: var(--primary-teal-active);
font-size: 13px;
font-style: normal;
font-weight: 800;
}
.knowledge-result,
.case-result {
display: grid;
gap: 10px;
}
.knowledge-result strong {
font-size: 16px;
line-height: 1.35;
}
.knowledge-result > p,
.case-result p,
.case-result dd {
margin: 0;
color: var(--text-secondary);
font-size: 13px;
line-height: 1.45;
font-weight: 650;
}
.panel-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.panel-tags span {
max-width: 100%;
padding: 4px 10px;
border-radius: 99px;
color: var(--primary-teal-active);
background: var(--primary-teal-light);
border: 1px solid rgba(15, 174, 166, 0.1);
font-size: 11px;
line-height: 1.2;
font-weight: 750;
}
.result-list,
.snippet-list {
display: grid;
gap: 8px;
}
.result-list h3,
.snippet-list h3,
.case-result h3 {
margin: 0;
font-size: 14px;
}
.result-list p,
.snippet-list blockquote,
.case-result > div {
margin: 0;
padding: 12px;
border: 1px solid rgba(213, 225, 233, 0.5);
border-radius: 12px;
background: #ffffff;
box-shadow: var(--shadow-sm);
}
.result-list b,
.snippet-list b,
.case-result b {
display: block;
color: var(--text-primary);
font-size: 14px;
line-height: 1.25;
font-weight: 800;
}
.result-list small,
.snippet-list span {
display: block;
margin-top: 4px;
color: var(--text-secondary);
font-size: 12px;
line-height: 1.4;
font-weight: 650;
}
.file-picker {
min-height: 90px;
padding: 16px;
border: 2px dashed rgba(15, 174, 166, 0.25);
border-radius: 14px;
background: rgba(240, 253, 252, 0.2);
display: grid;
align-content: center;
gap: 6px;
position: relative;
overflow: hidden;
transition: var(--transition-ease);
}
.file-picker:active {
background: rgba(240, 253, 252, 0.4);
}
.file-picker span {
color: var(--text-primary);
font-size: 14px;
font-weight: 800;
}
.file-picker small {
color: var(--text-secondary);
font-size: 11px;
line-height: 1.35;
font-weight: 600;
}
.file-picker input {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
.case-result dl {
margin: 0;
display: grid;
gap: 6px;
}
.case-result dt {
color: var(--primary-teal-active);
font-size: 13px;
font-weight: 900;
}
.panel-profile {
margin-top: 0;
}
.panel-actions {
display: flex;
justify-content: flex-end;
}
.panel-actions button {
min-width: 76px;
color: var(--primary-teal-active);
background: var(--primary-teal-light);
box-shadow: none;
}
.sop-overlay {
position: fixed;
inset: 0;
z-index: 120;
display: flex;
justify-content: center;
overflow-y: auto;
background:
radial-gradient(circle at 10% 20%, rgba(15, 174, 166, 0.07) 0%, transparent 40%),
radial-gradient(circle at 90% 10%, rgba(56, 132, 255, 0.07) 0%, transparent 35%),
linear-gradient(180deg, #f5f8fa 0%, #fcfdfe 100%);
}
.sop-panel {
width: min(100vw, 426px);
min-height: 100vh;
padding: 14px 14px 110px;
display: grid;
align-content: start;
gap: 14px;
}
.sop-head {
display: grid;
grid-template-columns: 72px 1fr 72px;
align-items: center;
padding: 2px 0 4px;
}
.sop-head h2 {
margin: 0;
text-align: center;
color: var(--text-primary);
font-size: 18px;
font-weight: 850;
}
.sop-back {
width: 64px;
height: 36px;
border-radius: 11px;
color: var(--primary-teal-active);
background: var(--primary-teal-light);
font-size: 14px;
font-weight: 800;
}
.sop-form {
padding: 16px;
border: 1px solid var(--glass-border);
border-top: 1px solid var(--glass-border-light);
border-radius: 18px;
background: var(--glass-bg);
box-shadow: var(--shadow-md);
display: grid;
gap: 10px;
}
.sop-form input {
width: 100%;
min-width: 0;
height: 46px;
border: 1px solid rgba(15, 174, 166, 0.2);
border-radius: 11px;
padding: 0 13px;
color: var(--text-primary);
background: #ffffff;
font: inherit;
font-size: 14px;
font-weight: 700;
outline: none;
}
.sop-form input:focus {
border-color: var(--primary-teal);
box-shadow: 0 0 0 3px var(--primary-teal-glow);
}
.sop-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 9px;
}
.sop-actions button {
min-height: 42px;
border-radius: 10px;
font-size: 14px;
font-weight: 800;
}
.sop-voice {
color: var(--primary-teal-active);
background: var(--primary-teal-light);
border: 1px solid rgba(15, 174, 166, 0.1);
}
.sop-submit {
color: #ffffff;
background: linear-gradient(135deg, #1cbdb4, #07948e);
box-shadow: 0 4px 12px rgba(9, 151, 144, 0.15);
}
.sop-actions button:disabled {
opacity: 0.55;
transform: none;
}
.sop-result {
padding: 16px;
border: 1px solid var(--glass-border);
border-radius: 18px;
background: #ffffff;
box-shadow: var(--shadow-md);
}
.tool-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.tool-grid button {
min-height: 88px;
border: 1px solid var(--glass-border);
border-radius: 14px;
padding: 14px 12px;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
background: #ffffff;
box-shadow: var(--shadow-sm);
transition: var(--transition-spring);
position: relative;
text-align: left;
}
.tool-grid button.tone-teal,
.tool-grid button.tone-blue,
.tool-grid button.tone-orange,
.tool-grid button.tone-purple,
.tool-grid button.tone-amber {
background: #ffffff;
border: 1px solid var(--glass-border);
color: var(--text-primary);
}
.tool-grid button:active {
transform: scale(0.96);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.02);
}
.tool-grid button.tone-teal:active { border-color: var(--primary-teal-active); }
.tool-grid button.tone-blue:active { border-color: var(--accent-blue); }
.tool-grid button.tone-orange:active { border-color: var(--accent-orange); }
.tool-grid button.tone-purple:active { border-color: var(--accent-purple); }
.tool-grid button.tone-amber:active { border-color: var(--accent-amber); }
.tool-grid button > .icon {
width: 16px;
height: 16px;
color: var(--text-muted);
}
.icon-bubble {
width: 48px;
height: 48px;
border-radius: 999px;
display: grid;
place-items: center;
flex: none;
}
.tone-teal {
color: var(--primary-teal-active);
border: 1px solid rgba(15, 174, 166, 0.2);
background: linear-gradient(135deg, #f0fdfa, #ffffff);
}
.tone-blue {
color: var(--accent-blue);
border: 1px solid rgba(87, 154, 255, 0.2);
background: linear-gradient(135deg, #eff6ff, #ffffff);
}
.tone-orange {
color: var(--accent-orange);
border: 1px solid rgba(244, 164, 80, 0.2);
background: linear-gradient(135deg, #fff7ed, #ffffff);
}
.tone-purple {
color: var(--accent-purple);
border: 1px solid rgba(144, 131, 245, 0.2);
background: linear-gradient(135deg, #faf5ff, #ffffff);
}
.tone-amber {
color: var(--accent-amber);
border: 1px solid rgba(238, 179, 54, 0.2);
background: linear-gradient(135deg, #fefce8, #ffffff);
}
.progress-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.progress-tile {
min-height: 110px;
border: 1px solid var(--glass-border);
border-radius: 14px;
padding: 14px 10px 12px;
background: #ffffff;
box-shadow: var(--shadow-sm);
display: grid;
align-content: space-between;
}
.progress-tile small,
.progress-tile em {
display: block;
color: var(--text-secondary);
font-style: normal;
font-weight: 600;
}
.progress-tile small {
margin-top: 2px;
font-size: 13px;
}
.progress-tile b {
display: block;
color: var(--text-primary);
font-size: 26px;
line-height: 1;
font-weight: 800;
letter-spacing: -0.01em;
}
.progress-tile em {
margin-top: 4px;
font-size: 11px;
line-height: 1.2;
}
.progress-tile.tone-blue .bar span {
background: linear-gradient(90deg, #2f85ff, #1670f8);
}
.progress-tile.tone-orange .bar span {
background: linear-gradient(90deg, #ff8a36, #f27a24);
}
.profile-card {
margin-top: 14px;
padding: 14px;
border: 1px solid rgba(15, 174, 166, 0.18);
border-radius: 16px;
background: rgba(240, 253, 252, 0.4);
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 9px;
}
.profile-card > span {
min-height: 58px;
border-radius: 10px;
background: #ffffff;
display: grid;
place-items: center;
box-shadow: var(--shadow-sm);
}
.profile-card small {
color: var(--text-secondary);
font-size: 11px;
font-weight: 700;
}
.profile-card b {
color: var(--text-primary);
font-size: 22px;
line-height: 1;
font-weight: 800;
}
.profile-dims {
grid-column: 1 / -1;
display: grid;
gap: 8px;
}
.profile-dims p {
min-height: 38px;
padding: 8px 12px;
border-radius: 10px;
background: #ffffff;
display: grid;
grid-template-columns: 50px 36px 1fr;
align-items: center;
gap: 8px;
box-shadow: var(--shadow-sm);
}
.profile-dims span {
color: var(--primary-teal-active);
font-size: 13px;
font-weight: 800;
}
.profile-dims b {
font-size: 17px;
font-weight: 800;
}
.profile-dims small {
color: var(--text-secondary);
font-size: 12px;
font-weight: 600;
}
.record-list {
margin-top: 14px;
display: grid;
gap: 8px;
}
.record-list h3 {
margin: 0;
color: var(--text-primary);
font-size: 16px;
font-weight: 800;
}
.record-list button {
min-height: 58px;
padding: 12px;
border: 1px solid rgba(213, 225, 233, 0.5);
border-radius: 12px;
background: #ffffff;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 9px;
align-items: center;
text-align: left;
box-shadow: var(--shadow-sm);
transition: var(--transition-spring);
}
.record-list button:active {
transform: scale(0.98);
}
.record-list b {
color: var(--text-primary);
font-size: 14px;
line-height: 1.25;
font-weight: 800;
}
.record-list small {
margin-top: 4px;
color: var(--text-secondary);
font-size: 12px;
line-height: 1.35;
font-weight: 600;
}
.record-list em {
min-width: 48px;
height: 24px;
padding: 0 8px;
border-radius: 99px;
color: var(--primary-teal-active);
background: var(--primary-teal-light);
display: grid;
place-items: center;
font-size: 11px;
font-style: normal;
font-weight: 800;
}
.review-message,
.review-detail > em {
color: var(--primary-teal-active);
font-size: 13px;
font-style: normal;
font-weight: 800;
}
.review-detail {
margin-top: 14px;
padding: 16px;
border: 1px solid rgba(15, 174, 166, 0.18);
border-radius: 16px;
background: rgba(240, 253, 252, 0.4);
display: grid;
gap: 12px;
}
.review-detail-head {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
}
.review-detail-head b,
.review-summary b {
display: block;
color: var(--text-primary);
font-size: 15px;
font-weight: 800;
}
.review-detail-head small,
.review-summary p {
margin-top: 4px;
color: var(--text-secondary);
font-size: 13px;
line-height: 1.45;
font-weight: 600;
}
.review-detail-head em {
min-width: 52px;
height: 24px;
padding: 0 8px;
border-radius: 99px;
color: var(--primary-teal-active);
background: var(--primary-teal-light);
display: grid;
place-items: center;
font-size: 11px;
font-style: normal;
font-weight: 800;
}
.review-score-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.review-score-grid p {
min-height: 58px;
border-radius: 10px;
background: #ffffff;
display: grid;
place-items: center;
box-shadow: var(--shadow-sm);
}
.review-score-grid span,
.review-score-grid small {
color: var(--text-secondary);
font-size: 11px;
font-weight: 700;
}
.review-score-grid b {
color: var(--text-primary);
font-size: 20px;
line-height: 1;
font-weight: 800;
}
.review-dialogue {
max-height: 250px;
overflow: auto;
}
.review-detail > button {
min-height: 42px;
border-radius: 10px;
color: #ffffff;
background: linear-gradient(135deg, #1cbdb4, #07948e);
box-shadow: 0 4px 12px rgba(9, 151, 144, 0.15);
font-size: 14px;
font-weight: 800;
transition: var(--transition-spring);
}
.review-detail > button:disabled {
color: var(--primary-teal-active);
background: var(--primary-teal-light);
box-shadow: none;
opacity: 0.55;
transform: none;
}
.team-card {
border: 1px solid rgba(15, 174, 166, 0.15);
border-radius: 16px;
padding: 20px 16px;
background: linear-gradient(135deg, #f0fdfa 0%, #ffffff 100%);
display: grid;
grid-template-columns: 1fr 154px;
gap: 12px;
box-shadow: var(--shadow-sm);
}
.team-copy h3 {
font-size: 20px;
line-height: 1.2;
font-weight: 800;
color: var(--text-primary);
}
.team-copy p {
margin-top: 10px;
color: var(--text-secondary);
font-size: 14px;
font-weight: 600;
}
.team-copy .bar {
margin-top: 24px;
}
.team-copy > span {
display: block;
margin-top: 12px;
color: var(--text-secondary);
font-size: 14px;
font-weight: 600;
}
.team-copy > span b {
color: var(--text-primary);
font-size: 16px;
font-weight: 800;
}
.ring-box {
display: grid;
justify-items: center;
align-content: start;
gap: 13px;
}
.ring {
width: 106px;
height: 106px;
border-radius: 50%;
background: conic-gradient(var(--primary-teal-active) calc(var(--value) * 1%), #e2e8f0 0);
display: grid;
place-items: center;
position: relative;
box-shadow: var(--shadow-sm);
}
.ring::before {
content: '';
position: absolute;
inset: 14px;
border-radius: inherit;
background: #ffffff;
}
.ring b,
.ring small {
position: relative;
z-index: 1;
}
.ring b {
margin-top: 8px;
color: var(--text-primary);
font-size: 24px;
line-height: 1;
font-weight: 800;
}
.ring small {
margin-top: -30px;
color: var(--text-secondary);
font-size: 12px;
font-weight: 600;
}
.ring-box button {
min-width: 105px;
height: 38px;
border-radius: 9px;
color: #ffffff;
background: linear-gradient(135deg, #1bbab1, #078f89);
box-shadow: 0 4px 12px rgba(10, 151, 144, 0.15);
font-size: 14px;
font-weight: 800;
transition: var(--transition-spring);
}
.ring-box button:active {
transform: scale(0.95);
}
.mini-stats {
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
}
.mini-stat {
min-height: 54px;
border: 1px solid rgba(213, 225, 233, 0.5);
border-radius: 12px;
background: rgba(255, 255, 255, 0.9);
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 4px;
box-shadow: var(--shadow-sm);
overflow: hidden;
}
.mini-stat .icon-bubble {
width: 28px;
height: 28px;
flex-shrink: 0;
}
.mini-stat span {
display: flex;
flex-direction: column;
justify-content: center;
min-width: 0;
}
.mini-stat small {
display: block;
color: var(--text-secondary);
font-size: 11px;
line-height: 1.1;
font-weight: 600;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.mini-stat b {
display: block;
color: var(--text-primary);
font-size: 17px;
line-height: 1.1;
font-weight: 800;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.todo-list {
border: 1px solid rgba(213, 225, 233, 0.5);
border-radius: 14px;
overflow: hidden;
box-shadow: var(--shadow-sm);
}
.todo-list button {
width: 100%;
min-height: 70px;
padding: 11px 14px;
display: grid;
grid-template-columns: auto 1fr auto auto;
align-items: center;
gap: 12px;
background: #ffffff;
text-align: left;
}
.todo-list button + button {
border-top: 1px solid rgba(213, 225, 233, 0.4);
}
.todo-list .icon-bubble {
width: 44px;
height: 44px;
}
.todo-list em {
width: 26px;
height: 26px;
border-radius: 999px;
color: #ffffff;
background: linear-gradient(135deg, #ff6363, #ef3535);
display: grid;
place-items: center;
font-size: 14px;
font-style: normal;
font-weight: 800;
}
.suggestion {
margin-top: 14px;
min-height: 62px;
padding: 12px 14px;
border: 1px solid rgba(239, 181, 54, 0.25);
border-radius: 14px;
background: linear-gradient(135deg, rgba(255, 250, 240, 0.7), rgba(255, 255, 255, 0.95));
box-shadow: var(--shadow-sm);
transition: var(--transition-spring);
}
.suggestion:active {
transform: scale(0.98);
}
.suggestion .icon-bubble {
width: 44px;
height: 44px;
}
.build-toast {
position: fixed;
left: 50%;
bottom: calc(88px + env(safe-area-inset-bottom));
z-index: 160;
width: min(360px, calc(100vw - 32px));
transform: translateX(-50%);
padding: 12px 14px;
border: 1px solid var(--glass-border);
border-radius: 14px;
color: var(--text-primary);
background: #ffffff;
box-shadow: var(--shadow-lg);
font-size: 13px;
line-height: 1.4;
font-weight: 800;
text-align: center;
}
.tabbar {
position: fixed;
left: 50%;
bottom: 16px;
z-index: 99;
width: calc(min(100vw, 426px) - 24px);
transform: translateX(-50%);
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 4px;
padding: 6px;
border: 1px solid var(--glass-border);
border-radius: 20px;
background: rgba(255, 255, 255, 0.88);
backdrop-filter: blur(20px);
box-shadow: 0 8px 32px rgba(18, 31, 49, 0.08);
}
.tabbar button {
min-height: 44px;
color: var(--text-secondary);
display: grid;
justify-items: center;
align-content: center;
gap: 2px;
border-radius: 14px;
transition: var(--transition-spring-fast);
}
.tabbar button:active {
transform: scale(0.92);
}
.tabbar button.active {
color: var(--primary-teal-active);
background: var(--primary-teal-light);
}
.tabbar span {
font-size: 11px;
line-height: 1;
font-weight: 700;
}
.icon {
display: block;
}
@media (max-width: 380px) {
.page {
padding-inline: 10px;
padding-bottom: 96px;
}
.tabbar {
width: calc(100vw - 16px);
bottom: 8px;
border-radius: 16px;
}
.top h2 {
font-size: 26px;
}
.task-card,
.team-card {
grid-template-columns: 1fr;
}
.headset-art {
display: none;
}
.task-card > button {
position: static;
margin-top: 8px;
width: 100%;
}
.mini-stats,
.progress-grid,
.tool-grid {
grid-template-columns: 1fr;
}
}
@media (min-width: 640px) {
.mobile-aihr {
justify-content: center;
}
}
</style>