feat(mobile): add employee learning page

This commit is contained in:
2026-07-10 00:57:55 +08:00
parent 3b81f07b27
commit 4b951d07a0
6 changed files with 531 additions and 3 deletions
@@ -0,0 +1,513 @@
<template>
<view class="page learning-page">
<view class="learning-head">
<text class="page-title">学</text>
<text class="page-subtitle">按你的岗位读取今日任务、SOP 依据和训练前预习卡。</text>
</view>
<view v-if="message" class="status-message">{{ message }}</view>
<view v-if="!loggedIn" class="panel login-panel">
<view>
<text class="panel-title">需要登录</text>
<text class="muted block">登录后才能按手机号读取今日任务和岗位学习内容。</text>
</view>
<button class="primary-button login-button" @click="goLogin">去登录</button>
</view>
<view v-if="loggedIn" class="learning-summary">
<view class="summary-item">
<text class="summary-value">{{ pendingCount }}</text>
<text class="summary-label">待学习</text>
</view>
<view class="summary-item">
<text class="summary-value">{{ completedCount }}</text>
<text class="summary-label">已完成</text>
</view>
<view class="summary-item">
<text class="summary-value">{{ referenceCount }}</text>
<text class="summary-label">知识引用</text>
</view>
</view>
<view v-if="loggedIn" class="panel">
<view class="panel-head">
<text class="panel-title">今日学习</text>
<button class="ghost-button compact-button" :disabled="assignmentLoading" @click="loadAssignments">
{{ assignmentLoading ? '刷新中' : '刷新' }}
</button>
</view>
<view v-if="assignments.length" class="task-list">
<view v-for="item in assignments" :key="item.id" class="task-row">
<view class="task-copy">
<text class="task-badge" :class="{ daily: item.source === 'daily' }">
{{ item.source === 'daily' ? '每日三题' : '专项训练' }}
</text>
<text class="task-title">{{ item.question || item.scenarioName }}</text>
<text v-if="assignmentDesc(item)" class="task-desc">{{ assignmentDesc(item) }}</text>
</view>
<button
class="secondary-button compact-button"
:disabled="item.status === '已完成' && item.source !== 'daily'"
@click="openAssignment(item)"
>
{{ item.status === '已完成' ? '查看' : '去完成' }}
</button>
</view>
</view>
<view v-else class="empty">暂无今日学习任务</view>
</view>
<view v-if="loggedIn" class="panel">
<view class="panel-head">
<text class="panel-title">岗位 SOP</text>
<button class="ghost-button compact-button" :disabled="knowledgeLoading" @click="loadKnowledge(activeTopic)">
{{ knowledgeLoading ? '检索中' : '重查' }}
</button>
</view>
<view class="chip-row topic-row">
<button
v-for="topic in topics"
:key="topic"
class="chip"
:class="{ active: topic === activeTopic }"
:disabled="knowledgeLoading"
@click="loadKnowledge(topic)"
>
{{ topic }}
</button>
</view>
<text v-if="knowledgeResult?.answer" class="answer-text">{{ knowledgeResult.answer }}</text>
<view v-if="knowledgeResult?.keyPoints?.length" class="keypoints">
<text v-for="(point, index) in knowledgeResult.keyPoints" :key="point" class="keypoint">
{{ index + 1 }}. {{ cleanOrderedText(point) }}
</text>
</view>
<view v-if="knowledgeResult?.snippets?.length" class="snippet-list">
<view v-for="(snippet, index) in knowledgeResult.snippets?.slice(0, 3)" :key="`${snippet.docTitle}-${index}`" class="snippet-row">
<text class="snippet-title">{{ snippet.docTitle || '知识片段' }}</text>
<text class="snippet-content">{{ snippetExcerpt(snippet.content) }}</text>
</view>
</view>
<view v-else-if="!knowledgeLoading" class="empty">知识库暂未命中可引用内容</view>
<button class="secondary-button full-action" @click="askTopic(activeTopic)">继续问师傅</button>
</view>
<view v-if="loggedIn" class="panel">
<view class="panel-head">
<text class="panel-title">训练前预习卡</text>
<button class="ghost-button compact-button" :disabled="prepLoading" @click="loadPrepCard">
{{ prepLoading ? '加载中' : '刷新' }}
</button>
</view>
<view v-if="prepCard" class="prep-grid">
<view class="prep-block">
<text class="prep-label">要点</text>
<text v-for="item in prepCard.keyPoints.slice(0, 3)" :key="`point-${item}`" class="prep-line">{{ item }}</text>
</view>
<view class="prep-block red">
<text class="prep-label">红线</text>
<text v-for="item in prepCard.redLines.slice(0, 3)" :key="`red-${item}`" class="prep-line">{{ item }}</text>
</view>
<view class="prep-block script">
<text class="prep-label">可说话术</text>
<text v-for="item in prepCard.scripts.slice(0, 3)" :key="`script-${item}`" class="prep-line">{{ item }}</text>
</view>
</view>
<view v-else-if="!prepLoading" class="empty">暂无可用预习卡</view>
<button class="primary-button full-action" @click="goPractice">练一遍</button>
</view>
<view v-if="loggedIn" class="panel action-panel">
<text class="panel-title">学习后动作</text>
<view class="action-list">
<button class="secondary-button action-button" @click="goPractice">进入训练</button>
<button class="secondary-button action-button" @click="askTopic(activeTopic)">问师傅</button>
<button class="secondary-button action-button" @click="goCases">提交素材</button>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import type {
KnowledgeSearchResponse,
PracticeAssignment,
PracticePrepCard
} from '@/types/api';
import { getPracticeAssignments, getPracticePrepCard } from '@/services/practice';
import { searchKnowledge } from '@/services/knowledge';
import { getSelectedPositionProfile, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { resetPageScroll } from '@/services/navigation';
import { ensureEmployeePosition } from '@/services/position';
const defaultScenarioId = 'complaint-water';
const pendingAssignmentKey = 'aihr_pending_assignment_id';
const pendingSopQueryKey = 'aihr_sop_pending_query';
const loggedIn = ref(false);
const message = ref('');
const assignments = ref<PracticeAssignment[]>([]);
const prepCard = ref<PracticePrepCard | null>(null);
const knowledgeResult = ref<KnowledgeSearchResponse | null>(null);
const activeTopic = ref('岗前 SOP');
const assignmentLoading = ref(false);
const prepLoading = ref(false);
const knowledgeLoading = ref(false);
const positionProfile = ref(getSelectedPositionProfile());
const topics = computed(() => {
const profile = positionProfile.value;
return [
`${profile.value} 岗前 SOP`,
...profile.quickQuestions
];
});
const pendingCount = computed(() => assignments.value.filter((item) => item.status !== '已完成').length);
const completedCount = computed(() => assignments.value.filter((item) => item.status === '已完成').length);
const referenceCount = computed(() => knowledgeResult.value?.snippets?.length || 0);
const firstPracticeAssignment = computed(() =>
assignments.value.find((item) => item.status !== '已完成' && item.source !== 'daily')
|| assignments.value.find((item) => item.source !== 'daily')
|| null
);
const prepScenarioId = computed(() => firstPracticeAssignment.value?.scenarioId || defaultScenarioId);
const refreshAuthState = () => {
loggedIn.value = isLoggedIn();
positionProfile.value = getSelectedPositionProfile();
if (!topics.value.includes(activeTopic.value)) {
activeTopic.value = topics.value[0] || '岗前 SOP';
}
};
const handleRequestError = (error: unknown, fallback: string) => {
refreshAuthState();
if (!loggedIn.value) {
message.value = '登录已过期,请重新登录';
return;
}
message.value = error instanceof Error ? error.message : fallback;
};
const assignmentDesc = (item: PracticeAssignment) => {
if (item.source === 'daily') {
if (item.status === '已完成' && item.feedback) return item.feedback;
return '提交后显示反馈与参考答案';
}
return item.reason || item.feedback;
};
const snippetExcerpt = (text = '') => {
const value = text.trim();
return value.length > 180 ? `${value.slice(0, 180)}...` : value;
};
const cleanOrderedText = (text = '') =>
text
.trim()
.replace(/^(?:[一二三四五六七八九十]+[、..]|[0-9]+[.)、..])\s*/, '')
.trim();
const loadAssignments = async () => {
if (!loggedIn.value) return;
assignmentLoading.value = true;
try {
assignments.value = await getPracticeAssignments(true);
} catch (error) {
handleRequestError(error, '学习任务加载失败');
} finally {
assignmentLoading.value = false;
}
};
const loadPrepCard = async () => {
if (!loggedIn.value) return;
prepLoading.value = true;
try {
prepCard.value = await getPracticePrepCard(prepScenarioId.value);
} catch (error) {
prepCard.value = null;
handleRequestError(error, '预习卡加载失败');
} finally {
prepLoading.value = false;
}
};
const loadKnowledge = async (topic: string) => {
if (!loggedIn.value || knowledgeLoading.value) return;
activeTopic.value = topic;
knowledgeLoading.value = true;
try {
knowledgeResult.value = await searchKnowledge(topic);
} catch (error) {
knowledgeResult.value = null;
handleRequestError(error, '岗位 SOP 检索失败');
} finally {
knowledgeLoading.value = false;
}
};
const loadPage = async () => {
refreshAuthState();
message.value = '';
if (!loggedIn.value) {
assignments.value = [];
prepCard.value = null;
knowledgeResult.value = null;
return;
}
if (!(await ensureEmployeePosition('/pages/user/learning/index'))) {
return;
}
refreshAuthState();
activeTopic.value = topics.value[0] || activeTopic.value;
await Promise.all([
loadAssignments(),
loadKnowledge(activeTopic.value)
]);
await loadPrepCard();
};
const goLogin = () => {
rememberLoginRedirect('/pages/user/learning/index');
uni.navigateTo({ url: '/pages/auth/login/index' });
};
const openAssignment = (item: PracticeAssignment) => {
uni.setStorageSync(pendingAssignmentKey, String(item.id));
uni.switchTab({ url: '/pages/user/practice/index' });
};
const goPractice = () => {
if (firstPracticeAssignment.value) {
uni.setStorageSync(pendingAssignmentKey, String(firstPracticeAssignment.value.id));
}
uni.switchTab({ url: '/pages/user/practice/index' });
};
const askTopic = (topic: string) => {
uni.setStorageSync(pendingSopQueryKey, topic);
uni.switchTab({ url: '/pages/user/sop/index' });
};
const goCases = () => {
uni.navigateTo({ url: '/pages/user/cases/index' });
};
onShow(() => {
resetPageScroll();
void loadPage();
});
</script>
<style scoped>
.learning-page {
padding-bottom: 42px;
}
.learning-head {
padding-top: 4px;
}
.login-panel {
display: flex;
gap: 14px;
align-items: center;
justify-content: space-between;
}
.login-button {
min-width: 104px;
}
.block {
display: block;
margin-top: 8px;
line-height: 1.5;
}
.learning-summary {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
margin-top: 16px;
}
.summary-item {
min-height: 82px;
padding: 14px 10px;
border: 1px solid #dbeafe;
border-radius: 14px;
background: #eff6ff;
}
.summary-value {
display: block;
color: #1d4ed8;
font-size: 25px;
font-weight: 850;
line-height: 1;
}
.summary-label {
display: block;
margin-top: 10px;
color: #475569;
font-size: 13px;
}
.panel-head {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
}
.compact-button {
min-height: 36px;
padding: 0 12px;
font-size: 14px;
}
.task-list,
.snippet-list,
.action-list {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 14px;
}
.task-row {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
padding: 13px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #f8fafc;
}
.task-copy {
flex: 1;
min-width: 0;
}
.task-badge {
display: inline-flex;
min-height: 24px;
align-items: center;
padding: 0 8px;
border-radius: 999px;
background: #fee2e2;
color: #991b1b;
font-size: 12px;
font-weight: 700;
}
.task-badge.daily {
background: #dcfce7;
color: #166534;
}
.task-title,
.snippet-title,
.prep-label {
display: block;
margin-top: 8px;
color: #0f172a;
font-size: 16px;
font-weight: 800;
line-height: 1.35;
}
.task-desc,
.snippet-content,
.prep-line {
display: block;
margin-top: 6px;
color: #64748b;
font-size: 14px;
line-height: 1.55;
}
.topic-row {
margin-top: 14px;
}
.chip.active {
background: #e60012;
color: #ffffff;
}
.answer-text {
display: block;
margin-top: 14px;
color: #1e293b;
font-size: 16px;
font-weight: 700;
line-height: 1.6;
}
.keypoints {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 12px;
}
.keypoint {
padding: 10px 12px;
border-radius: 10px;
background: #f8fafc;
color: #334155;
font-size: 14px;
line-height: 1.5;
}
.snippet-row {
padding: 13px;
border-left: 4px solid #2563eb;
border-radius: 12px;
background: #f8fafc;
}
.full-action {
width: 100%;
margin-top: 14px;
}
.prep-grid {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 14px;
}
.prep-block {
padding: 13px;
border-radius: 12px;
background: #ecfeff;
}
.prep-block.red {
background: #fff1f2;
}
.prep-block.script {
background: #fefce8;
}
.action-panel {
margin-bottom: 16px;
}
.action-button {
width: 100%;
}
</style>