Files
zkxw/web-admin/src/views/pilot-service/pilot-application/InfusionScheduleTab.vue
T
2026-06-16 17:10:01 +08:00

710 lines
24 KiB
Vue

<!-- 中试服务申请单 · 输注计划 Tab -->
<template>
<div class="infusion-schedule-tab">
<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="keywordPlaceholder" clearable />
</el-form-item>
<el-form-item v-if="!isBasicExperiment" label="输注状态">
<el-select
v-model="searchForm.infusionStatus"
placeholder="全部"
clearable
style="width: 100%"
>
<el-option label="待输注" value="dtz" />
<el-option label="已用户确认" value="yrqr" />
<el-option label="已取消" value="yqx" />
<el-option label="已完成" value="ywc" />
</el-select>
</el-form-item>
<el-form-item label="质检状态">
<el-select
v-model="searchForm.qcStatus"
placeholder="全部"
clearable
style="width: 100%"
>
<el-option label="待质检" value="dzc" />
<el-option label="已质检" value="yzj" />
<el-option label="已取消" value="yqx" />
</el-select>
</el-form-item>
<el-form-item :label="bizConfig.pickupTimeLabel">
<el-date-picker
v-model="searchForm.infusionTimeRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
</div>
<div class="filter-actions-row">
<span class="filter-hint">{{ filterHint }}</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>
</div>
</div>
<!-- 表格 -->
<div class="list-body list-wrap">
<el-table
ref="tableRef"
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
:row-class-name="infusionScheduleRowClassName"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="(i: number) => (pagination.page - 1) * pagination.pageSize + i + 1"
/>
<el-table-column
prop="applicationNo"
label="申请单号"
min-width="160"
align="center"
header-align="center"
/>
<el-table-column
v-if="isBasicExperiment"
prop="projectTitle"
label="课题名称"
min-width="140"
align="center"
header-align="center"
/>
<el-table-column
v-if="isBasicExperiment"
prop="customerName"
label="客户名称"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
v-if="bizConfig.showPatient"
prop="patientName"
label="患者姓名"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
v-if="!isBasicExperiment && bizConfig.showPatient"
prop="sex"
label="性别"
min-width="60"
align="center"
header-align="center"
>
<template #default="{ row }">{{ sexLabel(row.patientSex ?? row.sex) || '—' }}</template>
</el-table-column>
<el-table-column
v-if="!isBasicExperiment && bizConfig.showPatient"
prop="age"
label="年龄"
min-width="60"
align="center"
header-align="center"
>
<template #default="{ row }">{{ row.patientAge ?? row.age ?? '—' }}</template>
</el-table-column>
<el-table-column
v-if="bizConfig.showChannel"
prop="channelName"
label="渠道名称"
min-width="140"
align="center"
header-align="center"
>
<template #default="{ row }">{{ row.channelName || '—' }}</template>
</el-table-column>
<el-table-column
v-if="!isBasicExperiment"
label="第几次"
min-width="88"
align="center"
header-align="center"
>
<template #default="{ row }">{{ formatScheduleSortNo(row) }}</template>
</el-table-column>
<el-table-column
v-if="!isBasicExperiment"
label="总次数"
min-width="88"
align="center"
header-align="center"
>
<template #default="{ row }">{{ formatScheduleTotalCount(row) }}</template>
</el-table-column>
<el-table-column
prop="cellType"
label="细胞产品"
min-width="120"
align="center"
header-align="center"
>
<template #default="{ row }">{{ row.cellType || row.cellTypeName || '—' }}</template>
</el-table-column>
<el-table-column
prop="infusionTime"
:label="bizConfig.pickupTimeLabel"
min-width="160"
align="center"
header-align="center"
>
<template #default="{ row }">{{ row.infusionTime || '—' }}</template>
</el-table-column>
<el-table-column
v-if="!isBasicExperiment"
prop="infusionLocation"
label="输注地点"
min-width="140"
align="center"
header-align="center"
>
<template #default="{ row }">{{ row.infusionLocation || '—' }}</template>
</el-table-column>
<el-table-column
prop="scheduleStatus"
label="生产状态"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag :type="scheduleStatusTagType(row.scheduleStatus)" size="small">
{{ scheduleStatusLabel(row.scheduleStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
v-if="!isBasicExperiment"
prop="infusionStatus"
label="输注状态"
min-width="110"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag :type="infusionStatusTagType(row.infusionStatus)" size="small">
{{ infusionStatusLabel(row.infusionStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="质检状态" min-width="120" align="center" header-align="center">
<template #default="{ row }">
<el-tag :type="qcStatusTagType(row.qcStatus)" size="small">{{
qcStatusLabel(row.qcStatus)
}}</el-tag>
</template>
</el-table-column>
<el-table-column
v-if="isClinical || isBasicExperiment"
label="金额"
min-width="120"
align="right"
header-align="center"
>
<template #default="{ row }">
{{
row.amount != null && row.amount !== ''
? '¥' + Number(row.amount).toLocaleString('zh-CN', { minimumFractionDigits: 2 })
: '—'
}}
</template>
</el-table-column>
<el-table-column
v-if="isClinical || isBasicExperiment"
label="缴费状态"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag v-if="row.payStatus" :type="payStatusTagType(row.payStatus)" size="small">
{{ payStatusLabel(row.payStatus) }}
</el-tag>
<span v-else>—</span>
</template>
</el-table-column>
<el-table-column
label="操作"
min-width="480"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px">
<template v-if="hasPrimaryScheduleActions(row)">
<el-button
v-if="canConfirmSchedule(row)"
class="btn-op-item primary"
type="primary"
@click="handleConfirm(row)"
>
<el-icon><CircleCheck /></el-icon>用户已确认
</el-button>
<el-button
v-if="canCompleteSchedule(row)"
class="btn-op-item primary"
type="primary"
@click="handleComplete(row)"
>
<el-icon><SuccessFilled /></el-icon>完成
</el-button>
<el-button
v-if="canCancelSchedule(row)"
class="btn-op-item op-warn"
type="danger"
@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>
</template>
<el-button v-else class="btn-op-item primary" @click="handleView(row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-dropdown
v-if="hasMoreScheduleActions(row)"
trigger="hover"
@command="(cmd) => handleDropdownCommand(cmd, row)"
>
<el-button class="btn-op-item">
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="hasPrimaryScheduleActions(row)" command="view">
<el-icon><View /></el-icon>查看
</el-dropdown-item>
<el-dropdown-item v-if="canEditSchedule(row)" command="edit">
<el-icon><EditPen /></el-icon>编辑
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</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="
(v: number) => ((pagination.page = 1), (pagination.pageSize = v), loadData())
"
@current-change="(v: number) => ((pagination.page = v), loadData())"
/>
</div>
</div>
<!-- 编辑弹窗 -->
<ScheduleEditDialog v-model="editDialogVisible" :schedule-row="editRow" @success="loadData" />
<!-- 查看弹窗 -->
<ScheduleViewDialog v-model="viewDialogVisible" :schedule-row="viewRow" />
<!-- 取消弹窗 -->
<ScheduleCancelDialog
v-model="cancelDialogVisible"
:schedule-id="cancelScheduleId"
:patient-name="cancelPatientName"
:biz-type="props.bizType"
@success="loadData"
/>
<!-- 出库交接 -->
<InfusionScheduleHandoverDialog
v-model="handoverDialogVisible"
:schedule-id="handoverScheduleId"
:schedule-row="handoverRow"
:biz-type="props.bizType"
@saved="loadData"
/>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
RefreshLeft,
Search,
View,
EditPen,
CircleCheck,
CircleClose,
SuccessFilled,
Switch,
More
} from '@element-plus/icons-vue'
import { fetchInfusionScheduleMgmtList, updateInfusionScheduleStatus } from '@/api/admin'
import {
scheduleStatusLabel,
scheduleStatusTagType,
infusionStatusLabel,
infusionStatusTagType,
qcStatusLabel,
qcStatusTagType,
channelServiceOrderTypeLabel,
sexLabel,
payStatusLabel,
payStatusTagType
} from '@/utils/status-tag-registry'
import ScheduleEditDialog from '../infusion-schedule-management/ScheduleEditDialog.vue'
import ScheduleViewDialog from '../infusion-schedule-management/ScheduleViewDialog.vue'
import ScheduleCancelDialog from '../infusion-schedule-management/ScheduleCancelDialog.vue'
import InfusionScheduleHandoverDialog from '../infusion-schedule-management/InfusionScheduleHandoverDialog.vue'
import { getPilotBizConfig, resolvePilotBizType } from '@/config/pilot-biz-type'
defineOptions({ name: 'InfusionScheduleTab' })
const props = defineProps<{ bizType?: string }>()
const bizConfig = computed(() => getPilotBizConfig(props.bizType))
const isClinical = computed(() => props.bizType === 'clinical_application')
const isBasicExperiment = computed(() => props.bizType === 'basic_experiment')
const keywordPlaceholder = computed(() =>
isBasicExperiment.value ? '申请单号/课题名称/客户名称' : '申请单号/患者姓名/渠道名称'
)
const filterHint = computed(() =>
isBasicExperiment.value
? '支持申请单号、课题名称、客户名称模糊查询,输入即检索。'
: '支持申请单号、患者姓名、渠道名称模糊查询,输入即检索。'
)
const searchForm = reactive({
keyword: '',
infusionStatus: '',
qcStatus: '',
infusionTimeRange: null as string[] | null
})
// ── 表格 ─────────────────────────────
const loading = ref(false)
const tableData = ref<any[]>([])
const tableRef = ref()
const selectedRows = ref<any[]>([])
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const handleSelectionChange = (rows: any[]) => {
selectedRows.value = rows
}
/** 输注时间落在本周(周一至周日) */
const isInfusionThisWeek = (infusionTime?: string | null): boolean => {
if (!infusionTime) return false
const dateStr = String(infusionTime).trim().slice(0, 10)
const infusionDate = new Date(`${dateStr}T00:00:00`)
if (Number.isNaN(infusionDate.getTime())) return false
const today = new Date()
today.setHours(0, 0, 0, 0)
const weekday = today.getDay()
const diffToMonday = weekday === 0 ? -6 : 1 - weekday
const weekStart = new Date(today)
weekStart.setDate(today.getDate() + diffToMonday)
const weekEnd = new Date(weekStart)
weekEnd.setDate(weekStart.getDate() + 6)
weekEnd.setHours(23, 59, 59, 999)
return infusionDate >= weekStart && infusionDate <= weekEnd
}
const infusionScheduleRowClassName = ({ row }: { row: Record<string, unknown> }) => {
return isInfusionThisWeek(row.infusionTime as string) ? 'row-infusion-this-week' : ''
}
const formatScheduleSortNo = (row: Record<string, unknown>) => {
const n = row.sortNo ?? row.orderNum
if (n == null || n === '' || Number(n) <= 0) return '—'
return `第${Number(n)}次`
}
const formatScheduleTotalCount = (row: Record<string, unknown>) => {
const n = row.totalCourseCount
if (n == null || n === '' || Number(n) <= 0) return '—'
return `共${Number(n)}次`
}
// ── 弹窗 ─────────────────────────────
const editDialogVisible = ref(false)
const editRow = ref<any>(null)
const viewDialogVisible = ref(false)
const viewRow = ref<any>(null)
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 () => {
loading.value = true
try {
const params: any = {
page: pagination.page,
pageSize: pagination.pageSize,
keyword: searchForm.keyword || undefined,
infusionStatus: searchForm.infusionStatus || undefined,
qcStatus: searchForm.qcStatus || undefined,
bizType: resolvePilotBizType(props.bizType)
}
if (searchForm.infusionTimeRange?.length === 2) {
params.infusionTimeStart = searchForm.infusionTimeRange[0]
params.infusionTimeEnd = searchForm.infusionTimeRange[1]
}
const res: any = await fetchInfusionScheduleMgmtList(params)
const data = res?.data ?? res
tableData.value = data?.list ?? data?.records ?? []
pagination.total = data?.pagination?.total ?? data?.total ?? 0
} catch (e) {
console.error('加载输注计划列表失败:', e)
tableData.value = []
pagination.total = 0
} finally {
loading.value = false
}
}
// ── 事件 ──────────────────────────────
const handleSearch = () => {
pagination.page = 1
loadData()
}
const handleReset = () => {
Object.assign(searchForm, {
keyword: '',
infusionStatus: '',
qcStatus: '',
infusionTimeRange: null
})
pagination.page = 1
loadData()
}
const handleEdit = (row: any) => {
editRow.value = { ...row }
editDialogVisible.value = true
}
const handleView = (row: any) => {
viewRow.value = { ...row }
viewDialogVisible.value = true
}
const getInfusionStatus = (row: any) =>
String(row?.infusionStatus || row?.infusion_status || '').trim()
const canEditSchedule = (row: any) => {
const s = getInfusionStatus(row)
return s === 'dtz' || s === 'yrqr'
}
/** 待输注时可用户已确认 */
const canConfirmSchedule = (row: any) => getInfusionStatus(row) === 'dtz'
/** 待输注、已用户确认可取消 */
const canCancelSchedule = (row: any) => canEditSchedule(row)
/** 待输注、已用户确认均可完成(待输注时将自动先确认再完成) */
const canCompleteSchedule = (row: any) => canEditSchedule(row)
const canShowHandover = (row: any) => getInfusionStatus(row) !== 'yqx'
/** 是否存在用户已确认 / 完成 / 取消 / 出库交接主操作 */
const hasPrimaryScheduleActions = (row: any) =>
canConfirmSchedule(row) ||
canCompleteSchedule(row) ||
canCancelSchedule(row) ||
canShowHandover(row)
/** 更多菜单是否展示 */
const hasMoreScheduleActions = (row: any) => {
if (hasPrimaryScheduleActions(row)) {
return true
}
return canEditSchedule(row)
}
const handleDropdownCommand = (command: string, row: any) => {
switch (command) {
case 'view':
handleView(row)
break
case 'edit':
handleEdit(row)
break
}
}
const handleHandover = (row: any) => {
handoverScheduleId.value = row.id
handoverRow.value = { ...row }
handoverDialogVisible.value = true
}
const buildCompleteConfirmMessage = (row: any) => {
const scheduleLabel = bizConfig.value.scheduleLabel
if (isBasicExperiment.value) {
const subject = row.projectTitle
? `课题「${row.projectTitle}」`
: row.customerName
? `客户「${row.customerName}」`
: '该'
return `${subject}${scheduleLabel}将标记为已完成。是否继续?`
}
const patient = row.patientName ? `患者「${row.patientName}」` : '该患者'
const pendingConfirmHint =
getInfusionStatus(row) === 'dtz' ? '当前为待输注,系统将先记录用户确认再标记完成。' : ''
if (isClinical.value) {
const paymentHint = '临床应用不在此扣减渠道服务订单费用,缴费请在「缴费信息」中单独处理。'
return `${patient}的${scheduleLabel}将标记为已完成。${pendingConfirmHint}${paymentHint}是否继续?`
}
const orderTypeLabel = channelServiceOrderTypeLabel(row.orderType)
const statusHint =
getInfusionStatus(row) === 'dtz' ? '当前为待输注,系统将先记录用户确认再完成结算。' : ''
let settlementHint = '并触发关联服务订单的结算扣减。'
if (row.orderType === 'yfk') {
settlementHint = `关联服务订单类型为「${orderTypeLabel}」,系统将按渠道协议单价扣减服务订单账户余额。`
} else if (row.orderType === 'gmcp') {
settlementHint = `关联服务订单类型为「${orderTypeLabel}」,系统将按份数扣减服务订单中的细胞产品持仓。`
}
return `${patient}的${scheduleLabel}将标记为已完成。${statusHint}${settlementHint}是否继续?`
}
const handleConfirm = async (row: any) => {
try {
await ElMessageBox.confirm(
`确认该${bizConfig.value.scheduleLabel}已由用户确认?`,
'用户已确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'info'
})
await updateInfusionScheduleStatus({
scheduleId: row.id,
infusionStatus: 'yrqr'
})
ElMessage.success('已确认用户确认')
loadData()
} catch (e: any) {
if (e !== 'cancel') {
// error handled by request util
}
}
}
const handleComplete = async (row: any) => {
try {
await ElMessageBox.confirm(
buildCompleteConfirmMessage(row),
`完成${bizConfig.value.scheduleLabel}`,
{
confirmButtonText: '确定完成',
cancelButtonText: '取消',
type: 'warning'
})
const scheduleId = row.id
if (getInfusionStatus(row) === 'dtz') {
await updateInfusionScheduleStatus({
scheduleId,
infusionStatus: 'yrqr'
})
}
await updateInfusionScheduleStatus({
scheduleId,
infusionStatus: 'ywc'
})
ElMessage.success(
isBasicExperiment.value || isClinical.value
? `${bizConfig.value.scheduleLabel}已完成`
: `${bizConfig.value.scheduleLabel}已完成,结算扣减已处理`
)
loadData()
} catch (e: any) {
if (e !== 'cancel') {
// error handled by request util
}
}
}
const handleCancel = (row: any) => {
cancelScheduleId.value = row.id
cancelPatientName.value = row.patientName || ''
cancelDialogVisible.value = true
}
watchDebounced(
searchForm,
() => {
pagination.page = 1
loadData()
},
{ debounce: 300, deep: true }
)
onMounted(() => {
loadData()
})
</script>
<style scoped lang="scss">
.list-table :deep(tr.row-infusion-this-week > td.el-table__cell) {
background-color: #fee2e2 !important;
color: #991b1b;
}
.list-table :deep(tr.row-infusion-this-week:hover > td.el-table__cell) {
background-color: #fecaca !important;
}
</style>