feat(mobile): finish employee app page polish

This commit is contained in:
2026-07-11 18:59:30 +08:00
parent ca84e6756c
commit c66fc4e4b0
12 changed files with 1873 additions and 658 deletions
+434 -68
View File
@@ -1,10 +1,8 @@
<template>
<view class="page practice-page employee-page-shell">
<view class="practice-head">
<text class="employee-kicker practice-eyebrow">数字师傅 · 场景训练</text>
<text class="page-title">场景模拟</text>
<text class="page-subtitle">与业主、老师傅完成一轮真实工作对话,结束后自动写入训练记录。</text>
<text class="practice-tip">先看预习卡,再开始练习;红线命中时按老师傅建议重说本轮。</text>
<text class="page-title">练</text>
<text class="page-subtitle">数字师傅 · 场景训练</text>
</view>
<view v-if="message" class="status-message">{{ message }}</view>
@@ -19,26 +17,17 @@
<view v-if="loggedIn" class="panel scenario-card employee-card">
<view class="scenario-headline">
<text class="scenario-badge">今日小训</text>
<text class="scenario-title">{{ activeScenarioName }}</text>
<text class="scenario-desc">{{ scenarioDescription }}</text>
<text class="scenario-title">场景模拟</text>
<text class="scenario-desc">先看预习卡,再开始练习</text>
</view>
<view class="scenario-visual" aria-hidden="true">
<view class="visual-sheet" />
<view class="visual-bubble"><text>•••</text></view>
</view>
<view v-if="prepCard" class="prep-card">
<view class="prep-head">
<text>训练前预习卡</text>
</view>
<view class="prep-block">
<text class="prep-label">要点</text>
<text v-for="item in prepCard.keyPoints" :key="`point-${item}`" class="prep-line">{{ item }}</text>
</view>
<view class="prep-block">
<text class="prep-label">红线</text>
<text v-for="item in prepCard.redLines" :key="`red-${item}`" class="prep-line">{{ item }}</text>
</view>
<view class="prep-block">
<text class="prep-label">话术</text>
<text v-for="item in prepCard.scripts" :key="`script-${item}`" class="prep-line">{{ item }}</text>
</view>
<view class="prep-mini"><text class="prep-label">要点</text><text>关键要点先掌握</text></view>
<view class="prep-mini"><text class="prep-label">红线</text><text>风险红线要注意</text></view>
<view class="prep-mini"><text class="prep-label">可说话术</text><text>有效话术可参考</text></view>
</view>
<text v-else-if="prepLoading" class="muted">预习卡加载中</text>
<button
@@ -51,20 +40,36 @@
</button>
</view>
<view v-if="loggedIn" class="panel assignment-panel">
<view v-if="loggedIn" class="panel assignment-panel recent-panel">
<view class="assignment-head">
<text class="panel-title">今日任务</text>
<button class="ghost-button refresh-button" :disabled="assignmentLoading" @click="loadAssignments">刷新</button>
<text class="panel-title">最近练过</text>
<text class="recent-link">全部 ›</text>
</view>
<view v-if="assignments.length" class="assignment-list">
<view v-for="item in assignments" :key="item.id" class="assignment-row" :class="{ highlighted: highlightedAssignmentId === item.id }">
<view v-if="recentPracticeItems.length" class="assignment-list">
<view v-for="item in recentPracticeItems" :key="item.id" class="assignment-row recent-row" :class="{ highlighted: item.assignment && highlightedAssignmentId === item.assignment.id }" @click="openRecentItem(item)">
<view class="recent-icon" aria-hidden="true">{{ item.icon }}</view>
<view class="assignment-copy">
<text class="assignment-title">{{ item.title }}</text>
<text class="assignment-desc">{{ item.desc }}</text>
</view>
<text class="recent-arrow">›</text>
</view>
</view>
<view v-else class="empty">暂无最近练习</view>
</view>
<view v-if="loggedIn" class="panel assignment-panel daily-panel">
<view class="assignment-head">
<view><text class="panel-title">每日三题</text><text class="daily-subtitle">今日安排</text></view>
<text class="daily-action-label">去作答 ›</text>
</view>
<view v-if="dailyAssignments.length" class="assignment-list">
<view v-for="item in dailyAssignments" :key="item.id" class="assignment-row daily-row">
<view class="assignment-copy">
<text class="assignment-badge" :class="{ daily: item.source === 'daily' }">{{ item.source === 'daily' ? '每日三题' : '主管专项' }}</text>
<text class="assignment-title">{{ item.question || item.scenarioName }}</text>
<text v-if="assignmentDesc(item)" class="assignment-desc">{{ assignmentDesc(item) }}</text>
<text v-if="item.status === '已完成' && item.referenceAnswer" class="assignment-reference">参考:{{ item.referenceAnswer }}</text>
</view>
<view v-if="item.source === 'daily'" class="daily-answer">
<view class="daily-answer">
<textarea
v-model="drillAnswers[item.id]"
class="textarea daily-textarea"
@@ -76,10 +81,18 @@
{{ item.status === '已完成' ? '已完成' : '提交' }}
</button>
</view>
<button v-else class="secondary-button small-button" :disabled="!canStartPractice" @click="startAssignment(item)">开始专项</button>
</view>
</view>
<view v-else class="empty">暂无今日任务</view>
<view v-else class="daily-empty">
<text>今日暂无题目</text>
<text>完成专项训练后会自动生成每日练习</text>
</view>
</view>
<view v-if="loggedIn" class="capability-strip">
<view><text>情绪</text><text>识别与安抚</text></view>
<view><text>信任</text><text>建立与维护</text></view>
<view><text>红线纠偏</text><text>识别与规避</text></view>
</view>
<view v-if="turns.length" class="panel dialogue-card employee-card">
@@ -178,9 +191,10 @@ import type {
PracticePrepCard,
PracticeAssignment,
PracticeRole,
PracticeTurn
PracticeTurn,
PracticeRecord
} from '@/types/api';
import { answerDailyDrill, finishPractice, getPracticeAssignments, getPracticePrepCard, startPractice, submitPracticeTurn } from '@/services/practice';
import { answerDailyDrill, finishPractice, getPracticeAssignments, getPracticeHistory, getPracticePrepCard, startPractice, submitPracticeTurn } from '@/services/practice';
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { searchKnowledge } from '@/services/knowledge';
import { resetPageScroll } from '@/services/navigation';
@@ -188,6 +202,14 @@ import { ensureEmployeePosition } from '@/services/position';
import { chooseSpeechAudio, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech';
type PracticeStatus = 'idle' | 'starting' | 'active' | 'submitting' | 'finished';
type RecentPracticeItem = {
id: string;
title: string;
desc: string;
status: string;
icon: string;
assignment?: PracticeAssignment;
};
const defaultScenarioId = 'complaint-water';
const defaultScenarioName = '投诉接待';
@@ -213,6 +235,7 @@ const emotionScore = ref(0);
const trustScore = ref(0);
const redFlagActive = ref(false);
const assignments = ref<PracticeAssignment[]>([]);
const practiceHistory = ref<PracticeRecord[]>([]);
const drillAnswers = ref<Record<number, string>>({});
const assignmentLoading = ref(false);
const answeringId = ref<number | null>(null);
@@ -231,17 +254,27 @@ const hasTraineeReply = computed(() => turns.value.some((item) => item.role ===
const canFinishPractice = computed(() => hasTraineeReply.value && !redFlagActive.value);
const hasCoachState = computed(() => emotionScore.value > 0 || trustScore.value > 0 || redFlagActive.value);
const canAdjustCoach = computed(() => status.value === 'active' && lastCoachRoundIndex.value !== null && turns.value.some((item) => item.role === 'coach'));
const activeAssignment = computed(() => assignments.value.find((item) => item.id === activeAssignmentId.value));
const scenarioDescription = computed(() => {
const points = (prepCard.value?.keyPoints || []).map((item) => item.trim()).filter(Boolean).slice(0, 2);
if (points.length) return `本场重点:${points.join(';')}`;
if (activeAssignment.value?.reason) return activeAssignment.value.reason;
const name = activeScenarioName.value;
if (/催|缴|费|停车/.test(name)) return '练习承接缴费异议、说明规则依据、给出缴费路径和跟进节点。';
if (/报修|维修|设施|电梯|漏水/.test(name)) return '练习记录问题、安抚情绪、说明报修流转和反馈时限。';
if (/突发|应急|安全|火灾|摔|道闸|车库/.test(name)) return '练习先稳现场、同步上报、留痕取证和风险告知。';
return '练习承接业主情绪、说明处理动作、明确反馈时间和责任边界。';
const recentPracticeItems = computed<RecentPracticeItem[]>(() => {
const assignmentItems: RecentPracticeItem[] = assignments.value
.filter((item) => item.source !== 'daily')
.map((item) => ({
id: `assignment-${item.id}`,
title: item.question || item.scenarioName || '专项训练',
desc: item.status === '已完成' ? '已完成' : '待复盘',
status: item.status,
icon: item.scenarioName?.includes('催费') ? '⌕' : item.scenarioName?.includes('报修') ? '⌁' : '◉',
assignment: item
}));
const historyItems: RecentPracticeItem[] = practiceHistory.value.map((item) => ({
id: `history-${item.id || item.sessionId || item.time}`,
title: item.scene || '场景训练',
desc: `${item.score || 0} 分 · ${item.status || '已完成'}`,
status: item.status,
icon: item.scene?.includes('催费') ? '⌕' : item.scene?.includes('报修') ? '⌁' : '◉'
}));
return [...assignmentItems, ...historyItems].slice(0, 3);
});
const dailyAssignments = computed(() => assignments.value.filter((item) => item.source === 'daily'));
const styleOptions = [
{ value: 'serious', label: '再严肃' },
{ value: 'gentle', label: '再温柔' },
@@ -272,6 +305,7 @@ const refreshAuthState = () => {
const clearPracticeData = () => {
cancelRecording();
assignments.value = [];
practiceHistory.value = [];
drillAnswers.value = {};
prepCard.value = null;
prepLoading.value = false;
@@ -447,6 +481,11 @@ const loadAssignments = async () => {
assignmentLoading.value = true;
try {
assignments.value = await getPracticeAssignments(true);
try {
practiceHistory.value = await getPracticeHistory();
} catch {
practiceHistory.value = [];
}
drillAnswers.value = Object.fromEntries(assignments.value.map((item) => [item.id, item.userAnswer || '']));
consumePendingAssignment();
} catch (error) {
@@ -456,6 +495,14 @@ const loadAssignments = async () => {
}
};
const openRecentItem = (item: RecentPracticeItem) => {
if (item.assignment) {
startAssignment(item.assignment);
return;
}
uni.navigateTo({ url: '/pages/user/profile/index' });
};
const consumePendingAssignment = () => {
const raw = uni.getStorageSync(pendingAssignmentKey);
if (!raw) return;
@@ -869,10 +916,10 @@ onShow(() => {
.assignment-row {
margin-top: 12px;
padding: 16px;
border: 1px solid #deebff;
border: 1px solid #f1dfe0;
border-radius: 20px;
background: #ffffff;
box-shadow: 0 10px 26px rgba(47, 107, 255, 0.08);
box-shadow: 0 8px 22px rgba(24, 28, 36, 0.05);
}
.assignment-row.highlighted {
@@ -885,15 +932,15 @@ onShow(() => {
width: fit-content;
padding: 4px 8px;
border-radius: 5px;
background: #fff0f1;
color: #bd0010;
background: #fff1f2;
color: #d51b2b;
font-size: 12px;
font-weight: 800;
}
.assignment-badge.daily {
background: #fff5df;
color: #a86100;
background: #fff1f2;
color: #d51b2b;
}
.assignment-title {
@@ -927,8 +974,8 @@ onShow(() => {
width: fit-content;
padding: 5px 10px;
border-radius: 5px;
background: #edf7f0;
color: #168243;
background: #fff1f2;
color: #d51b2b;
font-size: 13px;
font-weight: 900;
}
@@ -1011,7 +1058,7 @@ onShow(() => {
}
.dialogue-card {
border-color: #e1e2e5;
border-color: #f0dfe0;
box-shadow: var(--employee-card-shadow);
}
@@ -1073,11 +1120,11 @@ onShow(() => {
}
.emotion-fill {
background: #d98200;
background: #e45757;
}
.trust-fill {
background: #168243;
background: #d51b2b;
}
.red-flag {
@@ -1110,8 +1157,8 @@ onShow(() => {
}
.turn-row.customer {
border-left-color: #315b8f;
background: #f0f4f8;
border-left-color: #b8bdc5;
background: #f7f7f8;
}
.turn-row.trainee {
@@ -1120,8 +1167,8 @@ onShow(() => {
}
.turn-row.coach {
border-left-color: #168243;
background: #edf7f0;
border-left-color: #d98282;
background: #fff5f5;
}
.reply-actions button.recording-active {
@@ -1151,9 +1198,9 @@ onShow(() => {
.style-chip {
width: auto;
border-color: #c9d9ce;
border-color: #f0c8ca;
background: #ffffff;
color: #168243;
color: #d51b2b;
}
.reply-card {
@@ -1163,7 +1210,7 @@ onShow(() => {
.practice-textarea {
min-height: 124px;
border-color: #d6deea;
border-color: #f0dfe0;
border-radius: 18px;
background: #f9fbff;
}
@@ -1183,8 +1230,8 @@ onShow(() => {
.help-ref {
padding: 5px 8px;
border-radius: 5px;
background: #edf7f0;
color: #168243;
background: #fff1f2;
color: #d51b2b;
font-size: 12px;
font-weight: 800;
}
@@ -1196,7 +1243,7 @@ onShow(() => {
}
.result-score {
color: #2f6bff;
color: #e52323;
font-size: 44px;
font-weight: 900;
line-height: 1;
@@ -1214,13 +1261,332 @@ onShow(() => {
flex-direction: column;
gap: 6px;
padding: 12px;
border-left: 3px solid #d98200;
border-left: 3px solid #e52323;
border-radius: 6px;
background: #fff9ed;
background: #fff6f6;
}
.mentor-rewrite text:first-child {
color: #855000;
color: #c71927;
font-weight: 900;
}
/* 练页入口态:对应高保真原型,真实对话状态仍沿用下方业务面板 */
.practice-page {
padding: 0 18px 104px;
background: #fafafa;
}
.practice-head {
margin: 0 -18px;
padding: 36px 18px 26px;
background: #ffffff;
text-align: center;
}
.practice-head::after,
.practice-eyebrow,
.practice-tip {
display: none;
}
.practice-head .page-title {
color: #111111;
font-size: 38px;
font-weight: 900;
}
.practice-head .page-subtitle {
max-width: none;
margin-top: 8px;
color: #858991;
font-size: 16px;
}
.scenario-card {
position: relative;
overflow: hidden;
margin-top: 18px;
padding: 22px 18px 18px;
border: 0;
border-radius: 20px;
border-left: 0;
background: #ffffff;
box-shadow: 0 8px 24px rgba(24, 28, 36, 0.06);
}
.scenario-headline {
position: relative;
z-index: 1;
max-width: 58%;
gap: 8px;
}
.scenario-title {
color: #111111;
font-size: 30px;
font-weight: 900;
}
.scenario-desc {
color: #858991;
font-size: 17px;
}
.scenario-visual {
position: absolute;
top: 24px;
right: 18px;
width: 42%;
height: 108px;
border-radius: 50%;
background: #fff1f2;
opacity: 0.9;
}
.visual-sheet {
position: absolute;
top: 18px;
right: 24px;
width: 92px;
height: 76px;
border-radius: 12px;
background: #ffffff;
box-shadow: 0 8px 16px rgba(231, 0, 20, 0.08);
transform: rotate(7deg);
}
.visual-sheet::before,
.visual-sheet::after {
position: absolute;
left: 14px;
width: 48px;
height: 6px;
border-radius: 6px;
background: #f5dfe1;
content: '';
}
.visual-sheet::before {
top: 20px;
}
.visual-sheet::after {
top: 36px;
width: 30px;
}
.visual-bubble {
position: absolute;
right: 34px;
bottom: 16px;
display: flex;
width: 72px;
height: 42px;
align-items: center;
justify-content: center;
border-radius: 14px;
background: #ef5b61;
color: #ffffff;
font-size: 19px;
letter-spacing: 3px;
box-shadow: 0 8px 14px rgba(231, 0, 20, 0.16);
}
.visual-bubble::after {
position: absolute;
right: 10px;
bottom: -7px;
width: 14px;
height: 14px;
background: #ef5b61;
content: '';
transform: rotate(35deg);
}
.prep-card {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin-top: 16px;
padding: 0;
border: 0;
background: transparent;
}
.prep-mini {
display: flex;
min-height: 68px;
flex-direction: column;
justify-content: center;
gap: 4px;
padding: 8px;
border: 1px solid #f4d9da;
border-radius: 12px;
background: #fffafa;
}
.prep-mini .prep-label {
color: #e70014;
font-size: 15px;
font-weight: 800;
}
.prep-mini text:last-child {
color: #8c8f96;
font-size: 11px;
line-height: 1.3;
}
.scenario-card .primary-button {
margin-top: 18px;
background: #e70014;
box-shadow: 0 8px 16px rgba(231, 0, 20, 0.18);
}
.recent-panel,
.daily-panel {
margin-top: 14px;
padding: 18px;
border: 0;
border-radius: 20px;
background: #ffffff;
box-shadow: 0 8px 24px rgba(24, 28, 36, 0.05);
}
.recent-link,
.daily-action-label {
color: #858991;
font-size: 14px;
}
.recent-row {
display: flex;
min-height: 64px;
align-items: center;
margin-top: 0;
padding: 10px 0;
border: 0;
border-bottom: 1px solid #f0f1f3;
border-radius: 0;
box-shadow: none;
}
.recent-icon {
display: flex;
width: 42px;
height: 42px;
flex: 0 0 42px;
align-items: center;
justify-content: center;
margin-right: 12px;
border-radius: 50%;
background: #fff1f2;
color: #e52323;
font-size: 24px;
font-weight: 800;
}
.recent-row .assignment-copy {
min-width: 0;
flex: 1;
}
.recent-row:last-child {
border-bottom: 0;
}
.recent-row .assignment-title {
font-size: 17px;
}
.recent-row .assignment-desc {
margin-top: 4px;
color: #858991;
}
.recent-arrow {
color: #8c9098;
font-size: 25px;
}
.daily-panel .assignment-head {
margin-top: 0;
}
.daily-subtitle {
display: block;
margin-top: 4px;
color: #858991;
font-size: 14px;
}
.daily-empty {
display: flex;
min-height: 72px;
flex-direction: column;
justify-content: center;
gap: 5px;
margin-top: 14px;
padding: 0 14px;
border: 1px dashed #f0c8ca;
border-radius: 14px;
background: #fffafa;
color: #858991;
font-size: 13px;
line-height: 1.45;
text-align: center;
}
.daily-empty text:first-child {
color: #d51b2b;
font-size: 15px;
font-weight: 800;
}
.daily-row {
display: block;
margin-top: 12px;
border-color: #f0f1f3;
border-radius: 14px;
box-shadow: none;
}
.daily-row .daily-answer {
margin-top: 8px;
}
.capability-strip {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0;
margin-top: 14px;
padding: 16px 8px;
border-radius: 18px;
background: #ffffff;
box-shadow: 0 8px 24px rgba(24, 28, 36, 0.05);
}
.capability-strip view {
display: flex;
flex-direction: column;
gap: 4px;
align-items: center;
border-right: 1px solid #ececee;
}
.capability-strip view:last-child {
border-right: 0;
}
.capability-strip text:first-child {
color: #1a1a1a;
font-size: 16px;
font-weight: 800;
}
.capability-strip text:last-child {
color: #8a8d93;
font-size: 11px;
}
</style>