Files
vueapp/pages/cases/cases.vue
T
2026-06-13 06:05:37 +08:00

915 lines
24 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="cases-page">
<view class="case-shell">
<view class="case-header">
<button class="icon-button" aria-label="设置" @click="openSettings">
<view class="settings-icon"></view>
</button>
<button class="icon-button home-button" aria-label="首页" @click="goHome">
<view class="home-icon"></view>
</button>
<view class="header-spacer"></view>
<button class="icon-button" aria-label="个人中心" @click="openProfile">
<view class="account-icon"></view>
</button>
</view>
<scroll-view class="case-content" scroll-y @scrolltolower="loadMoreCases">
<view class="list-hero">
<text class="list-title">{{ currentSourceConfig.title }}</text>
<text class="list-subtitle">{{ currentSourceConfig.subtitle }}</text>
</view>
<view class="search-row">
<view class="search-box">
<view class="search-icon"></view>
<input
class="search-input"
v-model="keyword"
type="text"
:placeholder="currentSourceConfig.placeholder"
placeholder-class="search-placeholder"
confirm-type="search"
@confirm="searchCases"
/>
</view>
<button class="search-button" :disabled="loading" @click="searchCases">搜索</button>
</view>
<view v-if="caseTypeFilters.length" class="filter-row">
<button
v-for="filter in caseTypeFilters"
:key="filter.value"
class="filter-chip"
:class="{ active: filters.case_type === filter.value }"
@click="chooseCaseType(filter.value)"
>
{{ filter.label }}
</button>
</view>
<view class="filter-row compact">
<button
v-for="filter in difficultyFilters"
:key="filter.value"
class="filter-chip"
:class="{ active: filters.difficulty === filter.value }"
@click="chooseDifficulty(filter.value)"
>
{{ filter.label }}
</button>
</view>
<view class="summary-row">
<text>{{ loading && cases.length === 0 ? '病例加载中...' : `${totalCount} 个病例` }}</text>
<text v-if="departmentName">科室{{ departmentName }}</text>
</view>
<view class="case-list">
<view
v-for="item in cases"
:key="item.id"
class="case-card"
:class="`mode-${item.mode}`"
@click="selectCase(item)"
>
<view class="case-main">
<view class="patient-avatar" :class="`avatar-${item.tone}`">
<text>{{ item.patientName.slice(0, 1) }}</text>
</view>
<view class="case-info">
<text class="case-title">{{ item.title }}</text>
<text class="case-meta">
{{ item.gender }}{{ item.age || '-' }}{{ item.department }}{{ item.caseTypeDisplay || item.scene }}
</text>
<text v-if="item.description" class="case-desc">{{ item.description }}</text>
<view v-if="item.competencyTags?.length" class="tag-row">
<text
v-for="tag in item.competencyTags.slice(0, 3)"
:key="tag"
class="case-tag"
>
{{ tag }}
</text>
</view>
</view>
</view>
<view class="case-footer">
<text class="case-no">病例编号: {{ item.caseNo }}</text>
<view class="mode-badge" :class="`mode-badge-${item.mode}`">
<view class="mode-icon" :class="`mode-icon-${item.mode}`"></view>
<text>{{ getModeLabel(item.mode) }}</text>
</view>
</view>
<view class="stat-row">
<text>{{ getDifficultyLabel(item.difficulty, item.difficultyScore) }}</text>
<text v-if="item.estimatedMinutes">{{ item.estimatedMinutes }}分钟</text>
<text v-if="item.myBestScore !== null && item.myBestScore !== undefined">最高分 {{ item.myBestScore }}</text>
<text v-if="item.myTrainCount !== null && item.myTrainCount !== undefined">已练 {{ item.myTrainCount }} </text>
</view>
</view>
<view v-if="loading && cases.length === 0" class="empty-state">
<view class="spinner"></view>
<text>正在获取病例列表...</text>
</view>
<view v-else-if="loadFailed" class="empty-state">
<text>{{ errorMessage || '病例列表加载失败' }}</text>
<button class="retry-button" @click="reloadCases">重新加载</button>
</view>
<view v-else-if="cases.length === 0" class="empty-state">
<text>暂无匹配病例</text>
</view>
<view v-else-if="loadingMore" class="load-more-state">
<text>继续加载中...</text>
</view>
<view v-else-if="!hasMore" class="load-more-state">
<text>已显示全部病例</text>
</view>
</view>
</scroll-view>
</view>
<view class="toast" :class="{ visible: toastVisible }">{{ toastMessage }}</view>
</view>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { fetchCaseListPage, type CaseListSource, type CaseMode, type ClinicalCase } from '../../api/cases'
import { createHomeNavigator, createProfileOpener, createSettingsOpener } from '../../api/navigation'
const emit = defineEmits<{
(event: 'open-settings'): void
(event: 'open-profile'): void
(event: 'go-home'): void
}>()
const openProfile = createProfileOpener(emit)
const openSettings = createSettingsOpener(emit)
const goHome = createHomeNavigator(emit)
const cases = ref<ClinicalCase[]>([])
const keyword = ref('')
const loading = ref(false)
const loadingMore = ref(false)
const loadFailed = ref(false)
const errorMessage = ref('')
const totalCount = ref(0)
const currentPage = ref(1)
const pageSize = ref(10)
const source = ref<CaseListSource>('recommended')
const departmentId = ref('')
const departmentName = ref('')
const toastMessage = ref('')
const toastVisible = ref(false)
const filters = reactive({
case_type: '',
difficulty: ''
})
let toastTimer: ReturnType<typeof setTimeout> | null = null
let searchTimer: ReturnType<typeof setTimeout> | null = null
let requestSeq = 0
const sourceConfigs: Record<CaseListSource, { title: string; subtitle: string; placeholder: string }> = {
recommended: {
title: '开始训练',
subtitle: '基于您的训练画像智能推荐病例',
placeholder: '搜索病例标题、科室、主诉'
},
specialty: {
title: '专项强化',
subtitle: '按配置科室聚焦专项病例',
placeholder: '搜索专项病例'
},
weak: {
title: '薄弱环节',
subtitle: '优先呈现训练分数低于 70 分的病例',
placeholder: '搜索待补强病例'
},
teaching: {
title: '教学互动',
subtitle: '练习模式与教学互动模式病例',
placeholder: '搜索教学互动病例'
},
'teacher-task': {
title: '教师任务',
subtitle: '老师配置的针对性任务训练',
placeholder: '搜索教师任务病例'
}
}
const caseTypeFilters = computed(() => {
if (source.value === 'teaching' || source.value === 'teacher-task') return []
return [
{ label: '全部类型', value: '' },
{ label: '练习模式', value: 'practice' },
{ label: '教学互动', value: 'teaching' }
]
})
const difficultyFilters = [
{ label: '全部难度', value: '' },
{ label: '简单', value: 'easy' },
{ label: '中等', value: 'medium' },
{ label: '困难', value: 'hard' }
]
const currentSourceConfig = computed(() => sourceConfigs[source.value])
const hasMore = computed(() => cases.value.length < totalCount.value)
watch(keyword, () => {
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
reloadCases()
}, 500)
})
function getModeLabel(mode: CaseMode) {
return mode === 'teaching' ? '教学模式' : '训练模式'
}
function getDifficultyLabel(difficulty?: string, score?: number) {
const labelMap: Record<string, string> = {
easy: '简单',
medium: '中等',
hard: '困难'
}
const label = difficulty ? labelMap[difficulty] || difficulty : '难度未设置'
return score ? `${label} · ${score}` : label
}
function chooseCaseType(value: string) {
if (filters.case_type === value) return
filters.case_type = value
reloadCases()
}
function chooseDifficulty(value: string) {
if (filters.difficulty === value) return
filters.difficulty = value
reloadCases()
}
function searchCases() {
if (searchTimer) clearTimeout(searchTimer)
reloadCases()
}
function buildQuery(page: number) {
return {
search: keyword.value.trim(),
case_type: source.value === 'teaching' || source.value === 'teacher-task' ? undefined : filters.case_type,
difficulty: filters.difficulty,
department: departmentId.value,
page,
page_size: pageSize.value
}
}
async function loadCases(page = 1) {
const isFirstPage = page === 1
if ((loading.value && isFirstPage) || loadingMore.value) return
const seq = ++requestSeq
loading.value = isFirstPage
loadingMore.value = !isFirstPage
loadFailed.value = false
errorMessage.value = ''
try {
const result = await fetchCaseListPage(source.value, buildQuery(page))
if (seq !== requestSeq) return
cases.value = isFirstPage ? result.results : [...cases.value, ...result.results]
totalCount.value = result.count
currentPage.value = page
departmentName.value = source.value === 'specialty'
? cases.value.find(item => item.department)?.department || departmentName.value
: ''
} catch (error) {
if (seq !== requestSeq) return
loadFailed.value = isFirstPage
errorMessage.value = error instanceof Error ? error.message : '病例列表加载失败'
showToast(errorMessage.value)
} finally {
if (seq === requestSeq) {
loading.value = false
loadingMore.value = false
}
}
}
function reloadCases() {
currentPage.value = 1
cases.value = []
totalCount.value = 0
loadCases(1)
}
function loadMoreCases() {
if (loading.value || loadingMore.value || !hasMore.value) return
loadCases(currentPage.value + 1)
}
function selectCase(item: ClinicalCase) {
uni.setStorageSync('clinical-thinking-selected-case', item)
uni.setStorageSync('clinical-thinking-case-mode', item.mode)
uni.navigateTo({
url: item.mode === 'teaching' ? '/pages/teaching/teaching' : '/pages/scenario/scenario'
})
}
function showToast(message: string) {
if (toastTimer) clearTimeout(toastTimer)
toastMessage.value = message
toastVisible.value = true
toastTimer = setTimeout(() => {
toastVisible.value = false
}, 2200)
}
onLoad(query => {
const querySource = query?.source
const queryMode = query?.mode
const queryDepartment = query?.department
const queryPageSize = Number(query?.page_size)
if (isCaseListSource(querySource)) {
source.value = querySource
} else if (queryMode === 'teaching') {
source.value = 'teaching'
}
if (typeof queryDepartment === 'string' && queryDepartment.trim()) {
departmentId.value = queryDepartment
}
if (Number.isInteger(queryPageSize) && queryPageSize > 0) {
pageSize.value = queryPageSize
}
})
onMounted(loadCases)
onUnmounted(() => {
if (toastTimer) clearTimeout(toastTimer)
if (searchTimer) clearTimeout(searchTimer)
})
function isCaseListSource(value: unknown): value is CaseListSource {
return value === 'recommended' ||
value === 'specialty' ||
value === 'weak' ||
value === 'teaching' ||
value === 'teacher-task'
}
</script>
<style>
page {
min-height: 100%;
background: #e7e8f0;
}
.cases-page {
min-height: 100vh;
background: #e7e8f0;
color: #191c21;
font-family: Inter, -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', Arial, sans-serif;
-webkit-tap-highlight-color: transparent;
}
.case-shell {
position: relative;
min-height: 100vh;
overflow: hidden;
background: #f2f3fb;
display: flex;
flex-direction: column;
}
.case-header {
position: absolute;
left: 0;
right: 0;
top: 0;
z-index: 20;
box-sizing: border-box;
height: 56px;
padding: 0 20px;
border-bottom: 1px solid rgba(194, 198, 212, 0.3);
background: #ffffff;
box-shadow: 0 2px 8px rgba(25, 28, 33, 0.04);
display: flex;
align-items: center;
}
.header-spacer {
flex: 1;
}
.icon-button {
width: 40px;
height: 40px;
padding: 0;
border-radius: 50%;
background: transparent;
display: flex;
align-items: center;
justify-content: center;
}
.icon-button::after {
border: 0;
}
.icon-button:active {
background: rgba(25, 28, 33, 0.05);
}
.home-button {
margin-left: 4px;
}
.settings-icon,
.home-icon,
.account-icon,
.search-icon {
background: #424752;
}
.settings-icon {
width: 22px;
height: 22px;
-webkit-mask: url("data:image/svg+xml,%3Csvg%20viewBox='0%200%2024%2024'%20xmlns='http://www.w3.org/2000/svg'%3E%3Cpath%20d='M19.43%2012.98c.04-.32.07-.65.07-.98s-.02-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.37-.31-.6-.22l-2.49%201c-.52-.4-1.08-.73-1.69-.98L14.5%202.42C14.47%202.18%2014.25%202%2014%202h-4c-.25%200-.46.18-.5.42l-.38%202.65c-.61.25-1.17.59-1.69.98l-2.49-1c-.23-.08-.48%200-.6.22l-2%203.46c-.13.22-.07.49.12.64l2.11%201.65c-.04.32-.08.65-.08.98s.03.66.08.98l-2.11%201.65c-.19.15-.24.42-.12.64l2%203.46c.12.22.37.31.6.22l2.49-1c.52.4%201.08.73%201.69.98l.38%202.65c.04.24.25.42.5.42h4c.25%200%20.46-.18.5-.42l.38-2.65c.61-.25%201.17-.58%201.69-.98l2.49%201c.23.08.48%200%20.6-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.65zM12%2015.5A3.5%203.5%200%201%201%2012%208a3.5%203.5%200%200%201%200%207.5z'/%3E%3C/svg%3E") center / contain no-repeat;
mask: url("data:image/svg+xml,%3Csvg%20viewBox='0%200%2024%2024'%20xmlns='http://www.w3.org/2000/svg'%3E%3Cpath%20d='M19.43%2012.98c.04-.32.07-.65.07-.98s-.02-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.37-.31-.6-.22l-2.49%201c-.52-.4-1.08-.73-1.69-.98L14.5%202.42C14.47%202.18%2014.25%202%2014%202h-4c-.25%200-.46.18-.5.42l-.38%202.65c-.61.25-1.17.59-1.69.98l-2.49-1c-.23-.08-.48%200-.6.22l-2%203.46c-.13.22-.07.49.12.64l2.11%201.65c-.04.32-.08.65-.08.98s.03.66.08.98l-2.11%201.65c-.19.15-.24.42-.12.64l2%203.46c.12.22.37.31.6.22l2.49-1c.52.4%201.08.73%201.69.98l.38%202.65c.04.24.25.42.5.42h4c.25%200%20.46-.18.5-.42l.38-2.65c.61-.25%201.17-.58%201.69-.98l2.49%201c.23.08.48%200%20.6-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.65zM12%2015.5A3.5%203.5%200%201%201%2012%208a3.5%203.5%200%200%201%200%207.5z'/%3E%3C/svg%3E") center / contain no-repeat;
}
.home-icon {
width: 23px;
height: 23px;
-webkit-mask: url("data:image/svg+xml,%3Csvg%20viewBox='0%200%2024%2024'%20xmlns='http://www.w3.org/2000/svg'%3E%3Cpath%20d='M10%2020v-6h4v6h5v-8h3L12%203%202%2012h3v8h5z'/%3E%3C/svg%3E") center / contain no-repeat;
mask: url("data:image/svg+xml,%3Csvg%20viewBox='0%200%2024%2024'%20xmlns='http://www.w3.org/2000/svg'%3E%3Cpath%20d='M10%2020v-6h4v6h5v-8h3L12%203%202%2012h3v8h5z'/%3E%3C/svg%3E") center / contain no-repeat;
}
.account-icon {
width: 24px;
height: 24px;
-webkit-mask: url("data:image/svg+xml,%3Csvg%20viewBox='0%200%2024%2024'%20xmlns='http://www.w3.org/2000/svg'%3E%3Cpath%20d='M12%202a10%2010%200%201%200%200%2020%2010%2010%200%200%200%200-20zm0%203a3.5%203.5%200%201%201%200%207%203.5%203.5%200%200%201%200-7zm0%2015a8%208%200%200%201-6.4-3.2c1.18-2.02%203.57-3.3%206.4-3.3s5.22%201.28%206.4%203.3A8%208%200%200%201%2012%2020z'/%3E%3C/svg%3E") center / contain no-repeat;
mask: url("data:image/svg+xml,%3Csvg%20viewBox='0%200%2024%2024'%20xmlns='http://www.w3.org/2000/svg'%3E%3Cpath%20d='M12%202a10%2010%200%201%200%200%2020%2010%2010%200%200%200%200-20zm0%203a3.5%203.5%200%201%201%200%207%203.5%203.5%200%200%201%200-7zm0%2015a8%208%200%200%201-6.4-3.2c1.18-2.02%203.57-3.3%206.4-3.3s5.22%201.28%206.4%203.3A8%208%200%200%201%2012%2020z'/%3E%3C/svg%3E") center / contain no-repeat;
}
.case-content {
box-sizing: border-box;
height: 100vh;
padding: 76px 20px 24px;
}
.list-hero {
margin-bottom: 16px;
display: flex;
flex-direction: column;
gap: 4px;
}
.list-title {
color: #191c21;
font-size: 22px;
line-height: 30px;
font-weight: 700;
}
.list-subtitle {
color: #727783;
font-size: 13px;
line-height: 20px;
}
.search-row {
margin-bottom: 12px;
display: flex;
gap: 8px;
}
.search-box {
position: relative;
flex: 1;
min-width: 0;
height: 40px;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 2px 8px rgba(25, 28, 33, 0.04);
}
.search-button {
flex: 0 0 auto;
width: 64px;
height: 40px;
margin: 0;
padding: 0;
border-radius: 8px;
background: #00478d;
color: #ffffff;
font-size: 14px;
line-height: 40px;
font-weight: 700;
}
.search-button::after,
.filter-chip::after,
.retry-button::after {
border: 0;
}
.search-button[disabled] {
background: #c2c6d4;
color: #ffffff;
opacity: 1;
}
.search-icon {
position: absolute;
left: 12px;
top: 50%;
width: 20px;
height: 20px;
background: #727783;
transform: translateY(-50%);
-webkit-mask: url("data:image/svg+xml,%3Csvg%20viewBox='0%200%2024%2024'%20xmlns='http://www.w3.org/2000/svg'%3E%3Cpath%20d='M9.5%203a6.5%206.5%200%200%200%200%2013c1.61%200%203.09-.59%204.23-1.57L19.29%2020%2020.7%2018.59l-5.56-5.56A6.47%206.47%200%200%200%2016%209.5%206.5%206.5%200%200%200%209.5%203zm0%202a4.5%204.5%200%201%201%200%209%204.5%204.5%200%200%201%200-9z'/%3E%3C/svg%3E") center / contain no-repeat;
mask: url("data:image/svg+xml,%3Csvg%20viewBox='0%200%2024%2024'%20xmlns='http://www.w3.org/2000/svg'%3E%3Cpath%20d='M9.5%203a6.5%206.5%200%200%200%200%2013c1.61%200%203.09-.59%204.23-1.57L19.29%2020%2020.7%2018.59l-5.56-5.56A6.47%206.47%200%200%200%2016%209.5%206.5%206.5%200%200%200%209.5%203zm0%202a4.5%204.5%200%201%201%200%209%204.5%204.5%200%200%201%200-9z'/%3E%3C/svg%3E") center / contain no-repeat;
}
.search-input {
box-sizing: border-box;
width: 100%;
height: 40px;
padding: 0 16px 0 40px;
border: 0;
background: transparent;
color: #191c21;
font-size: 14px;
line-height: 20px;
}
.search-placeholder {
color: #c2c6d4;
}
.filter-row {
margin-bottom: 8px;
display: flex;
gap: 8px;
overflow-x: auto;
white-space: nowrap;
}
.filter-row.compact {
margin-bottom: 12px;
}
.filter-chip {
flex: 0 0 auto;
min-width: 64px;
height: 32px;
margin: 0;
padding: 0 12px;
border: 1px solid rgba(194, 198, 212, 0.5);
border-radius: 999px;
background: #ffffff;
color: #424752;
font-size: 12px;
line-height: 18px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.filter-chip.active {
border-color: rgba(0, 71, 141, 0.28);
background: #d6e3ff;
color: #00478d;
}
.summary-row {
margin: 0 0 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: #727783;
font-size: 12px;
line-height: 18px;
}
.case-list {
display: flex;
flex-direction: column;
gap: 12px;
padding-bottom: 20px;
}
.case-card {
padding: 12px;
border: 1px solid rgba(194, 198, 212, 0.3);
border-radius: 8px;
background: #ffffff;
box-shadow: 0 2px 8px rgba(25, 28, 33, 0.04);
display: flex;
flex-direction: column;
gap: 8px;
}
.case-card.mode-teaching {
border-color: rgba(0, 71, 141, 0.22);
}
.case-card:active {
transform: scale(0.99);
}
.case-main {
display: flex;
gap: 12px;
}
.patient-avatar {
position: relative;
flex: 0 0 auto;
width: 70px;
height: 70px;
border-radius: 4px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 28px;
line-height: 1;
font-weight: 700;
}
.patient-avatar::before {
content: '';
position: absolute;
left: 50%;
top: 15px;
width: 20px;
height: 20px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.42);
transform: translateX(-50%);
}
.patient-avatar::after {
content: '';
position: absolute;
left: 50%;
bottom: -7px;
width: 50px;
height: 34px;
border-radius: 50% 50% 0 0;
background: rgba(255, 255, 255, 0.28);
transform: translateX(-50%);
}
.patient-avatar text {
position: relative;
z-index: 1;
}
.avatar-blue {
background: linear-gradient(135deg, #7da6d9, #00478d);
}
.avatar-teal {
background: linear-gradient(135deg, #5dd8e2, #006970);
}
.avatar-pink {
background: linear-gradient(135deg, #ffb3c7, #a63a5f);
}
.avatar-orange {
background: linear-gradient(135deg, #ffb691, #9f4300);
}
.avatar-purple {
background: linear-gradient(135deg, #b5a6ff, #5f4bb6);
}
.avatar-green {
background: linear-gradient(135deg, #9edc9a, #347a35);
}
.case-info {
flex: 1;
min-width: 0;
}
.case-title {
display: -webkit-box;
margin-bottom: 4px;
overflow: hidden;
color: #191c21;
font-size: 16px;
line-height: 20px;
font-weight: 600;
text-overflow: ellipsis;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.case-meta {
display: block;
margin-bottom: 4px;
color: #424752;
font-size: 13px;
line-height: 20px;
}
.case-desc {
display: -webkit-box;
margin-bottom: 6px;
overflow: hidden;
color: #727783;
font-size: 12px;
line-height: 18px;
text-overflow: ellipsis;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.tag-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.case-tag {
max-width: 96px;
padding: 2px 8px;
border-radius: 999px;
background: #ecedf6;
color: #424752;
font-size: 11px;
line-height: 16px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.case-footer {
display: flex;
align-items: flex-end;
justify-content: space-between;
}
.case-no {
color: #c2c6d4;
font-size: 11px;
line-height: 16px;
font-weight: 600;
}
.mode-badge {
flex: 0 0 auto;
height: 24px;
padding: 0 8px;
border-radius: 999px;
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
line-height: 16px;
font-weight: 700;
}
.mode-badge-training {
background: #ecedf6;
color: #424752;
}
.mode-badge-teaching {
background: #d6e3ff;
color: #00478d;
}
.mode-icon {
width: 14px;
height: 14px;
background: currentColor;
}
.mode-icon-training {
-webkit-mask: url("data:image/svg+xml,%3Csvg%20viewBox='0%200%2024%2024'%20xmlns='http://www.w3.org/2000/svg'%3E%3Cpath%20d='M9%2016.2l-3.5-3.5L4%2014.2%209%2019%2020%208l-1.5-1.5L9%2016.2z'/%3E%3C/svg%3E") center / contain no-repeat;
mask: url("data:image/svg+xml,%3Csvg%20viewBox='0%200%2024%2024'%20xmlns='http://www.w3.org/2000/svg'%3E%3Cpath%20d='M9%2016.2l-3.5-3.5L4%2014.2%209%2019%2020%208l-1.5-1.5L9%2016.2z'/%3E%3C/svg%3E") center / contain no-repeat;
}
.mode-icon-teaching {
-webkit-mask: url("data:image/svg+xml,%3Csvg%20viewBox='0%200%2024%2024'%20xmlns='http://www.w3.org/2000/svg'%3E%3Cpath%20d='M12%203L1%209l11%206%209-4.91V17h2V9L12%203zm0%202.28L18.85%209%2012%2012.72%205.15%209%2012%205.28zM5%2013.18v3.2C6.63%2018.24%209.26%2019%2012%2019s5.37-.76%207-2.62v-3.2l-7%203.82-7-3.82z'/%3E%3C/svg%3E") center / contain no-repeat;
mask: url("data:image/svg+xml,%3Csvg%20viewBox='0%200%2024%2024'%20xmlns='http://www.w3.org/2000/svg'%3E%3Cpath%20d='M12%203L1%209l11%206%209-4.91V17h2V9L12%203zm0%202.28L18.85%209%2012%2012.72%205.15%209%2012%205.28zM5%2013.18v3.2C6.63%2018.24%209.26%2019%2012%2019s5.37-.76%207-2.62v-3.2l-7%203.82-7-3.82z'/%3E%3C/svg%3E") center / contain no-repeat;
}
.stat-row {
padding-top: 8px;
border-top: 1px solid rgba(194, 198, 212, 0.22);
display: flex;
flex-wrap: wrap;
gap: 8px;
color: #727783;
font-size: 11px;
line-height: 16px;
font-weight: 600;
}
.empty-state {
min-height: 160px;
padding: 40px 0;
text-align: center;
color: #727783;
font-size: 14px;
line-height: 20px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
}
.retry-button {
width: 96px;
height: 36px;
margin: 0;
padding: 0;
border-radius: 8px;
background: #00478d;
color: #ffffff;
font-size: 13px;
line-height: 36px;
font-weight: 700;
}
.load-more-state {
padding: 8px 0 16px;
text-align: center;
color: #a0a5b2;
font-size: 12px;
line-height: 18px;
}
.spinner {
width: 22px;
height: 22px;
border: 2px solid rgba(0, 71, 141, 0.18);
border-top-color: #00478d;
border-radius: 50%;
box-sizing: border-box;
animation: spin 1s linear infinite;
}
.toast {
position: fixed;
left: 50%;
bottom: 96px;
z-index: 100;
max-width: 320px;
padding: 12px 24px;
border-radius: 999px;
background: #2e3037;
color: #eff0f8;
font-size: 14px;
line-height: 20px;
font-weight: 600;
text-align: center;
pointer-events: none;
opacity: 0;
transform: translate(-50%, 16px);
transition: opacity 0.3s ease, transform 0.3s ease;
}
.toast.visible {
opacity: 1;
transform: translate(-50%, 0);
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>