This commit is contained in:
zlwu committed 2026-05-27 05:23:28 +08:00
1 parent ad8f8d696f
commit 41aa90aa05
115 files changed
+29626 -10210

No files matched your search

@@ -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>
@@ -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}」` : '该患者'
@@ -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>
@@ -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"