feat: 病例列表联调

This commit is contained in:
王天骄
2026-06-13 06:05:37 +08:00
parent 37716f200b
commit bc2e03920e
13 changed files with 1518 additions and 258 deletions
+443 -30
View File
@@ -14,7 +14,12 @@
</button>
</view>
<scroll-view class="case-content" scroll-y>
<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>
@@ -22,15 +27,47 @@
class="search-input"
v-model="keyword"
type="text"
placeholder="科室、主诉模糊搜索"
: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 filteredCases"
v-for="item in cases"
:key="item.id"
class="case-card"
:class="`mode-${item.mode}`"
@@ -43,8 +80,18 @@
<view class="case-info">
<text class="case-title">{{ item.title }}</text>
<text class="case-meta">
{{ item.patientName }}{{ item.gender }}{{ item.age }}{{ item.department }}{{ item.scene }}
{{ 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">
@@ -54,11 +101,35 @@
<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="filteredCases.length === 0" class="empty-state">
<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>
@@ -68,9 +139,9 @@
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { fetchCaseList, type CaseMode, type ClinicalCase } from '../../api/cases'
import { fetchCaseListPage, type CaseListSource, type CaseMode, type ClinicalCase } from '../../api/cases'
import { createHomeNavigator, createProfileOpener, createSettingsOpener } from '../../api/navigation'
const emit = defineEmits<{
@@ -85,38 +156,168 @@ 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 modeFilter = ref<CaseMode | ''>('')
const filters = reactive({
case_type: '',
difficulty: ''
})
let toastTimer: ReturnType<typeof setTimeout> | null = null
let searchTimer: ReturnType<typeof setTimeout> | null = null
let requestSeq = 0
const filteredCases = computed(() => {
const value = keyword.value.trim().toLowerCase()
const source = modeFilter.value ? cases.value.filter(item => item.mode === modeFilter.value) : cases.value
if (!value) return source
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: '搜索教师任务病例'
}
}
return source.filter(item => {
return [
item.title,
item.patientName,
item.gender,
String(item.age),
item.department,
item.scene,
item.caseNo
].some(field => field.toLowerCase().includes(value))
})
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 loadCases() {
fetchCaseList().then(result => {
cases.value = result
})
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) {
@@ -137,9 +338,23 @@ function showToast(message: string) {
}
onLoad(query => {
const mode = query?.mode
if (mode === 'teaching' || mode === 'training') {
modeFilter.value = mode
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
}
})
@@ -147,7 +362,16 @@ 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>
@@ -250,18 +474,68 @@ page {
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;
@@ -290,6 +564,53 @@ page {
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;
@@ -409,11 +730,45 @@ page {
}
.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;
@@ -466,12 +821,61 @@ page {
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 {
@@ -498,4 +902,13 @@ page {
opacity: 1;
transform: translate(-50%, 0);
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>