feat(mobile): add employee learning page
This commit is contained in:
@@ -31,6 +31,12 @@
|
||||
"navigationBarTitleText": "案例素材"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/user/learning/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "学"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/auth/login/index",
|
||||
"style": {
|
||||
|
||||
@@ -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>
|
||||
@@ -243,6 +243,10 @@ const openTool = (title: string) => {
|
||||
uni.switchTab({ url: '/pages/user/practice/index' });
|
||||
return;
|
||||
}
|
||||
if (title.includes('学') || title.includes('案例学习')) {
|
||||
uni.navigateTo({ url: '/pages/user/learning/index' });
|
||||
return;
|
||||
}
|
||||
if (title.includes('案例') || title.includes('素材') || title.includes('提交')) {
|
||||
uni.navigateTo({ url: '/pages/user/cases/index' });
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user