AutoMedinfo/frontend/src/views/inquiry/InquiryDetail.vue

791 lines
26 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>
<div class="inquiry-detail">
<el-card v-loading="loading">
<template #header>
<div class="card-header">
<span>查询详情 - {{ inquiry.requestNumber }}</span>
<el-tag :type="getStatusType(inquiry.status)">
{{ getStatusText(inquiry.status) }}
</el-tag>
</div>
</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="请求编号">
{{ inquiry.requestNumber }}
</el-descriptions-item>
<el-descriptions-item label="客户姓名">
{{ inquiry.customerName }}
</el-descriptions-item>
<el-descriptions-item label="客户邮箱">
{{ inquiry.customerEmail }}
</el-descriptions-item>
<el-descriptions-item label="客户职称">
{{ inquiry.customerTitle }}
</el-descriptions-item>
<el-descriptions-item label="指派给">
{{ inquiry.assignedTo }}
</el-descriptions-item>
<el-descriptions-item label="创建时间">
{{ inquiry.createdAt }}
</el-descriptions-item>
<el-descriptions-item label="查询内容" :span="2">
<div style="white-space: pre-wrap;">{{ inquiry.inquiryContent }}</div>
</el-descriptions-item>
</el-descriptions>
<el-divider />
<!-- 操作按钮:与“提取关键词”并排展示 -->
<div class="action-section">
<el-button
type="primary"
@click="handleExtractKeywords"
:loading="extractLoading"
:disabled="extractLoading || searchLoading || !['CREATED','SEARCHED'].includes(inquiry.status)"
>提取关键词</el-button>
<el-button
type="primary"
@click="handleSearchWithParams"
:loading="searchLoading"
:disabled="extractLoading || searchLoading || !standardQuery?.trim()"
>一键检索</el-button>
<el-button @click="showResults" :disabled="!hasAnyResults">查看检索结果</el-button>
<el-button
type="warning"
@click="handleGenerateResponse"
:loading="processing"
:disabled="processing || inquiry.status !== 'SEARCHED'"
>生成回复</el-button>
<el-button @click="goToResponseView" :disabled="!inquiry.responseContent">查看回复</el-button>
</div>
<!-- AI识别结果 + 标准检索词 -->
<div class="result-section">
<h3>AI识别结果</h3>
<div v-if="inquiry.keywords && isStructuredKeywords(inquiry.keywords)" class="keywords-structured">
<el-descriptions :column="3" border>
<el-descriptions-item label="药物中文名">
<el-tag type="success">{{ getKeywordField(inquiry.keywords, 'drugNameChinese') }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="药物英文名">
<el-tag type="primary">{{ getKeywordField(inquiry.keywords, 'drugNameEnglish') }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="查询项目">
<el-tag type="warning">{{ getKeywordField(inquiry.keywords, 'requestItem') || '未指定' }}</el-tag>
</el-descriptions-item>
</el-descriptions>
</div>
<div v-else>
<el-tag v-for="(keyword, index) in parseKeywords(inquiry.keywords)" :key="index" style="margin-right: 10px;">
{{ keyword }}
</el-tag>
</div>
</div>
<!-- 新:检索范围与标准检索词表格(第一列包含选择框) -->
<div class="result-section">
<h3>检索范围与标准检索词</h3>
<el-table :data="tableRows" border style="width: 100%;">
<el-table-column label="检索范围" width="260">
<template #default="{ row }">
<el-checkbox
:label="row.scope"
:model-value="isScopeSelected(row.scope)"
@change="toggleScope(row.scope)"
>{{ row.label }}</el-checkbox>
</template>
</el-table-column>
<el-table-column label="检索式(标准检索词)">
<template #default="{ row }">
<div class="query-editor">
<div
class="query-chip"
v-for="(item, idx) in scopeQueries[row.scope]"
:key="idx"
>
<el-input
v-model="scopeQueries[row.scope][idx].text"
:disabled="!isScopeSelected(row.scope) || !!item.summary"
placeholder="请输入检索式"
size="small"
style="width: 520px;"
/>
<el-button
:disabled="!isScopeSelected(row.scope)"
size="small"
type="danger"
link
@click="removeQuery(row.scope, idx)"
>删除</el-button>
</div>
<el-button
:disabled="!isScopeSelected(row.scope)"
type="primary"
size="small"
plain
style="width: 40px;"
@click="addQuery(row.scope)"
>+ 添加检索式</el-button>
</div>
</template>
</el-table-column>
<el-table-column label="结果概述" width="900">
<template #default="{ row }">
<div>
<div
v-for="(item, idx) in scopeQueries[row.scope]"
:key="`s-${row.scope}-${idx}`"
style="margin-bottom: 6px;"
>
<span v-if="item.summary">{{ item.summary }}</span>
<span v-else style="color:#909399;">—</span>
</div>
</div>
</template>
</el-table-column>
</el-table>
<div style="margin-top: 8px; color:#909399; font-size: 12px;">
未选中的检索范围禁用编辑;当该条有“结果概述”表示已完成,不可修改。
</div>
</div>
<el-divider />
<!-- 结果多 Tab 展示 -->
<el-tabs v-model="activeResultsTab">
<!-- 动态:临床试验分组 Tab按多条检索式分别展示 -->
<el-tab-pane
v-for="(group, gIdx) in clinicalTrialsGroups"
:key="`ct-${gIdx}`"
:label="group.label"
:name="group.name"
>
<!-- 临床试验部分(沿用原表格) -->
<div class="result-section">
<div class="section-header">
<h3>临床试验信息 (ClinicalTrials.gov)</h3>
<div class="section-actions">
<el-button v-if="group.data.length > 0" type="success" @click="handleExportClinicalTrials" icon="Download">
导出CSV
</el-button>
<el-button v-if="group.data.length > 0" @click="loadClinicalTrials" icon="Refresh">
刷新
</el-button>
</div>
</div>
<div v-if="group.data.length > 0" style="margin-top: 20px;">
<el-alert :title="`共找到 ${group.data.length} 个临床试验`" type="info" :closable="false" style="margin-bottom: 15px;" />
<el-table :data="group.data" border stripe style="width: 100%">
<el-table-column type="expand">
<template #default="{ row }">
<div style="padding: 20px;">
<el-descriptions :column="2" border>
<el-descriptions-item label="NCT ID">
<a :href="row.url" target="_blank" style="color: #409EFF;">
{{ row.nctId }}
</a>
</el-descriptions-item>
<el-descriptions-item label="研究状态">
<el-tag :type="getStatusTagType(row.overallStatus)">
{{ row.overallStatus }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="研究类型">
{{ row.studyType }}
</el-descriptions-item>
<el-descriptions-item label="研究阶段">
{{ row.phase || 'N/A' }}
</el-descriptions-item>
<el-descriptions-item label="开始日期">
{{ row.startDate || 'N/A' }}
</el-descriptions-item>
<el-descriptions-item label="完成日期">
{{ row.completionDate || 'N/A' }}
</el-descriptions-item>
<el-descriptions-item label="入组人数">
{{ row.enrollment || 'N/A' }}
</el-descriptions-item>
<el-descriptions-item label="主要研究者">
{{ row.sponsor }}
</el-descriptions-item>
<el-descriptions-item label="适应症" :span="2">
<el-tag v-for="(condition, index) in row.conditions" :key="index" style="margin-right: 5px; margin-bottom: 5px;">
{{ condition }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="干预措施" :span="2">
<el-tag v-for="(intervention, index) in row.interventions" :key="index" type="success" style="margin-right: 5px; margin-bottom: 5px;">
{{ intervention }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="研究摘要" :span="2">
<div style="white-space: pre-wrap; max-height: 200px; overflow-y: auto;">
{{ row.briefSummary || 'N/A' }}
</div>
</el-descriptions-item>
<el-descriptions-item label="研究地点" :span="2">
<div v-if="row.locations && row.locations.length > 0">
<div v-for="(location, index) in row.locations.slice(0, 5)" :key="index" style="margin-bottom: 5px;">
{{ location.facility }} - {{ location.city }}, {{ location.country }}
</div>
<div v-if="row.locations.length > 5" style="color: #909399;">
... 和其他 {{ row.locations.length - 5 }} 个地点
</div>
</div>
<span v-else>N/A</span>
</el-descriptions-item>
</el-descriptions>
</div>
</template>
</el-table-column>
<el-table-column prop="nctId" label="NCT ID" width="130">
<template #default="{ row }">
<a :href="row.url" target="_blank" style="color: #409EFF;">
{{ row.nctId }}
</a>
</template>
</el-table-column>
<el-table-column prop="studyTitle" label="研究标题" min-width="300" show-overflow-tooltip />
<el-table-column prop="overallStatus" label="状态" width="120">
<template #default="{ row }">
<el-tag :type="getStatusTagType(row.overallStatus)" size="small">
{{ row.overallStatus }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="phase" label="阶段" width="100" />
<el-table-column prop="enrollment" label="入组人数" width="100" />
<el-table-column prop="sponsor" label="主要研究者" min-width="200" show-overflow-tooltip />
</el-table>
</div>
<el-empty v-else-if="!searchingClinicalTrials" description="暂无临床试验数据,请输入药品名称进行搜索" />
</div>
</el-tab-pane>
<el-tab-pane label="内部数据" name="INTERNAL">
<el-empty description="暂无数据或未实现" />
</el-tab-pane>
<el-tab-pane label="知识库" name="KNOWLEDGE_BASE">
<el-empty description="暂无数据或未实现" />
</el-tab-pane>
<el-tab-pane label="知网" name="CNKI">
<el-empty description="暂无数据或未实现" />
</el-tab-pane>
</el-tabs>
<div class="result-section">
<el-button @click="goBack">返回列表</el-button>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, computed, onMounted, reactive } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import {
getInquiryDetail,
extractKeywords,
performSearch,
generateResponse,
reviewResponse,
completeInquiry,
searchClinicalTrials,
getClinicalTrials,
exportClinicalTrials,
performAutoSearch,
executeFullWorkflow
} from '@/api/inquiry'
import { ElMessage, ElMessageBox } from 'element-plus'
const router = useRouter()
const route = useRoute()
const loading = ref(false)
const processing = ref(false)
const extractLoading = ref(false)
const searchLoading = ref(false)
const inquiry = ref({})
const clinicalTrials = ref([])
// 多检索式的临床试验结果分组:[{ name, label, data }]
const clinicalTrialsGroups = ref([])
const searchingClinicalTrials = ref(false)
// 新增标准检索词与检索范围、多Tab
const standardQuery = ref('')
const selectedSources = ref(['CLINICAL_TRIALS']) // 仍用于结果Tab默认行为
const activeResultsTab = ref('CLINICAL_TRIALS')
// 新:详情页内的范围选择控制(独立于 selectedSources 的展示用途)
const selectedScopes = ref([])
// 新:每个范围多条检索式,条目为 { text, summary }
const scopeQueries = reactive({
INTERNAL: [],
KNOWLEDGE_BASE: [],
CNKI: [],
CLINICAL_TRIALS: []
})
const scopeMeta = {
INTERNAL: { label: '内部数据' },
KNOWLEDGE_BASE: { label: '知识库' },
CNKI: { label: '知网 (CNKI)' },
CLINICAL_TRIALS: { label: 'ClinicalTrials.gov' }
}
const tableRows = computed(() => [
{ scope: 'INTERNAL', label: scopeMeta.INTERNAL.label },
{ scope: 'KNOWLEDGE_BASE', label: scopeMeta.KNOWLEDGE_BASE.label },
{ scope: 'CNKI', label: scopeMeta.CNKI.label },
{ scope: 'CLINICAL_TRIALS', label: scopeMeta.CLINICAL_TRIALS.label }
])
const isScopeSelected = (scope) => selectedScopes.value.includes(scope)
const toggleScope = (scope) => {
const idx = selectedScopes.value.indexOf(scope)
if (idx >= 0) {
selectedScopes.value.splice(idx, 1)
} else {
selectedScopes.value.push(scope)
}
}
const addQuery = (scope) => {
scopeQueries[scope].push({ text: '', summary: '' })
}
const removeQuery = (scope, index) => {
scopeQueries[scope].splice(index, 1)
}
const hasAnyResults = computed(() => {
if (clinicalTrialsGroups.value && clinicalTrialsGroups.value.length > 0) {
return clinicalTrialsGroups.value.some(g => (g.data || []).length > 0)
}
return clinicalTrials.value && clinicalTrials.value.length > 0
})
onMounted(() => {
loadDetail()
loadClinicalTrials()
})
const loadDetail = async () => {
loading.value = true
try {
const id = route.params.id
inquiry.value = await getInquiryDetail(id)
// 同步范围到 selectedScopes初始来自后端选择
const scopes = []
if (inquiry.value.searchInternalData) scopes.push('INTERNAL')
if (inquiry.value.searchKnowledgeBase) scopes.push('KNOWLEDGE_BASE')
if (inquiry.value.searchCnki) scopes.push('CNKI')
if (inquiry.value.searchClinicalTrials) scopes.push('CLINICAL_TRIALS')
selectedScopes.value = scopes.length ? scopes : ['CLINICAL_TRIALS']
if (inquiry.value?.keywords) {
buildStandardQueryFromKeywords()
// 由AI关键词构建每范围默认检索式中文+事项、英文+事项)
buildDefaultQueriesFromKeywords()
}
} catch (error) {
ElMessage.error('加载详情失败')
} finally {
loading.value = false
}
}
// 从AI关键词构建默认检索式作为每范围多条的默认项
const buildDefaultQueriesFromKeywords = () => {
try {
const parsed = JSON.parse(inquiry.value.keywords || '{}')
const cn = parsed.drugNameChinese || ''
const en = parsed.drugNameEnglish || ''
const item = parsed.requestItem || ''
const cnQuery = [cn, item].filter(Boolean).join(' + ')
const enQuery = [en, item].filter(Boolean).join(' + ')
const defaults = []
if (cnQuery) defaults.push({ text: cnQuery, summary: '' })
if (enQuery && enQuery !== cnQuery) defaults.push({ text: enQuery, summary: '' })
const applyIfEmpty = (scope) => {
if (!scopeQueries[scope] || scopeQueries[scope].length === 0) {
// 深拷贝每个默认检索项,避免跨范围引用同一对象导致联动
scopeQueries[scope] = defaults.map(item => ({ text: item.text, summary: item.summary }))
}
}
applyIfEmpty('CLINICAL_TRIALS')
applyIfEmpty('KNOWLEDGE_BASE')
applyIfEmpty('CNKI')
applyIfEmpty('INTERNAL')
// 维持旧按钮的行为:取 CT 第一条作为标准检索词回退
standardQuery.value = scopeQueries.CLINICAL_TRIALS[0]?.text || standardQuery.value
} catch {
// ignore
}
}
const loadClinicalTrials = async () => {
try {
const id = route.params.id
clinicalTrials.value = await getClinicalTrials(id)
// 将后端保存的单次结果映射为一个默认分组,便于统一渲染
clinicalTrialsGroups.value = [
{
name: 'CLINICAL_TRIALS',
label: 'ClinicalTrials',
data: clinicalTrials.value || []
}
]
} catch (error) {
console.error('加载临床试验数据失败', error)
}
}
const handleExtractKeywords = async () => {
extractLoading.value = true
try {
await extractKeywords(inquiry.value.id)
ElMessage.success('关键词提取成功')
await loadDetail()
buildStandardQueryFromKeywords()
buildDefaultQueriesFromKeywords()
} catch (error) {
ElMessage.error('关键词提取失败')
} finally {
extractLoading.value = false
}
}
const handleSearchWithParams = async () => {
const keyword = (standardQuery.value || '').trim()
if (!keyword) {
ElMessage.warning('请先填写标准检索词')
return
}
searchLoading.value = true
try {
// 仅当选择了 ClinicalTrials 时,执行临床试验检索
if (selectedSources.value.includes('CLINICAL_TRIALS')) {
await handleSearchClinicalTrials()
}
ElMessage.success('检索完成')
activeResultsTab.value = 'CLINICAL_TRIALS'
} catch (error) {
ElMessage.error('检索失败')
} finally {
searchLoading.value = false
}
}
const showResults = () => {
activeResultsTab.value = selectedSources.value[0] || 'CLINICAL_TRIALS'
}
const handleGenerateResponse = async () => {
processing.value = true
try {
await generateResponse(inquiry.value.id)
ElMessage.success('回复生成成功')
loadDetail()
} catch (error) {
ElMessage.error('回复生成失败')
} finally {
processing.value = false
}
}
const handleApprove = async () => {
processing.value = true
try {
await reviewResponse(inquiry.value.id, { approved: true })
ElMessage.success('回复已批准')
loadDetail()
} catch (error) {
ElMessage.error('操作失败')
} finally {
processing.value = false
}
}
const handleReject = async () => {
ElMessageBox.prompt('请输入修改意见', '要求修改', {
confirmButtonText: '确定',
cancelButtonText: '取消'
}).then(async ({ value }) => {
processing.value = true
try {
await reviewResponse(inquiry.value.id, { approved: false, comments: value })
ElMessage.success('已要求修改')
loadDetail()
} catch (error) {
ElMessage.error('操作失败')
} finally {
processing.value = false
}
}).catch(() => {})
}
const handleDownloadLiteratures = () => {
ElMessage.info('文献下载功能开发中...')
}
const handleComplete = async () => {
processing.value = true
try {
await completeInquiry(inquiry.value.id)
ElMessage.success('处理已完成')
loadDetail()
} catch (error) {
ElMessage.error('操作失败')
} finally {
processing.value = false
}
}
const goBack = () => {
router.back()
}
const getStatusType = (status) => {
const typeMap = {
'CREATED': 'info',
'SEARCHING': 'warning',
'SEARCHED': '',
'RESPONDING': 'warning',
'COMPLETED': 'success'
}
return typeMap[status] || 'info'
}
const getStatusText = (status) => {
const textMap = {
'CREATED': '已创建',
'SEARCHING': '检索中',
'SEARCHED': '已检索',
'RESPONDING': '回复中',
'COMPLETED': '已完成'
}
return textMap[status] || status
}
const parseKeywords = (keywords) => {
try {
const parsed = JSON.parse(keywords)
return Array.isArray(parsed) ? parsed : [keywords]
} catch {
return keywords ? [keywords] : []
}
}
const getStatusTagType = (status) => {
const statusMap = {
'COMPLETED': 'success',
'ACTIVE': 'primary',
'RECRUITING': 'warning',
'NOT_YET_RECRUITING': 'info',
'TERMINATED': 'danger',
'WITHDRAWN': 'danger',
'SUSPENDED': 'warning',
'ENROLLING_BY_INVITATION': 'warning'
}
return statusMap[status?.toUpperCase().replace(/ /g, '_')] || 'info'
}
const makeClinicalTabLabel = (q, idx) => {
const t = (q || '').trim()
const snippet = t.length > 20 ? t.slice(0, 20) + '…' : t
return `Clinical Trials ${idx + 1}${snippet ? ' - ' + snippet : ''}`
}
// 针对 ClinicalTrials.gov 进行检索式清洗:
// 1) 去掉加号压缩空白2) 若包含中文,优先回退到英文药名
const sanitizeForClinicalTrials = (q) => {
const original = (q || '').trim()
// 去掉加号、压缩空白
let cleaned = original.replace(/\+/g, ' ').replace(/\s+/g, ' ').trim()
// 若包含中文,尝试用英文药名
const hasCJK = /[\u4e00-\u9fa5]/.test(cleaned)
if (hasCJK) {
try {
const parsed = JSON.parse(inquiry.value.keywords || '{}')
const en = (parsed.drugNameEnglish || '').trim()
if (en) {
cleaned = en
}
} catch {
// ignore
}
}
return cleaned
}
const handleSearchClinicalTrials = async () => {
// 取被选中的 ClinicalTrials 范围内的所有检索式(最多两条按需求展示)
const queries = (scopeQueries.CLINICAL_TRIALS || [])
.map(q => (q.text || '').trim())
.filter(Boolean)
// 若未设置多条,则回退到标准检索词
if (queries.length === 0) {
const fallback = (standardQuery.value || '').trim()
if (fallback) queries.push(fallback)
}
if (queries.length === 0) {
ElMessage.warning('请先填写标准检索词')
return
}
searchingClinicalTrials.value = true
try {
clinicalTrialsGroups.value = []
// 逐条检索并收集结果,不依赖后端保存的数据进行展示
for (let i = 0; i < queries.length; i++) {
const q = queries[i]
const searchTerm = sanitizeForClinicalTrials(q)
if (!searchTerm) {
// 若清洗后为空,则跳过该条但仍保留一个空分组用于可见性
clinicalTrialsGroups.value.push({
name: `CLINICAL_TRIALS_${i + 1}`,
label: makeClinicalTabLabel(q, i),
data: []
})
continue
}
// 调用现有接口(该接口会清空并保存到后端),但我们直接使用返回值渲染分组,避免相互覆盖的问题
const result = await searchClinicalTrials(inquiry.value.id, searchTerm)
clinicalTrialsGroups.value.push({
name: `CLINICAL_TRIALS_${i + 1}`,
label: makeClinicalTabLabel(q, i),
data: result || []
})
}
// 默认激活第一个分组
activeResultsTab.value = clinicalTrialsGroups.value[0]?.name || 'CLINICAL_TRIALS'
ElMessage.success('临床试验搜索完成')
} catch (error) {
ElMessage.error('搜索失败: ' + (error.message || '未知错误'))
} finally {
searchingClinicalTrials.value = false
}
}
const handleExportClinicalTrials = () => {
const exportUrl = exportClinicalTrials(inquiry.value.id)
const baseURL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080'
window.open(baseURL + exportUrl, '_blank')
ElMessage.success('开始下载CSV文件')
}
const isStructuredKeywords = (keywords) => {
try {
const parsed = JSON.parse(keywords)
return parsed.drugNameChinese || parsed.drugNameEnglish || parsed.requestItem
} catch {
return false
}
}
const buildStandardQueryFromKeywords = () => {
try {
const parsed = JSON.parse(inquiry.value.keywords || '{}')
const parts = [parsed.drugNameChinese, parsed.drugNameEnglish, parsed.requestItem].filter(Boolean)
standardQuery.value = parts.join(' ')
} catch {
// ignore
}
}
const getKeywordField = (keywords, field) => {
try {
const parsed = JSON.parse(keywords)
return parsed[field] || ''
} catch {
return ''
}
}
const goToResponseView = () => {
router.push(`/inquiry/${inquiry.value.id}/response-view`)
}
</script>
<style scoped>
.inquiry-detail {
width: 100%;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.action-section {
margin: 30px 0;
text-align: center;
}
.action-section .el-button {
margin: 0 10px;
}
.result-section {
margin: 20px 0;
}
.result-section h3 {
margin-bottom: 15px;
color: #303133;
}
.result-section pre {
background-color: #f5f7fa;
padding: 15px;
border-radius: 4px;
overflow-x: auto;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.section-header h3 {
margin: 0;
}
.section-actions {
display: flex;
align-items: center;
}
.query-editor {
display: flex;
flex-direction: column;
gap: 8px;
}
.query-chip {
display: flex;
align-items: center;
gap: 8px;
}
</style>