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

1550 lines
48 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 employee-page-shell">
<view class="subpage-header glass-header">
<button
class="subpage-back-button"
aria-label="返回今日"
title="返回今日"
@click="navigateBackToToday"
>
<uni-icons type="back" size="20" color="#1a1a1a" />
</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>
<template v-else>
<!-- 1. 原生 App 概览看板 -->
<view class="dashboard-card">
<view class="dashboard-grid">
<view class="dash-item dash-pending">
<view class="dash-icon-box box-pending">
<uni-icons type="calendar-filled" size="18" color="#e60012" />
</view>
<view class="dash-copy">
<text class="dash-value text-pending">{{ pendingCount }}</text>
<text class="dash-label">待完成</text>
</view>
</view>
<view class="dash-item dash-completed">
<view class="dash-icon-box box-completed">
<uni-icons type="checkbox-filled" size="18" color="#10b981" />
</view>
<view class="dash-copy">
<text class="dash-value text-completed">{{ completedCount }}</text>
<text class="dash-label">今日完成</text>
</view>
</view>
<view class="dash-item dash-reference">
<view class="dash-icon-box box-reference">
<uni-icons type="folder-add-filled" size="18" color="#2d80ff" />
</view>
<view class="dash-copy">
<text class="dash-value text-reference">{{ referenceCount }}</text>
<text class="dash-label">知识引用</text>
</view>
</view>
</view>
</view>
<!-- 2. 原生 Segmented Tab 切换控制器 -->
<view class="segmented-control">
<button
class="segmented-item"
:class="{ active: activeTab === 'tasks' }"
@click="activeTab = 'tasks'"
>
<text class="segmented-text">今日必练</text>
<view v-if="pendingCount > 0" class="tab-badge">{{ pendingCount }}</view>
<view class="tab-indicator"></view>
</button>
<button
class="segmented-item"
:class="{ active: activeTab === 'sop' }"
@click="activeTab = 'sop'"
>
<text class="segmented-text">岗位 SOP</text>
<view class="tab-indicator"></view>
</button>
<button
class="segmented-item"
:class="{ active: activeTab === 'exam' }"
@click="activeTab = 'exam'"
>
<text class="segmented-text">考试与资格</text>
<view class="tab-indicator"></view>
</button>
</view>
<!-- Tab 1: 今日必练 (今日任务 + 错题本) -->
<view v-if="activeTab === 'tasks'" class="tab-content-view">
<!-- 今日任务 Panel -->
<view class="panel section-card">
<view class="panel-head">
<view class="section-title">
<view class="section-bar" />
<text>今日任务</text>
</view>
<button class="icon-refresh-btn" :disabled="assignmentLoading" @click="loadAssignments" title="刷新">
<uni-icons type="refresh" size="16" color="#64748b" :class="{ spinning: assignmentLoading }" />
</button>
</view>
<view v-if="assignments.length" class="task-list">
<view v-for="item in assignments" :key="item.id" class="app-task-row" :class="assignmentAccentClass(item.source)">
<view class="app-task-header">
<view class="task-badge" :class="assignmentTagClass(item.source)">
<uni-icons :type="assignmentTagIcon(item.source)" size="12" :color="assignmentTagColor(item.source)" />
<text class="task-badge-text">{{ assignmentSourceLabel(item.source) }}</text>
</view>
<view class="task-time-meta">
<uni-icons type="clock" size="12" color="#9a9da3" />
<text class="time-text">预计 3 分钟</text>
</view>
</view>
<view class="app-task-body">
<view class="task-copy">
<text class="task-title">{{ item.question || item.scenarioName }}</text>
<text v-if="assignmentDesc(item)" class="task-desc">{{ assignmentDesc(item) }}</text>
</view>
<button
class="schedule-action primary-gradient-btn"
:disabled="item.status === '已完成' && item.source !== 'daily'"
@click="openAssignment(item)"
>
{{ item.status === '已完成' ? '查看' : '去完成' }}
</button>
</view>
</view>
</view>
<view v-else class="app-empty-state">
<view class="empty-icon-circle">
<uni-icons type="checkmarkempty" size="22" color="#10b981" />
</view>
<text class="empty-title">暂无待完成任务</text>
<text class="empty-desc">今日练习任务已全部搞定,保持优秀!</text>
</view>
</view>
<!-- 错题本 Panel -->
<view class="panel mistake-panel section-card">
<view class="panel-head">
<view class="section-title">
<view class="section-bar" />
<view>
<text>错题本</text>
<text class="panel-caption">低分与违规自动归集</text>
</view>
</view>
<button class="icon-refresh-btn" :disabled="mistakeLoading" @click="loadMistakes" title="刷新">
<uni-icons type="refresh" size="16" color="#64748b" :class="{ spinning: mistakeLoading }" />
</button>
</view>
<!-- 横向可滑动错题筛选 Chips -->
<scroll-view class="chip-scroll-view" scroll-x show-scrollbar="false">
<view class="mistake-pill-row">
<button
v-for="cause in mistakeFilters"
:key="cause"
class="mistake-pill"
:class="{ active: cause === activeMistakeCause }"
:disabled="mistakeLoading"
@click="activeMistakeCause = cause"
>
{{ cause }}
</button>
</view>
</scroll-view>
<view v-if="visibleMistakes.length" class="mistake-list">
<view v-for="item in displayedMistakes" :key="item.id" class="app-task-row accent-retry">
<view class="app-task-header">
<view class="task-badge tag-retry">
<uni-icons type="refresh" size="12" color="#d97706" />
<text class="task-badge-text">{{ item.cause }}</text>
</view>
<text class="mistake-score-pill">{{ item.score }} 分</text>
</view>
<view class="app-task-body">
<view class="mistake-copy">
<text class="mistake-title">{{ item.scenarioName }}</text>
<text class="mistake-desc">{{ item.time }} · {{ item.summary }}</text>
</view>
<button
v-if="item.retryAssignmentId && item.retryStatus !== '已完成'"
class="schedule-action primary-gradient-btn"
@click="openMistake(item)"
>
去再练
</button>
<text v-else-if="item.retryAssignmentId" class="mistake-completed">已完成</text>
</view>
</view>
<button v-if="visibleMistakes.length > 2" class="fold-toggle" @click="showAllMistakes = !showAllMistakes">
{{ showAllMistakes ? '收起 ▲' : `展开全部 ${visibleMistakes.length} 条 ▼` }}
</button>
</view>
<view v-else-if="!mistakeLoading" class="app-empty-state">
<view class="empty-icon-circle circle-amber">
<uni-icons type="flag-filled" size="22" color="#f59e0b" />
</view>
<text class="empty-title">表现优异</text>
<text class="empty-desc">{{ mistakes.length ? '当前归因筛选下暂无错题' : '完成对练后,低分和红线回合将归集于此' }}</text>
</view>
</view>
<view class="dock-spacer" />
</view>
<!-- Tab 2: 岗位 SOP (SOP 检索 + 预习卡) -->
<view v-else-if="activeTab === 'sop'" class="tab-content-view">
<!-- 岗位 SOP Panel -->
<view class="panel section-card">
<view class="panel-head">
<view class="section-title">
<view class="section-bar" />
<text>岗位 SOP 知识库</text>
</view>
<button class="icon-refresh-btn" :disabled="knowledgeLoading" @click="loadKnowledge(activeTopic)" title="刷新">
<uni-icons type="refresh" size="16" color="#64748b" :class="{ spinning: 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>
</view>
<!-- 学习主题滑轨 -->
<scroll-view class="topic-scroll-view" scroll-x show-scrollbar="false">
<view class="topic-pill-row">
<button
v-for="topic in topics"
:key="topic"
class="topic-pill"
:class="{ active: topic === activeTopic }"
:disabled="knowledgeLoading"
@click="loadKnowledge(topic)"
>
{{ topic }}
</button>
</view>
</scroll-view>
<!-- 答案与要点分框 -->
<view v-if="knowledgeResult?.answer" class="sop-answer-box">
<text class="answer-text">{{ displayedAnswer }}</text>
</view>
<view v-if="displayedKeyPoints.length" class="keypoints-container">
<view v-for="(point, index) in displayedKeyPoints" :key="point" class="keypoint-card">
<view class="keypoint-icon-box">
<uni-icons type="info-filled" size="14" color="#2d80ff" />
</view>
<text class="keypoint-text">{{ index + 1 }}. {{ cleanOrderedText(point) }}</text>
</view>
</view>
<button v-if="sopFoldNeeded" class="fold-toggle" @click="showFullAnswer = !showFullAnswer">
{{ showFullAnswer ? '收起全文 ▲' : `展开全文(含 ${knowledgeResult?.keyPoints?.length || 0} 条要点)▼` }}
</button>
<!-- 知识片段卡片 -->
<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"
>
<button class="snippet-head" @click="toggleSnippet(index)">
<uni-icons type="paperclip" size="16" color="#6366f1" />
<text class="snippet-title">{{ snippet.docTitle || '知识片段' }}</text>
<text class="snippet-arrow">{{ expandedSnippets[index] ? '▲' : '▼' }}</text>
</button>
<text v-if="expandedSnippets[index]" class="snippet-content">{{ snippetExcerpt(snippet.content) }}</text>
</view>
</view>
<view v-else-if="!knowledgeLoading" class="app-empty-state">
<view class="empty-icon-circle circle-blue">
<uni-icons type="info-filled" size="22" color="#2d80ff" />
</view>
<text class="empty-title">知识空间提示</text>
<text class="empty-desc">知识库暂未命中可引用内容,欢迎咨询师傅!</text>
</view>
</view>
<!-- 训练前预习卡 Panel -->
<view class="panel section-card prep-card-panel">
<view class="panel-head">
<view class="section-title">
<view class="section-bar" />
<text>训练前预习卡</text>
</view>
<button class="icon-refresh-btn" :disabled="prepLoading" @click="loadPrepCard" title="刷新">
<uni-icons type="refresh" size="16" color="#64748b" :class="{ spinning: prepLoading }" />
</button>
</view>
<view v-if="prepCard" class="prep-cards-container">
<!-- 核心要点卡框 -->
<view class="prep-color-block block-point">
<view class="prep-block-title">
<uni-icons type="lightbulb-filled" size="16" color="#2d80ff" />
<text class="prep-title-text">核心要点</text>
</view>
<text v-for="item in prepCard.keyPoints.slice(0, 3)" :key="`point-${item}`" class="prep-line">{{ item }}</text>
</view>
<template v-if="showPrepDetail">
<!-- 红线预警卡框 -->
<view class="prep-color-block block-red">
<view class="prep-block-title">
<uni-icons type="warn-filled" size="16" color="#e60012" />
<text class="prep-title-text text-red">红线禁忌</text>
</view>
<text v-for="item in prepCard.redLines.slice(0, 3)" :key="`red-${item}`" class="prep-line">{{ item }}</text>
</view>
<!-- 推荐话术卡框 -->
<view class="prep-color-block block-script">
<view class="prep-block-title">
<uni-icons type="chatboxes-filled" size="16" color="#059669" />
<text class="prep-title-text text-script">推荐话术</text>
</view>
<text v-for="item in prepCard.scripts.slice(0, 3)" :key="`script-${item}`" class="prep-line">{{ item }}</text>
</view>
</template>
<button class="fold-toggle" @click="showPrepDetail = !showPrepDetail">
{{ showPrepDetail ? '收起 ▲' : '展开红线与推荐话术 ▼' }}
</button>
</view>
<!-- 流式嵌入快捷操作入口 -->
<view class="sop-inline-actions">
<button class="sop-act-btn outline" @click="askTopic(activeTopic)">
<uni-icons type="chatboxes" size="15" color="#1a1a1a" />
<text class="sop-act-text">咨询师傅</text>
</button>
<button class="sop-act-btn primary-gradient-btn" @click="goPractice">
<uni-icons type="paperplane-filled" size="15" color="#ffffff" />
<text class="sop-act-text text-white">开启实操对练</text>
</button>
</view>
</view>
</view>
<!-- Tab 3: 考试与资格 (岗前任务 + 考试 + 上岗资格) -->
<view v-else-if="activeTab === 'exam'" class="tab-content-view">
<!-- 上岗资格 Card -->
<view class="panel qualification-panel section-card">
<view class="panel-head">
<view class="section-title">
<view class="section-bar" />
<text>上岗资格</text>
</view>
<button class="icon-refresh-btn" :disabled="qualificationLoading" @click="loadQualification" title="刷新">
<uni-icons type="refresh" size="16" color="#64748b" :class="{ spinning: qualificationLoading }" />
</button>
</view>
<view class="qualification-status" :class="qualificationTone">
<text class="qualification-label">{{ qualificationStatusLabel }}</text>
<text class="qualification-note">{{ qualificationNote }}</text>
</view>
<view v-if="qualification?.gates?.length" class="qualification-list">
<view v-for="gate in qualification.gates" :key="gate.id" class="qualification-row">
<text class="qualification-cert">{{ gate.certId }}</text>
<text class="qualification-meta">{{ gate.passed ? '已通过' : '未通过' }}{{ gate.validThru ? ` · 有效至 ${gate.validThru}` : '' }}</text>
</view>
</view>
</view>
<!-- 考试任务 Panel -->
<view class="panel exam-panel section-card">
<view class="panel-head">
<view class="section-title">
<view class="section-bar" />
<view>
<text>考试任务</text>
<text class="panel-caption">岗位考试与自动评分</text>
</view>
</view>
<button class="icon-refresh-btn" :disabled="examLoading" @click="loadExams" title="刷新">
<uni-icons type="refresh" size="16" color="#64748b" :class="{ spinning: examLoading }" />
</button>
</view>
<view v-if="exams.length" class="exam-list">
<view v-for="item in exams" :key="item.id" class="app-task-row accent-daily">
<view class="app-task-body">
<view class="exam-copy">
<text class="exam-title">{{ item.title }}</text>
<text class="exam-meta">{{ item.status }} · {{ item.questionCount }}题 · 及格{{ item.passingScore }}分{{ item.dueDate ? ` · 截止${item.dueDate}` : '' }}</text>
</view>
<button class="schedule-action primary-gradient-btn" @click="openExam(item.id)">
{{ item.submissionCount ? '再次查看' : '去考试' }}
</button>
</view>
</view>
</view>
<view v-else-if="!examLoading" class="app-empty-state">
<view class="empty-icon-circle circle-amber">
<uni-icons type="compose" size="22" color="#f59e0b" />
</view>
<text class="empty-title">暂无考试任务</text>
<text class="empty-desc">经理或主管发布考试后将出现在这里</text>
</view>
</view>
<!-- 岗前/入职任务 Panel -->
<view class="panel onboarding-panel section-card">
<view class="panel-head">
<view class="section-title">
<view class="section-bar" />
<view>
<text>岗前 / 入职任务</text>
<text class="panel-caption">组织系统已派发的任务</text>
</view>
</view>
<button class="icon-refresh-btn" :disabled="onboardingLoading" @click="loadOnboarding" title="刷新">
<uni-icons type="refresh" size="16" color="#64748b" :class="{ spinning: onboardingLoading }" />
</button>
</view>
<view v-if="onboardTasks.length" class="onboarding-list">
<view v-for="item in onboardTasks" :key="item.id" class="app-task-row accent-review">
<view class="app-task-body">
<view class="onboarding-copy">
<text class="onboarding-title">{{ item.taskType }} · {{ item.positionCode || positionProfile.value }}</text>
<text class="onboarding-meta">{{ item.status }}{{ item.projectCode ? ` · ${item.projectCode}` : '' }}</text>
<text v-if="item.dueTime" class="onboarding-meta">截止:{{ item.dueTime }}</text>
</view>
<button
v-if="item.status !== '已完成'"
class="schedule-action primary-gradient-btn"
:disabled="onboardingCompletingId === item.id"
@click="completeOnboarding(item)"
>
{{ onboardingCompletingId === item.id ? '提交中' : '确认完成' }}
</button>
<text v-else class="mistake-completed">已完成</text>
</view>
</view>
</view>
<view v-else-if="!onboardingLoading" class="app-empty-state">
<view class="empty-icon-circle circle-blue">
<uni-icons type="staff" size="22" color="#2d80ff" />
</view>
<text class="empty-title">暂无岗前派发任务</text>
<text class="empty-desc">等待 HR 或组织系统派发正式任务</text>
</view>
</view>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
import type {
KnowledgeSearchResponse,
OnboardTask,
PositionSopSummary,
PracticeAssignment,
PracticeMistake,
PracticePrepCard,
QualificationSummary,
ExamSummary
} from '@/types/api';
import { completeOnboardTask, getOnboardTasks, getPracticeAssignments, getPracticeMistakes, getPracticePrepCard, getQualification } 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';
import { listMyExams } from '@/services/exam';
import { createPageRequestScope, currentAccountKey, type RequestScopeTicket } from '@/services/request-scope';
import { stripInternalCodes } from '@/services/text';
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 onboardTasks = ref<OnboardTask[]>([]);
const qualification = ref<QualificationSummary | null>(null);
const onboardingLoading = ref(false);
const onboardingCompletingId = ref<number | null>(null);
const qualificationLoading = ref(false);
const exams = ref<ExamSummary[]>([]);
const examLoading = ref(false);
const requests = createPageRequestScope();
let visibleAccountKey = '';
const activeTab = ref<'tasks' | 'sop' | 'exam'>('tasks');
const assignmentAccentClass = (source?: string) => {
if (source === 'daily') return 'accent-daily';
if (source === 'retry') return 'accent-retry';
return 'accent-practice';
};
const assignmentTagClass = (source?: string) => {
if (source === 'daily') return 'tag-daily';
if (source === 'retry') return 'tag-retry';
return 'tag-practice';
};
const assignmentTagIcon = (source?: string) => {
if (source === 'daily') return 'compose';
if (source === 'retry') return 'refresh';
return 'flag-filled';
};
const assignmentTagColor = (source?: string) => {
if (source === 'daily') return '#2d80ff';
if (source === 'retry') return '#d97706';
return '#e60012';
};
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));
/* APP-like folding: long content collapsed by default, nothing removed. */
const ANSWER_PREVIEW_CHARS = 140;
const showAllMistakes = ref(false);
const showFullAnswer = ref(false);
const expandedSnippets = ref<Record<number, boolean>>({});
const displayedMistakes = computed(() => (showAllMistakes.value ? visibleMistakes.value : visibleMistakes.value.slice(0, 2)));
const answerIsLong = computed(() => (knowledgeResult.value?.answer || '').length > ANSWER_PREVIEW_CHARS);
const displayedAnswer = computed(() => {
const answer = knowledgeResult.value?.answer || '';
if (showFullAnswer.value || answer.length <= ANSWER_PREVIEW_CHARS) return answer;
return `${answer.slice(0, ANSWER_PREVIEW_CHARS)}…`;
});
const KEYPOINT_PREVIEW_COUNT = 2;
const displayedKeyPoints = computed(() => {
const points = knowledgeResult.value?.keyPoints || [];
return showFullAnswer.value ? points : points.slice(0, KEYPOINT_PREVIEW_COUNT);
});
const sopFoldNeeded = computed(() =>
answerIsLong.value || (knowledgeResult.value?.keyPoints?.length || 0) > KEYPOINT_PREVIEW_COUNT);
const showPrepDetail = ref(false);
const toggleSnippet = (index: number) => {
expandedSnippets.value = { ...expandedSnippets.value, [index]: !expandedSnippets.value[index] };
};
const qualificationStatusLabel = computed(() => {
switch (qualification.value?.status) {
case 'PASSED': return '已通过';
case 'BLOCKED': return '未通过';
case 'EXPIRED': return '已过期';
default: return '未配置';
}
});
const qualificationTone = computed(() => {
switch (qualification.value?.status) {
case 'PASSED': return 'passed';
case 'BLOCKED': return 'blocked';
case 'EXPIRED': return 'expired';
default: return 'not-configured';
}
});
const qualificationNote = computed(() => {
switch (qualification.value?.status) {
case 'PASSED': return '当前资格记录已通过正式复核。';
case 'BLOCKED': return '存在未通过的资格门禁,请联系主管或 HR。';
case 'EXPIRED': return '资格记录已过有效期,请重新提交证据复核。';
default: return '当前没有正式资格规则或证据记录,不能据此判断已具备上岗资格。';
}
});
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 stripInternalCodes(item.reason || item.feedback || '');
};
const assignmentSourceLabel = (source?: string) => {
if (source === 'daily') return '每日三题';
if (source === 'retry') return '错题再练';
if (source === 'camp') return '训练营';
return '专项训练';
};
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 loadAssignmentsRequest = async (request: RequestScopeTicket) => {
if (!loggedIn.value) return;
assignmentLoading.value = true;
try {
const nextAssignments = await getPracticeAssignments(true);
if (!requests.isCurrent(request)) return;
assignments.value = nextAssignments;
} catch (error) {
if (!requests.isCurrent(request)) return;
assignments.value = [];
handleRequestError(error, '学习任务加载失败');
} finally {
if (requests.isCurrent(request)) assignmentLoading.value = false;
}
};
const loadAssignments = () => loadAssignmentsRequest(requests.begin('assignments'));
const loadMistakesRequest = async (request: RequestScopeTicket) => {
if (!loggedIn.value) return;
mistakeLoading.value = true;
try {
const nextMistakes = await getPracticeMistakes();
if (!requests.isCurrent(request)) return;
mistakes.value = nextMistakes;
} catch (error) {
if (!requests.isCurrent(request)) return;
mistakes.value = [];
handleRequestError(error, '错题本加载失败');
} finally {
if (requests.isCurrent(request)) mistakeLoading.value = false;
}
};
const loadMistakes = () => loadMistakesRequest(requests.begin('mistakes'));
const loadOnboardingRequest = async (request: RequestScopeTicket) => {
if (!loggedIn.value) return;
onboardingLoading.value = true;
try {
const nextTasks = await getOnboardTasks();
if (!requests.isCurrent(request)) return;
onboardTasks.value = nextTasks;
} catch (error) {
if (!requests.isCurrent(request)) return;
onboardTasks.value = [];
exams.value = [];
handleRequestError(error, '岗前任务加载失败');
} finally {
if (requests.isCurrent(request)) onboardingLoading.value = false;
}
};
const loadOnboarding = () => loadOnboardingRequest(requests.begin('onboarding'));
const completeOnboarding = async (task: OnboardTask) => {
if (onboardingCompletingId.value !== null || task.status === '已完成') return;
const request = requests.begin('complete-onboarding');
onboardingCompletingId.value = task.id;
try {
await completeOnboardTask(task.id);
if (!requests.isCurrent(request)) return;
onboardTasks.value = onboardTasks.value.map((item) => item.id === task.id
? { ...item, status: '已完成', completedTime: new Date().toISOString() }
: item);
message.value = '任务已记录为完成,正式资格仍以 HR 复核为准';
} catch (error) {
if (!requests.isCurrent(request)) return;
handleRequestError(error, '任务完成记录失败');
} finally {
if (requests.isCurrent(request)) onboardingCompletingId.value = null;
}
};
const loadQualificationRequest = async (request: RequestScopeTicket) => {
if (!loggedIn.value) return;
qualificationLoading.value = true;
try {
const nextQualification = await getQualification();
if (!requests.isCurrent(request)) return;
qualification.value = nextQualification;
} catch (error) {
if (!requests.isCurrent(request)) return;
qualification.value = { status: 'NOT_CONFIGURED', positionCode: '', gates: [] };
handleRequestError(error, '上岗资格加载失败');
} finally {
if (requests.isCurrent(request)) qualificationLoading.value = false;
}
};
const loadQualification = () => loadQualificationRequest(requests.begin('qualification'));
const loadExamsRequest = async (request: RequestScopeTicket) => {
if (!loggedIn.value) return;
examLoading.value = true;
try {
const nextExams = await listMyExams();
if (!requests.isCurrent(request)) return;
exams.value = nextExams;
} catch (error) {
if (!requests.isCurrent(request)) return;
exams.value = [];
handleRequestError(error, '考试任务加载失败');
} finally {
if (requests.isCurrent(request)) examLoading.value = false;
}
};
const loadExams = () => loadExamsRequest(requests.begin('exams'));
const loadPrepCardRequest = async (request: RequestScopeTicket) => {
if (!loggedIn.value) return;
prepLoading.value = true;
try {
const nextPrepCard = await getPracticePrepCard(prepScenarioId.value);
if (!requests.isCurrent(request)) return;
prepCard.value = nextPrepCard;
} catch (error) {
if (!requests.isCurrent(request)) return;
prepCard.value = null;
handleRequestError(error, '预习卡加载失败');
} finally {
if (requests.isCurrent(request)) prepLoading.value = false;
}
};
const loadPrepCard = () => loadPrepCardRequest(requests.begin('prep-card'));
const loadKnowledge = async (topic: string, request: RequestScopeTicket = requests.begin('knowledge')) => {
if (!loggedIn.value || knowledgeLoading.value) return;
activeTopic.value = topic;
knowledgeLoading.value = true;
try {
const nextKnowledge = await searchKnowledge(topic);
if (!requests.isCurrent(request)) return;
knowledgeResult.value = nextKnowledge;
showFullAnswer.value = false;
expandedSnippets.value = {};
} catch (error) {
if (!requests.isCurrent(request)) return;
knowledgeResult.value = null;
handleRequestError(error, '岗位 SOP 检索失败');
} finally {
if (requests.isCurrent(request)) knowledgeLoading.value = false;
}
};
const loadPositionSopSummary = async (request: RequestScopeTicket = requests.begin('position-sop')) => {
if (!loggedIn.value) return;
try {
const nextSummary = await getPositionSopSummary(positionProfile.value.value);
if (!requests.isCurrent(request)) return;
positionSopSummary.value = nextSummary;
} catch (error) {
if (!requests.isCurrent(request)) return;
positionSopSummary.value = null;
handleRequestError(error, '岗位学习范围加载失败');
}
};
const loadPage = async () => {
const pageRequest = requests.begin('page');
refreshAuthState();
message.value = '';
if (!loggedIn.value) {
assignments.value = [];
mistakes.value = [];
onboardTasks.value = [];
qualification.value = null;
prepCard.value = null;
knowledgeResult.value = null;
positionSopSummary.value = null;
return;
}
if (!(await ensureEmployeePosition('/pages/user/learning/index')) || !requests.isCurrent(pageRequest)) {
return;
}
refreshAuthState();
await Promise.all([
loadAssignmentsRequest(requests.begin('assignments')),
loadMistakesRequest(requests.begin('mistakes')),
loadOnboardingRequest(requests.begin('onboarding')),
loadQualificationRequest(requests.begin('qualification')),
loadExamsRequest(requests.begin('exams'))
]);
if (!requests.isCurrent(pageRequest)) return;
activeTopic.value = selectLearningTopic(assignments.value, defaultTopic.value);
await Promise.all([
loadPositionSopSummary(requests.begin('position-sop')),
loadKnowledge(activeTopic.value, requests.begin('knowledge')),
loadPrepCardRequest(requests.begin('prep-card'))
]);
};
const invalidateRequests = () => {
requests.invalidate();
assignmentLoading.value = false;
mistakeLoading.value = false;
prepLoading.value = false;
knowledgeLoading.value = false;
onboardingLoading.value = false;
onboardingCompletingId.value = null;
qualificationLoading.value = false;
examLoading.value = false;
};
const clearPageData = () => {
assignments.value = [];
mistakes.value = [];
onboardTasks.value = [];
qualification.value = null;
prepCard.value = null;
knowledgeResult.value = null;
positionSopSummary.value = null;
exams.value = [];
};
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 openExam = (id: number) => {
uni.navigateTo({ url: `/pages/user/exam/index?id=${id}` });
};
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(() => {
const nextAccountKey = currentAccountKey();
if (visibleAccountKey && visibleAccountKey !== nextAccountKey) {
clearPageData();
message.value = '';
}
visibleAccountKey = nextAccountKey;
requests.activate();
resetPageScroll();
void loadPage();
});
onHide(invalidateRequests);
onUnload(invalidateRequests);
</script>
<style scoped>
.learning-page {
padding-bottom: 32px;
background: #f7f8fa;
min-height: 100vh;
}
.glass-header {
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
}
.page-title {
font-size: 20px;
font-weight: 800;
color: #1a1a1a;
}
.page-subtitle {
font-size: 12px;
color: #8a8d93;
margin-top: 2px;
}
/* 1. Dashboard Stat Grid */
.dashboard-card {
margin: 12px 16px 14px;
padding: 16px;
border-radius: 20px;
background: #ffffff;
box-shadow: 0 8px 24px rgba(24, 28, 36, 0.04);
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.dash-item {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 10px;
border-radius: 14px;
}
.dash-pending {
background: #fff4f4;
border: 1px solid #ffe2e2;
}
.dash-completed {
background: #edf7f0;
border: 1px solid #dcfce7;
}
.dash-reference {
background: #eff6ff;
border: 1px solid #dbeafe;
}
.dash-icon-box {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 10px;
flex: none;
}
.box-pending { background: rgba(230, 0, 18, 0.1); }
.box-completed { background: rgba(16, 185, 129, 0.1); }
.box-reference { background: rgba(45, 128, 255, 0.1); }
.dash-copy {
display: flex;
flex-direction: column;
min-width: 0;
}
.dash-value {
font-size: 18px;
font-weight: 900;
line-height: 1.1;
}
.text-pending { color: #e60012; }
.text-completed { color: #10b981; }
.text-reference { color: #2d80ff; }
.dash-label {
font-size: 11px;
color: #6b7280;
margin-top: 3px;
font-weight: 600;
}
/* 2. Native Segmented Control */
.segmented-control {
display: flex;
align-items: center;
justify-content: space-between;
margin: 0 16px 14px;
padding: 4px;
background: #eef1f6;
border-radius: 14px;
position: relative;
}
.segmented-item {
position: relative;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 40px;
border: none;
background: transparent;
border-radius: 10px;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
padding: 0;
}
.segmented-item.active {
background: #ffffff;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.segmented-text {
font-size: 14px;
font-weight: 700;
color: #64748b;
transition: color 0.2s ease;
}
.segmented-item.active .segmented-text {
color: #e60012;
font-weight: 800;
}
.tab-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 999px;
background: #e60012;
color: #ffffff;
font-size: 11px;
font-weight: 800;
line-height: 1;
}
/* 3. Tab Content View */
.tab-content-view {
padding: 0 16px 24px;
display: flex;
flex-direction: column;
gap: 14px;
}
.section-card {
padding: 16px;
border-radius: 20px;
background: #ffffff;
box-shadow: 0 8px 24px rgba(24, 28, 36, 0.04);
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
margin-bottom: 10px;
}
.section-title {
display: flex;
align-items: center;
gap: 8px;
color: #1a1a1a;
font-size: 17px;
font-weight: 800;
flex: 1;
min-width: 0;
}
.section-bar {
width: 4px;
height: 18px;
border-radius: 2px;
background: var(--employee-primary, #e60012);
flex: none;
}
.panel-caption {
font-size: 11px;
color: #9a9da3;
font-weight: 500;
margin-left: 6px;
}
/* App Task Row */
.task-list {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 14px;
}
.app-task-row {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px;
border-radius: 16px;
background: #ffffff;
border: 1px solid #f0f1f5;
border-left: 4px solid #e60012;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.03);
}
.app-task-row.accent-daily { border-left-color: #2d80ff; }
.app-task-row.accent-retry { border-left-color: #d97706; }
.app-task-row.accent-practice { border-left-color: #e60012; }
.app-task-row.accent-review { border-left-color: #10b981; }
.app-task-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.task-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border-radius: 8px;
}
.task-badge-text {
font-size: 11px;
font-weight: 800;
}
.tag-daily { background: rgba(45, 128, 255, 0.1); color: #2d80ff; }
.tag-retry { background: rgba(245, 158, 11, 0.1); color: #d97706; }
.tag-practice { background: rgba(230, 0, 18, 0.1); color: #e60012; }
.task-time-meta {
display: flex;
align-items: center;
gap: 4px;
}
.time-text {
font-size: 11px;
color: #9a9da3;
}
.app-task-body {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
width: 100%;
}
.task-copy, .mistake-copy, .exam-copy, .onboarding-copy {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
flex: 1;
}
.task-title, .mistake-title, .exam-title, .onboarding-title {
color: #1a1a1a;
font-size: 15px;
font-weight: 800;
line-height: 1.45;
}
.task-desc, .mistake-desc, .exam-meta, .onboarding-meta {
color: #8a8d93;
font-size: 12px;
line-height: 1.4;
}
.mistake-score-pill {
padding: 2px 8px;
border-radius: 6px;
background: #fee2e2;
color: #e60012;
font-size: 11px;
font-weight: 800;
}
.schedule-action {
display: flex;
width: auto;
min-width: 76px;
height: 38px;
min-height: 38px;
flex: none;
align-items: center;
justify-content: center;
padding: 0 12px;
border-radius: 10px;
background: var(--employee-primary, #e60012);
color: #ffffff;
font-size: 13px;
font-weight: 800;
white-space: nowrap;
}
.primary-gradient-btn {
background: linear-gradient(135deg, #ff4d4f 0%, #e60012 100%) !important;
box-shadow: 0 4px 12px rgba(230, 0, 18, 0.22) !important;
}
/* SOP Scrollable Topic Pills */
.topic-scroll-view {
width: 100%;
white-space: nowrap;
margin-top: 12px;
}
.topic-pill-row {
display: inline-flex;
gap: 8px;
padding: 2px 0;
}
.topic-pill {
display: inline-flex;
align-items: center;
height: 34px;
padding: 0 14px;
border-radius: 999px;
background: #f1f3f7;
color: #4b5563;
font-size: 13px;
font-weight: 700;
border: none;
white-space: nowrap;
transition: all 0.2s ease;
}
.topic-pill.active {
background: #1a1a1a;
color: #ffffff;
}
.sop-answer-box {
margin-top: 12px;
padding: 14px;
border-radius: 14px;
background: #f8fafc;
border: 1px solid #e2e8f0;
}
.answer-text {
font-size: 14px;
color: #334155;
line-height: 1.6;
font-weight: 600;
}
.keypoints-container {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 10px;
}
.keypoint-card {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 12px;
border-radius: 12px;
background: #eff6ff;
border: 1px solid #dbeafe;
}
.keypoint-icon-box {
margin-top: 2px;
flex: none;
}
.keypoint-text {
font-size: 13px;
color: #1e40af;
line-height: 1.45;
font-weight: 600;
}
/* Prep Color Blocks */
.prep-cards-container {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 12px;
}
.prep-color-block {
padding: 14px;
border-radius: 14px;
display: flex;
flex-direction: column;
gap: 6px;
}
.prep-block-title {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 2px;
}
.prep-title-text {
font-size: 14px;
font-weight: 800;
color: #1d4ed8;
}
.prep-title-text.text-red { color: #e60012; }
.prep-title-text.text-script { color: #059669; }
.block-point { background: #eff6ff; border: 1px solid #dbeafe; }
.block-red { background: #fff4f4; border: 1px solid #ffe2e2; }
.block-script { background: #edf7f0; border: 1px solid #dcfce7; }
.prep-line {
font-size: 13px;
color: #374151;
line-height: 1.45;
}
/* Qualification */
.qualification-status {
padding: 12px;
border-radius: 12px;
margin-top: 10px;
}
.qualification-status.passed { background: #edf7f0; color: #059669; }
.qualification-status.not-configured { background: #fff8e6; color: #d97706; }
.qualification-status.blocked, .qualification-status.expired { background: #fff4f4; color: #e60012; }
/* In-flow SOP Inline Actions */
.sop-inline-actions {
display: flex;
align-items: center;
gap: 10px;
margin-top: 16px;
padding-top: 14px;
border-top: 1px dashed #e2e8f0;
}
.sop-act-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 40px;
border-radius: 12px;
border: none;
font-size: 13px;
font-weight: 800;
}
.sop-act-btn.outline {
background: #f1f5f9;
color: #1a1a1a;
}
.sop-act-text {
font-size: 13px;
font-weight: 800;
color: #1a1a1a;
}
.sop-act-text.text-white {
color: #ffffff;
}
.icon-refresh-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
background: #f1f5f9;
border: none;
padding: 0;
flex: none;
transition: background 0.2s ease, transform 0.15s ease;
}
.icon-refresh-btn:active {
background: #e2e8f0;
transform: scale(0.92);
}
.spinning {
animation: spin 0.8s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.app-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px 16px;
text-align: center;
}
.empty-icon-circle {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 50%;
background: #edf7f0;
margin-bottom: 10px;
}
.empty-icon-circle.circle-amber {
background: #fff8e6;
}
.empty-icon-circle.circle-blue {
background: #eff6ff;
}
.empty-title {
font-size: 14px;
font-weight: 800;
color: #1a1a1a;
}
.empty-desc {
font-size: 12px;
color: #8a8d93;
margin-top: 4px;
line-height: 1.4;
}
/* Horizontal mistake scroll view */
.chip-scroll-view {
width: 100%;
white-space: nowrap;
margin-top: 10px;
margin-bottom: 12px;
}
.mistake-pill-row {
display: inline-flex;
gap: 8px;
padding: 2px 0;
}
.mistake-pill {
display: inline-flex;
align-items: center;
height: 32px;
padding: 0 12px;
border-radius: 999px;
background: #f1f3f7;
color: #64748b;
font-size: 12px;
font-weight: 700;
border: none;
white-space: nowrap;
transition: all 0.2s ease;
}
.mistake-pill.active {
background: #d97706;
color: #ffffff;
}
/* Hide scrollbars for WebKit scroll-views */
.chip-scroll-view ::-webkit-scrollbar,
.topic-scroll-view ::-webkit-scrollbar {
display: none !important;
width: 0 !important;
height: 0 !important;
background: transparent !important;
}
.fold-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 36px;
margin-top: 8px;
border: none;
background: transparent;
color: #9a9da3;
font-size: 12px;
font-weight: 600;
}
.dock-spacer {
height: 90px;
width: 100%;
flex: none;
}
.fold-toggle::after { border: none; }
</style>