feat: 联调

This commit is contained in:
王天骄
2026-06-12 18:10:15 +08:00
parent 9fddb42ebe
commit 1d093c9589
12 changed files with 2309 additions and 56 deletions
+177
View File
@@ -0,0 +1,177 @@
<template>
<div class="page-stack">
<section class="page-toolbar">
<div>
<h1>AI病例生成</h1>
<p>输入病例长描述并选择病例类型生成结构化传统病例或教学病例</p>
</div>
<div class="toolbar-actions">
<el-button :icon="Refresh" @click="resetForm">重置</el-button>
<el-button :icon="Cpu" :loading="generating" type="primary" @click="submitGenerate">开始生成</el-button>
</div>
</section>
<section class="ai-case-workbench">
<div class="data-section">
<div class="section-header">
<div>
<h2>生成配置</h2>
<p>填写病例描述后生成结构化内容可直接用于后续草稿录入</p>
</div>
</div>
<el-form ref="formRef" class="ai-case-form" :model="form" :rules="rules" label-position="top">
<el-form-item label="病例类型" prop="case_type">
<el-radio-group v-model="form.case_type">
<el-radio-button label="traditional">传统病例</el-radio-button>
<el-radio-button label="teaching">教学病例</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="病例长描述" prop="prompt">
<el-input
v-model="form.prompt"
type="textarea"
:rows="10"
maxlength="2000"
show-word-limit
placeholder="请生成一个儿科急性上呼吸道感染传统病例,患儿4岁男孩,发热咳嗽3天。"
/>
</el-form-item>
</el-form>
</div>
<div class="data-section ai-result-section">
<div class="section-header">
<div>
<h2>生成结果</h2>
<p>{{ result ? `解析ID${result.parseId || '-'}` : '等待生成结果返回。' }}</p>
</div>
<el-tag v-if="result" type="success">{{ caseTypeLabel(result.caseType) }}</el-tag>
</div>
<el-empty v-if="!result" description="暂无生成结果" />
<template v-else>
<div class="ai-result-kpis">
<div>
<span>输入消耗</span>
<strong>{{ formatNumber(result.aiUsage.promptTokens) }}</strong>
</div>
<div>
<span>输出消耗</span>
<strong>{{ formatNumber(result.aiUsage.completionTokens) }}</strong>
</div>
<div>
<span>生成耗时</span>
<strong>{{ formatSeconds(result.generatingSeconds) }}</strong>
</div>
<div>
<span>解析耗时</span>
<strong>{{ formatSeconds(result.parsingSeconds) }}</strong>
</div>
</div>
<el-descriptions :column="2" border>
<el-descriptions-item label="标题">{{ result.data.title || '-' }}</el-descriptions-item>
<el-descriptions-item label="科室">{{ result.data.department_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="主诉" :span="2">{{ result.data.chief_complaint || '-' }}</el-descriptions-item>
<el-descriptions-item label="患者年龄">{{ formatValue(result.data.patient_age) }}</el-descriptions-item>
<el-descriptions-item label="患者性别">{{ patientGenderLabel(result.data.patient_gender) }}</el-descriptions-item>
<el-descriptions-item label="Prompt版本" :span="2">{{ result.promptVersion || '-' }}</el-descriptions-item>
</el-descriptions>
</template>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { Cpu, Refresh } from '@element-plus/icons-vue'
import { generateCaseWithAi, type AiGenerateCaseResult, type DraftCaseType } from '@/api/cases'
import { useAppStore } from '@/stores/app'
const appStore = useAppStore()
const formRef = ref<FormInstance>()
const generating = ref(false)
const result = ref<AiGenerateCaseResult | null>(null)
const form = reactive({
case_type: 'traditional' as DraftCaseType,
prompt: '请生成一个儿科急性上呼吸道感染传统病例,患儿4岁男孩,发热咳嗽3天。'
})
const rules: FormRules = {
case_type: [{ required: true, message: '请选择病例类型', trigger: 'change' }],
prompt: [{ required: true, message: '请输入病例长描述', trigger: 'blur' }]
}
async function submitGenerate() {
if (!appStore.token) {
ElMessage.warning('缺少登录信息,请重新登录')
return
}
const isValid = await formRef.value?.validate().catch(() => false)
if (!isValid) {
return
}
try {
generating.value = true
result.value = await generateCaseWithAi({
token: appStore.token,
payload: {
case_type: form.case_type,
prompt: form.prompt.trim()
}
})
ElMessage.success('AI病例生成完成')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : 'AI病例生成失败')
} finally {
generating.value = false
}
}
function resetForm() {
form.case_type = 'traditional'
form.prompt = ''
result.value = null
formRef.value?.clearValidate()
}
function caseTypeLabel(type: string) {
const labels: Record<string, string> = {
traditional: '传统病例',
teaching: '教学病例'
}
return labels[type] || type || '-'
}
function patientGenderLabel(value: unknown) {
if (value === 'male' || value === '男') return '男'
if (value === 'female' || value === '女') return '女'
if (value === 'unknown') return '未知'
return formatValue(value)
}
function formatNumber(value: number | null) {
return value === null ? '-' : value.toLocaleString()
}
function formatSeconds(value: number | null) {
return value === null ? '-' : `${value}s`
}
function formatValue(value: unknown) {
if (value === undefined || value === null || value === '') {
return '-'
}
return String(value)
}
</script>
+308
View File
@@ -0,0 +1,308 @@
<template>
<div class="page-stack">
<section class="page-toolbar">
<div>
<h1>病例审核</h1>
<p>审核内容管理员提交的病例确认无误后发布到医院病例库</p>
</div>
<div class="toolbar-actions">
<el-button :icon="Refresh" @click="resetFilters">刷新</el-button>
</div>
</section>
<section class="filter-bar case-review-filter">
<el-input v-model="filters.search" :prefix-icon="Search" clearable placeholder="搜索病例" @keyup.enter="handleSearch" />
<el-input v-model="filters.department" clearable placeholder="科室ID" @keyup.enter="handleSearch" />
<el-button :icon="Search" type="primary" @click="handleSearch">查询</el-button>
<el-button :icon="Refresh" @click="resetFilters">重置</el-button>
</section>
<section class="data-section">
<el-table v-loading="loading" :data="cases" empty-text="暂无待审核病例" row-key="id">
<el-table-column prop="id" label="ID" width="90" />
<el-table-column prop="title" label="病例标题" min-width="240">
<template #default="{ row }">
<strong>{{ row.title }}</strong>
<span v-if="row.chiefComplaint" class="table-subtext">{{ row.chiefComplaint }}</span>
</template>
</el-table-column>
<el-table-column label="类型" width="110">
<template #default="{ row }">
<el-tag>{{ caseTypeLabel(row.caseType) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="科室" min-width="150">
<template #default="{ row }">
{{ row.departmentName || row.departmentId || '未关联科室' }}
</template>
</el-table-column>
<el-table-column label="难度" width="90">
<template #default="{ row }">
<el-tag :type="difficultyTagType(row.difficulty)">{{ row.difficulty || '-' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="标签/ICD" min-width="180">
<template #default="{ row }">
<div class="case-tag-list">
<el-tag v-for="tag in row.tags.slice(0, 2)" :key="tag" size="small" type="info">{{ tag }}</el-tag>
<el-tag v-for="code in row.icdCodes.slice(0, 2)" :key="code" size="small">{{ code }}</el-tag>
<span v-if="row.tags.length + row.icdCodes.length > 4" class="table-subtext">+{{ row.tags.length + row.icdCodes.length - 4 }}</span>
<span v-if="!row.tags.length && !row.icdCodes.length">-</span>
</div>
</template>
</el-table-column>
<el-table-column label="更新时间" width="170">
<template #default="{ row }">
{{ formatDateTime(row.updatedAt || row.createdAt) }}
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag type="warning">{{ publishStatusLabel(row.publishStatus) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openDetailDrawer(row)">查看</el-button>
<el-button link type="success" @click="confirmPublishCase(row)">发布</el-button>
</template>
</el-table-column>
</el-table>
<div class="table-pagination">
<el-pagination
v-model:current-page="pagination.page"
:total="pagination.total"
background
layout="total, prev, pager, next, jumper"
@current-change="loadReviewCases"
/>
</div>
</section>
<el-drawer v-model="detailDrawerVisible" :title="detailDrawerTitle" size="50%" destroy-on-close>
<div v-loading="detailLoading" class="case-detail-drawer">
<el-descriptions v-if="detailCase" :column="2" border>
<el-descriptions-item label="ID">{{ detailDisplay.id }}</el-descriptions-item>
<el-descriptions-item label="类型">{{ caseTypeLabel(detailDisplay.caseType) }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ publishStatusLabel(detailCase.publishStatus) }}</el-descriptions-item>
<el-descriptions-item label="难度">{{ detailDisplay.difficulty }}</el-descriptions-item>
<el-descriptions-item label="科室">{{ detailDisplay.department }}</el-descriptions-item>
<el-descriptions-item label="患者">{{ detailDisplay.patient }}</el-descriptions-item>
<el-descriptions-item label="预计时长">{{ detailDisplay.estimatedMinutes }}</el-descriptions-item>
<el-descriptions-item label="主诉" :span="2">{{ detailDisplay.chiefComplaint }}</el-descriptions-item>
<el-descriptions-item label="描述" :span="2">{{ detailDisplay.description }}</el-descriptions-item>
</el-descriptions>
</div>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Refresh, Search } from '@element-plus/icons-vue'
import {
fetchCaseFull,
fetchCases,
publishCase,
type CaseListItem,
type CasePublishStatus
} from '@/api/cases'
import { useAppStore } from '@/stores/app'
const appStore = useAppStore()
const loading = ref(false)
const publishing = ref(false)
const detailLoading = ref(false)
const detailDrawerVisible = ref(false)
const cases = ref<CaseListItem[]>([])
const detailCase = ref<CaseListItem | null>(null)
const caseDetail = ref<unknown>(null)
const filters = reactive({
search: '',
department: ''
})
const pagination = reactive({
page: 1,
total: 0
})
const detailDrawerTitle = computed(() => (detailCase.value ? `病例详情:${detailCase.value.title}` : '病例详情'))
const detailDisplay = computed(() => createDetailDisplay(detailCase.value, caseDetail.value))
async function loadReviewCases() {
if (!appStore.token) {
ElMessage.warning('缺少登录信息,请重新登录')
return
}
try {
loading.value = true
const result = await fetchCases({
token: appStore.token,
publish_status: 1,
search: filters.search,
department: filters.department,
page: pagination.page
})
cases.value = result.cases
pagination.total = result.total
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '获取待审核病例失败')
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
loadReviewCases()
}
function resetFilters() {
filters.search = ''
filters.department = ''
pagination.page = 1
loadReviewCases()
}
async function openDetailDrawer(row: CaseListItem) {
if (!appStore.token) {
ElMessage.warning('缺少登录信息,请重新登录')
return
}
detailCase.value = row
detailDrawerVisible.value = true
caseDetail.value = null
try {
detailLoading.value = true
caseDetail.value = await fetchCaseFull({
token: appStore.token,
id: row.id
})
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '获取病例详情失败')
} finally {
detailLoading.value = false
}
}
async function confirmPublishCase(row: CaseListItem) {
if (!appStore.token || publishing.value) {
return
}
try {
await ElMessageBox.confirm(`确认发布「${row.title}」吗?`, '发布病例', {
confirmButtonText: '发布',
cancelButtonText: '取消',
type: 'warning'
})
publishing.value = true
await publishCase({
token: appStore.token,
id: row.id
})
ElMessage.success('病例已发布')
await loadReviewCases()
} catch (error) {
if (error !== 'cancel' && error !== 'close') {
ElMessage.error(error instanceof Error ? error.message : '发布病例失败')
}
} finally {
publishing.value = false
}
}
function createDetailDisplay(row: CaseListItem | null, fullData: unknown) {
const record = getDetailRecord(fullData)
const title = getDetailString(record, ['title', 'name']) || row?.title || '-'
const caseType = getDetailString(record, ['case_type', 'caseType']) || row?.caseType || ''
const difficulty = getDetailString(record, ['difficulty']) || row?.difficulty || '-'
const department = getDetailString(record, ['department_name', 'departmentName']) || row?.departmentName || row?.departmentId || '-'
const chiefComplaint = getDetailString(record, ['chief_complaint', 'chiefComplaint']) || row?.chiefComplaint || '-'
const description = getDetailString(record, ['description', 'summary', 'content']) || '-'
const patientAge = getDetailString(record, ['patient_age', 'patientAge'])
const patientGender = patientGenderLabel(getDetailString(record, ['patient_gender', 'patientGender']))
const estimatedMinutes = getDetailString(record, ['estimated_minutes', 'estimatedMinutes']) || (row?.estimatedMinutes ? String(row.estimatedMinutes) : '')
return {
id: getDetailString(record, ['id']) || row?.id || '-',
title,
caseType,
difficulty,
department,
chiefComplaint,
description,
patient: [patientAge ? `${patientAge}` : '', patientGender].filter(Boolean).join(' / ') || '-',
estimatedMinutes: estimatedMinutes ? `${estimatedMinutes} 分钟` : '-'
}
}
function getDetailRecord(value: unknown): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {}
}
const record = value as Record<string, unknown>
if (record.data && typeof record.data === 'object' && !Array.isArray(record.data)) {
return record.data as Record<string, unknown>
}
return record
}
function getDetailString(record: Record<string, unknown>, keys: string[]) {
for (const key of keys) {
const value = record[key]
if (typeof value === 'string' && value.trim()) return value
if (typeof value === 'number') return String(value)
}
return ''
}
function caseTypeLabel(type: string) {
const labels: Record<string, string> = {
traditional: '传统病例',
teaching: '教学病例'
}
return labels[type] || type || '-'
}
function patientGenderLabel(value: string) {
if (value === 'male' || value === '男') return '男'
if (value === 'female' || value === '女') return '女'
if (value === 'unknown') return '未知'
return value || ''
}
function publishStatusLabel(status: CasePublishStatus | null) {
if (status === 0) return '草稿'
if (status === 1) return '待审核'
if (status === 2) return '已发布'
return '-'
}
function difficultyTagType(value: string) {
if (['高', 'high', 'hard'].includes(value.toLowerCase())) return 'danger'
if (['中', 'medium', 'middle'].includes(value.toLowerCase())) return 'warning'
if (['低', 'low', 'easy'].includes(value.toLowerCase())) return 'success'
return 'info'
}
function formatDateTime(value: string) {
if (!value) {
return '-'
}
return value.replace('T', ' ').slice(0, 19)
}
onMounted(loadReviewCases)
</script>
+1082 -44
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -57,7 +57,7 @@
<div class="section-header">
<div>
<h2>内容质量处理队列</h2>
<p>可直接用于后续接入病例列表接口</p>
<p>集中跟进低通过率和待优化内容</p>
</div>
</div>
<div class="quality-list">
+1 -1
View File
@@ -93,7 +93,7 @@ async function handleLogin() {
role: form.role
})
if (!result.token) {
throw new Error('登录接口未返回访问令牌')
throw new Error('登录失败,请稍后重试')
}
appStore.login(form.account, form.role, result.token, result.roleType || form.role)
loading.value = false
+1 -1
View File
@@ -67,7 +67,7 @@
<div class="section-header">
<div>
<h2>任务列表</h2>
<p>展示已下发任务情况后续可接老师创建任务后的统计接口</p>
<p>展示已下发任务的完成进度与训练情况</p>
</div>
<el-button :icon="Plus" type="primary">新建任务</el-button>
</div>