员工端首页从功能陈列改为情境任务流,回应甲方「学的时候怎么用、 工作的时候怎么用、碰到问题怎么用」反馈:五区依次为「今天要学」 (训练卡进度接真数据:practiceHistory 按今日 MM-dd 统计,完成后 CTA 变再练一次,接口失败保持静态兜底)、「练完要复盘」(最近一条评分+ 导师改写摘要,点击进复盘详情,含空态引导)、「工作中要用」(查SOP 升级为通栏大按钮卡)、「碰到问题要报」(素材上报)、「我的成长」 (进度+能力画像移至底部)。员工端 ToolGrid 移除,候选人/主管端零 改动,demo-check 14 条标记保留。
4713 lines
130 KiB
Vue
4713 lines
130 KiB
Vue
<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>
|