feat(mobile): restructure worker home into scenario-based task flow
员工端首页从功能陈列改为情境任务流,回应甲方「学的时候怎么用、 工作的时候怎么用、碰到问题怎么用」反馈:五区依次为「今天要学」 (训练卡进度接真数据:practiceHistory 按今日 MM-dd 统计,完成后 CTA 变再练一次,接口失败保持静态兜底)、「练完要复盘」(最近一条评分+ 导师改写摘要,点击进复盘详情,含空态引导)、「工作中要用」(查SOP 升级为通栏大按钮卡)、「碰到问题要报」(素材上报)、「我的成长」 (进度+能力画像移至底部)。员工端 ToolGrid 移除,候选人/主管端零 改动,demo-check 14 条标记保留。
This commit is contained in:
+350
-62
@@ -52,7 +52,7 @@
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<Card :title="roleKey === 'supervisor' ? '今日处理流程' : '今日使用流程'">
|
||||
<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>
|
||||
@@ -278,73 +278,164 @@
|
||||
</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>
|
||||
|
||||
<div v-if="roleKey === 'user' && 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>
|
||||
<template v-if="roleKey === 'user'">
|
||||
<section class="scenario-section">
|
||||
<div class="scenario-title">
|
||||
<h2>今天要学</h2>
|
||||
<p>跟着任务走,学完自动记录</p>
|
||||
</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 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-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 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>
|
||||
<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>
|
||||
</Card>
|
||||
|
||||
<Card :title="workerRole.toolTitle">
|
||||
<ToolGrid :items="workerRole.tools" :handler="openUserTool" />
|
||||
</Card>
|
||||
<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">
|
||||
@@ -602,7 +693,53 @@
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="我的进度" :action="workerRole.recommend" compact-action @action="openUserAction('recommend')">
|
||||
<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>
|
||||
@@ -1061,6 +1198,7 @@ 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('住宅投诉处理流程');
|
||||
@@ -2214,6 +2352,7 @@ const fetchMobileHome = async () => {
|
||||
|
||||
const fetchPracticeData = async () => {
|
||||
if (!authToken.value || roleKey.value === 'candidate') {
|
||||
practiceDataReady.value = false;
|
||||
practiceHistory.value = [];
|
||||
practiceReviews.value = [];
|
||||
selectedReview.value = null;
|
||||
@@ -2231,16 +2370,19 @@ const fetchPracticeData = async () => {
|
||||
]);
|
||||
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;
|
||||
@@ -2307,6 +2449,29 @@ const supervisorTeamTiles = computed<MetricItem[]>(() =>
|
||||
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>?]>> = {
|
||||
@@ -3081,6 +3246,129 @@ p {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user