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
+1 -1
View File
@@ -98,7 +98,7 @@
### 零散待办
- **总结卡「保存图片」按钮**(设计基准 summary-modal 图内有):需 html2canvas 类依赖做 DOM 转图,2026-07-05 P0-2 实现时按"不加依赖"红线砍掉;引依赖或后端出图方案定型后补。
- **员工端「学」板块**(我的地图橙色块):当前进「建设中」整页,承接 B8 五段旅程的岗前课程/每日小训模块。
- **员工端「学」板块**(我的地图橙色块):已在 `mobile-uni/src/pages/user/learning/index.vue` 落成第一版,从今日任务、岗位 SOP 检索和训练前预习卡读取现有真实接口;后续再接课程/考试/视频学习数据。
## Future Enhancements
+2 -2
View File
@@ -1,6 +1,6 @@
# 一期 MVP 演示与验收清单
本文件是 MVP 现场演示入口:先跑预检,再按演示流逐条点击。当前证明一期 MVP 路径可讲、可点、可截图;用户侧三端已在 `mobile-uni/` 按真实页面栈重建,portless 入口为 `https://wygj-mobile-uni.localhost/h5/`,已接手机号登录、问师傅、员工训练、案例素材、候选人面试/资料和主管复盘/派专项;员工端训练完成后写入 `aihr_practice_session`,用于训练历史、主管待复盘列表和能力画像;候选人端面试复用 AI 面试 API,补充资料上传写 `sys_oss` 和 `aihr_candidate_material`,HR 可在管理端审核通过/驳回;SOP 检索已可查本地 MySQL seed 片段,并支持 txt/md/PDF/Word/Excel/PPT 上传解析为本地 fragment,配置 vector 模型后会写入 embedding 并尽力同步 Qdrant,管理端 AI 面试和案例沉淀按真实模型/ASR 优先验收。
本文件是 MVP 现场演示入口:先跑预检,再按演示流逐条点击。当前证明一期 MVP 路径可讲、可点、可截图;用户侧三端已在 `mobile-uni/` 按真实页面栈重建,portless 入口为 `https://wygj-mobile-uni.localhost/h5/`,已接手机号登录、问师傅、员工训练、员工学习、案例素材、候选人面试/资料和主管复盘/派专项;员工端训练完成后写入 `aihr_practice_session`,用于训练历史、主管待复盘列表和能力画像;候选人端面试复用 AI 面试 API,补充资料上传写 `sys_oss` 和 `aihr_candidate_material`,HR 可在管理端审核通过/驳回;SOP 检索已可查本地 MySQL seed 片段,并支持 txt/md/PDF/Word/Excel/PPT 上传解析为本地 fragment,配置 vector 模型后会写入 embedding 并尽力同步 Qdrant,管理端 AI 面试和案例沉淀按真实模型/ASR 优先验收。
## 演示前预检
@@ -15,7 +15,7 @@
| 顺序 | 页面 | 点击路径 | 必须看到 |
|---|---|---|---|
| 1 | 首页 `/index` | 打开首页 | 只展示“首页 / AI面试 / 三角色对练 / 案例沉淀 / SOP知识库 / 资料处理 / 系统设置-模型配置” |
| 2 | uni-app 三端 `https://wygj-mobile-uni.localhost/h5/#/pages/user/today/index`、`/pages/candidate/index/index`、`/pages/supervisor/index/index` | 打开后先手机号登录;已配置 `AIHR_SMS_LOGIN_TEMPLATE_ID` 时可真实收短信,新手机号会自动注册;员工端今日页进入问师傅、练习、案例素材和个人页;练习页完成开始练习、提交回应、结束评分、每日三题提交;问师傅页可检索、反馈、生成总结卡;案例素材页可上传语音并回读入库记录;候选人端点“开始面试/面试练习”和“补充资料”;主管端查看团队画像、待复盘、标记已复盘并从画像派专项 | 员工端显示评分、“训练完成,主管端待复盘已更新”、训练历史和能力画像;底部 tab 整页切换(员工端为 今日/练/问/我);未实现入口进「建设中」整页(返回按钮),无死按钮;主管端待复盘列表增加,复盘详情可看评分、话术、导师改写,并可标记已复盘;从团队画像派专项后最近专项立即回读;候选人端可拉题/答题/评分,资料上传后显示审核状态;HR 审核后候选人端显示 `已通过/已驳回` |
| 2 | uni-app 三端 `https://wygj-mobile-uni.localhost/h5/#/pages/user/today/index`、`/pages/candidate/index/index`、`/pages/supervisor/index/index` | 打开后先手机号登录;已配置 `AIHR_SMS_LOGIN_TEMPLATE_ID` 时可真实收短信,新手机号会自动注册;员工端今日页进入问师傅、练习、学习页、案例素材和个人页;学习页读取今日任务、岗位 SOP 和训练前预习卡;练习页完成开始练习、提交回应、结束评分、每日三题提交;问师傅页可检索、反馈、生成总结卡;案例素材页可上传语音并回读入库记录;候选人端点“开始面试/面试练习”和“补充资料”;主管端查看团队画像、待复盘、标记已复盘并从画像派专项 | 员工端显示评分、“训练完成,主管端待复盘已更新”、训练历史和能力画像;学习页不再落到「建设中」,内容来自现有任务、知识库和预习卡接口;底部 tab 整页切换(员工端为 今日/练/问/我);未实现入口进「建设中」整页(返回按钮),无死按钮;主管端待复盘列表增加,复盘详情可看评分、话术、导师改写,并可标记已复盘;从团队画像派专项后最近专项立即回读;候选人端可拉题/答题/评分,资料上传后显示审核状态;HR 审核后候选人端显示 `已通过/已驳回` |
| 3 | AI面试 `/recruit/interview` | 生成题目 → 填满参考回答或输入真实回答 → 完成评分 | 配置 chat 模型时真实生成题目和评分;未配置时用本地 Rubric 估分,不返回固定候选人分数;应看到新增面试记录 |
| 4 | 三角色对练 `/train/practice` | 开始对练 → 填入参考回复或输入真实回复 → 继续一轮 → 结束并评分 | 已完成闭环、导师改写、新增对练记录;数据库已启用 chat 模型时,客户回复与评分为真实 LLM 生成(回复不再是固定台词,分数随话术变化),未配置时使用本地剧本兜底 |
| 5 | 案例沉淀 `/knowledge/cases` | 上传真实语音 → ASR 转写 → AI 整理 → 送审 → 入库 → 查看样片 | 已完成闭环,案例列表新增真实转写生成的记录;视频仍用样片兜底 |
+6
View File
@@ -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;
+5
View File
@@ -129,6 +129,7 @@ contains frontend/src/router/index.ts "/system/model"
contains mobile-uni/package.json "aihr-mobile-uni"
contains mobile-uni/src/pages.json "pages/user/today/index"
contains mobile-uni/src/pages.json "pages/user/learning/index"
contains mobile-uni/src/pages.json "pages/supervisor/index/index"
contains mobile-uni/src/pages.json "pages/candidate/index/index"
contains mobile-uni/src/pages/auth/login/index.vue "/resource/sms/code"
@@ -144,6 +145,10 @@ contains mobile-uni/src/services/knowledge.ts "/api/knowledge/summary-card"
contains mobile-uni/src/services/case.ts "/api/knowledge/case/upload"
contains mobile-uni/src/services/candidate.ts "/api/recruit/interview/start"
contains mobile-uni/src/services/candidate.ts "/api/aihr/mobile/candidate/materials"
contains mobile-uni/src/pages/user/learning/index.vue "岗位 SOP"
contains mobile-uni/src/pages/user/learning/index.vue "getPracticeAssignments"
contains mobile-uni/src/pages/user/learning/index.vue "searchKnowledge"
contains mobile-uni/src/pages/user/learning/index.vue "getPracticePrepCard"
contains frontend/src/views/recruit/interview.vue "建议复试"
contains frontend/src/views/train/practice.vue "导师改写"
contains frontend/src/views/knowledge/cases.vue "真实语音"