fix: 解决查看病历字段缺失问题
This commit is contained in:
+524
-17
@@ -80,19 +80,174 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<el-drawer v-model="detailDrawerVisible" :title="detailDrawerTitle" size="50%" destroy-on-close>
|
<el-drawer v-model="detailDrawerVisible" :title="detailDrawerTitle" size="72%" destroy-on-close @closed="resetDetailForm">
|
||||||
<div v-loading="detailLoading" class="case-detail-drawer">
|
<div v-loading="detailLoading" class="case-detail-drawer">
|
||||||
<el-descriptions v-if="detailCase" :column="2" border>
|
<el-form
|
||||||
<el-descriptions-item label="ID">{{ detailDisplay.id }}</el-descriptions-item>
|
v-if="detailCase"
|
||||||
<el-descriptions-item label="类型">{{ caseTypeLabel(detailDisplay.caseType) }}</el-descriptions-item>
|
class="case-create-form case-detail-form"
|
||||||
<el-descriptions-item label="状态">{{ publishStatusLabel(detailCase.publishStatus) }}</el-descriptions-item>
|
:model="detailForm"
|
||||||
<el-descriptions-item label="难度">{{ detailDisplay.difficulty }}</el-descriptions-item>
|
disabled
|
||||||
<el-descriptions-item label="科室">{{ detailDisplay.department }}</el-descriptions-item>
|
label-position="top"
|
||||||
<el-descriptions-item label="患者">{{ detailDisplay.patient }}</el-descriptions-item>
|
>
|
||||||
<el-descriptions-item label="预计时长">{{ detailDisplay.estimatedMinutes }}</el-descriptions-item>
|
<div class="case-form-section">
|
||||||
<el-descriptions-item label="主诉" :span="2">{{ detailDisplay.chiefComplaint }}</el-descriptions-item>
|
<div class="case-section-title">
|
||||||
<el-descriptions-item label="描述" :span="2">{{ detailDisplay.description }}</el-descriptions-item>
|
<h3>基础信息</h3>
|
||||||
</el-descriptions>
|
<el-tag type="warning">ID {{ detailDisplay.id }} / {{ publishStatusLabel(detailCase.publishStatus) }}</el-tag>
|
||||||
|
</div>
|
||||||
|
<el-row :gutter="14">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="病例标题">
|
||||||
|
<el-input v-model="detailForm.title" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="病例类型">
|
||||||
|
<el-select v-model="detailForm.case_type">
|
||||||
|
<el-option label="传统病例" value="traditional" />
|
||||||
|
<el-option label="教学病例" value="teaching" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-form-item label="机构">
|
||||||
|
<el-input v-model="detailForm.institution_name" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-form-item label="科室">
|
||||||
|
<el-input v-model="detailForm.department_name" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-form-item label="难度">
|
||||||
|
<el-select v-model="detailForm.difficulty">
|
||||||
|
<el-option label="低" value="低" />
|
||||||
|
<el-option label="中" value="中" />
|
||||||
|
<el-option label="高" value="高" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="主诉">
|
||||||
|
<el-input v-model="detailForm.chief_complaint" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-form-item label="患者年龄">
|
||||||
|
<el-input-number v-model="detailForm.patient_age" :min="0" :max="130" :precision="0" :controls="false" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-form-item label="患者性别">
|
||||||
|
<el-select v-model="detailForm.patient_gender" clearable>
|
||||||
|
<el-option label="男" value="male" />
|
||||||
|
<el-option label="女" value="female" />
|
||||||
|
<el-option label="未知" value="unknown" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-form-item label="标签">
|
||||||
|
<el-input v-model="detailForm.tags" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-form-item label="ICD 编码">
|
||||||
|
<el-input v-model="detailForm.icd_codes" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="4">
|
||||||
|
<el-form-item label="预计分钟">
|
||||||
|
<el-input-number v-model="detailForm.estimated_minutes" :min="1" :precision="0" :controls="false" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="4">
|
||||||
|
<el-form-item label="OSCE">
|
||||||
|
<el-switch v-model="detailForm.osce_enabled" active-text="开启" inactive-text="关闭" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="24">
|
||||||
|
<el-form-item label="描述">
|
||||||
|
<el-input v-model="detailForm.description" type="textarea" :rows="3" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="case-form-section">
|
||||||
|
<h3>{{ caseTypeLabel(detailForm.case_type) }}内容</h3>
|
||||||
|
<template v-if="detailForm.case_type === 'traditional'">
|
||||||
|
<el-row :gutter="14">
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-form-item label="标准诊断">
|
||||||
|
<el-input v-model="detailForm.traditional_standard_diagnosis" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-form-item label="标准治疗">
|
||||||
|
<el-input v-model="detailForm.traditional_standard_treatment" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-form-item label="指南依据">
|
||||||
|
<el-input v-model="detailForm.traditional_guideline_reference" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<el-row :gutter="14">
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-form-item label="教学目标">
|
||||||
|
<el-input v-model="detailForm.teaching_learning_objectives" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-form-item label="教学重点">
|
||||||
|
<el-input v-model="detailForm.teaching_key_points" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-form-item label="参考答案">
|
||||||
|
<el-input v-model="detailForm.teaching_reference_answer" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="visibleScoringRules.length" class="case-form-section">
|
||||||
|
<div class="case-section-title">
|
||||||
|
<h3>评分规则</h3>
|
||||||
|
</div>
|
||||||
|
<el-table :data="visibleScoringRules" border size="small" empty-text="暂无评分规则">
|
||||||
|
<el-table-column prop="dimension" label="维度" min-width="160" />
|
||||||
|
<el-table-column label="权重" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatScoreWeight(row.score_weight) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="scoring_standard" label="评分标准" min-width="260" show-overflow-tooltip />
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="case-form-section">
|
||||||
|
<div class="case-section-title">
|
||||||
|
<h3>检查/检验项目</h3>
|
||||||
|
</div>
|
||||||
|
<div v-if="!detailForm.exam_items.length" class="case-empty-line">暂无检查/检验项目</div>
|
||||||
|
<div v-for="(item, index) in detailForm.exam_items" :key="`review-detail-item-${index}`" class="exam-item-row review-exam-item-row">
|
||||||
|
<el-input v-model="item.item_code" />
|
||||||
|
<el-input v-model="item.item_name" />
|
||||||
|
<el-input v-model="item.item_type" />
|
||||||
|
<el-input v-model="item.result_text" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<div class="drawer-form-footer">
|
||||||
|
<el-button @click="detailDrawerVisible = false">关闭</el-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-drawer>
|
</el-drawer>
|
||||||
</div>
|
</div>
|
||||||
@@ -111,6 +266,46 @@ import {
|
|||||||
} from '@/api/cases'
|
} from '@/api/cases'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
|
|
||||||
|
type ReviewCaseType = 'traditional' | 'teaching'
|
||||||
|
|
||||||
|
interface ScoringRuleForm {
|
||||||
|
dimension: string
|
||||||
|
score_weight: number
|
||||||
|
ai_auto_score: boolean
|
||||||
|
scoring_standard: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExamItemForm {
|
||||||
|
item_code: string
|
||||||
|
item_name: string
|
||||||
|
item_type: string
|
||||||
|
result_text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReviewCaseDetailForm {
|
||||||
|
title: string
|
||||||
|
case_type: ReviewCaseType
|
||||||
|
institution_name: string
|
||||||
|
department_name: string
|
||||||
|
difficulty: string
|
||||||
|
chief_complaint: string
|
||||||
|
description: string
|
||||||
|
patient_age?: number
|
||||||
|
patient_gender: string
|
||||||
|
tags: string
|
||||||
|
icd_codes: string
|
||||||
|
estimated_minutes?: number
|
||||||
|
osce_enabled: boolean
|
||||||
|
traditional_standard_diagnosis: string
|
||||||
|
traditional_standard_treatment: string
|
||||||
|
traditional_guideline_reference: string
|
||||||
|
teaching_learning_objectives: string
|
||||||
|
teaching_key_points: string
|
||||||
|
teaching_reference_answer: string
|
||||||
|
scoring_rules: ScoringRuleForm[]
|
||||||
|
exam_items: ExamItemForm[]
|
||||||
|
}
|
||||||
|
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const publishing = ref(false)
|
const publishing = ref(false)
|
||||||
@@ -119,6 +314,29 @@ const detailDrawerVisible = ref(false)
|
|||||||
const cases = ref<CaseListItem[]>([])
|
const cases = ref<CaseListItem[]>([])
|
||||||
const detailCase = ref<CaseListItem | null>(null)
|
const detailCase = ref<CaseListItem | null>(null)
|
||||||
const caseDetail = ref<unknown>(null)
|
const caseDetail = ref<unknown>(null)
|
||||||
|
const detailForm = reactive<ReviewCaseDetailForm>({
|
||||||
|
title: '',
|
||||||
|
case_type: 'traditional',
|
||||||
|
institution_name: '',
|
||||||
|
department_name: '',
|
||||||
|
difficulty: '',
|
||||||
|
chief_complaint: '',
|
||||||
|
description: '',
|
||||||
|
patient_age: undefined,
|
||||||
|
patient_gender: '',
|
||||||
|
tags: '',
|
||||||
|
icd_codes: '',
|
||||||
|
estimated_minutes: undefined,
|
||||||
|
osce_enabled: false,
|
||||||
|
traditional_standard_diagnosis: '',
|
||||||
|
traditional_standard_treatment: '',
|
||||||
|
traditional_guideline_reference: '',
|
||||||
|
teaching_learning_objectives: '',
|
||||||
|
teaching_key_points: '',
|
||||||
|
teaching_reference_answer: '',
|
||||||
|
scoring_rules: [],
|
||||||
|
exam_items: []
|
||||||
|
})
|
||||||
|
|
||||||
const filters = reactive({
|
const filters = reactive({
|
||||||
search: '',
|
search: '',
|
||||||
@@ -131,6 +349,9 @@ const pagination = reactive({
|
|||||||
|
|
||||||
const detailDrawerTitle = computed(() => (detailCase.value ? `病例详情:${detailCase.value.title}` : '病例详情'))
|
const detailDrawerTitle = computed(() => (detailCase.value ? `病例详情:${detailCase.value.title}` : '病例详情'))
|
||||||
const detailDisplay = computed(() => createDetailDisplay(detailCase.value, caseDetail.value))
|
const detailDisplay = computed(() => createDetailDisplay(detailCase.value, caseDetail.value))
|
||||||
|
const visibleScoringRules = computed(() =>
|
||||||
|
detailForm.scoring_rules.filter(rule => rule.dimension.trim() || rule.scoring_standard.trim())
|
||||||
|
)
|
||||||
|
|
||||||
async function loadReviewCases() {
|
async function loadReviewCases() {
|
||||||
if (!appStore.token) {
|
if (!appStore.token) {
|
||||||
@@ -184,6 +405,7 @@ async function openDetailDrawer(row: CaseListItem) {
|
|||||||
token: appStore.token,
|
token: appStore.token,
|
||||||
id: row.id
|
id: row.id
|
||||||
})
|
})
|
||||||
|
fillDetailForm(row, caseDetail.value)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(error instanceof Error ? error.message : '获取病例详情失败')
|
ElMessage.error(error instanceof Error ? error.message : '获取病例详情失败')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -218,6 +440,68 @@ async function confirmPublishCase(row: CaseListItem) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetDetailForm() {
|
||||||
|
detailCase.value = null
|
||||||
|
caseDetail.value = null
|
||||||
|
resetReviewDetailForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetReviewDetailForm() {
|
||||||
|
detailForm.title = ''
|
||||||
|
detailForm.case_type = 'traditional'
|
||||||
|
detailForm.institution_name = ''
|
||||||
|
detailForm.department_name = ''
|
||||||
|
detailForm.difficulty = ''
|
||||||
|
detailForm.chief_complaint = ''
|
||||||
|
detailForm.description = ''
|
||||||
|
detailForm.patient_age = undefined
|
||||||
|
detailForm.patient_gender = ''
|
||||||
|
detailForm.tags = ''
|
||||||
|
detailForm.icd_codes = ''
|
||||||
|
detailForm.estimated_minutes = undefined
|
||||||
|
detailForm.osce_enabled = false
|
||||||
|
detailForm.traditional_standard_diagnosis = ''
|
||||||
|
detailForm.traditional_standard_treatment = ''
|
||||||
|
detailForm.traditional_guideline_reference = ''
|
||||||
|
detailForm.teaching_learning_objectives = ''
|
||||||
|
detailForm.teaching_key_points = ''
|
||||||
|
detailForm.teaching_reference_answer = ''
|
||||||
|
detailForm.scoring_rules = []
|
||||||
|
detailForm.exam_items = []
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillDetailForm(row: CaseListItem, fullData: unknown) {
|
||||||
|
resetReviewDetailForm()
|
||||||
|
|
||||||
|
const record = getDetailRecord(fullData)
|
||||||
|
const traditional = getRecord(getFirst(record, ['traditional']))
|
||||||
|
const teaching = getRecord(getFirst(record, ['teaching']))
|
||||||
|
const scoringRules = getScoringRules(fullData, record)
|
||||||
|
const examItems = getExamItems(fullData, record)
|
||||||
|
|
||||||
|
detailForm.title = getString(record, ['title', 'name', 'case_title', 'caseTitle'], row.title)
|
||||||
|
detailForm.case_type = normalizeCaseType(getString(record, ['case_type', 'caseType'], row.caseType), normalizeCaseType(row.caseType, 'traditional'))
|
||||||
|
detailForm.institution_name = getString(record, ['institution_name', 'institutionName'], row.institutionName || row.institutionId)
|
||||||
|
detailForm.department_name = getString(record, ['department_name', 'departmentName'], row.departmentName || row.departmentId)
|
||||||
|
detailForm.difficulty = getString(record, ['difficulty'], row.difficulty)
|
||||||
|
detailForm.chief_complaint = getString(record, ['chief_complaint', 'chiefComplaint'], row.chiefComplaint)
|
||||||
|
detailForm.description = getString(record, ['description', 'summary', 'content'])
|
||||||
|
detailForm.patient_age = getNumber(record, ['patient_age', 'patientAge']) ?? undefined
|
||||||
|
detailForm.patient_gender = normalizeGender(getFirst(record, ['patient_gender', 'patientGender']))
|
||||||
|
detailForm.tags = getStringList(record, ['tags', 'tag_list', 'tagList']).join(', ')
|
||||||
|
detailForm.icd_codes = getStringList(record, ['icd_codes', 'icdCodes', 'icd_list', 'icdList']).join(', ')
|
||||||
|
detailForm.estimated_minutes = getNumber(record, ['estimated_minutes', 'estimatedMinutes']) ?? row.estimatedMinutes ?? undefined
|
||||||
|
detailForm.osce_enabled = getBoolean(record, ['osce_enabled', 'osceEnabled'])
|
||||||
|
detailForm.traditional_standard_diagnosis = getString(traditional, ['standard_diagnosis', 'standardDiagnosis'])
|
||||||
|
detailForm.traditional_standard_treatment = getString(traditional, ['standard_treatment', 'standardTreatment'])
|
||||||
|
detailForm.traditional_guideline_reference = getString(traditional, ['guideline_reference', 'guidelineReference'])
|
||||||
|
detailForm.teaching_learning_objectives = getString(teaching, ['learning_objectives', 'learningObjectives'])
|
||||||
|
detailForm.teaching_key_points = getString(teaching, ['key_points', 'keyPoints'])
|
||||||
|
detailForm.teaching_reference_answer = getString(teaching, ['reference_answer', 'referenceAnswer'])
|
||||||
|
detailForm.scoring_rules = scoringRules
|
||||||
|
detailForm.exam_items = examItems
|
||||||
|
}
|
||||||
|
|
||||||
function createDetailDisplay(row: CaseListItem | null, fullData: unknown) {
|
function createDetailDisplay(row: CaseListItem | null, fullData: unknown) {
|
||||||
const record = getDetailRecord(fullData)
|
const record = getDetailRecord(fullData)
|
||||||
const title = getDetailString(record, ['title', 'name']) || row?.title || '-'
|
const title = getDetailString(record, ['title', 'name']) || row?.title || '-'
|
||||||
@@ -244,16 +528,225 @@ function createDetailDisplay(row: CaseListItem | null, fullData: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getDetailRecord(value: unknown): Record<string, unknown> {
|
function getDetailRecord(value: unknown): Record<string, unknown> {
|
||||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
return findDetailRecord(value) || {}
|
||||||
return {}
|
}
|
||||||
|
|
||||||
|
function findDetailRecord(value: unknown, depth = 0): Record<string, unknown> | null {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value) || depth > 4) {
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const record = value as Record<string, unknown>
|
const record = value as Record<string, unknown>
|
||||||
if (record.data && typeof record.data === 'object' && !Array.isArray(record.data)) {
|
const preferredKeys = ['case', 'case_detail', 'caseDetail', 'case_info', 'caseInfo', 'detail', 'full', 'record', 'item', 'data', 'result']
|
||||||
return record.data as Record<string, unknown>
|
for (const key of preferredKeys) {
|
||||||
|
const found = findDetailRecord(record[key], depth + 1)
|
||||||
|
if (found) {
|
||||||
|
return found
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return record
|
if (hasCaseDetailFields(record)) {
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const child of Object.values(record)) {
|
||||||
|
const found = findDetailRecord(child, depth + 1)
|
||||||
|
if (found) {
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasCaseDetailFields(record: Record<string, unknown>) {
|
||||||
|
return [
|
||||||
|
'title',
|
||||||
|
'case_title',
|
||||||
|
'caseTitle',
|
||||||
|
'case_type',
|
||||||
|
'caseType',
|
||||||
|
'chief_complaint',
|
||||||
|
'chiefComplaint',
|
||||||
|
'description',
|
||||||
|
'summary',
|
||||||
|
'content',
|
||||||
|
'difficulty',
|
||||||
|
'estimated_minutes',
|
||||||
|
'estimatedMinutes',
|
||||||
|
'patient_age',
|
||||||
|
'patientAge',
|
||||||
|
'patient_gender',
|
||||||
|
'patientGender'
|
||||||
|
].some(key => record[key] !== undefined && record[key] !== null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRecord(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFirst(record: Record<string, unknown>, keys: string[]) {
|
||||||
|
for (const key of keys) {
|
||||||
|
if (record[key] !== undefined && record[key] !== null) {
|
||||||
|
return record[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function getString(record: Record<string, unknown>, keys: string[], fallback = '') {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = record[key]
|
||||||
|
if (typeof value === 'string' && value.trim()) return value.trim()
|
||||||
|
if (typeof value === 'number') return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNumber(record: Record<string, unknown>, keys: string[]) {
|
||||||
|
const value = getFirst(record, keys)
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if (typeof value === 'string' && value.trim() && Number.isFinite(Number(value))) {
|
||||||
|
return Number(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBoolean(record: Record<string, unknown>, keys: string[]) {
|
||||||
|
const value = getFirst(record, keys)
|
||||||
|
if (typeof value === 'boolean') return value
|
||||||
|
if (typeof value === 'number') return value === 1
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return ['true', '1', 'yes', 'y', 'on', '开启', '启用', '是'].includes(value.trim().toLowerCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStringList(record: Record<string, unknown>, keys: string[]) {
|
||||||
|
const value = getFirst(record, keys)
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value
|
||||||
|
.map(item => {
|
||||||
|
if (typeof item === 'string' || typeof item === 'number') {
|
||||||
|
return String(item).trim()
|
||||||
|
}
|
||||||
|
return getString(getRecord(item), ['name', 'title', 'code', 'value'])
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value
|
||||||
|
.split(/[,\n,;;]/)
|
||||||
|
.map(item => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
function getScoringRules(...sources: unknown[]): ScoringRuleForm[] {
|
||||||
|
const raw = findArray(['scoring_rules', 'scoringRules', 'score_rules', 'scoreRules', 'rules'], sources)
|
||||||
|
if (!Array.isArray(raw)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw
|
||||||
|
.map(item => {
|
||||||
|
const itemRecord = getRecord(item)
|
||||||
|
const scoreWeight = getNumber(itemRecord, ['score_weight', 'scoreWeight', 'weight'])
|
||||||
|
const rawAutoScore = getFirst(itemRecord, ['ai_auto_score', 'aiAutoScore'])
|
||||||
|
return {
|
||||||
|
dimension: getString(itemRecord, ['dimension', 'name']),
|
||||||
|
score_weight: scoreWeight ?? 1,
|
||||||
|
ai_auto_score: rawAutoScore === undefined ? true : getBoolean(itemRecord, ['ai_auto_score', 'aiAutoScore']),
|
||||||
|
scoring_standard: getString(itemRecord, ['scoring_standard', 'scoringStandard', 'standard', 'description', 'content'])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(item => item.dimension || item.scoring_standard)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExamItems(...sources: unknown[]): ExamItemForm[] {
|
||||||
|
const raw = findArray(['exam_items', 'examItems', 'exams', 'exam_list', 'examList', 'items'], sources)
|
||||||
|
if (!Array.isArray(raw)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw
|
||||||
|
.map(item => {
|
||||||
|
const itemRecord = getRecord(item)
|
||||||
|
return {
|
||||||
|
item_code: getString(itemRecord, ['item_code', 'itemCode', 'code']),
|
||||||
|
item_name: getString(itemRecord, ['item_name', 'itemName', 'name']),
|
||||||
|
item_type: getString(itemRecord, ['item_type', 'itemType', 'type']),
|
||||||
|
result_text: getString(itemRecord, ['result_text', 'resultText', 'result', 'value', 'content'])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(item => item.item_code || item.item_name || item.item_type || item.result_text)
|
||||||
|
}
|
||||||
|
|
||||||
|
function findArray(keys: string[], sources: unknown[]) {
|
||||||
|
for (const source of sources) {
|
||||||
|
const found = findArrayInSource(source, keys)
|
||||||
|
if (found) {
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function findArrayInSource(value: unknown, keys: string[], depth = 0): unknown[] | undefined {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value) || depth > 5) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = value as Record<string, unknown>
|
||||||
|
for (const key of keys) {
|
||||||
|
const nested = record[key]
|
||||||
|
if (Array.isArray(nested)) {
|
||||||
|
return nested
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferredKeys = ['data', 'case', 'case_detail', 'caseDetail', 'case_info', 'caseInfo', 'detail', 'full', 'result', 'record', 'item']
|
||||||
|
for (const key of preferredKeys) {
|
||||||
|
const found = findArrayInSource(record[key], keys, depth + 1)
|
||||||
|
if (found) {
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const child of Object.values(record)) {
|
||||||
|
const found = findArrayInSource(child, keys, depth + 1)
|
||||||
|
if (found) {
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCaseType(value: string, fallback: ReviewCaseType): ReviewCaseType {
|
||||||
|
return value === 'teaching' ? 'teaching' : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGender(value: unknown) {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = String(value).trim().toLowerCase()
|
||||||
|
if (['male', 'm', 'man', '1', '男'].includes(normalized)) return 'male'
|
||||||
|
if (['female', 'f', 'woman', '2', '女'].includes(normalized)) return 'female'
|
||||||
|
if (['unknown', 'unknow', '0', '未知', '不详'].includes(normalized)) return 'unknown'
|
||||||
|
|
||||||
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDetailString(record: Record<string, unknown>, keys: string[]) {
|
function getDetailString(record: Record<string, unknown>, keys: string[]) {
|
||||||
@@ -282,6 +775,14 @@ function patientGenderLabel(value: string) {
|
|||||||
return value || ''
|
return value || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatScoreWeight(value: number) {
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
return value <= 1 ? `${Math.round(value * 100)}%` : String(value)
|
||||||
|
}
|
||||||
|
|
||||||
function publishStatusLabel(status: CasePublishStatus | null) {
|
function publishStatusLabel(status: CasePublishStatus | null) {
|
||||||
if (status === 0) return '草稿'
|
if (status === 0) return '草稿'
|
||||||
if (status === 1) return '待审核'
|
if (status === 1) return '待审核'
|
||||||
@@ -306,3 +807,9 @@ function formatDateTime(value: string) {
|
|||||||
|
|
||||||
onMounted(loadReviewCases)
|
onMounted(loadReviewCases)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.review-exam-item-row {
|
||||||
|
grid-template-columns: minmax(130px, 0.8fr) minmax(140px, 1fr) minmax(110px, 0.7fr) minmax(150px, 1fr);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
+84
-25
@@ -550,7 +550,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="showPublishedScoringRules" class="case-form-section">
|
<div v-if="visibleScoringRules.length" class="case-form-section">
|
||||||
<div class="case-section-title">
|
<div class="case-section-title">
|
||||||
<h3>评分规则</h3>
|
<h3>评分规则</h3>
|
||||||
</div>
|
</div>
|
||||||
@@ -799,11 +799,8 @@ const searchPlaceholder = computed(() => (isContentAdmin.value ? '搜索病例'
|
|||||||
const detailDrawerTitle = computed(() => (detailCase.value ? `病例详情:${detailCase.value.title}` : '病例详情'))
|
const detailDrawerTitle = computed(() => (detailCase.value ? `病例详情:${detailCase.value.title}` : '病例详情'))
|
||||||
const detailDisplay = computed(() => createDetailDisplay(detailCase.value, caseDetail.value))
|
const detailDisplay = computed(() => createDetailDisplay(detailCase.value, caseDetail.value))
|
||||||
const canEditDetailCase = computed(() => detailCase.value?.publishStatus === 0)
|
const canEditDetailCase = computed(() => detailCase.value?.publishStatus === 0)
|
||||||
const showPublishedScoringRules = computed(() => detailCase.value?.publishStatus === 2)
|
|
||||||
const visibleScoringRules = computed(() =>
|
const visibleScoringRules = computed(() =>
|
||||||
showPublishedScoringRules.value
|
detailForm.scoring_rules.filter(rule => rule.dimension.trim() || rule.scoring_standard.trim())
|
||||||
? detailForm.scoring_rules.filter(rule => rule.dimension.trim() || rule.scoring_standard.trim())
|
|
||||||
: []
|
|
||||||
)
|
)
|
||||||
const relationsCurrentTitle = computed(() => {
|
const relationsCurrentTitle = computed(() => {
|
||||||
if (!relationsCase.value) {
|
if (!relationsCase.value) {
|
||||||
@@ -1503,8 +1500,8 @@ function fillCaseFormFromImportedPdf(result: ImportCasePdfResult) {
|
|||||||
const caseType = normalizeImportCaseType(getImportString(record, ['case_type', 'caseType']), result.caseType)
|
const caseType = normalizeImportCaseType(getImportString(record, ['case_type', 'caseType']), result.caseType)
|
||||||
const traditional = getImportRecord(getImportFirst(record, ['traditional']))
|
const traditional = getImportRecord(getImportFirst(record, ['traditional']))
|
||||||
const teaching = getImportRecord(getImportFirst(record, ['teaching']))
|
const teaching = getImportRecord(getImportFirst(record, ['teaching']))
|
||||||
const scoringRules = getImportScoringRules(record)
|
const scoringRules = getImportScoringRules(result.raw, result.data, record)
|
||||||
const examItems = getImportExamItems(record)
|
const examItems = getImportExamItems(result.raw, result.data, record)
|
||||||
|
|
||||||
caseForm.case_type = caseType
|
caseForm.case_type = caseType
|
||||||
caseForm.title = getImportString(record, ['title', 'name', 'case_title', 'caseTitle'])
|
caseForm.title = getImportString(record, ['title', 'name', 'case_title', 'caseTitle'])
|
||||||
@@ -1539,8 +1536,8 @@ function fillDetailForm(row: CaseListItem, fullData: unknown) {
|
|||||||
const record = getDetailRecord(fullData)
|
const record = getDetailRecord(fullData)
|
||||||
const traditional = getImportRecord(getImportFirst(record, ['traditional']))
|
const traditional = getImportRecord(getImportFirst(record, ['traditional']))
|
||||||
const teaching = getImportRecord(getImportFirst(record, ['teaching']))
|
const teaching = getImportRecord(getImportFirst(record, ['teaching']))
|
||||||
const scoringRules = getImportScoringRules(record)
|
const scoringRules = getImportScoringRules(fullData, record)
|
||||||
const examItems = getImportExamItems(record)
|
const examItems = getImportExamItems(fullData, record)
|
||||||
|
|
||||||
detailForm.title = getImportString(record, ['title', 'name', 'case_title', 'caseTitle'], row.title)
|
detailForm.title = getImportString(record, ['title', 'name', 'case_title', 'caseTitle'], row.title)
|
||||||
detailForm.case_type = normalizeImportCaseType(getImportString(record, ['case_type', 'caseType'], row.caseType), normalizeImportCaseType(row.caseType, 'traditional'))
|
detailForm.case_type = normalizeImportCaseType(getImportString(record, ['case_type', 'caseType'], row.caseType), normalizeImportCaseType(row.caseType, 'traditional'))
|
||||||
@@ -1646,8 +1643,8 @@ function getImportStringList(record: Record<string, unknown>, keys: string[]): s
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
function getImportScoringRules(record: Record<string, unknown>): ScoringRuleForm[] {
|
function getImportScoringRules(...sources: unknown[]): ScoringRuleForm[] {
|
||||||
const raw = getImportFirst(record, ['scoring_rules', 'scoringRules'])
|
const raw = findImportArray(['scoring_rules', 'scoringRules', 'score_rules', 'scoreRules', 'rules'], sources)
|
||||||
if (!Array.isArray(raw)) {
|
if (!Array.isArray(raw)) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
@@ -1661,14 +1658,14 @@ function getImportScoringRules(record: Record<string, unknown>): ScoringRuleForm
|
|||||||
dimension: getImportString(itemRecord, ['dimension', 'name']),
|
dimension: getImportString(itemRecord, ['dimension', 'name']),
|
||||||
score_weight: scoreWeight ?? 1,
|
score_weight: scoreWeight ?? 1,
|
||||||
ai_auto_score: rawAutoScore === undefined ? true : getImportBoolean(itemRecord, ['ai_auto_score', 'aiAutoScore']),
|
ai_auto_score: rawAutoScore === undefined ? true : getImportBoolean(itemRecord, ['ai_auto_score', 'aiAutoScore']),
|
||||||
scoring_standard: getImportString(itemRecord, ['scoring_standard', 'scoringStandard', 'description'])
|
scoring_standard: getImportString(itemRecord, ['scoring_standard', 'scoringStandard', 'standard', 'description', 'content'])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.filter(item => item.dimension || item.scoring_standard)
|
.filter(item => item.dimension || item.scoring_standard)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getImportExamItems(record: Record<string, unknown>): ExamItemForm[] {
|
function getImportExamItems(...sources: unknown[]): ExamItemForm[] {
|
||||||
const raw = getImportFirst(record, ['exam_items', 'examItems'])
|
const raw = findImportArray(['exam_items', 'examItems', 'exams', 'exam_list', 'examList', 'items'], sources)
|
||||||
if (!Array.isArray(raw)) {
|
if (!Array.isArray(raw)) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
@@ -1680,12 +1677,70 @@ function getImportExamItems(record: Record<string, unknown>): ExamItemForm[] {
|
|||||||
item_code: getImportString(itemRecord, ['item_code', 'itemCode', 'code']),
|
item_code: getImportString(itemRecord, ['item_code', 'itemCode', 'code']),
|
||||||
item_name: getImportString(itemRecord, ['item_name', 'itemName', 'name']),
|
item_name: getImportString(itemRecord, ['item_name', 'itemName', 'name']),
|
||||||
item_type: getImportString(itemRecord, ['item_type', 'itemType', 'type']),
|
item_type: getImportString(itemRecord, ['item_type', 'itemType', 'type']),
|
||||||
result_text: getImportString(itemRecord, ['result_text', 'resultText'])
|
result_text: getImportString(itemRecord, ['result_text', 'resultText', 'result', 'value', 'content'])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.filter(item => item.item_code || item.item_name || item.item_type || item.result_text)
|
.filter(item => item.item_code || item.item_name || item.item_type || item.result_text)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findImportArray(keys: string[], sources: unknown[]) {
|
||||||
|
for (const source of sources) {
|
||||||
|
const found = findImportArrayInSource(source, keys)
|
||||||
|
if (found) {
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function findImportArrayInSource(value: unknown, keys: string[], depth = 0): unknown[] | undefined {
|
||||||
|
if (!value || typeof value !== 'object' || depth > 5) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = value as Record<string, unknown>
|
||||||
|
for (const key of keys) {
|
||||||
|
const nested = record[key]
|
||||||
|
if (Array.isArray(nested)) {
|
||||||
|
return nested
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferredKeys = [
|
||||||
|
'data',
|
||||||
|
'case',
|
||||||
|
'case_detail',
|
||||||
|
'caseDetail',
|
||||||
|
'case_info',
|
||||||
|
'caseInfo',
|
||||||
|
'detail',
|
||||||
|
'full',
|
||||||
|
'result',
|
||||||
|
'record',
|
||||||
|
'item'
|
||||||
|
]
|
||||||
|
for (const key of preferredKeys) {
|
||||||
|
const found = findImportArrayInSource(record[key], keys, depth + 1)
|
||||||
|
if (found) {
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const child of Object.values(record)) {
|
||||||
|
const found = findImportArrayInSource(child, keys, depth + 1)
|
||||||
|
if (found) {
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeImportCaseType(value: string, fallback: DraftCaseType): DraftCaseType {
|
function normalizeImportCaseType(value: string, fallback: DraftCaseType): DraftCaseType {
|
||||||
return value === 'teaching' ? 'teaching' : fallback
|
return value === 'teaching' ? 'teaching' : fallback
|
||||||
}
|
}
|
||||||
@@ -1740,12 +1795,7 @@ function findDetailRecord(value: unknown, depth = 0): Record<string, unknown> |
|
|||||||
}
|
}
|
||||||
|
|
||||||
const record = value as Record<string, unknown>
|
const record = value as Record<string, unknown>
|
||||||
if (hasCaseDetailFields(record)) {
|
|
||||||
return record
|
|
||||||
}
|
|
||||||
|
|
||||||
const preferredKeys = [
|
const preferredKeys = [
|
||||||
'data',
|
|
||||||
'case',
|
'case',
|
||||||
'case_detail',
|
'case_detail',
|
||||||
'caseDetail',
|
'caseDetail',
|
||||||
@@ -1753,9 +1803,10 @@ function findDetailRecord(value: unknown, depth = 0): Record<string, unknown> |
|
|||||||
'caseInfo',
|
'caseInfo',
|
||||||
'detail',
|
'detail',
|
||||||
'full',
|
'full',
|
||||||
'result',
|
|
||||||
'record',
|
'record',
|
||||||
'item'
|
'item',
|
||||||
|
'data',
|
||||||
|
'result'
|
||||||
]
|
]
|
||||||
for (const key of preferredKeys) {
|
for (const key of preferredKeys) {
|
||||||
const found = findDetailRecord(record[key], depth + 1)
|
const found = findDetailRecord(record[key], depth + 1)
|
||||||
@@ -1764,6 +1815,10 @@ function findDetailRecord(value: unknown, depth = 0): Record<string, unknown> |
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasCaseDetailFields(record)) {
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
for (const child of Object.values(record)) {
|
for (const child of Object.values(record)) {
|
||||||
const found = findDetailRecord(child, depth + 1)
|
const found = findDetailRecord(child, depth + 1)
|
||||||
if (found) {
|
if (found) {
|
||||||
@@ -1783,12 +1838,16 @@ function hasCaseDetailFields(record: Record<string, unknown>) {
|
|||||||
'caseType',
|
'caseType',
|
||||||
'chief_complaint',
|
'chief_complaint',
|
||||||
'chiefComplaint',
|
'chiefComplaint',
|
||||||
|
'description',
|
||||||
|
'summary',
|
||||||
|
'content',
|
||||||
|
'difficulty',
|
||||||
|
'estimated_minutes',
|
||||||
|
'estimatedMinutes',
|
||||||
'patient_age',
|
'patient_age',
|
||||||
'patientAge',
|
'patientAge',
|
||||||
'patient_gender',
|
'patient_gender',
|
||||||
'patientGender',
|
'patientGender'
|
||||||
'publish_status',
|
|
||||||
'publishStatus'
|
|
||||||
].some(key => record[key] !== undefined && record[key] !== null)
|
].some(key => record[key] !== undefined && record[key] !== null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,6 @@
|
|||||||
<h1>超级管理员数据驾驶舱</h1>
|
<h1>超级管理员数据驾驶舱</h1>
|
||||||
<p>从机构、用户活跃、训练效果、病例资产和 AI 服务稳定性监控平台整体运营状况。</p>
|
<p>从机构、用户活跃、训练效果、病例资产和 AI 服务稳定性监控平台整体运营状况。</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-actions">
|
|
||||||
<el-button :icon="Download">导出报表</el-button>
|
|
||||||
<el-button :icon="Refresh" :loading="loading" @click="loadOverview">刷新数据</el-button>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="stats-grid dashboard-kpis">
|
<section class="stats-grid dashboard-kpis">
|
||||||
@@ -76,7 +72,6 @@
|
|||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import type { EChartsOption } from 'echarts'
|
import type { EChartsOption } from 'echarts'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { Download, Refresh } from '@element-plus/icons-vue'
|
|
||||||
import ChartPanel from '@/components/ChartPanel.vue'
|
import ChartPanel from '@/components/ChartPanel.vue'
|
||||||
import { fetchPlatformOverview, type PlatformOverview } from '@/api/stats'
|
import { fetchPlatformOverview, type PlatformOverview } from '@/api/stats'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
|
|||||||
Reference in New Issue
Block a user