Files
prop-ai-hr/mobile-uni/src/pages/user/learning/index.vue
T

739 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="page learning-page">
<view class="subpage-header">
<button
class="subpage-back-button"
aria-label="返回今日"
title="返回今日"
@click="navigateBackToToday"
>
←
</button>
<view class="subpage-heading">
<text class="page-title">学</text>
<text class="page-subtitle">按你的岗位读取今日任务、SOP 依据和训练前预习卡。</text>
</view>
</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 pending">
<text class="summary-value">{{ pendingCount }}</text>
<text class="summary-label">待完成</text>
</view>
<view class="summary-item completed">
<text class="summary-value">{{ completedCount }}</text>
<text class="summary-label">今日完成</text>
</view>
<view class="summary-item reference">
<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 mistake-panel">
<view class="panel-head">
<view>
<text class="panel-title">错题本</text>
<text class="panel-caption">从低分和红线回合中自动归集</text>
</view>
<button class="ghost-button compact-button" :disabled="mistakeLoading" @click="loadMistakes">
{{ mistakeLoading ? '加载中' : '刷新' }}
</button>
</view>
<view class="chip-row mistake-filter-row">
<button
v-for="cause in mistakeFilters"
:key="cause"
class="chip"
:class="{ active: cause === activeMistakeCause }"
:disabled="mistakeLoading"
@click="activeMistakeCause = cause"
>
{{ cause }}
</button>
</view>
<view v-if="visibleMistakes.length" class="mistake-list">
<view v-for="item in visibleMistakes" :key="item.id" class="mistake-row">
<view class="mistake-copy">
<view class="mistake-meta">
<text class="mistake-cause">{{ item.cause }}</text>
<text class="mistake-score">{{ item.score }} 分</text>
</view>
<text class="mistake-title">{{ item.scenarioName }}</text>
<text class="mistake-desc">{{ item.time }} · {{ item.summary }}</text>
<text class="mistake-status">再练状态:{{ item.retryStatus }}</text>
</view>
<button
v-if="item.retryAssignmentId"
class="secondary-button compact-button"
@click="openMistake(item)"
>
去再练
</button>
</view>
</view>
<view v-else-if="!mistakeLoading" class="empty">
{{ mistakes.length ? '当前归因筛选暂无错题' : '完成训练后,低分和红线回合会自动出现在这里' }}
</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 v-if="positionSopSummary" class="position-sop-status">
<text class="position-sop-title">
{{ positionSopSummary.position }} · {{ positionSopSummary.topics.length }} 个学习主题
</text>
<text class="position-sop-note">{{ positionSopSummary.message }}</text>
<text v-if="!positionSopSummary.formalApplicabilityConfigured" class="position-sop-warning">当前摘要不代表正式上岗资格</text>
</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,
PositionSopSummary,
PracticeAssignment,
PracticeMistake,
PracticePrepCard
} from '@/types/api';
import { getPracticeAssignments, getPracticeMistakes, getPracticePrepCard } from '@/services/practice';
import { getPositionSopSummary, searchKnowledge } from '@/services/knowledge';
import { getSelectedPositionProfile, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { buildLearningTopics, selectLearningTopic } from '@/services/learning';
import { navigateBackToToday, 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 mistakes = ref<PracticeMistake[]>([]);
const prepCard = ref<PracticePrepCard | null>(null);
const knowledgeResult = ref<KnowledgeSearchResponse | null>(null);
const positionSopSummary = ref<PositionSopSummary | null>(null);
const activeTopic = ref('岗前 SOP');
const assignmentLoading = ref(false);
const mistakeLoading = ref(false);
const mistakeFilters = ['全部', '红线话术', '情绪承接', '流程合规', '综合得分不足'];
const activeMistakeCause = ref('全部');
const prepLoading = ref(false);
const knowledgeLoading = ref(false);
const positionProfile = ref(getSelectedPositionProfile());
const defaultTopic = computed(() => `${positionProfile.value.value} 岗前 SOP`);
const topics = computed(() => buildLearningTopics(
assignments.value,
defaultTopic.value,
positionProfile.value.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 visibleMistakes = computed(() => activeMistakeCause.value === '全部'
? mistakes.value
: mistakes.value.filter((item) => item.cause === activeMistakeCause.value));
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) {
assignments.value = [];
handleRequestError(error, '学习任务加载失败');
} finally {
assignmentLoading.value = false;
}
};
const loadMistakes = async () => {
if (!loggedIn.value) return;
mistakeLoading.value = true;
try {
mistakes.value = await getPracticeMistakes();
} catch (error) {
mistakes.value = [];
handleRequestError(error, '错题本加载失败');
} finally {
mistakeLoading.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 loadPositionSopSummary = async () => {
if (!loggedIn.value) return;
try {
positionSopSummary.value = await getPositionSopSummary(positionProfile.value.value);
} catch (error) {
positionSopSummary.value = null;
handleRequestError(error, '岗位学习范围加载失败');
}
};
const loadPage = async () => {
refreshAuthState();
message.value = '';
if (!loggedIn.value) {
assignments.value = [];
mistakes.value = [];
prepCard.value = null;
knowledgeResult.value = null;
positionSopSummary.value = null;
return;
}
if (!(await ensureEmployeePosition('/pages/user/learning/index'))) {
return;
}
refreshAuthState();
await Promise.all([loadAssignments(), loadMistakes()]);
activeTopic.value = selectLearningTopic(assignments.value, defaultTopic.value);
await Promise.all([
loadPositionSopSummary(),
loadKnowledge(activeTopic.value),
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 openMistake = (item: PracticeMistake) => {
if (!item.retryAssignmentId) return;
uni.setStorageSync(pendingAssignmentKey, String(item.retryAssignmentId));
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;
}
.panel-caption {
display: block;
margin-top: 4px;
color: #7b8494;
font-size: 12px;
}
.position-sop-status {
display: grid;
gap: 4px;
margin-bottom: 12px;
padding: 10px 12px;
border-radius: 10px;
background: #f3f7ff;
}
.position-sop-title {
color: #183b70;
font-size: 13px;
font-weight: 700;
}
.position-sop-note {
color: #60708a;
font-size: 12px;
line-height: 1.5;
}
.position-sop-warning {
color: #9a6700;
font-size: 12px;
line-height: 1.5;
}
.mistake-list {
display: grid;
gap: 10px;
}
.mistake-filter-row {
margin-top: 12px;
margin-bottom: 12px;
}
.mistake-row {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
padding: 12px;
border: 1px solid #fee2e2;
border-radius: 12px;
background: #fff8f8;
}
.mistake-copy {
min-width: 0;
flex: 1;
}
.mistake-meta {
display: flex;
gap: 8px;
align-items: center;
}
.mistake-cause {
color: #bd0010;
font-size: 12px;
font-weight: 700;
}
.mistake-score {
color: #7b8494;
font-size: 12px;
}
.mistake-title,
.mistake-desc,
.mistake-status {
display: block;
}
.mistake-title {
margin-top: 4px;
color: #1f2937;
font-size: 14px;
font-weight: 700;
}
.mistake-desc,
.mistake-status {
margin-top: 4px;
color: #7b8494;
font-size: 12px;
line-height: 1.5;
}
.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-item.pending {
background: #fff0f1;
border-color: #fee2e2;
}
.summary-item.pending .summary-value {
color: #bd0010;
}
.summary-item.completed {
background: #edf7f0;
border-color: #dcfce7;
}
.summary-item.completed .summary-value {
color: #168243;
}
.summary-item.reference {
background: #eff6ff;
border-color: #dbeafe;
}
.summary-item.reference .summary-value {
color: #1d4ed8;
}
.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>