提交
This commit is contained in:
1 parent
ad8f8d696f
commit
41aa90aa05
115 files changed
+29626
-10210
No files matched your search
+402
@@ -0,0 +1,402 @@
|
||||
<!-- 输注计划管理 — 出库交接 -->
|
||||
<!-- eslint-disable vue/no-v-model-argument -->
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="modelValue"
|
||||
title="出库交接"
|
||||
width="1120px"
|
||||
class="dialog-form-layout outbound-handover-dialog"
|
||||
align-center
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
style="backdrop-filter: none"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
@closed="handleClosed"
|
||||
>
|
||||
<div v-if="scheduleSummary" class="material-summary">
|
||||
<div class="material-summary-row">
|
||||
<span class="material-summary-label">申请单号</span>
|
||||
<span class="material-summary-value">{{ scheduleSummary.applicationNo || '—' }}</span>
|
||||
</div>
|
||||
<div class="material-summary-row">
|
||||
<span class="material-summary-label">患者姓名</span>
|
||||
<span class="material-summary-value">{{ scheduleSummary.patientName || '—' }}</span>
|
||||
</div>
|
||||
<div class="material-summary-row">
|
||||
<span class="material-summary-label">输注时间</span>
|
||||
<span class="material-summary-value">{{ scheduleSummary.infusionTime || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-loading="loading" class="handover-body">
|
||||
<OutboundMaterialFormSections ref="materialSectionsRef" require-frozen-cell-product />
|
||||
|
||||
<section class="section-card handover-form-section">
|
||||
<h3 class="section-title section-title--mint-bar">交接信息</h3>
|
||||
<el-form label-position="top" class="biz-dialog-form">
|
||||
<div class="form-grid-2">
|
||||
<el-form-item label="交接人" required>
|
||||
<el-select
|
||||
v-model="handoverUserId"
|
||||
filterable
|
||||
remote
|
||||
clearable
|
||||
reserve-keyword
|
||||
placeholder="搜索并选择用户"
|
||||
:remote-method="remoteSearchHandoverUsers"
|
||||
:loading="handoverUserLoading"
|
||||
class="w-full"
|
||||
@change="onHandoverUserChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="u in handoverUserOptions"
|
||||
:key="u.id"
|
||||
:label="formatHandoverUserLabel(u)"
|
||||
:value="u.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="交接时间" required>
|
||||
<el-date-picker
|
||||
v-model="handoverTime"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
placeholder="选择交接时间"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="交接地点">
|
||||
<el-input
|
||||
v-model="handoverLocation"
|
||||
placeholder="可选"
|
||||
maxlength="200"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="接收人" required>
|
||||
<el-input
|
||||
v-model="receiverPerson"
|
||||
placeholder="请输入接收人"
|
||||
maxlength="200"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" class="form-col-span-2">
|
||||
<el-input
|
||||
v-model="handoverRemark"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="可选"
|
||||
maxlength="512"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="emit('update:modelValue', false)">
|
||||
<el-icon><Close /></el-icon>
|
||||
取消
|
||||
</el-button>
|
||||
<el-button class="btn-main" type="primary" :loading="submitting" @click="handleSubmit">
|
||||
<el-icon><Check /></el-icon>
|
||||
保存
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Check, Close } from '@element-plus/icons-vue'
|
||||
import { fetchUserList } from '@/api/admin'
|
||||
import {
|
||||
fetchInfusionScheduleOutboundHandover,
|
||||
saveInfusionScheduleOutboundHandover
|
||||
} from '@/api/infusion-schedule-handover'
|
||||
import OutboundMaterialFormSections from '@/components/business/outbound-material-form-sections/index.vue'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
|
||||
defineOptions({ name: 'InfusionScheduleHandoverDialog' })
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
scheduleId: string
|
||||
scheduleRow?: Record<string, any> | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', val: boolean): void
|
||||
(e: 'saved'): void
|
||||
}>()
|
||||
|
||||
const userStore = useUserStore()
|
||||
const materialSectionsRef = ref<InstanceType<typeof OutboundMaterialFormSections> | null>(null)
|
||||
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const scheduleSummary = ref<{
|
||||
applicationNo?: string
|
||||
patientName?: string
|
||||
infusionTime?: string
|
||||
} | null>(null)
|
||||
|
||||
const handoverUserId = ref('')
|
||||
const handoverTime = ref('')
|
||||
const handoverLocation = ref('')
|
||||
const receiverPerson = ref('')
|
||||
const handoverRemark = ref('')
|
||||
|
||||
const handoverUserOptions = ref<Api.User.UserItem[]>([])
|
||||
const handoverSelectedSnapshot = ref<Api.User.UserItem | null>(null)
|
||||
const handoverUserLoading = ref(false)
|
||||
|
||||
function formatNowDatetime() {
|
||||
const d = new Date()
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
|
||||
function formatHandoverUserLabel(u: Api.User.UserItem) {
|
||||
const name = (u.nickName || '').trim() || '-'
|
||||
const no = (u.userNo || '').trim()
|
||||
const phone = (u.phoneNo || '').trim()
|
||||
const parts = [name]
|
||||
if (no) parts.push(no)
|
||||
if (phone) parts.push(phone)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
function applyDefaultHandoverUser() {
|
||||
const info = (userStore.info || {}) as Record<string, any>
|
||||
const uid = String(info.userId ?? info.id ?? '').trim()
|
||||
const nick = String(
|
||||
info.nickName ?? info.realName ?? info.userName ?? info.username ?? ''
|
||||
).trim()
|
||||
if (!uid) return
|
||||
handoverUserId.value = uid
|
||||
handoverSelectedSnapshot.value = {
|
||||
id: uid,
|
||||
nickName: nick || uid,
|
||||
userNo: info.userName || info.username || '',
|
||||
phoneNo: info.phone || info.phoneNo || ''
|
||||
} as Api.User.UserItem
|
||||
handoverUserOptions.value = handoverSelectedSnapshot.value
|
||||
? [handoverSelectedSnapshot.value]
|
||||
: []
|
||||
}
|
||||
|
||||
function resetHandoverForm() {
|
||||
handoverUserId.value = ''
|
||||
handoverTime.value = formatNowDatetime()
|
||||
handoverLocation.value = ''
|
||||
receiverPerson.value = ''
|
||||
handoverRemark.value = ''
|
||||
handoverUserOptions.value = []
|
||||
handoverSelectedSnapshot.value = null
|
||||
}
|
||||
|
||||
async function remoteSearchHandoverUsers(query: string) {
|
||||
handoverUserLoading.value = true
|
||||
try {
|
||||
const q = (query || '').trim()
|
||||
const res = await fetchUserList({
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
keyword: q || undefined,
|
||||
status: '1'
|
||||
})
|
||||
let list = [...(res.list || [])]
|
||||
const curId = handoverUserId.value
|
||||
if (
|
||||
curId &&
|
||||
handoverSelectedSnapshot.value?.id === curId &&
|
||||
!list.some((x) => x.id === curId)
|
||||
) {
|
||||
list = [handoverSelectedSnapshot.value, ...list]
|
||||
}
|
||||
handoverUserOptions.value = list
|
||||
} catch {
|
||||
handoverUserOptions.value = handoverSelectedSnapshot.value
|
||||
? [handoverSelectedSnapshot.value]
|
||||
: []
|
||||
} finally {
|
||||
handoverUserLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onHandoverUserChange(val: string | null | undefined) {
|
||||
if (!val) {
|
||||
handoverUserId.value = ''
|
||||
handoverSelectedSnapshot.value = null
|
||||
handoverUserOptions.value = []
|
||||
return
|
||||
}
|
||||
const hit = handoverUserOptions.value.find((u) => u.id === val)
|
||||
if (hit) handoverSelectedSnapshot.value = hit
|
||||
}
|
||||
|
||||
function fillHandoverFromApi(handover?: {
|
||||
handoverUserId?: string
|
||||
handoverPerson?: string
|
||||
handoverTime?: string
|
||||
handoverLocation?: string
|
||||
receiverPerson?: string
|
||||
remark?: string
|
||||
}) {
|
||||
const hasRecord = !!(handover?.handoverUserId || handover?.handoverTime || handover?.receiverPerson)
|
||||
if (!hasRecord) {
|
||||
resetHandoverForm()
|
||||
applyDefaultHandoverUser()
|
||||
return
|
||||
}
|
||||
handoverUserId.value = handover?.handoverUserId || ''
|
||||
handoverTime.value = handover?.handoverTime || formatNowDatetime()
|
||||
handoverLocation.value = handover?.handoverLocation || ''
|
||||
receiverPerson.value = handover?.receiverPerson || ''
|
||||
handoverRemark.value = handover?.remark || ''
|
||||
if (handoverUserId.value) {
|
||||
handoverSelectedSnapshot.value = {
|
||||
id: handoverUserId.value,
|
||||
nickName: handover?.handoverPerson || handoverUserId.value,
|
||||
userNo: '',
|
||||
phoneNo: ''
|
||||
} as Api.User.UserItem
|
||||
handoverUserOptions.value = [handoverSelectedSnapshot.value]
|
||||
}
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
if (!props.scheduleId) return
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await fetchInfusionScheduleOutboundHandover(props.scheduleId)
|
||||
scheduleSummary.value = data.scheduleSummary || {
|
||||
applicationNo: props.scheduleRow?.applicationNo,
|
||||
patientName: props.scheduleRow?.patientName,
|
||||
infusionTime: props.scheduleRow?.infusionTime
|
||||
}
|
||||
await materialSectionsRef.value?.ensureMaterialOptions()
|
||||
await materialSectionsRef.value?.loadFromApiData({
|
||||
remark: data.outboundMaterial?.remark,
|
||||
cells: data.outboundMaterial?.cells as any,
|
||||
qcMaterials: data.outboundMaterial?.qcMaterials,
|
||||
paperMaterials: data.outboundMaterial?.paperMaterials
|
||||
})
|
||||
fillHandoverFromApi(data.handover)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.modelValue, props.scheduleId] as const,
|
||||
([vis, id]) => {
|
||||
if (vis && id) {
|
||||
nextTick(() => loadData())
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function handleClosed() {
|
||||
materialSectionsRef.value?.resetState()
|
||||
scheduleSummary.value = null
|
||||
resetHandoverForm()
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!props.scheduleId) return
|
||||
const sections = materialSectionsRef.value
|
||||
if (!sections?.validateMaterial()) return
|
||||
|
||||
if (!handoverUserId.value?.trim()) {
|
||||
ElMessage.warning('请选择交接人')
|
||||
return
|
||||
}
|
||||
if (!handoverTime.value?.trim()) {
|
||||
ElMessage.warning('请选择交接时间')
|
||||
return
|
||||
}
|
||||
if (!receiverPerson.value?.trim()) {
|
||||
ElMessage.warning('请填写接收人')
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await saveInfusionScheduleOutboundHandover(props.scheduleId, {
|
||||
outboundMaterial: sections.buildSavePayload(),
|
||||
handover: {
|
||||
handoverUserId: handoverUserId.value.trim(),
|
||||
handoverTime: handoverTime.value.trim(),
|
||||
handoverLocation: handoverLocation.value.trim() || undefined,
|
||||
receiverPerson: receiverPerson.value.trim(),
|
||||
remark: handoverRemark.value.trim() || undefined
|
||||
}
|
||||
})
|
||||
ElMessage.success('出库交接已保存')
|
||||
emit('update:modelValue', false)
|
||||
emit('saved')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.material-summary {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 16px;
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px 20px;
|
||||
}
|
||||
|
||||
.material-summary-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.material-summary-label {
|
||||
color: #64748b;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.material-summary-value {
|
||||
color: #1e293b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.handover-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.section-card {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px 16px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0 0 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.handover-form-section :deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
+616
-94
@@ -1,105 +1,217 @@
|
||||
<!-- 输注计划管理 — 查看弹窗 -->
|
||||
<!-- 输注计划管理 — 查看弹窗(全链路关联信息) -->
|
||||
<template>
|
||||
<SystemViewDialog
|
||||
v-model:visible="innerVisible"
|
||||
:title="viewData.patientName || '输注计划详情'"
|
||||
:title="heroTitle"
|
||||
:icon="Document"
|
||||
:code="viewData.applicationNo"
|
||||
:code="heroCode"
|
||||
code-label="申请单号"
|
||||
:sections="sections"
|
||||
:loading="loading"
|
||||
:grid-columns="3"
|
||||
width="1080px"
|
||||
:grid-columns="4"
|
||||
compact
|
||||
width="1120px"
|
||||
append-to-body
|
||||
modal-class="infusion-schedule-view-dialog-mask"
|
||||
>
|
||||
<template #products>
|
||||
<div class="list-body list-wrap view-inner-table-wrap">
|
||||
<el-table
|
||||
v-if="viewData.products && viewData.products.length"
|
||||
:data="viewData.products"
|
||||
v-if="products.length"
|
||||
:data="products"
|
||||
stripe
|
||||
class="list-table inner-table"
|
||||
max-height="320"
|
||||
>
|
||||
<el-table-column
|
||||
type="index"
|
||||
label="序号"
|
||||
min-width="68"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="projectName"
|
||||
label="项目名称"
|
||||
min-width="160"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
prop="cellTypeName"
|
||||
label="细胞类型"
|
||||
min-width="120"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="specText"
|
||||
label="规格"
|
||||
min-width="100"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
label="协议价格(元)"
|
||||
min-width="120"
|
||||
align="right"
|
||||
header-align="center"
|
||||
>
|
||||
<el-table-column type="index" label="序号" min-width="68" align="center" />
|
||||
<el-table-column prop="projectName" label="项目名称" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="cellTypeName" label="细胞类型" min-width="120" align="center" />
|
||||
<el-table-column prop="specText" label="规格" min-width="100" align="center" />
|
||||
<el-table-column label="协议价格(元)" min-width="120" align="right">
|
||||
<template #default="{ row }">
|
||||
{{ row.agreementPrice != null ? `¥${formatMoneyDisplay(row.agreementPrice)}` : '—' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="实际价格(元)"
|
||||
min-width="120"
|
||||
align="right"
|
||||
header-align="center"
|
||||
>
|
||||
<el-table-column label="实际价格(元)" min-width="120" align="right">
|
||||
<template #default="{ row }">
|
||||
{{ row.actualPrice != null ? `¥${formatMoneyDisplay(row.actualPrice)}` : '—' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="quantity"
|
||||
label="数量"
|
||||
min-width="80"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column prop="quantity" label="数量" min-width="80" align="center" />
|
||||
</el-table>
|
||||
<el-empty v-else description="暂无产品" :image-size="48" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #production-info>
|
||||
<SampleProductionInfoCards :production-info="detail?.productionInfo" />
|
||||
</template>
|
||||
|
||||
<template #qc-info>
|
||||
<SampleQcInfoCards :qc-info="detail?.qcInfo" hide-sample-context />
|
||||
</template>
|
||||
|
||||
<template #outbound-material>
|
||||
<div class="view-section-slot outbound-material-view">
|
||||
<div v-if="!hasOutboundMaterial" class="outbound-material-empty">暂无出库物料准备记录</div>
|
||||
<template v-else>
|
||||
<div v-if="outboundMaterialCells.length" class="outbound-material-block">
|
||||
<h4 class="outbound-material-subtitle">出库细胞</h4>
|
||||
<el-table :data="outboundMaterialCells" class="list-table inner-table" stripe size="small">
|
||||
<el-table-column type="index" label="序号" min-width="56" align="center" />
|
||||
<el-table-column prop="productCode" label="产品编码" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="productName" label="产品名称" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="productTypeName" label="类型" min-width="90" />
|
||||
<el-table-column prop="specName" label="规格" min-width="90" />
|
||||
<el-table-column prop="storageSlotName" label="储物格" min-width="130" show-overflow-tooltip />
|
||||
<el-table-column label="状态" min-width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
v-if="row.productStatus"
|
||||
:type="cellProductStatusTagType(row.productStatus) as any"
|
||||
size="small"
|
||||
>
|
||||
{{ cellProductStatusLabel(row.productStatus) }}
|
||||
</el-tag>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<div v-if="outboundMaterialQcRows.length" class="outbound-material-block">
|
||||
<h4 class="outbound-material-subtitle">使用的物料</h4>
|
||||
<el-table :data="outboundMaterialQcRows" class="list-table inner-table" stripe size="small">
|
||||
<el-table-column type="index" label="序号" min-width="56" align="center" />
|
||||
<el-table-column prop="materialName" label="物料名称" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="specModel" label="规格型号" min-width="110" />
|
||||
<el-table-column label="计划用量" min-width="100" align="center">
|
||||
<template #default="{ row }">{{ row.qty ?? '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="unit" label="单位" min-width="72" align="center" />
|
||||
</el-table>
|
||||
</div>
|
||||
<div v-if="outboundPaperRows.length" class="outbound-material-block">
|
||||
<h4 class="outbound-material-subtitle">其他纸质材料</h4>
|
||||
<el-table :data="outboundPaperRows" class="list-table inner-table" stripe size="small">
|
||||
<el-table-column type="index" label="序号" min-width="56" align="center" />
|
||||
<el-table-column prop="paperName" label="名称" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column label="份数" min-width="80" align="center">
|
||||
<template #default="{ row }">{{ row.copies ?? '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="附件" min-width="320">
|
||||
<template #default="{ row }">
|
||||
<div v-if="parsePaperAttachments(row.attachmentUrl).length" class="paper-att-cards">
|
||||
<div
|
||||
v-for="(att, attIdx) in parsePaperAttachments(row.attachmentUrl)"
|
||||
:key="attIdx"
|
||||
class="paper-att-card"
|
||||
>
|
||||
<el-icon class="paper-att-card__icon"><Document /></el-icon>
|
||||
<span class="paper-att-card__name" :title="att.fileName">{{
|
||||
att.fileName || '附件'
|
||||
}}</span>
|
||||
<div class="paper-att-card__actions">
|
||||
<el-icon
|
||||
v-if="isImageFile(att)"
|
||||
class="paper-att-card__action"
|
||||
title="预览"
|
||||
@click="handlePreviewPaperAttachment(att)"
|
||||
>
|
||||
<View />
|
||||
</el-icon>
|
||||
<el-icon
|
||||
class="paper-att-card__action"
|
||||
title="下载"
|
||||
@click="handleDownloadPaperAttachment(att)"
|
||||
>
|
||||
<Download />
|
||||
</el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<p v-if="outboundMaterialRemark" class="outbound-material-remark">
|
||||
备注:{{ outboundMaterialRemark }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #review-attachments>
|
||||
<div class="view-section-slot">
|
||||
<ProtocolAttachmentReadonly
|
||||
:files="reviewAttachmentFiles"
|
||||
@preview="handleAttachmentPreview"
|
||||
@download="handleAttachmentDownload"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</SystemViewDialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="previewVisible"
|
||||
title="文件预览"
|
||||
width="800px"
|
||||
align-center
|
||||
destroy-on-close
|
||||
append-to-body
|
||||
style="backdrop-filter: none"
|
||||
>
|
||||
<div v-if="previewType === 'image'" class="preview-container">
|
||||
<img
|
||||
:src="previewUrl"
|
||||
alt="预览"
|
||||
style="max-width: 100%; max-height: 70vh; display: block; margin: 0 auto"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="preview-container">
|
||||
<video
|
||||
:src="previewUrl"
|
||||
controls
|
||||
style="max-width: 100%; max-height: 70vh; display: block; margin: 0 auto"
|
||||
/>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { Document } from '@element-plus/icons-vue'
|
||||
import { Document, Download, View } from '@element-plus/icons-vue'
|
||||
import SystemViewDialog, {
|
||||
type ViewSection,
|
||||
type TagType
|
||||
} from '@/components/business/system-view-dialog/index.vue'
|
||||
import { fetchDictDataOptions, fetchInfusionScheduleMgmtDetail } from '@/api/admin'
|
||||
import SampleProductionInfoCards from '@/components/business/sample-production-info-cards/index.vue'
|
||||
import SampleQcInfoCards from '@/components/business/sample-qc-info-cards/index.vue'
|
||||
import ProtocolAttachmentReadonly from '@/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue'
|
||||
import { fetchInfusionScheduleMgmtDetail } from '@/api/admin'
|
||||
import {
|
||||
applicationStatusLabel,
|
||||
applicationStatusTagType,
|
||||
channelServiceOrderTypeLabel,
|
||||
channelServiceOrderTypeTagType,
|
||||
storageReleaseConclusionLabel,
|
||||
storageReleaseConclusionTagType,
|
||||
scheduleStatusLabel,
|
||||
scheduleStatusTagType,
|
||||
infusionStatusLabel,
|
||||
infusionStatusTagType,
|
||||
qcStatusLabel,
|
||||
qcStatusTagType,
|
||||
infusionMethodLabel,
|
||||
cellProductStatusLabel,
|
||||
cellProductStatusTagType,
|
||||
sexLabel
|
||||
} from '@/utils/status-tag-registry'
|
||||
import { formatMoneyDisplay } from '@/utils/money-format'
|
||||
import { maskIdCard } from '@/utils/mask-sensitive'
|
||||
import { downloadAttachmentFile } from '@/utils/download-attachment'
|
||||
|
||||
defineOptions({ name: 'ScheduleViewDialog' })
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
@@ -113,56 +225,247 @@
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const viewData = ref<Record<string, any>>({})
|
||||
const infusionMethodOptions = ref<Api.Dict.DictDataOption[]>([])
|
||||
const detail = ref<Record<string, any> | null>(null)
|
||||
const previewVisible = ref(false)
|
||||
const previewUrl = ref('')
|
||||
const previewType = ref<'image' | 'video'>('image')
|
||||
|
||||
const formatInfusionMethod = (code: unknown) => {
|
||||
if (!code) return undefined
|
||||
const hit = infusionMethodOptions.value.find((o) => o.value === code)
|
||||
return hit?.label || String(code)
|
||||
type PaperAttachmentView = {
|
||||
fileName: string
|
||||
attachmentUrl: string
|
||||
fileUrl: string
|
||||
fileType?: string
|
||||
}
|
||||
|
||||
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
|
||||
|
||||
const heroTitle = computed(() => {
|
||||
const patient = detail.value?.patient || {}
|
||||
return patient.patientName || detail.value?.patientName || '输注计划详情'
|
||||
})
|
||||
|
||||
const heroCode = computed(() => {
|
||||
const app = detail.value?.application || {}
|
||||
return app.applicationNo || detail.value?.applicationNo || ''
|
||||
})
|
||||
|
||||
const products = computed(() => detail.value?.products || [])
|
||||
|
||||
const outboundMaterial = computed(() => detail.value?.outboundMaterial || null)
|
||||
const outboundMaterialCells = computed(() => outboundMaterial.value?.cells || [])
|
||||
const outboundMaterialQcRows = computed(() => outboundMaterial.value?.qcMaterials || [])
|
||||
const outboundMaterialRemark = computed(() => outboundMaterial.value?.remark || '')
|
||||
const outboundPaperRows = computed(() => {
|
||||
const materials = outboundMaterial.value?.paperMaterials || []
|
||||
if (materials.length) {
|
||||
return materials.map((p: Record<string, any>) => ({
|
||||
paperName: p.paperName || '',
|
||||
copies: p.copies,
|
||||
attachmentUrl: p.attachmentUrl || ''
|
||||
}))
|
||||
}
|
||||
const urls = outboundMaterial.value?.paperAttachments || ''
|
||||
if (!urls.trim()) return []
|
||||
return urls
|
||||
.split(',')
|
||||
.map((u: string) => u.trim())
|
||||
.filter(Boolean)
|
||||
.map((attachmentUrl: string) => ({
|
||||
paperName: paperAttachmentName(attachmentUrl),
|
||||
copies: 1,
|
||||
attachmentUrl
|
||||
}))
|
||||
})
|
||||
const hasOutboundMaterial = computed(
|
||||
() =>
|
||||
outboundMaterialCells.value.length > 0 ||
|
||||
outboundMaterialQcRows.value.length > 0 ||
|
||||
outboundPaperRows.value.length > 0 ||
|
||||
!!outboundMaterialRemark.value.trim()
|
||||
)
|
||||
|
||||
const reviewAttachmentFiles = computed(() => {
|
||||
const urls = detail.value?.reviewInfo?.reviewAttachments || ''
|
||||
if (!urls.trim()) return []
|
||||
return urls
|
||||
.split(',')
|
||||
.map((u: string) => u.trim())
|
||||
.filter(Boolean)
|
||||
.map((fileUrl: string, i: number) => ({
|
||||
id: `att_${i}`,
|
||||
fileUrl,
|
||||
fileName: fileUrl.split('/').pop() || ''
|
||||
}))
|
||||
})
|
||||
|
||||
function formatCourseNo(schedule: Record<string, any>) {
|
||||
const sortNo = schedule?.sortNo ?? detail.value?.sortNo
|
||||
const total = schedule?.totalCourseCount ?? detail.value?.totalCourseCount
|
||||
if (sortNo != null && total != null) {
|
||||
return `第 ${sortNo} 次 / 共 ${total} 次`
|
||||
}
|
||||
if (sortNo != null) return `第 ${sortNo} 次`
|
||||
return '—'
|
||||
}
|
||||
|
||||
function formatNeedCheckup(v: unknown) {
|
||||
if (v === 1 || v === '1') return '需要'
|
||||
if (v === 0 || v === '0') return '不需要'
|
||||
return '—'
|
||||
}
|
||||
|
||||
function formatCellType(plan: Record<string, any>, schedule: Record<string, any>) {
|
||||
return schedule.cellType || plan.cellType || detail.value?.cellType || '—'
|
||||
}
|
||||
|
||||
function hasInfusionConfirm(confirm: Record<string, any>) {
|
||||
return !!(confirm?.id || confirm?.confirmTime || confirm?.confirmUserName)
|
||||
}
|
||||
|
||||
const sections = computed((): ViewSection[] => {
|
||||
const row = viewData.value
|
||||
if (!row?.id) return []
|
||||
const d = detail.value
|
||||
if (!d?.id) return []
|
||||
|
||||
const app = d.application || {}
|
||||
const patient = d.patient || {}
|
||||
const plan = d.infusionPlan || {}
|
||||
const schedule = d.infusionSchedule || {}
|
||||
const review = d.reviewInfo || {}
|
||||
const oh = d.outboundHandover || {}
|
||||
const confirm = d.infusionConfirm || {}
|
||||
|
||||
const reviewConclusionValue = review.reviewConclusion || ''
|
||||
const reviewConclusionDisplay = reviewConclusionValue
|
||||
? storageReleaseConclusionLabel(reviewConclusionValue)
|
||||
: '待审核'
|
||||
const reviewConclusionTag = reviewConclusionValue
|
||||
? storageReleaseConclusionTagType(reviewConclusionValue)
|
||||
: 'info'
|
||||
|
||||
const orderType = app.orderType || d.orderType || ''
|
||||
const confirmFields: ViewSection['fields'] = hasInfusionConfirm(confirm)
|
||||
? [
|
||||
{ label: '确认人', value: confirm.confirmUserName || '—' },
|
||||
{ label: '确认时间', value: confirm.confirmTime || '—' },
|
||||
{ label: '确认说明', value: confirm.confirmDesc || '—', span: true },
|
||||
{ label: '备注', value: confirm.remark || '—', span: true }
|
||||
]
|
||||
: schedule.infusionStatus === 'yrqr' || d.infusionStatus === 'yrqr'
|
||||
? [
|
||||
{
|
||||
label: '确认状态',
|
||||
value: infusionStatusLabel('yrqr'),
|
||||
tagType: infusionStatusTagType('yrqr') as TagType
|
||||
},
|
||||
{
|
||||
label: '说明',
|
||||
value: '已在输注计划管理中确认,暂无独立应用确认记录',
|
||||
span: true
|
||||
}
|
||||
]
|
||||
: [{ label: '确认状态', value: '未确认', tagType: 'info' }]
|
||||
|
||||
return [
|
||||
{
|
||||
title: '申请信息',
|
||||
title: '中试申请 / 渠道 / 服务订单',
|
||||
fields: [
|
||||
{ label: '申请单号', value: row.applicationNo },
|
||||
{ label: '渠道名称', value: row.channelName }
|
||||
{ label: '申请单号', value: app.applicationNo || d.applicationNo || '—' },
|
||||
{ label: '申请单名称', value: app.applicationName || d.applicationName || '—' },
|
||||
{
|
||||
label: '申请单状态',
|
||||
value: applicationStatusLabel(app.applicationStatus || d.applicationStatus || ''),
|
||||
tagType: applicationStatusTagType(app.applicationStatus || d.applicationStatus || '')
|
||||
},
|
||||
{ label: '渠道名称', value: app.channelName || d.channelName || '—' },
|
||||
{
|
||||
label: '服务订单',
|
||||
value: app.channelServiceOrderName || d.channelServiceOrderName || '—'
|
||||
},
|
||||
{
|
||||
label: '订单类型',
|
||||
value: orderType ? channelServiceOrderTypeLabel(orderType) : '—',
|
||||
tagType: orderType ? channelServiceOrderTypeTagType(orderType) : undefined
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '患者信息',
|
||||
fields: [
|
||||
{ label: '患者姓名', value: row.patientName },
|
||||
{ label: '性别', value: sexLabel(row.sex) || '—' },
|
||||
{ label: '年龄', value: row.age != null ? String(row.age) : undefined }
|
||||
{ label: '患者姓名', value: patient.patientName || d.patientName || '—' },
|
||||
{ label: '性别', value: sexLabel(patient.sex || d.patientSex) || '—' },
|
||||
{
|
||||
label: '年龄',
|
||||
value:
|
||||
patient.age != null && patient.age !== ''
|
||||
? String(patient.age)
|
||||
: d.patientAge != null
|
||||
? String(d.patientAge)
|
||||
: '—'
|
||||
},
|
||||
{ label: '联系电话', value: patient.phoneNo || '—' },
|
||||
{
|
||||
label: '身份证号',
|
||||
value: patient.idCardNo ? maskIdCard(patient.idCardNo) : '—'
|
||||
},
|
||||
{ label: '紧急联系人', value: patient.emergencyContact || '—' },
|
||||
{ label: '紧急联系电话', value: patient.emergencyPhone || '—' },
|
||||
{ label: '是否需要体检', value: formatNeedCheckup(patient.needCheckup) },
|
||||
{ label: '临床诊断', value: patient.clinicalDiagnosis || '—', span: true }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '输注计划',
|
||||
title: '输注方案',
|
||||
fields: [
|
||||
{ label: '规格', value: row.specText },
|
||||
{ label: '输注时间', value: row.infusionTime },
|
||||
{ label: '输注地点', value: row.infusionLocation },
|
||||
{ label: '输注方式', value: formatInfusionMethod(row.infusionMethod) },
|
||||
{ label: '细胞类型', value: formatCellType(plan, schedule) },
|
||||
{ label: '规格', value: plan.specText || schedule.specText || d.specText || '—' },
|
||||
{
|
||||
label: '总疗程数',
|
||||
value:
|
||||
plan.totalCourseCount != null
|
||||
? String(plan.totalCourseCount)
|
||||
: schedule.totalCourseCount != null
|
||||
? String(schedule.totalCourseCount)
|
||||
: d.totalCourseCount != null
|
||||
? String(d.totalCourseCount)
|
||||
: '—'
|
||||
},
|
||||
{ label: '疗程间隔', value: plan.courseInterval || '—' },
|
||||
{ label: '输注开始日期', value: plan.infusionStartDate || '—' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '本次输注计划',
|
||||
fields: [
|
||||
{ label: '疗程进度', value: formatCourseNo(schedule) },
|
||||
{ label: '细胞类型', value: formatCellType(plan, schedule) },
|
||||
{ label: '规格', value: schedule.specText || d.specText || '—' },
|
||||
{ label: '输注时间', value: schedule.infusionTime || d.infusionTime || '—' },
|
||||
{ label: '输注地点', value: schedule.infusionLocation || d.infusionLocation || '—' },
|
||||
{
|
||||
label: '输注方式',
|
||||
value: infusionMethodLabel(schedule.infusionMethod || d.infusionMethod || '')
|
||||
},
|
||||
{
|
||||
label: '生产状态',
|
||||
value: scheduleStatusLabel(row.scheduleStatus),
|
||||
tagType: scheduleStatusTagType(row.scheduleStatus) as TagType
|
||||
value: scheduleStatusLabel(schedule.scheduleStatus || d.scheduleStatus || ''),
|
||||
tagType: scheduleStatusTagType(schedule.scheduleStatus || d.scheduleStatus || '') as TagType
|
||||
},
|
||||
{
|
||||
label: '输注状态',
|
||||
value: infusionStatusLabel(row.infusionStatus),
|
||||
tagType: infusionStatusTagType(row.infusionStatus) as TagType
|
||||
value: infusionStatusLabel(schedule.infusionStatus || d.infusionStatus || ''),
|
||||
tagType: infusionStatusTagType(schedule.infusionStatus || d.infusionStatus || '') as TagType
|
||||
},
|
||||
{
|
||||
label: '质检状态',
|
||||
value: qcStatusLabel(row.qcStatus),
|
||||
tagType: qcStatusTagType(row.qcStatus) as TagType
|
||||
}
|
||||
value: qcStatusLabel(schedule.qcStatus || d.qcStatus || ''),
|
||||
tagType: qcStatusTagType(schedule.qcStatus || d.qcStatus || '') as TagType
|
||||
},
|
||||
{
|
||||
label: '取消原因',
|
||||
value: schedule.cancelReason || d.cancelReason || '—',
|
||||
span: true
|
||||
},
|
||||
{ label: '备注', value: schedule.remark || d.remark || '—', span: true }
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -171,33 +474,138 @@
|
||||
slot: 'products'
|
||||
},
|
||||
{
|
||||
title: '其他信息',
|
||||
fields: [{ label: '备注', value: row.remark || undefined, span: true }]
|
||||
title: '出库生产工单 + 任务',
|
||||
slot: 'production-info',
|
||||
fields: []
|
||||
},
|
||||
{
|
||||
title: '出库质检工单 + 任务',
|
||||
slot: 'qc-info',
|
||||
fields: []
|
||||
},
|
||||
{
|
||||
title: '出库物料准备',
|
||||
slot: 'outbound-material',
|
||||
fields: []
|
||||
},
|
||||
{
|
||||
title: '出库放行审核',
|
||||
slot: 'review-attachments',
|
||||
fields: [
|
||||
{
|
||||
label: '审核结论',
|
||||
value: reviewConclusionDisplay,
|
||||
tagType: reviewConclusionTag
|
||||
},
|
||||
{ label: '审核人', value: review.reviewByName || '—' },
|
||||
{ label: '审核时间', value: review.reviewTime || '—' },
|
||||
{ label: '审核备注', value: review.reviewRemark || '—', span: true }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '出库交接',
|
||||
fields: [
|
||||
{ label: '交接人', value: oh.handoverPerson || '—' },
|
||||
{ label: '交接时间', value: oh.handoverTime || '—' },
|
||||
{ label: '交接地点', value: oh.handoverLocation || '—' },
|
||||
{ label: '接收人', value: oh.receiverPerson || '—' },
|
||||
{ label: '备注', value: oh.remark || '—', span: true }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '应用确认',
|
||||
fields: confirmFields
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const loadInfusionMethodOptions = async () => {
|
||||
try {
|
||||
infusionMethodOptions.value = (await fetchDictDataOptions('szfs')) || []
|
||||
} catch {
|
||||
infusionMethodOptions.value = []
|
||||
}
|
||||
function paperAttachmentName(url: string) {
|
||||
return url.split('/').pop() || '附件'
|
||||
}
|
||||
|
||||
function splitUrls(str: string): string[] {
|
||||
if (!str?.trim()) return []
|
||||
return str
|
||||
.split(',')
|
||||
.map((u) => u.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function parsePaperAttachments(urlStr: string): PaperAttachmentView[] {
|
||||
return splitUrls(urlStr).map((url) => ({
|
||||
fileName: paperAttachmentName(url),
|
||||
attachmentUrl: url,
|
||||
fileUrl: url
|
||||
}))
|
||||
}
|
||||
|
||||
function isImageFile(file: PaperAttachmentView) {
|
||||
const url = (file.fileUrl || file.attachmentUrl || '').toLowerCase()
|
||||
const name = (file.fileName || '').toLowerCase()
|
||||
return (
|
||||
/\.(jpg|jpeg|png|gif|bmp|webp|svg)(\?|$)/.test(url) ||
|
||||
/\.(jpg|jpeg|png|gif|bmp|webp|svg)$/.test(name) ||
|
||||
(file.fileType || '').startsWith('image/')
|
||||
)
|
||||
}
|
||||
|
||||
function isVideoFile(file: PaperAttachmentView) {
|
||||
const url = (file.fileUrl || file.attachmentUrl || '').toLowerCase()
|
||||
const name = (file.fileName || '').toLowerCase()
|
||||
return (
|
||||
/\.(mp4|webm|ogg|mov|avi)(\?|$)/.test(url) ||
|
||||
/\.(mp4|webm|ogg|mov|avi)$/.test(name) ||
|
||||
(file.fileType || '').startsWith('video/')
|
||||
)
|
||||
}
|
||||
|
||||
function handlePreviewPaperAttachment(att: PaperAttachmentView) {
|
||||
if (!att.attachmentUrl) return
|
||||
previewUrl.value = attachmentFullUrl(att.attachmentUrl)
|
||||
previewType.value = isVideoFile(att) ? 'video' : 'image'
|
||||
previewVisible.value = true
|
||||
}
|
||||
|
||||
function handleDownloadPaperAttachment(att: PaperAttachmentView) {
|
||||
if (!att.attachmentUrl) return
|
||||
downloadAttachmentFile(
|
||||
{
|
||||
fileName: att.fileName,
|
||||
fileUrl: att.attachmentUrl,
|
||||
fileType: att.fileType || ''
|
||||
},
|
||||
{ fullFileUrl: attachmentFullUrl }
|
||||
)
|
||||
}
|
||||
|
||||
function attachmentFullUrl(url: string) {
|
||||
const u = String(url || '').trim()
|
||||
if (!u) return ''
|
||||
if (/^https?:\/\//i.test(u)) return u
|
||||
return minioBaseUrl ? `${minioBaseUrl}/${u.replace(/^\//, '')}` : u
|
||||
}
|
||||
|
||||
function handleAttachmentPreview(file: Record<string, unknown>) {
|
||||
const url = attachmentFullUrl(String(file.fileUrl || ''))
|
||||
if (url) window.open(url, '_blank')
|
||||
}
|
||||
|
||||
function handleAttachmentDownload(file: Record<string, unknown>) {
|
||||
downloadAttachmentFile(file, { fullFileUrl: attachmentFullUrl })
|
||||
}
|
||||
|
||||
const loadDetail = async () => {
|
||||
const id = props.scheduleRow?.id
|
||||
if (!id) return
|
||||
viewData.value = { ...(props.scheduleRow || {}) }
|
||||
detail.value = { ...(props.scheduleRow || {}) }
|
||||
loading.value = true
|
||||
try {
|
||||
await loadInfusionMethodOptions()
|
||||
const res: any = await fetchInfusionScheduleMgmtDetail({ scheduleId: id })
|
||||
const detail =
|
||||
const data =
|
||||
res && typeof res === 'object' && !Array.isArray(res)
|
||||
? (res as Record<string, any>)
|
||||
: ((res as any)?.data ?? {})
|
||||
viewData.value = { ...viewData.value, ...detail }
|
||||
detail.value = { ...detail.value, ...data }
|
||||
} catch {
|
||||
// keep initial data from row
|
||||
} finally {
|
||||
@@ -211,7 +619,7 @@
|
||||
if (val && props.scheduleRow?.id) {
|
||||
loadDetail()
|
||||
} else if (!val) {
|
||||
viewData.value = {}
|
||||
detail.value = null
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -250,4 +658,118 @@
|
||||
padding: 24px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.view-section-slot :deep(.sample-card__section-label) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.view-section-slot :deep(.sample-card__section--qc),
|
||||
.view-section-slot :deep(.sample-card__section--production) {
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.outbound-material-view {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.outbound-material-empty {
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.outbound-material-block {
|
||||
margin-bottom: 16px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.outbound-material-subtitle {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.outbound-material-remark {
|
||||
margin: 12px 0 0;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.paper-att-cards {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.paper-att-card {
|
||||
width: 100px;
|
||||
height: 72px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
background: #f8fafc;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6px 4px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.paper-att-card__icon {
|
||||
color: #0284c7;
|
||||
font-size: 18px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.paper-att-card__name {
|
||||
width: 100%;
|
||||
font-size: 11px;
|
||||
color: #334155;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.paper-att-card__actions {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.paper-att-card:hover .paper-att-card__actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.paper-att-card__action {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #10b981;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -203,7 +203,7 @@
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="操作"
|
||||
min-width="420"
|
||||
min-width="520"
|
||||
fixed="right"
|
||||
align="center"
|
||||
header-align="center"
|
||||
@@ -229,6 +229,9 @@
|
||||
<el-button v-if="canEditSchedule(row)" class="btn-op-item" @click="handleCancel(row)">
|
||||
<el-icon><CircleClose /></el-icon>取消
|
||||
</el-button>
|
||||
<el-button v-if="canShowHandover(row)" class="btn-op-item" @click="handleHandover(row)">
|
||||
<el-icon><Switch /></el-icon>出库交接
|
||||
</el-button>
|
||||
<el-button class="btn-op-item" @click="handleView(row)">
|
||||
<el-icon><View /></el-icon>查看
|
||||
</el-button>
|
||||
@@ -264,6 +267,14 @@
|
||||
:patient-name="cancelPatientName"
|
||||
@success="loadData"
|
||||
/>
|
||||
|
||||
<!-- 出库交接 -->
|
||||
<InfusionScheduleHandoverDialog
|
||||
v-model="handoverDialogVisible"
|
||||
:schedule-id="handoverScheduleId"
|
||||
:schedule-row="handoverRow"
|
||||
@saved="loadData"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -277,7 +288,8 @@
|
||||
EditPen,
|
||||
CircleCheck,
|
||||
CircleClose,
|
||||
SuccessFilled
|
||||
SuccessFilled,
|
||||
Switch
|
||||
} from '@element-plus/icons-vue'
|
||||
import { fetchInfusionScheduleMgmtList, updateInfusionScheduleStatus } from '@/api/admin'
|
||||
import {
|
||||
@@ -293,6 +305,7 @@
|
||||
import ScheduleEditDialog from './ScheduleEditDialog.vue'
|
||||
import ScheduleViewDialog from './ScheduleViewDialog.vue'
|
||||
import ScheduleCancelDialog from './ScheduleCancelDialog.vue'
|
||||
import InfusionScheduleHandoverDialog from './InfusionScheduleHandoverDialog.vue'
|
||||
|
||||
defineOptions({ name: 'InfusionScheduleManagement' })
|
||||
|
||||
@@ -367,6 +380,9 @@
|
||||
const cancelDialogVisible = ref(false)
|
||||
const cancelScheduleId = ref('')
|
||||
const cancelPatientName = ref('')
|
||||
const handoverDialogVisible = ref(false)
|
||||
const handoverScheduleId = ref('')
|
||||
const handoverRow = ref<any>(null)
|
||||
|
||||
// ── 数据加载 ──────────────────────────
|
||||
const loadData = async () => {
|
||||
@@ -434,6 +450,14 @@
|
||||
/** 待输注、已用户确认均可完成(待输注时将自动先确认再完成) */
|
||||
const canCompleteSchedule = (row: any) => canEditSchedule(row)
|
||||
|
||||
const canShowHandover = (row: any) => getInfusionStatus(row) !== 'yqx'
|
||||
|
||||
const handleHandover = (row: any) => {
|
||||
handoverScheduleId.value = row.id
|
||||
handoverRow.value = { ...row }
|
||||
handoverDialogVisible.value = true
|
||||
}
|
||||
|
||||
const buildCompleteConfirmMessage = (row: any) => {
|
||||
const orderTypeLabel = channelServiceOrderTypeLabel(row.orderType)
|
||||
const patient = row.patientName ? `患者「${row.patientName}」` : '该患者'
|
||||
|
||||
+1011
File diff suppressed because it is too large.
Load diff
+595
@@ -0,0 +1,595 @@
|
||||
<!-- 出库放行审核 — 审核弹窗(支持单条和批量) -->
|
||||
<!-- eslint-disable vue/no-v-model-argument -->
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="modelValue"
|
||||
:title="dialogTitle"
|
||||
width="640px"
|
||||
class="dialog-form-layout"
|
||||
align-center
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
style="backdrop-filter: none"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
@closed="resetForm"
|
||||
>
|
||||
<div v-if="isSingleMode && singleDetail" class="review-info-summary">
|
||||
<div class="review-summary-row">
|
||||
<span class="review-summary-label">申请单号</span>
|
||||
<span class="review-summary-value">{{ singleDetail.application?.applicationNo || '—' }}</span>
|
||||
</div>
|
||||
<div class="review-summary-row">
|
||||
<span class="review-summary-label">患者姓名</span>
|
||||
<span class="review-summary-value">{{ singleDetail.patient?.patientName || '—' }}</span>
|
||||
</div>
|
||||
<div class="review-summary-row">
|
||||
<span class="review-summary-label">细胞类型</span>
|
||||
<span class="review-summary-value">{{ singleDetail.infusionPlan?.cellType || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else-if="scheduleIds.length > 1" class="audit-target-hint">
|
||||
将对已选
|
||||
<strong>{{ scheduleIds.length }}</strong> 条记录提交相同审核结果。
|
||||
</p>
|
||||
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-position="top"
|
||||
class="biz-dialog-form"
|
||||
@submit.prevent
|
||||
>
|
||||
<el-form-item label="审核结论" prop="reviewConclusion">
|
||||
<el-radio-group v-model="form.reviewConclusion">
|
||||
<el-radio value="tg">通过</el-radio>
|
||||
<el-radio value="btg">不通过</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input
|
||||
v-model="form.reviewRemark"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="可选"
|
||||
maxlength="1000"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="附件" class="form-col-span-2">
|
||||
<div class="attachment-upload">
|
||||
<div class="attachment-list">
|
||||
<el-upload
|
||||
ref="attachmentUploadRef"
|
||||
:auto-upload="false"
|
||||
:show-file-list="false"
|
||||
:multiple="true"
|
||||
accept="image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar"
|
||||
@change="handleAttachmentChange"
|
||||
>
|
||||
<div class="upload-card">
|
||||
<el-icon :size="28" color="#10b981"><Plus /></el-icon>
|
||||
<span class="upload-card-text">上传附件</span>
|
||||
</div>
|
||||
</el-upload>
|
||||
<div
|
||||
v-for="(file, fIdx) in attachmentFiles"
|
||||
:key="file.id || fIdx"
|
||||
class="attachment-item"
|
||||
:class="{ 'is-uploading': file._uploading }"
|
||||
>
|
||||
<div
|
||||
class="attachment-preview"
|
||||
@click="
|
||||
isImageFile(file) && file.fileUrl ? handlePreviewAttachment(file) : undefined
|
||||
"
|
||||
>
|
||||
<template v-if="isImageFile(file) && file.fileUrl">
|
||||
<img :src="fullFileUrl(file.fileUrl)" :alt="file.fileName" />
|
||||
</template>
|
||||
<template v-else-if="isImageFile(file) && !file.fileUrl">
|
||||
<el-icon :size="24" color="#94a3b8"><Loading /></el-icon>
|
||||
</template>
|
||||
<template v-else-if="isVideoFile(file)">
|
||||
<div class="attachment-preview__icon attachment-preview__icon--video">
|
||||
<el-icon :size="28"><VideoPlay /></el-icon>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="attachment-preview__icon attachment-preview__icon--doc">
|
||||
<el-icon :size="28"><Document /></el-icon>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="attachment-footer">
|
||||
<span class="attachment-name" :title="file.fileName">{{ file.fileName }}</span>
|
||||
<div class="attachment-actions">
|
||||
<el-icon
|
||||
class="action-btn"
|
||||
title="下载"
|
||||
@click.stop="handleDownloadAttachment(file)"
|
||||
>
|
||||
<Download />
|
||||
</el-icon>
|
||||
<el-icon
|
||||
class="action-btn action-btn--danger"
|
||||
title="删除"
|
||||
@click.stop="removeAttachment(fIdx)"
|
||||
>
|
||||
<Delete />
|
||||
</el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="upload-hint">支持图片、视频、PDF、Word、Excel 等文件</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="emit('update:modelValue', false)">
|
||||
<el-icon><Close /></el-icon>
|
||||
取消
|
||||
</el-button>
|
||||
<el-button class="btn-main" type="primary" :loading="submitting" @click="handleSubmit">
|
||||
<el-icon><Check /></el-icon>
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="previewVisible"
|
||||
title="文件预览"
|
||||
width="800px"
|
||||
align-center
|
||||
destroy-on-close
|
||||
append-to-body
|
||||
style="backdrop-filter: none"
|
||||
>
|
||||
<div v-if="previewType === 'image'" class="preview-container">
|
||||
<img
|
||||
:src="previewUrl"
|
||||
alt="预览"
|
||||
style="max-width: 100%; max-height: 70vh; display: block; margin: 0 auto"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="preview-container">
|
||||
<video
|
||||
:src="previewUrl"
|
||||
controls
|
||||
style="max-width: 100%; max-height: 70vh; display: block; margin: 0 auto"
|
||||
/>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
Check,
|
||||
Close,
|
||||
Plus,
|
||||
Document,
|
||||
Delete,
|
||||
Download,
|
||||
Loading,
|
||||
VideoPlay
|
||||
} from '@element-plus/icons-vue'
|
||||
import { fetchUploadAttachment } from '@/api/business'
|
||||
import { downloadAttachmentFile } from '@/utils/download-attachment'
|
||||
import {
|
||||
fetchOutboundReleaseReviewDetail,
|
||||
submitOutboundReleaseReview,
|
||||
batchReviewOutboundRelease,
|
||||
type OutboundReleaseReviewDetail
|
||||
} from '@/api/outbound-release-review'
|
||||
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
|
||||
|
||||
type AttachmentItem = {
|
||||
id: string
|
||||
fileName: string
|
||||
fileUrl: string
|
||||
fileType: string
|
||||
_uploading?: boolean
|
||||
}
|
||||
|
||||
defineOptions({ name: 'OutboundReleaseReviewDialog' })
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
scheduleIds: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', val: boolean): void
|
||||
(e: 'submitted'): void
|
||||
}>()
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const submitting = ref(false)
|
||||
const singleDetail = ref<OutboundReleaseReviewDetail | null>(null)
|
||||
const attachmentFiles = ref<AttachmentItem[]>([])
|
||||
|
||||
const isSingleMode = computed(() => props.scheduleIds.length === 1)
|
||||
|
||||
const form = reactive({
|
||||
reviewConclusion: 'tg',
|
||||
reviewRemark: ''
|
||||
})
|
||||
|
||||
const rules: FormRules = {
|
||||
reviewConclusion: [{ required: true, message: '请选择审核结论', trigger: 'change' }]
|
||||
}
|
||||
|
||||
const dialogTitle = computed(() =>
|
||||
props.scheduleIds.length > 1
|
||||
? `批量审核(${props.scheduleIds.length} 条)`
|
||||
: '出库放行审核'
|
||||
)
|
||||
|
||||
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
|
||||
|
||||
function fullFileUrl(url: string) {
|
||||
if (!url) return ''
|
||||
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('/')) {
|
||||
return url
|
||||
}
|
||||
return minioBaseUrl ? `${minioBaseUrl}/${url.replace(/^\//, '')}` : url
|
||||
}
|
||||
|
||||
function isImageFile(file: AttachmentItem) {
|
||||
const url = (file.fileUrl || '').toLowerCase()
|
||||
const name = (file.fileName || '').toLowerCase()
|
||||
return (
|
||||
/\.(jpg|jpeg|png|gif|bmp|webp|svg)(\?|$)/.test(url) ||
|
||||
/\.(jpg|jpeg|png|gif|bmp|webp|svg)$/.test(name) ||
|
||||
(file.fileType || '').startsWith('image/')
|
||||
)
|
||||
}
|
||||
|
||||
function isVideoFile(file: AttachmentItem) {
|
||||
const url = (file.fileUrl || '').toLowerCase()
|
||||
const name = (file.fileName || '').toLowerCase()
|
||||
return (
|
||||
/\.(mp4|webm|ogg|mov|avi)(\?|$)/.test(url) ||
|
||||
/\.(mp4|webm|ogg|mov|avi)$/.test(name) ||
|
||||
(file.fileType || '').startsWith('video/')
|
||||
)
|
||||
}
|
||||
|
||||
function removeAttachment(index: number) {
|
||||
attachmentFiles.value.splice(index, 1)
|
||||
}
|
||||
|
||||
function handlePreviewAttachment(file: AttachmentItem) {
|
||||
if (file._uploading || !file.fileUrl) return
|
||||
previewUrl.value = fullFileUrl(file.fileUrl)
|
||||
previewType.value = isVideoFile(file) ? 'video' : 'image'
|
||||
previewVisible.value = true
|
||||
}
|
||||
|
||||
function handleDownloadAttachment(file: AttachmentItem) {
|
||||
if (file._uploading || !file.fileUrl) return
|
||||
downloadAttachmentFile(file, { fullFileUrl })
|
||||
}
|
||||
|
||||
async function handleAttachmentChange(uploadFile: { raw?: File }) {
|
||||
const file = uploadFile.raw
|
||||
if (!file) return
|
||||
|
||||
const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
||||
attachmentFiles.value.push({
|
||||
id: tempId,
|
||||
fileName: file.name,
|
||||
fileUrl: '',
|
||||
fileType: file.type || '',
|
||||
_uploading: true
|
||||
})
|
||||
const idx = attachmentFiles.value.length - 1
|
||||
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('folder', 'outbound-release-review')
|
||||
fd.append('file', file)
|
||||
const res = await fetchUploadAttachment(fd)
|
||||
attachmentFiles.value[idx] = {
|
||||
...attachmentFiles.value[idx],
|
||||
id: res.id || tempId,
|
||||
fileName: file.name || res.fileName,
|
||||
fileUrl: res.fileUrl,
|
||||
fileType: res.fileType || file.type || '',
|
||||
_uploading: false
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('文件上传失败')
|
||||
attachmentFiles.value.splice(idx, 1)
|
||||
} finally {
|
||||
nextTick(() => attachmentUploadRef.value?.clearFiles())
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.modelValue, props.scheduleIds] as const,
|
||||
async ([vis, ids]) => {
|
||||
if (!vis || !ids.length) {
|
||||
singleDetail.value = null
|
||||
return
|
||||
}
|
||||
if (ids.length === 1) {
|
||||
try {
|
||||
singleDetail.value = await fetchOutboundReleaseReviewDetail(ids[0])
|
||||
} catch {
|
||||
singleDetail.value = null
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function resetForm() {
|
||||
form.reviewConclusion = 'tg'
|
||||
form.reviewRemark = ''
|
||||
attachmentFiles.value = []
|
||||
singleDetail.value = null
|
||||
previewVisible.value = false
|
||||
formRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid || !props.scheduleIds.length) return
|
||||
|
||||
if (attachmentFiles.value.some((f) => f._uploading)) {
|
||||
ElMessage.warning('附件上传中,请稍候')
|
||||
return
|
||||
}
|
||||
|
||||
const attachmentUrls = attachmentFiles.value
|
||||
.filter((f) => f.fileUrl && !f._uploading)
|
||||
.map((f) => f.fileUrl)
|
||||
.join(',')
|
||||
|
||||
const payload = {
|
||||
reviewConclusion: form.reviewConclusion,
|
||||
reviewRemark: form.reviewRemark.trim() || undefined,
|
||||
reviewAttachments: attachmentUrls || undefined
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
if (props.scheduleIds.length === 1) {
|
||||
await submitOutboundReleaseReview(props.scheduleIds[0], payload)
|
||||
ElMessage.success('审核成功')
|
||||
} else {
|
||||
await withFullscreenLoading(async () => {
|
||||
await batchReviewOutboundRelease({
|
||||
scheduleIds: props.scheduleIds,
|
||||
...payload
|
||||
})
|
||||
ElMessage.success(`批量审核成功,共 ${props.scheduleIds.length} 条`)
|
||||
}, fullscreenLoadingText.save)
|
||||
}
|
||||
emit('update:modelValue', false)
|
||||
emit('submitted')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.audit-target-hint {
|
||||
margin: 0 0 16px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
|
||||
strong {
|
||||
color: #1e293b;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.review-info-summary {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 16px;
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px 20px;
|
||||
}
|
||||
|
||||
.review-summary-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.review-summary-label {
|
||||
color: #64748b;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.review-summary-value {
|
||||
color: #1e293b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-col-span-2 {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 附件上传卡片化布局 — 对齐 OpenSpec ui-conventions / storage-cert */
|
||||
.attachment-upload {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.attachment-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.upload-card {
|
||||
width: 140px;
|
||||
height: 96px;
|
||||
border: 1px dashed #a7f3d0;
|
||||
border-radius: 8px;
|
||||
background: #f0fdf4;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
background 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: #10b981;
|
||||
border-style: solid;
|
||||
background: #ecfdf5;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-card-text {
|
||||
font-size: 12px;
|
||||
color: #10b981;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.upload-hint {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
line-height: 1;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.attachment-item {
|
||||
width: 140px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: #10b981;
|
||||
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
|
||||
&.is-uploading {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.attachment-preview {
|
||||
width: 100%;
|
||||
height: 96px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f8fafc;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.attachment-preview__icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&--video {
|
||||
background: #ede9fe;
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
&--doc {
|
||||
background: #e0f2fe;
|
||||
color: #0284c7;
|
||||
}
|
||||
}
|
||||
|
||||
.attachment-footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.attachment-name {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 11px;
|
||||
color: #334155;
|
||||
line-height: 1.4;
|
||||
word-break: break-all;
|
||||
min-height: calc(11px * 1.4 * 2);
|
||||
}
|
||||
|
||||
.attachment-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
background: #f1f5f9;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: #10b981;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&--danger:hover {
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
+588
@@ -0,0 +1,588 @@
|
||||
<!-- 出库放行审核 — 只读查看 -->
|
||||
<!-- eslint-disable vue/no-v-model-argument -->
|
||||
<template>
|
||||
<SystemViewDialog
|
||||
v-model:visible="innerVisible"
|
||||
title="出库放行审核详情"
|
||||
width="1120px"
|
||||
:grid-columns="4"
|
||||
compact
|
||||
:icon="CircleCheck"
|
||||
:meta="viewHeroMeta"
|
||||
:sections="viewSections"
|
||||
:loading="loading"
|
||||
append-to-body
|
||||
modal-class="outbound-release-view-dialog-mask"
|
||||
>
|
||||
<template #production-info>
|
||||
<SampleProductionInfoCards :production-info="detail?.productionInfo" />
|
||||
</template>
|
||||
|
||||
<template #qc-info>
|
||||
<SampleQcInfoCards :qc-info="detail?.qcInfo" hide-sample-context />
|
||||
</template>
|
||||
|
||||
<template #review-attachments>
|
||||
<div class="view-section-slot">
|
||||
<ProtocolAttachmentReadonly
|
||||
:files="reviewAttachmentFiles"
|
||||
@preview="handleAttachmentPreview"
|
||||
@download="handleAttachmentDownload"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #outbound-material>
|
||||
<div class="view-section-slot outbound-material-view">
|
||||
<div v-if="!hasOutboundMaterial" class="outbound-material-empty">暂无出库物料准备记录</div>
|
||||
<template v-else>
|
||||
<div v-if="outboundMaterialCells.length" class="outbound-material-block">
|
||||
<h4 class="outbound-material-subtitle">出库细胞</h4>
|
||||
<el-table :data="outboundMaterialCells" class="list-table inner-table" stripe size="small">
|
||||
<el-table-column type="index" label="序号" min-width="56" align="center" />
|
||||
<el-table-column prop="productCode" label="产品编码" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="productName" label="产品名称" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="productTypeName" label="类型" min-width="90" />
|
||||
<el-table-column prop="specName" label="规格" min-width="90" />
|
||||
<el-table-column prop="storageSlotName" label="储物格" min-width="130" show-overflow-tooltip />
|
||||
<el-table-column label="状态" min-width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
v-if="row.productStatus"
|
||||
:type="cellProductStatusTagType(row.productStatus) as any"
|
||||
size="small"
|
||||
>
|
||||
{{ cellProductStatusLabel(row.productStatus) }}
|
||||
</el-tag>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<div v-if="outboundMaterialQcRows.length" class="outbound-material-block">
|
||||
<h4 class="outbound-material-subtitle">使用的物料</h4>
|
||||
<el-table :data="outboundMaterialQcRows" class="list-table inner-table" stripe size="small">
|
||||
<el-table-column type="index" label="序号" min-width="56" align="center" />
|
||||
<el-table-column prop="materialName" label="物料名称" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="specModel" label="规格型号" min-width="110" />
|
||||
<el-table-column label="计划用量" min-width="100" align="center">
|
||||
<template #default="{ row }">{{ row.qty ?? '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="unit" label="单位" min-width="72" align="center" />
|
||||
</el-table>
|
||||
</div>
|
||||
<div v-if="outboundPaperRows.length" class="outbound-material-block">
|
||||
<h4 class="outbound-material-subtitle">其他纸质材料</h4>
|
||||
<el-table :data="outboundPaperRows" class="list-table inner-table" stripe size="small">
|
||||
<el-table-column type="index" label="序号" min-width="56" align="center" />
|
||||
<el-table-column prop="paperName" label="名称" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column label="份数" min-width="80" align="center">
|
||||
<template #default="{ row }">{{ row.copies ?? '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="附件" min-width="320">
|
||||
<template #default="{ row }">
|
||||
<div v-if="parsePaperAttachments(row.attachmentUrl).length" class="paper-att-cards">
|
||||
<div
|
||||
v-for="(att, attIdx) in parsePaperAttachments(row.attachmentUrl)"
|
||||
:key="attIdx"
|
||||
class="paper-att-card"
|
||||
>
|
||||
<el-icon class="paper-att-card__icon"><Document /></el-icon>
|
||||
<span class="paper-att-card__name" :title="att.fileName">{{
|
||||
att.fileName || '附件'
|
||||
}}</span>
|
||||
<div class="paper-att-card__actions">
|
||||
<el-icon
|
||||
v-if="isImageFile(att)"
|
||||
class="paper-att-card__action"
|
||||
title="预览"
|
||||
@click="handlePreviewPaperAttachment(att)"
|
||||
>
|
||||
<View />
|
||||
</el-icon>
|
||||
<el-icon
|
||||
class="paper-att-card__action"
|
||||
title="下载"
|
||||
@click="handleDownloadPaperAttachment(att)"
|
||||
>
|
||||
<Download />
|
||||
</el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<p v-if="outboundMaterialRemark" class="outbound-material-remark">
|
||||
备注:{{ outboundMaterialRemark }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</SystemViewDialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="previewVisible"
|
||||
title="文件预览"
|
||||
width="800px"
|
||||
align-center
|
||||
destroy-on-close
|
||||
append-to-body
|
||||
style="backdrop-filter: none"
|
||||
>
|
||||
<div v-if="previewType === 'image'" class="preview-container">
|
||||
<img
|
||||
:src="previewUrl"
|
||||
alt="预览"
|
||||
style="max-width: 100%; max-height: 70vh; display: block; margin: 0 auto"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="preview-container">
|
||||
<video
|
||||
:src="previewUrl"
|
||||
controls
|
||||
style="max-width: 100%; max-height: 70vh; display: block; margin: 0 auto"
|
||||
/>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CircleCheck, Document, Download, View } from '@element-plus/icons-vue'
|
||||
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
|
||||
import type { ViewSection } from '@/components/business/system-view-dialog/index.vue'
|
||||
import SampleQcInfoCards from '@/components/business/sample-qc-info-cards/index.vue'
|
||||
import SampleProductionInfoCards from '@/components/business/sample-production-info-cards/index.vue'
|
||||
import {
|
||||
fetchOutboundReleaseReviewDetail,
|
||||
type OutboundReleaseReviewDetail
|
||||
} from '@/api/outbound-release-review'
|
||||
import {
|
||||
applicationStatusLabel,
|
||||
applicationStatusTagType,
|
||||
storageReleaseConclusionLabel,
|
||||
storageReleaseConclusionTagType,
|
||||
scheduleStatusLabel,
|
||||
scheduleStatusTagType,
|
||||
qcStatusLabel,
|
||||
qcStatusTagType,
|
||||
infusionMethodLabel,
|
||||
cellProductStatusLabel,
|
||||
cellProductStatusTagType
|
||||
} from '@/utils/status-tag-registry'
|
||||
import { maskIdCard } from '@/utils/mask-sensitive'
|
||||
import { downloadAttachmentFile } from '@/utils/download-attachment'
|
||||
|
||||
defineOptions({ name: 'OutboundReleaseViewDialog' })
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
scheduleId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', val: boolean): void
|
||||
}>()
|
||||
|
||||
const innerVisible = computed({
|
||||
get: () => props.visible,
|
||||
set: (val) => emit('update:visible', val)
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const detail = ref<OutboundReleaseReviewDetail | null>(null)
|
||||
const previewVisible = ref(false)
|
||||
const previewUrl = ref('')
|
||||
const previewType = ref<'image' | 'video'>('image')
|
||||
|
||||
type PaperAttachmentView = {
|
||||
fileName: string
|
||||
attachmentUrl: string
|
||||
fileUrl: string
|
||||
fileType?: string
|
||||
}
|
||||
|
||||
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
|
||||
|
||||
watch(
|
||||
() => [props.visible, props.scheduleId] as const,
|
||||
async ([vis, id]) => {
|
||||
if (!vis || !id) {
|
||||
detail.value = null
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
detail.value = await fetchOutboundReleaseReviewDetail(id)
|
||||
} catch {
|
||||
detail.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const viewHeroMeta = computed(() => {
|
||||
if (!detail.value) return ''
|
||||
return resolvePilotDetail(detail.value).application?.applicationNo || ''
|
||||
})
|
||||
|
||||
const reviewAttachmentFiles = computed(() => {
|
||||
const urls = detail.value?.reviewInfo?.reviewAttachments || ''
|
||||
if (!urls.trim()) return []
|
||||
return urls
|
||||
.split(',')
|
||||
.map((u) => u.trim())
|
||||
.filter(Boolean)
|
||||
.map((fileUrl, i) => ({
|
||||
id: `att_${i}`,
|
||||
fileUrl,
|
||||
fileName: fileUrl.split('/').pop() || ''
|
||||
}))
|
||||
})
|
||||
|
||||
const outboundMaterial = computed(() => detail.value?.outboundMaterial || null)
|
||||
const outboundMaterialCells = computed(() => outboundMaterial.value?.cells || [])
|
||||
const outboundMaterialQcRows = computed(() => outboundMaterial.value?.qcMaterials || [])
|
||||
const outboundMaterialRemark = computed(() => outboundMaterial.value?.remark || '')
|
||||
const outboundPaperRows = computed(() => {
|
||||
const materials = outboundMaterial.value?.paperMaterials || []
|
||||
if (materials.length) {
|
||||
return materials.map((p) => ({
|
||||
paperName: p.paperName || '',
|
||||
copies: p.copies,
|
||||
attachmentUrl: p.attachmentUrl || ''
|
||||
}))
|
||||
}
|
||||
const urls = outboundMaterial.value?.paperAttachments || ''
|
||||
if (!urls.trim()) return []
|
||||
return urls
|
||||
.split(',')
|
||||
.map((u) => u.trim())
|
||||
.filter(Boolean)
|
||||
.map((attachmentUrl) => ({
|
||||
paperName: paperAttachmentName(attachmentUrl),
|
||||
copies: 1,
|
||||
attachmentUrl
|
||||
}))
|
||||
})
|
||||
const hasOutboundMaterial = computed(
|
||||
() =>
|
||||
outboundMaterialCells.value.length > 0 ||
|
||||
outboundMaterialQcRows.value.length > 0 ||
|
||||
outboundPaperRows.value.length > 0 ||
|
||||
!!outboundMaterialRemark.value.trim()
|
||||
)
|
||||
|
||||
function paperAttachmentName(url: string) {
|
||||
return url.split('/').pop() || '附件'
|
||||
}
|
||||
|
||||
function splitUrls(str: string): string[] {
|
||||
if (!str?.trim()) return []
|
||||
return str
|
||||
.split(',')
|
||||
.map((u) => u.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function parsePaperAttachments(urlStr: string): PaperAttachmentView[] {
|
||||
return splitUrls(urlStr).map((url) => ({
|
||||
fileName: paperAttachmentName(url),
|
||||
attachmentUrl: url,
|
||||
fileUrl: url
|
||||
}))
|
||||
}
|
||||
|
||||
function isImageFile(file: PaperAttachmentView) {
|
||||
const url = (file.fileUrl || file.attachmentUrl || '').toLowerCase()
|
||||
const name = (file.fileName || '').toLowerCase()
|
||||
return (
|
||||
/\.(jpg|jpeg|png|gif|bmp|webp|svg)(\?|$)/.test(url) ||
|
||||
/\.(jpg|jpeg|png|gif|bmp|webp|svg)$/.test(name) ||
|
||||
(file.fileType || '').startsWith('image/')
|
||||
)
|
||||
}
|
||||
|
||||
function isVideoFile(file: PaperAttachmentView) {
|
||||
const url = (file.fileUrl || file.attachmentUrl || '').toLowerCase()
|
||||
const name = (file.fileName || '').toLowerCase()
|
||||
return (
|
||||
/\.(mp4|webm|ogg|mov|avi)(\?|$)/.test(url) ||
|
||||
/\.(mp4|webm|ogg|mov|avi)$/.test(name) ||
|
||||
(file.fileType || '').startsWith('video/')
|
||||
)
|
||||
}
|
||||
|
||||
function handlePreviewPaperAttachment(att: PaperAttachmentView) {
|
||||
if (!att.attachmentUrl) return
|
||||
previewUrl.value = attachmentFullUrl(att.attachmentUrl)
|
||||
previewType.value = isVideoFile(att) ? 'video' : 'image'
|
||||
previewVisible.value = true
|
||||
}
|
||||
|
||||
function handleDownloadPaperAttachment(att: PaperAttachmentView) {
|
||||
if (!att.attachmentUrl) return
|
||||
downloadAttachmentFile(
|
||||
{
|
||||
fileName: att.fileName,
|
||||
fileUrl: att.attachmentUrl,
|
||||
fileType: att.fileType || ''
|
||||
},
|
||||
{ fullFileUrl: attachmentFullUrl }
|
||||
)
|
||||
}
|
||||
|
||||
/** 兼容旧版嵌套 pilotContext;新版详情已与 OutboundReleaseReviewDetail 展平一致 */
|
||||
function resolvePilotDetail(d: OutboundReleaseReviewDetail) {
|
||||
const nested = (d as { pilotContext?: OutboundReleaseReviewDetail }).pilotContext
|
||||
if (nested && typeof nested === 'object') {
|
||||
return { ...nested, productionInfo: d.productionInfo, qcInfo: d.qcInfo, reviewInfo: d.reviewInfo }
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
const viewSections = computed<ViewSection[]>(() => {
|
||||
const d = detail.value
|
||||
if (!d) return []
|
||||
const ctx = resolvePilotDetail(d)
|
||||
const app = ctx.application || {}
|
||||
const patient = ctx.patient || {}
|
||||
const plan = ctx.infusionPlan || {}
|
||||
const schedule = ctx.infusionSchedule || {}
|
||||
const review = ctx.reviewInfo || {}
|
||||
|
||||
const reviewConclusionValue = review.reviewConclusion || ''
|
||||
const reviewConclusionDisplay = reviewConclusionValue
|
||||
? storageReleaseConclusionLabel(reviewConclusionValue)
|
||||
: '待审核'
|
||||
const reviewConclusionTag = reviewConclusionValue
|
||||
? storageReleaseConclusionTagType(reviewConclusionValue)
|
||||
: 'info'
|
||||
|
||||
return [
|
||||
{
|
||||
title: '中试服务申请单信息',
|
||||
fields: [
|
||||
{ label: '申请单号', value: app.applicationNo || '—' },
|
||||
{ label: '申请单名称', value: app.applicationName || '—' },
|
||||
{
|
||||
label: '申请单状态',
|
||||
value: applicationStatusLabel(app.applicationStatus || ''),
|
||||
tagType: applicationStatusTagType(app.applicationStatus || '')
|
||||
},
|
||||
{ label: '渠道名称', value: app.channelName || '—' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '患者信息',
|
||||
fields: [
|
||||
{ label: '患者姓名', value: patient.patientName || '—' },
|
||||
{ label: '性别', value: patient.sex || '—' },
|
||||
{ label: '年龄', value: patient.age || '—' },
|
||||
{ label: '联系电话', value: patient.phoneNo || '—' },
|
||||
{
|
||||
label: '身份证号',
|
||||
value: patient.idCardNo ? maskIdCard(patient.idCardNo) : '—'
|
||||
},
|
||||
{ label: '紧急联系人', value: patient.emergencyContact || '—' },
|
||||
{ label: '紧急联系电话', value: patient.emergencyPhone || '—' },
|
||||
{ label: '临床诊断', value: patient.clinicalDiagnosis || '—', span: true }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '输注方案信息',
|
||||
fields: [
|
||||
{
|
||||
label: '总疗程数',
|
||||
value: plan.totalCourseCount != null ? String(plan.totalCourseCount) : '—'
|
||||
},
|
||||
{ label: '疗程间隔', value: plan.courseInterval || '—' },
|
||||
{ label: '输注开始日期', value: plan.infusionStartDate || '—' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '本次输注计划',
|
||||
fields: [
|
||||
{ label: '输注时间', value: schedule.infusionTime || '—' },
|
||||
{ label: '输注地点', value: schedule.infusionLocation || '—' },
|
||||
{ label: '输注方式', value: infusionMethodLabel(schedule.infusionMethod || '') },
|
||||
{
|
||||
label: '计划状态',
|
||||
value: scheduleStatusLabel(schedule.scheduleStatus || ''),
|
||||
tagType: scheduleStatusTagType(schedule.scheduleStatus || '')
|
||||
},
|
||||
{
|
||||
label: '质检状态',
|
||||
value: qcStatusLabel(schedule.qcStatus || ''),
|
||||
tagType: qcStatusTagType(schedule.qcStatus || '')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '生产信息',
|
||||
slot: 'production-info',
|
||||
fields: []
|
||||
},
|
||||
{
|
||||
title: '质检信息',
|
||||
slot: 'qc-info',
|
||||
fields: []
|
||||
},
|
||||
{
|
||||
title: '出库物料准备',
|
||||
slot: 'outbound-material',
|
||||
fields: []
|
||||
},
|
||||
{
|
||||
title: '放行审核',
|
||||
slot: 'review-attachments',
|
||||
fields: [
|
||||
{
|
||||
label: '审核结论',
|
||||
value: reviewConclusionDisplay,
|
||||
tagType: reviewConclusionTag
|
||||
},
|
||||
{ label: '审核人', value: review.reviewByName || '—' },
|
||||
{ label: '审核时间', value: review.reviewTime || '—' },
|
||||
{ label: '审核备注', value: review.reviewRemark || '—', span: true }
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
function attachmentFullUrl(url: string) {
|
||||
const u = String(url || '').trim()
|
||||
if (!u) return ''
|
||||
if (/^https?:\/\//i.test(u)) return u
|
||||
return minioBaseUrl ? `${minioBaseUrl}/${u.replace(/^\//, '')}` : u
|
||||
}
|
||||
|
||||
function handleAttachmentPreview(file: Record<string, unknown>) {
|
||||
const url = attachmentFullUrl(String(file.fileUrl || ''))
|
||||
if (url) window.open(url, '_blank')
|
||||
}
|
||||
|
||||
function handleAttachmentDownload(file: Record<string, unknown>) {
|
||||
downloadAttachmentFile(file, { fullFileUrl: attachmentFullUrl })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.view-section-slot :deep(.sample-card__section-label) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.view-section-slot :deep(.sample-card__section--qc),
|
||||
.view-section-slot :deep(.sample-card__section--production) {
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.outbound-material-view {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.outbound-material-empty {
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.outbound-material-block {
|
||||
margin-bottom: 16px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.outbound-material-subtitle {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.outbound-material-remark {
|
||||
margin: 12px 0 0;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.paper-att-cards {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.paper-att-card {
|
||||
width: 100px;
|
||||
height: 72px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
background: #f8fafc;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6px 4px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.paper-att-card__icon {
|
||||
color: #0284c7;
|
||||
font-size: 18px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.paper-att-card__name {
|
||||
width: 100%;
|
||||
font-size: 11px;
|
||||
color: #334155;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.paper-att-card__actions {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.paper-att-card:hover .paper-att-card__actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.paper-att-card__action {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #10b981;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,376 @@
|
||||
<!-- 出库放行审核 -->
|
||||
<!-- eslint-disable vue/no-v-model-argument -->
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<header class="page-head">
|
||||
<h1>出库放行审核</h1>
|
||||
<p class="page-desc">审核出库放行记录,管理中试服务出库流程。</p>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-top">
|
||||
<h2>审核列表</h2>
|
||||
</div>
|
||||
|
||||
<div class="filter-block">
|
||||
<el-form :model="searchForm" class="filter-form" @submit.prevent>
|
||||
<div class="filter-grid">
|
||||
<el-form-item label="关键词">
|
||||
<el-input
|
||||
v-model="searchForm.keyword"
|
||||
placeholder="申请单号/患者姓名"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="审核结论">
|
||||
<el-select
|
||||
v-model="searchForm.reviewConclusion"
|
||||
clearable
|
||||
placeholder="全部"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option label="待审核" value="" />
|
||||
<el-option
|
||||
v-for="item in reviewConclusionOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="filter-actions-row">
|
||||
<span class="filter-hint">支持按申请单号、患者姓名搜索。</span>
|
||||
<div class="filter-actions-end">
|
||||
<el-button class="btn-ghost" @click="handleReset">
|
||||
<el-icon><RefreshLeft /></el-icon>重置
|
||||
</el-button>
|
||||
<el-button class="btn-main" type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>搜索
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="list-toolbar">
|
||||
<div class="list-toolbar-row">
|
||||
<div class="list-toolbar-selected">
|
||||
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
|
||||
</div>
|
||||
<el-button
|
||||
type="primary"
|
||||
class="btn-main"
|
||||
:disabled="!canBatchReview"
|
||||
@click="openReviewDialog()"
|
||||
>
|
||||
<el-icon><CircleCheck /></el-icon>
|
||||
批量审核
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list-body list-wrap">
|
||||
<el-table
|
||||
class="list-table outbound-release-review-table"
|
||||
:data="tableData"
|
||||
v-loading="loading"
|
||||
stripe
|
||||
row-key="id"
|
||||
@selection-change="handleSelectionChange"
|
||||
@row-dblclick="handleView"
|
||||
>
|
||||
<el-table-column type="selection" min-width="50" align="center" header-align="center" />
|
||||
<el-table-column
|
||||
prop="applicationNo"
|
||||
label="申请单号"
|
||||
min-width="160"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="patientName"
|
||||
label="患者姓名"
|
||||
min-width="100"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="bizType"
|
||||
label="类型"
|
||||
min-width="110"
|
||||
align="center"
|
||||
header-align="center"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
v-if="row.bizType"
|
||||
:type="bizTypeTagType(row.bizType) as any"
|
||||
size="small"
|
||||
>
|
||||
{{ row.bizTypeLabel || bizTypeLabel(row.bizType) }}
|
||||
</el-tag>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="infusionTime"
|
||||
label="输注时间"
|
||||
min-width="170"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="scheduleStatus"
|
||||
label="计划状态"
|
||||
min-width="100"
|
||||
align="center"
|
||||
header-align="center"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
v-if="row.scheduleStatus"
|
||||
:type="scheduleStatusTagType(row.scheduleStatus)"
|
||||
size="small"
|
||||
>
|
||||
{{ scheduleStatusLabel(row.scheduleStatus) }}
|
||||
</el-tag>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="qcStatus"
|
||||
label="质检状态"
|
||||
min-width="100"
|
||||
align="center"
|
||||
header-align="center"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.qcStatus" :type="qcStatusTagType(row.qcStatus)" size="small">
|
||||
{{ qcStatusLabel(row.qcStatus) }}
|
||||
</el-tag>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="reviewConclusion"
|
||||
label="审核结论"
|
||||
min-width="100"
|
||||
align="center"
|
||||
header-align="center"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
v-if="row.reviewConclusion"
|
||||
:type="storageReleaseConclusionTagType(row.reviewConclusion)"
|
||||
size="small"
|
||||
>
|
||||
{{ storageReleaseConclusionLabel(row.reviewConclusion) }}
|
||||
</el-tag>
|
||||
<el-tag v-else type="info" size="small">待审核</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="reviewByName"
|
||||
label="审核人"
|
||||
min-width="100"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="reviewTime"
|
||||
label="审核时间"
|
||||
min-width="170"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
label="操作"
|
||||
min-width="260"
|
||||
fixed="right"
|
||||
align="center"
|
||||
header-align="center"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<el-button class="btn-op-item" @click="handleView(row)">
|
||||
<el-icon><View /></el-icon>查看
|
||||
</el-button>
|
||||
<el-button class="btn-op-item" @click="openMaterialDialog(row)">
|
||||
<el-icon><Box /></el-icon>出库物料
|
||||
</el-button>
|
||||
<el-button class="btn-op-item" @click="openReviewDialog(row)">
|
||||
<el-icon><CircleCheck /></el-icon>审核
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination list-pager-bar">
|
||||
<el-pagination
|
||||
:current-page="pagination.page"
|
||||
:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
|
||||
@current-change="(val) => ((pagination.page = val), loadData())"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OutboundReleaseViewDialog v-model:visible="viewVisible" :schedule-id="viewScheduleId" />
|
||||
|
||||
<OutboundReleaseReviewDialog
|
||||
v-model="reviewDialogVisible"
|
||||
:schedule-ids="reviewDialogIds"
|
||||
@submitted="onReviewSubmitted"
|
||||
/>
|
||||
|
||||
<OutboundReleaseMaterialDialog
|
||||
v-model="materialDialogVisible"
|
||||
:schedule-id="materialScheduleId"
|
||||
:schedule-summary="materialScheduleSummary"
|
||||
@saved="loadData"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { View, Search, RefreshLeft, CircleCheck, Box } from '@element-plus/icons-vue'
|
||||
import OutboundReleaseViewDialog from './OutboundReleaseViewDialog.vue'
|
||||
import OutboundReleaseReviewDialog from './OutboundReleaseReviewDialog.vue'
|
||||
import OutboundReleaseMaterialDialog from './OutboundReleaseMaterialDialog.vue'
|
||||
import {
|
||||
fetchOutboundReleaseReviewList,
|
||||
type OutboundReleaseReview
|
||||
} from '@/api/outbound-release-review'
|
||||
import {
|
||||
storageReleaseConclusionLabel,
|
||||
storageReleaseConclusionTagType,
|
||||
scheduleStatusLabel,
|
||||
scheduleStatusTagType,
|
||||
qcStatusLabel,
|
||||
qcStatusTagType,
|
||||
bizTypeLabel,
|
||||
bizTypeTagType
|
||||
} from '@/utils/status-tag-registry'
|
||||
|
||||
defineOptions({ name: 'PilotServiceOutboundReleaseReview' })
|
||||
|
||||
const reviewConclusionOptions = [
|
||||
{ value: 'tg', label: '通过' },
|
||||
{ value: 'btg', label: '不通过' }
|
||||
]
|
||||
|
||||
const searchForm = reactive({
|
||||
reviewConclusion: '',
|
||||
keyword: ''
|
||||
})
|
||||
const tableData = ref<OutboundReleaseReview[]>([])
|
||||
const selectedRows = ref<OutboundReleaseReview[]>([])
|
||||
const loading = ref(false)
|
||||
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
|
||||
|
||||
const handleSelectionChange = (rows: OutboundReleaseReview[]) => {
|
||||
selectedRows.value = rows
|
||||
}
|
||||
|
||||
const canBatchReview = computed(() => selectedRows.value.length > 0)
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetchOutboundReleaseReviewList({
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
reviewConclusion: searchForm.reviewConclusion || undefined,
|
||||
keyword: searchForm.keyword || undefined
|
||||
})
|
||||
tableData.value = res.list || []
|
||||
pagination.total = res.total ?? 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
searchForm.reviewConclusion = ''
|
||||
searchForm.keyword = ''
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
const viewVisible = ref(false)
|
||||
const viewScheduleId = ref('')
|
||||
|
||||
function handleView(row: OutboundReleaseReview) {
|
||||
viewScheduleId.value = String(row.scheduleId || row.id)
|
||||
viewVisible.value = true
|
||||
}
|
||||
|
||||
const reviewDialogVisible = ref(false)
|
||||
const reviewDialogIds = ref<string[]>([])
|
||||
|
||||
function openReviewDialog(row?: OutboundReleaseReview) {
|
||||
const rows = row ? [row] : selectedRows.value
|
||||
if (!rows.length) {
|
||||
ElMessage.warning('请先选择要审核的记录')
|
||||
return
|
||||
}
|
||||
reviewDialogIds.value = rows.map((r) => String(r.scheduleId || r.id))
|
||||
reviewDialogVisible.value = true
|
||||
}
|
||||
|
||||
function onReviewSubmitted() {
|
||||
selectedRows.value = []
|
||||
loadData()
|
||||
}
|
||||
|
||||
const materialDialogVisible = ref(false)
|
||||
const materialScheduleId = ref('')
|
||||
const materialScheduleSummary = ref<{
|
||||
applicationNo?: string
|
||||
patientName?: string
|
||||
infusionTime?: string
|
||||
}>()
|
||||
|
||||
function openMaterialDialog(row: OutboundReleaseReview) {
|
||||
materialScheduleId.value = String(row.scheduleId || row.id)
|
||||
materialScheduleSummary.value = {
|
||||
applicationNo: row.applicationNo,
|
||||
patientName: row.patientName,
|
||||
infusionTime: row.infusionTime
|
||||
}
|
||||
materialDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 输入检索条件时自动搜索(防抖 300ms)
|
||||
watchDebounced(
|
||||
searchForm,
|
||||
() => {
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
},
|
||||
{ debounce: 300, deep: true }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.outbound-release-review-table {
|
||||
:deep(td.el-table__cell:has(.btn-op-item)),
|
||||
:deep(td.el-table-fixed-column--right:has(.btn-op-item)),
|
||||
:deep(th.el-table-fixed-column--right) {
|
||||
min-width: 260px !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -568,6 +568,12 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InfusionScheduleOutboundChainView
|
||||
v-if="schedule.id"
|
||||
:schedule-id="schedule.id"
|
||||
:active="!!schedule._expanded"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -766,6 +772,7 @@
|
||||
type ViewSection
|
||||
} from '@/components/business/system-view-dialog/index.vue'
|
||||
import ServiceOrderTxnTable from '@/views/pilot-service/channel-service-order/ServiceOrderTxnTable.vue'
|
||||
import InfusionScheduleOutboundChainView from '@/components/business/infusion-schedule-outbound-chain-view/index.vue'
|
||||
import {
|
||||
fetchPilotApplicationDetail,
|
||||
fetchPilotPatient,
|
||||
|
||||
@@ -187,7 +187,7 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="activeTypeTab !== 'inbound'"
|
||||
v-if="activeTypeTab !== 'inbound' && activeTypeTab !== 'outbound'"
|
||||
prop="qcBizTypeLabel"
|
||||
label="业务类型"
|
||||
min-width="100"
|
||||
|
||||
Reference in new issue
Block a user