fix(mobile): bottom tabbar switches whole views instead of inline panels

用户反馈「底部菜单不像正常菜单切换,点击后只在当前页面」修复:
tab 视图互斥渲染(isHomeTab + activeUserPanel/activeSupervisorPanel
条件重组),三端首页/训练/知识/我的整页切换,不再页中叠加;切 tab
滚动回顶;训练 tab 不自动开局、进行中会话切走可切回;SOP 全屏层
返回进入前 tab;首页「我的成长」收敛为摘要卡(综合能力/今日完成/
待复盘+查看全部),完整画像与历史仅在「我的」tab,消除同屏重复。
This commit is contained in:
2026-07-04 14:50:28 +08:00
parent 3dfb98ff1b
commit 03247bcad9
+184 -60
View File
@@ -52,7 +52,7 @@
</p>
</header>
<Card v-if="roleKey !== 'user'" :title="roleKey === 'supervisor' ? '今日处理流程' : '今日使用流程'">
<Card v-if="roleKey !== 'user' && isHomeTab" :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>
@@ -66,6 +66,7 @@
</Card>
<template v-if="roleKey === 'supervisor'">
<template v-if="isHomeTab">
<Card title="团队今日概况">
<div class="team-card">
<div class="team-copy">
@@ -169,8 +170,9 @@
</span>
<Icon name="chevron-right" size="26" />
</button>
</template>
<Card v-if="activeSupervisorPanel" :title="supervisorPanelTitle">
<Card v-if="activeSupervisorPanel && !isHomeTab" :title="supervisorPanelTitle">
<div class="user-panel">
<template v-if="activeSupervisorPanel === 'team' || activeSupervisorPanel === 'weakness'">
<div class="progress-grid">
@@ -201,6 +203,42 @@
</button>
</div>
<em v-else class="panel-message">暂无待复盘记录</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>
<div class="result-list">
<h3>专项建议</h3>
<p>
@@ -270,15 +308,12 @@
</div>
</template>
<div class="panel-actions">
<button type="button" @click="activeSupervisorPanel = ''">收起</button>
</div>
</div>
</Card>
</template>
<template v-else>
<template v-if="roleKey === 'user'">
<template v-if="roleKey === 'user' && isHomeTab">
<section class="scenario-section">
<div class="scenario-title">
<h2>今天要学</h2>
@@ -406,7 +441,7 @@
</section>
</template>
<template v-else>
<template v-else-if="isHomeTab">
<Card title="今日任务" action="查看全部" @action="openUserAction('tasks')">
<div class="task-card">
<div class="task-copy">
@@ -437,9 +472,75 @@
</Card>
</template>
<Card v-if="roleKey !== 'supervisor' && activeUserPanel" :title="userPanelTitle">
<Card v-if="roleKey !== 'supervisor' && activeUserPanel && !isHomeTab" :title="userPanelTitle">
<div class="user-panel">
<template v-if="activeUserPanel === 'tasks'">
<template v-if="roleKey === 'user'">
<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>
<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>
<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>
<button class="panel-row" type="button" @click="handlePrimaryAction">
<IconBubble :icon="workerRole.primary.icon" tone="teal" />
<span>
@@ -507,9 +608,14 @@
</div>
<em v-if="interviewMessage">{{ interviewMessage }}</em>
</div>
</template>
</template>
<template v-else-if="activeUserPanel === 'sop' || activeUserPanel === 'cases'">
<div class="knowledge-tabs">
<button type="button" :class="{ active: activeUserPanel === 'sop' }" @click="switchKnowledgePanel('sop')">SOP</button>
<button type="button" :class="{ active: activeUserPanel === 'cases' }" @click="switchKnowledgePanel('cases')">案例</button>
</div>
<form class="knowledge-search" @submit.prevent="searchKnowledge()">
<input v-model.trim="knowledgeQuery" :placeholder="activeUserPanel === 'cases' ? '搜索案例关键词' : '搜索SOP关键词'" />
<button type="submit" :disabled="knowledgeLoading">{{ knowledgeLoading ? '查询中' : '查询' }}</button>
@@ -687,59 +793,34 @@
</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">
<section v-if="roleKey === 'user' && isHomeTab" class="scenario-section growth-section">
<div class="scenario-title">
<h2>我的成长</h2>
<p>训练、复盘和能力画像集中在这里看</p>
<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">
<Card title="我的进度" action="查看全部" compact-action @action="openUserAction('profile')">
<div class="profile-card growth-summary">
<span>
<small>综合能力</small>
<b>{{ competencyProfile.score }}</b>
<b>{{ competencyProfile?.score || workerRole.metrics[1]?.value || '-' }}</b>
</span>
<span>
<small>训练历史</small>
<b>{{ competencyProfile.completed }}</b>
<small>今日完成</small>
<b>{{ todayPracticeCompleted }}</b>
</span>
<span>
<small>待复盘</small>
<b>{{ competencyProfile.pendingReview }}</b>
<b>{{ competencyProfile?.pendingReview ?? workerRole.metrics[2]?.value ?? '-' }}</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')">
<Card v-else-if="roleKey === 'candidate' && isHomeTab" 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>
@@ -1168,6 +1249,7 @@ const phonePattern = /^1[3-9]\d{9}$/;
const roleKey = ref<RoleKey>(readRole());
const activeTabLabel = ref('首页');
const isHomeTab = computed(() => activeTabLabel.value === '首页');
const tapped = ref('');
const toastMessage = ref('');
const phone = ref(localStorage.getItem(phoneKey) || '');
@@ -1206,6 +1288,7 @@ const knowledgeResult = ref<KnowledgeSearchResponse | null>(null);
const knowledgeLoading = ref(false);
const knowledgeMessage = ref('');
const sopPanelOpen = ref(false);
const sopReturnTabLabel = ref('首页');
const sopQuery = ref('');
const sopResult = ref<KnowledgeSearchResponse | null>(null);
const sopLoading = ref(false);
@@ -1232,6 +1315,9 @@ let sopRecordedChunks: Blob[] = [];
let customerAudio: HTMLAudioElement | null = null;
const syncPath = () => window.history.pushState({}, '', rolePaths[roleKey.value]);
const scrollToTop = () => {
window.requestAnimationFrame(() => window.scrollTo({ top: 0 }));
};
const tap = (name: string) => {
tapped.value = name;
toastMessage.value = `「${name}」本期演示未开放,正在建设中`;
@@ -1434,9 +1520,7 @@ const transcribeRecording = async (blob: Blob, filename = 'practice.webm') => {
};
const openSopPanel = () => {
activeTabLabel.value = roleKey.value === 'supervisor' ? '案例' : '知识';
activeUserPanel.value = '';
activeSupervisorPanel.value = '';
sopReturnTabLabel.value = activeTabLabel.value;
sopPanelOpen.value = true;
sopQuery.value = '';
sopResult.value = null;
@@ -1448,6 +1532,7 @@ const closeSopPanel = () => {
sopMediaRecorder?.stop();
}
sopPanelOpen.value = false;
activeTabLabel.value = sopReturnTabLabel.value;
};
const toggleSopRecording = async () => {
@@ -1578,6 +1663,14 @@ const searchKnowledge = async (query = knowledgeQuery.value, category = activeUs
}
};
const switchKnowledgePanel = (panel: Extract<UserPanelKey, 'sop' | 'cases'>) => {
activeTabLabel.value = '知识';
activeUserPanel.value = panel;
knowledgeQuery.value =
panel === 'cases' ? '投诉接待优秀案例' : roleKey.value === 'candidate' ? '客服接待基础流程' : '住宅投诉处理流程';
void searchKnowledge(knowledgeQuery.value, panel === 'cases' ? 'case' : 'sop');
};
const openFlowStep = (item: FlowStep) => {
if (roleKey.value === 'supervisor') {
openSupervisorAction(item.target as SupervisorPanelKey);
@@ -1601,6 +1694,7 @@ const openCandidateAction = (panel: UserPanelKey) => {
activeUserPanel.value = panel;
userReviewDetail.value = null;
userReviewMessage.value = '';
scrollToTop();
if (panel === 'sop') {
knowledgeQuery.value = '客服接待基础流程';
@@ -1642,10 +1736,11 @@ const openUserAction = (panel: UserPanelKey) => {
}
activeTabLabel.value =
({ tasks: '首页', sop: '知识', cases: '知识', submit: '知识', profile: '我的', history: '我的', recommend: '知识' } as Partial<Record<UserPanelKey, string>>)[panel] || '首页';
({ tasks: '训练', sop: '知识', cases: '知识', submit: '知识', profile: '我的', history: '我的', recommend: '知识' } as Partial<Record<UserPanelKey, string>>)[panel] || '首页';
activeUserPanel.value = panel;
userReviewDetail.value = null;
userReviewMessage.value = '';
scrollToTop();
if (panel === 'cases') {
knowledgeQuery.value = '投诉接待优秀案例';
@@ -1722,6 +1817,7 @@ const openSupervisorAction = (panel: SupervisorPanelKey) => {
({ team: '团队', reviews: '团队', assign: '团队', weakness: '团队', case: '案例', sop: '案例', profile: '我的' } as Partial<Record<SupervisorPanelKey, string>>)[panel] || '首页';
activeSupervisorPanel.value = panel;
activeUserPanel.value = '';
scrollToTop();
if (panel === 'reviews' || panel === 'assign') {
void fetchPracticeData();
}
@@ -1760,10 +1856,13 @@ const openSupervisorTool = (item: ToolItem) => {
};
const openTab = (label: string) => {
scrollToTop();
activeTabLabel.value = label;
if (roleKey.value === 'supervisor') {
if (label === '首页') {
activeSupervisorPanel.value = '';
activeUserPanel.value = '';
selectedReview.value = null;
return;
}
if (label === '团队') {
@@ -1783,6 +1882,8 @@ const openTab = (label: string) => {
if (roleKey.value === 'candidate') {
if (label === '首页') {
activeUserPanel.value = '';
userReviewDetail.value = null;
userReviewMessage.value = '';
return;
}
if (label === '面试') {
@@ -1805,14 +1906,19 @@ const openTab = (label: string) => {
if (label === '首页') {
activeUserPanel.value = '';
userReviewDetail.value = null;
userReviewMessage.value = '';
return;
}
if (label === '训练') {
void startMobilePractice();
activeUserPanel.value = 'tasks';
userReviewDetail.value = null;
userReviewMessage.value = '';
void fetchPracticeData();
return;
}
if (label === '知识') {
openUserAction('sop');
switchKnowledgePanel('sop');
return;
}
if (label === '我的') {
@@ -1823,16 +1929,19 @@ const openTab = (label: string) => {
};
const openUserHistory = async (item: PracticeRecord) => {
activeTabLabel.value = '我的';
if (!item.id) {
activeUserPanel.value = 'history';
userReviewMessage.value = '这条训练记录暂无复盘详情';
userReviewDetail.value = null;
scrollToTop();
return;
}
activeUserPanel.value = 'history';
userReviewDetail.value = null;
userReviewMessage.value = '';
scrollToTop();
try {
userReviewDetail.value = await readApi<PracticeReviewDetail>(
await fetch(`/dev-api/api/aihr/mobile/practice/reviews/${item.id}`, { headers: apiHeaders() })
@@ -2031,6 +2140,7 @@ const submitMobilePractice = async () => {
const startCandidateInterview = async () => {
activeUserPanel.value = 'tasks';
activeTabLabel.value = '面试';
scrollToTop();
interviewStatus.value = 'starting';
interviewSessionId.value = '';
interviewQuestions.value = [];
@@ -2458,6 +2568,7 @@ const countTodayPracticeCompletions = () => {
const prefix = todayPrefix();
return practiceHistory.value.filter((item) => item.time.startsWith(prefix)).length;
};
const todayPracticeCompleted = computed(() => (roleKey.value === 'user' && practiceDataReady.value ? countTodayPracticeCompletions() : 0));
const userPrimaryTask = computed<PrimaryTask>(() => {
const base = workerRole.value.primary;
if (roleKey.value !== 'user' || !practiceDataReady.value) return base;
@@ -3686,6 +3797,28 @@ p {
gap: 8px;
}
.knowledge-tabs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.knowledge-tabs button {
min-height: 38px;
border-radius: 10px;
color: var(--text-secondary);
background: #ffffff;
border: 1px solid rgba(213, 225, 233, 0.7);
font-size: 14px;
font-weight: 800;
}
.knowledge-tabs button.active {
color: var(--primary-teal-active);
background: var(--primary-teal-light);
border-color: rgba(15, 174, 166, 0.22);
}
.knowledge-search input {
min-width: 0;
height: 42px;
@@ -3707,8 +3840,7 @@ p {
}
.knowledge-search button,
.panel-primary,
.panel-actions button {
.panel-primary {
min-height: 42px;
border-radius: 10px;
color: #ffffff;
@@ -3869,16 +4001,8 @@ p {
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;
.growth-summary {
margin-top: 0;
}
.sop-overlay {