1550 lines
48 KiB
Vue
1550 lines
48 KiB
Vue
<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>
|