Files
shanxia2.0/web-admin/src/views/pilot-service/pilot-application/ApplicationFormDialog.vue
T
2026-05-26 12:32:19 +08:00

2411 lines
80 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 中试服务申请单 — 新增/编辑表单弹窗 -->
<template>
<el-dialog
:model-value="modelValue"
:title="isEdit ? '编辑申请单' : '新增申请单'"
width="1380px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
@open="handleOpen"
@update:model-value="emit('update:modelValue', $event)"
>
<DialogFormBody :loading="formLoading">
<div class="detail-tabs">
<div
v-for="tab in formTabs"
:key="tab.key"
class="detail-tab"
:class="{ active: activeTab === tab.key }"
@click="scrollToSection(tab.key)"
>
{{ tab.label }}
</div>
</div>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-position="top"
scroll-to-error
>
<!-- ═══ Tab 0: 申请单信息 ═══ -->
<div id="section-application" class="form-tab-pane">
<div class="section-card">
<div class="section-title">
<div class="section-title-left"><h3>申请单信息</h3></div>
</div>
<div class="form-grid form-grid--col4">
<el-form-item label="申请单名称" prop="applicationName">
<el-input v-model="formData.applicationName" placeholder="请输入申请单名称" />
</el-form-item>
<el-form-item label="渠道" prop="channelId">
<el-select
v-model="formData.channelId"
class="w-full"
filterable
clearable
placeholder="请选择渠道"
:loading="channelLoading"
@change="onChannelChange"
>
<el-option
v-for="ch in channelOptions"
:key="ch.id"
:label="ch.channelName"
:value="ch.id"
/>
</el-select>
</el-form-item>
<el-form-item label="服务订单" prop="channelServiceOrderId" class="form-col-span-2-cols">
<div class="field-with-action">
<el-select
v-model="formData.channelServiceOrderId"
class="field-with-action__main"
filterable
clearable
:disabled="!formData.channelId"
placeholder="请先选择渠道"
:loading="serviceOrderLoading"
@change="onServiceOrderChange"
>
<el-option
v-for="so in serviceOrderOptions"
:key="so.id"
:label="formatServiceOrderLabel(so)"
:value="so.id"
/>
</el-select>
<el-button
class="field-with-action__btn"
:disabled="!formData.channelServiceOrderId"
@click="openServiceOrderView"
>
<el-icon><View /></el-icon>查看
</el-button>
</div>
</el-form-item>
<el-form-item label="备注" class="form-col-full">
<el-input
v-model="formData.remark"
type="textarea"
placeholder="请输入备注"
:rows="3"
/>
</el-form-item>
</div>
</div>
</div>
<!-- ═══ Tab 1: 基本信息(患者信息) ═══ -->
<div id="section-basic" class="form-tab-pane">
<!-- 患者信息 -->
<div class="section-card section-card--white" style="margin-top: 16px">
<div class="section-title">
<div class="section-title-left"><h3>患者信息</h3></div>
</div>
<div class="form-grid-4">
<!-- 客户远程搜索 -->
<el-form-item label="关联客户">
<el-select
v-model="patientForm.customerId"
class="w-full"
filterable
remote
clearable
reserve-keyword
:remote-method="remoteSearchCustomers"
:loading="customerLoading"
placeholder="输入姓名/电话/身份证号检索客户"
@change="onCustomerSelect"
>
<el-option
v-for="c in customerOptions"
:key="c.id"
:label="formatCustomerLabel(c)"
:value="c.id"
/>
<el-option disabled :value="'__add_customer__'" class="customer-add-option">
<span
style="color: #10b981; cursor: pointer"
@click.stop="openNewCustomerDialog"
>
<el-icon><Plus /></el-icon> 新增客户...
</span>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="患者姓名" prop="patientName">
<el-input v-model="patientForm.patientName" placeholder="请输入患者姓名" />
</el-form-item>
<el-form-item label="性别" prop="sex">
<el-select v-model="patientForm.sex" placeholder="请选择" clearable>
<el-option label="男" value="0" />
<el-option label="女" value="1" />
</el-select>
</el-form-item>
<el-form-item label="年龄" prop="age">
<el-input-number
v-model="patientForm.age"
:min="0"
:max="200"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="身份证号" prop="idCardNo">
<el-input v-model="patientForm.idCardNo" placeholder="请输入身份证号" />
</el-form-item>
<el-form-item label="联系电话" prop="phoneNo">
<el-input v-model="patientForm.phoneNo" placeholder="请输入联系电话" />
</el-form-item>
<el-form-item label="紧急联系人" prop="emergencyContact">
<el-input v-model="patientForm.emergencyContact" placeholder="请输入紧急联系人" />
</el-form-item>
<el-form-item label="紧急联系人电话" prop="emergencyPhone">
<el-input v-model="patientForm.emergencyPhone" placeholder="请输入紧急联系人电话" />
</el-form-item>
<el-form-item label="是否需要体检" prop="needCheckup" class="patient-checkup-field">
<el-switch
v-model="patientForm.needCheckup"
:active-value="1"
:inactive-value="0"
/>
</el-form-item>
<el-form-item
label="临床诊断"
prop="clinicalDiagnosis"
class="patient-diagnosis-field"
>
<el-input
v-model="patientForm.clinicalDiagnosis"
type="textarea"
placeholder="请输入临床诊断"
:rows="2"
/>
</el-form-item>
<el-form-item label="诊断附件" class="form-col-span-2">
<div class="attachment-upload">
<div class="attachment-list">
<el-upload
ref="diagnosisUploadRef"
:show-file-list="false"
:auto-upload="false"
:multiple="true"
@change="handleDiagnosisFileChange"
>
<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, idx) in diagnosisFileList"
:key="file.id || idx"
class="attachment-item"
:class="{ 'is-uploading': file._uploading }"
>
<div class="attachment-preview">
<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" @click.stop="downloadFile(file)">
<Download />
</el-icon>
<el-icon
class="action-btn action-btn--danger"
@click.stop="removeDiagnosisFile(idx)"
>
<Delete />
</el-icon>
</div>
</div>
</div>
</div>
<span class="upload-hint">支持图片、视频、PDF、Word、Excel等文件</span>
</div>
</el-form-item>
</div>
</div>
</div>
<!-- ═══ Tab 2: 输注方案 ═══ -->
<div id="section-infusion" class="form-tab-pane">
<div class="section-card">
<div class="section-title">
<div class="section-title-left section-title-left--with-hint">
<h3>输注方案</h3>
<p v-if="!selectedServiceOrder" class="section-title-hint">
请先在「申请单信息」中选择渠道与服务订单,系统将按订单类型加载协议与可选细胞产品。
</p>
</div>
</div>
<!-- 订单关联信息 -->
<div class="form-grid form-grid--col3 infusion-order-meta">
<el-form-item label="渠道协议">
<div class="field-with-action">
<span class="agreement-display field-with-action__main">{{
currentAgreementLabel || '—'
}}</span>
<el-button
class="field-with-action__btn"
:disabled="!infusionPlan.channelAgreementId"
@click="openAgreementView"
>
<el-icon><View /></el-icon>查看
</el-button>
</div>
</el-form-item>
<el-form-item label="服务订单类型">
<el-tag
v-if="selectedServiceOrder"
:type="channelServiceOrderTypeTagType(selectedServiceOrder.orderType)"
size="small"
>
{{ channelServiceOrderTypeLabel(selectedServiceOrder.orderType) }}
</el-tag>
<span v-else class="meta-text">—</span>
</el-form-item>
<el-form-item label="结算方式">
<span class="meta-text">{{ settlementModeLabel }}</span>
</el-form-item>
</div>
<!-- 输注方案主表 -->
<div class="form-grid form-grid--col4 infusion-plan-fields">
<el-form-item label="疗程数">
<el-input-number
v-model="infusionPlan.totalCourseCount"
:min="0"
:max="999"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item>
<template #label>疗程间隔<span class="form-label-tip">(月)</span></template>
<el-input
v-model="infusionPlan.courseInterval"
placeholder="请输入疗程间隔"
/>
</el-form-item>
<el-form-item label="输注开始日期">
<el-date-picker
v-model="infusionPlan.infusionStartDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="请选择开始日期"
style="width: 100%"
/>
</el-form-item>
<el-form-item v-if="isYfkOrder" label="方案总金额">
<span class="infusion-amount-summary">
¥{{ formatOrderBalance(infusionPlanTotalAmount) }}
</span>
</el-form-item>
<el-form-item
v-else-if="isGmcpOrder"
label="输注份数汇总"
class="form-col-span-2-cols"
>
<div class="infusion-qty-summary">{{ infusionPlanQtySummaryText }}</div>
</el-form-item>
</div>
<!-- 输注计划列表 -->
<div style="margin-top: 20px">
<div class="cell-product-toolbar">
<div class="section-subtitle">输注计划</div>
<el-button type="primary" @click="addSchedule">
<el-icon><Plus /></el-icon>
添加输注计划
</el-button>
</div>
<div class="schedule-details">
<div
v-for="(schedule, sIdx) in infusionSchedules"
:key="schedule._localId"
class="schedule-card"
:class="{ expanded: schedule._expanded }"
>
<div class="schedule-header" @click="toggleScheduleExpand(sIdx)">
<span class="schedule-chevron">▶</span>
<span class="schedule-title">输注计划 #{{ sIdx + 1 }}</span>
<el-button
link
type="danger"
size="small"
class="schedule-delete-btn"
@click.stop="removeSchedule(sIdx)"
>
<el-icon><Delete /></el-icon>
</el-button>
</div>
<div v-show="schedule._expanded" class="schedule-body">
<div class="schedule-body-inner">
<div class="form-grid form-grid--col4">
<el-form-item label="输注时间">
<el-date-picker
v-model="schedule.infusionTime"
type="date"
value-format="YYYY-MM-DD"
placeholder="自动计算,可修改"
clearable
style="width: 100%"
/>
</el-form-item>
<el-form-item label="输注地点">
<el-input
v-model="schedule.infusionLocation"
placeholder="请输入输注地点"
/>
</el-form-item>
<el-form-item label="输注方式">
<el-select
v-model="schedule.infusionMethod"
clearable
filterable
placeholder="请选择输注方式"
style="width: 100%"
>
<el-option
v-for="opt in infusionMethodOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
</div>
<!-- 细胞产品子表 -->
<div style="margin-top: 14px">
<div class="cell-product-toolbar">
<div class="section-subtitle">细胞产品</div>
<el-button
type="primary"
size="small"
:disabled="!canAddCellProduct"
@click="addCellProduct(sIdx)"
>
<el-icon><Plus /></el-icon>
添加细胞产品
</el-button>
</div>
<el-table
:data="schedule.cellProducts"
stripe
class="cell-product-table"
style="width: 100%"
>
<el-table-column label="细胞项目名称" min-width="150">
<template #default="{ $index: pIdx }">
<el-select
v-model="schedule.cellProducts[pIdx].priceId"
filterable
clearable
placeholder="选择项目"
class="cell-product-table__control"
:disabled="!canAddCellProduct"
@change="
onCellProductSelect(sIdx, pIdx, $event)
"
>
<el-option
v-for="p in getCellProductPriceOptions(sIdx, pIdx)"
:key="p.priceId"
:label="formatProjectOptionLabel(p)"
:value="p.priceId"
/>
</el-select>
</template>
</el-table-column>
<el-table-column
prop="cellTypeName"
label="细胞类型"
min-width="100"
/>
<el-table-column
prop="specText"
label="规格"
min-width="100"
/>
<el-table-column label="协议单价(元)" min-width="130" align="right">
<template #default="{ row }">
<span class="cell-product-price-text">
¥{{ formatOrderBalance(row.agreementPrice) }}
</span>
</template>
</el-table-column>
<el-table-column label="实际单价(元)" min-width="170">
<template #default="{ row }">
<el-input-number
v-model="row.actualPrice"
:min="0"
:precision="2"
controls-position="right"
class="cell-product-table__control"
/>
</template>
</el-table-column>
<el-table-column label="份数" min-width="100">
<template #default="{ row }">
<el-input-number
v-model="row.quantity"
:min="1"
controls-position="right"
class="cell-product-table__control"
/>
</template>
</el-table-column>
<el-table-column label="备注" min-width="120">
<template #default="{ row }">
<el-input
v-model="row.remark"
placeholder="备注"
class="cell-product-table__control"
/>
</template>
</el-table-column>
<el-table-column label="状态" min-width="80" align="center">
<template #default="{ row }">
<el-tag size="small" type="info">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" min-width="60" align="center">
<template #default="{ $index: pIdx }">
<el-button
link
type="danger"
size="small"
@click="removeCellProduct(sIdx, pIdx)"
>
<el-icon><Delete /></el-icon>
</el-button>
</template>
</el-table-column>
</el-table>
<el-empty
v-if="!schedule.cellProducts.length"
description="暂无细胞产品"
:image-size="32"
/>
<div
v-if="isYfkOrder && schedule.cellProducts.length"
class="schedule-amount-summary"
>
本次输注计划金额:
<span class="schedule-amount-summary__value">
¥{{ formatOrderBalance(calcScheduleAmount(schedule)) }}
</span>
</div>
</div>
</div>
</div>
</div>
<el-empty
v-if="!infusionSchedules.length"
description="暂无输注计划,点击上方按钮添加或调整疗程数"
:image-size="48"
/>
</div>
</div>
</div>
</div>
</el-form>
</DialogFormBody>
<template #footer>
<el-button @click="emit('update:modelValue', false)">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">
<el-icon><Check /></el-icon>保存
</el-button>
</template>
</el-dialog>
<!-- ═══ 新增客户子弹窗 ═══ -->
<el-dialog
v-model="newCustomerDialogVisible"
title="新增客户"
width="500px"
destroy-on-close
:close-on-click-modal="false"
>
<div class="dialog-form-layout">
<el-form
ref="newCustomerFormRef"
:model="newCustomerForm"
:rules="newCustomerRules"
label-position="top"
>
<div class="form-grid-2">
<el-form-item label="客户姓名" prop="customerName">
<el-input v-model="newCustomerForm.customerName" placeholder="请输入" />
</el-form-item>
<el-form-item label="性别">
<el-select v-model="newCustomerForm.sex" clearable>
<el-option label="男" value="0" />
<el-option label="女" value="1" />
</el-select>
</el-form-item>
<el-form-item label="年龄">
<el-input-number
v-model="newCustomerForm.age"
:min="0"
:max="200"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="手机号" prop="phoneNo">
<el-input v-model="newCustomerForm.phoneNo" placeholder="请输入" />
</el-form-item>
<el-form-item label="身份证号" class="form-col-span-2">
<el-input v-model="newCustomerForm.idCardNo" placeholder="请输入" />
</el-form-item>
</div>
</el-form>
</div>
<template #footer>
<el-button @click="newCustomerDialogVisible = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button type="primary" :loading="newCustomerSaving" @click="handleCreateCustomer">
<el-icon><Check /></el-icon>确定
</el-button>
</template>
</el-dialog>
<!-- 服务订单查看 -->
<SystemViewDialog
v-model:visible="serviceOrderViewVisible"
:title="serviceOrderViewData.orderName || serviceOrderViewData.channelName || '订单详情'"
:icon="Document"
:code="serviceOrderViewData.id"
code-label="订单编号"
:sections="serviceOrderViewSections"
:loading="serviceOrderViewLoading"
width="960px"
append-to-body
>
<template v-if="serviceOrderViewData.orderType === 'gmcp'" #order-products>
<div class="list-body list-wrap view-inner-table-wrap">
<el-table
v-if="serviceOrderViewData.products?.length"
:data="serviceOrderViewData.products"
stripe
class="list-table inner-table"
max-height="360"
>
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
<el-table-column prop="projectName" label="项目名称" min-width="150" show-overflow-tooltip />
<el-table-column prop="cellType" label="细胞类型" min-width="120" show-overflow-tooltip />
<el-table-column prop="specText" label="规格文本" min-width="150" show-overflow-tooltip />
<el-table-column label="协议单价(元)" min-width="130" align="right" header-align="center">
<template #default="{ row }">¥{{ formatOrderBalance(row.agreementUnitPrice) }}</template>
</el-table-column>
<el-table-column prop="currentQty" label="当前持有" min-width="100" align="center" header-align="center" />
<el-table-column prop="totalBuyQty" label="累计购买" min-width="100" align="center" header-align="center" />
<el-table-column
prop="totalUnsubscribeQty"
label="累计退订"
min-width="100"
align="center"
header-align="center"
/>
</el-table>
<el-empty v-else description="暂无产品持仓" :image-size="48" />
</div>
</template>
<template #txn-timeline>
<ServiceOrderTxnTable :rows="serviceOrderViewData.transactions || []" />
</template>
</SystemViewDialog>
<!-- 渠道协议查看 -->
<SystemViewDialog
v-model:visible="agreementViewVisible"
:title="agreementViewTitle"
:icon="Document"
:code="agreementViewData.id"
code-label="协议编号"
:sections="agreementViewSections"
:loading="agreementViewLoading"
width="960px"
append-to-body
>
<template #agreement-prices>
<div class="list-body list-wrap view-inner-table-wrap">
<el-table
v-if="agreementViewPrices.length"
:data="agreementViewPrices"
stripe
class="list-table inner-table"
max-height="360"
>
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
<el-table-column prop="projectName" label="项目名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="projectCode" label="项目编码" min-width="110" show-overflow-tooltip />
<el-table-column prop="cellTypeName" label="细胞类型" min-width="110" show-overflow-tooltip />
<el-table-column prop="specText" label="规格" min-width="120" show-overflow-tooltip />
<el-table-column label="协议单价(元)" min-width="130" align="right" header-align="center">
<template #default="{ row }">¥{{ formatOrderBalance(row.unitPrice) }}</template>
</el-table-column>
</el-table>
<el-empty v-else description="暂无协议价格" :image-size="48" />
</div>
</template>
</SystemViewDialog>
</template>
<script setup lang="ts">
import type { FormInstance } from 'element-plus'
import { Plus, Delete, Close, Check, Download, Document, VideoPlay, Loading, View } from '@element-plus/icons-vue'
import {
savePilotApplication,
fetchPilotApplicationDetail,
fetchChannelList,
fetchChannelServiceOrderList,
fetchChannelServiceOrderDetail,
fetchPilotPatient,
fetchChannelAgreements,
fetchChannelAgreementPrices,
fetchDictDataOptions,
fetchCustomerList,
fetchCreateCustomer
} from '@/api/admin'
import { fetchUploadAttachment } from '@/api/business'
import { fetchAllPagedRecords } from '@/utils/fetch-paged-list'
import DialogFormBody from '@/components/business/dialog-form-body/index.vue'
import SystemViewDialog, { type ViewSection } from '@/components/business/system-view-dialog/index.vue'
import ServiceOrderTxnTable from '@/views/pilot-service/channel-service-order/ServiceOrderTxnTable.vue'
import {
channelServiceOrderTypeLabel,
channelServiceOrderTypeTagType,
channelServiceOrderStatusLabel,
channelServiceOrderStatusTagType,
agreementTypeLabel,
agreementTypeTagType,
agreementStatusLabel,
agreementStatusTagType,
settlementTypeLabel,
settlementTypeTagType
} from '@/utils/status-tag-registry'
import { formatMoneyDisplay } from '@/utils/money-format'
defineOptions({ name: 'ApplicationFormDialog' })
const props = defineProps<{
modelValue: boolean
editId: string | null
listRow?: any
}>()
const emit = defineEmits<{
(e: 'update:modelValue', val: boolean): void
(e: 'saved'): void
}>()
const isEdit = computed(() => !!props.editId)
// ── Tabs ───────────────────────────────────────────────────────────────
const formTabs = [
{ key: 'application', label: '申请单信息' },
{ key: 'basic', label: '基本信息' },
{ key: 'infusion', label: '输注方案' }
]
const activeTab = ref('application')
const scrollToSection = (key: string) => {
activeTab.value = key
const el = document.getElementById(`section-${key}`)
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
// ── 表单主数据 ─────────────────────────────────────────────────────────
const formRef = ref<FormInstance>()
const formLoading = ref(false)
const submitting = ref(false)
const formData = reactive({
applicationName: '',
channelId: '',
channelServiceOrderId: '',
remark: ''
})
const formRules = reactive({
applicationName: [{ required: true, message: '请输入申请单名称', trigger: 'blur' }],
channelId: [{ required: true, message: '请选择渠道', trigger: 'change' }],
channelServiceOrderId: [{ required: true, message: '请选择服务订单', trigger: 'change' }]
})
// ── 患者信息 ───────────────────────────────────────────────────────────
const patientForm = reactive({
customerId: '',
patientName: '',
sex: '',
age: null as number | null,
idCardNo: '',
phoneNo: '',
emergencyContact: '',
emergencyPhone: '',
clinicalDiagnosis: '',
needCheckup: 0
})
const diagnosisFileList = ref<
Array<{
id?: string
fileName: string
fileUrl: string
fileSize: number
fileSizeDisplay?: string
fileType?: string
_uploading?: boolean
}>
>([])
// ── 渠道 & 服务订单 ────────────────────────────────────────────────────
const channelLoading = ref(false)
const channelOptions = ref<any[]>([])
const serviceOrderLoading = ref(false)
const serviceOrderOptions = ref<any[]>([])
const loadChannels = async () => {
channelLoading.value = true
try {
channelOptions.value = await fetchAllPagedRecords((page, pageSize) =>
fetchChannelList({ page, pageSize })
)
} catch {
channelOptions.value = []
} finally {
channelLoading.value = false
}
}
const onChannelChange = async (channelId: string) => {
formData.channelServiceOrderId = ''
serviceOrderOptions.value = []
resetInfusionOrderContext()
if (!channelId) return
serviceOrderLoading.value = true
try {
serviceOrderOptions.value = await fetchAllPagedRecords((page, pageSize) =>
fetchChannelServiceOrderList({ channelId, page, pageSize })
)
} catch {
serviceOrderOptions.value = []
} finally {
serviceOrderLoading.value = false
}
await loadAgreements(channelId)
}
const selectedServiceOrder = ref<any | null>(null)
const resetInfusionOrderContext = () => {
selectedServiceOrder.value = null
agreementOptions.value = []
agreementPriceOptions.value = []
infusionPlan.channelAgreementId = ''
clearScheduleCellProducts()
}
const clearScheduleCellProducts = () => {
infusionSchedules.value.forEach((s: any) => {
s.cellProducts = []
})
}
const unwrapApiList = (res: any): any[] => {
const d = res?.data ?? res
if (Array.isArray(d)) return d
return d?.list ?? d?.records ?? []
}
function normalizeAgreementRow(row: Record<string, any>) {
const status = String(row.agreementStatus ?? row.status ?? 'dsx').trim() || 'dsx'
return {
...row,
settlementType: row.settleMode ?? row.settlementType ?? '',
creditLimit: row.creditLimitAmount ?? row.creditLimit ?? null,
expiryDate: row.expireDate ?? row.expiryDate ?? '',
effectiveDate: row.effectiveDate ?? '',
status
}
}
const onServiceOrderChange = async (
orderId: string,
options?: { preserveCellProducts?: boolean }
) => {
if (!orderId || !formData.channelId) {
resetInfusionOrderContext()
return
}
if (!options?.preserveCellProducts) {
clearScheduleCellProducts()
}
selectedServiceOrder.value = null
agreementPriceOptions.value = []
infusionPlan.channelAgreementId = ''
let detail: any = serviceOrderOptions.value.find((o: any) => o.id === orderId) || null
try {
const res: any = await fetchChannelServiceOrderDetail(orderId)
detail = res?.data ?? res ?? detail
} catch {
if (!detail) {
ElMessage.warning('加载服务订单详情失败')
return
}
}
selectedServiceOrder.value = detail
const agreementId = detail.channelAgreementId || ''
infusionPlan.channelAgreementId = agreementId
if (formData.channelId) {
await loadAgreements(formData.channelId)
}
await syncCellProductOptionsFromOrder(detail)
}
const syncCellProductOptionsFromOrder = async (order: any) => {
agreementPriceOptions.value = []
const orderType = order?.orderType || ''
const agreementId = order?.channelAgreementId || ''
if (orderType === 'yfk') {
if (!agreementId) {
ElMessage.warning('该预付款订单未关联渠道协议,无法加载协议价格')
return
}
await onAgreementChange(agreementId)
return
}
if (orderType === 'gmcp') {
let products = order?.products
if (!Array.isArray(products) || !products.length) {
try {
const res: any = await fetchChannelServiceOrderDetail(order.id)
const detail = res?.data ?? res
products = detail?.products
if (detail) selectedServiceOrder.value = detail
} catch {
products = []
}
}
agreementPriceOptions.value = normalizeOrderProductRows(products || [])
if (!agreementPriceOptions.value.length) {
ElMessage.warning('该购买型订单暂无产品持仓,请先完成购买产品')
}
return
}
ElMessage.warning('不支持的订单类型,无法配置输注产品')
}
const normalizeOrderProductRows = (rows: any[]) => {
return (Array.isArray(rows) ? rows : [])
.filter((p) => (p.currentQty ?? 0) > 0 || (p.buyQty ?? 0) > 0)
.map((p) => ({
priceId: p.id || `${p.projectCode}_${p.cellType}_${p.specText}`,
projectCode: p.projectCode || '',
projectName: p.projectName || '',
cellTypeCode: p.cellType || p.cellTypeCode || '',
cellTypeName: p.cellType || p.cellTypeName || '',
specText: p.specText || '',
unitPrice: p.agreementUnitPrice ?? p.unitPrice ?? 0,
currentQty: p.currentQty ?? 0,
source: 'order' as const
}))
}
const settlementModeLabel = computed(() => {
const t = selectedServiceOrder.value?.orderType
if (t === 'yfk') return '按协议单价扣减订单余额'
if (t === 'gmcp') return '按份数扣减订单产品持仓'
return '—'
})
const canAddCellProduct = computed(
() => !!selectedServiceOrder.value && agreementPriceOptions.value.length > 0
)
const isYfkOrder = computed(() => selectedServiceOrder.value?.orderType === 'yfk')
const isGmcpOrder = computed(() => selectedServiceOrder.value?.orderType === 'gmcp')
const infusionMethodOptions = ref<Api.Dict.DictDataOption[]>([])
const loadInfusionMethodOptions = async () => {
try {
const res = await fetchDictDataOptions('szfs')
infusionMethodOptions.value = res || []
} catch {
infusionMethodOptions.value = []
}
}
const formatOrderBalance = (v: any) => formatMoneyDisplay(v, 2)
const calcScheduleAmount = (schedule: any): number => {
return (schedule?.cellProducts || []).reduce((sum: number, cp: any) => {
const price = Number(cp.actualPrice) || 0
const qty = Number(cp.quantity) || 0
return sum + price * qty
}, 0)
}
const infusionPlanTotalAmount = computed(() =>
infusionSchedules.value.reduce((sum: number, s: any) => sum + calcScheduleAmount(s), 0)
)
const formatCellProductDisplayLabel = (cp: any): string => {
const name = cp?.projectName || '-'
const cellType = cp?.cellTypeName || '-'
const spec = cp?.specText || '-'
return `${name}(${cellType}、${spec})`
}
const infusionPlanQtySummaryText = computed(() => {
if (!isGmcpOrder.value) return '—'
const map = new Map<string, { label: string; qty: number }>()
for (const schedule of infusionSchedules.value) {
for (const cp of schedule.cellProducts || []) {
const key = (cp.projectCode || cp.priceId || '').trim()
if (!key) continue
const label = formatCellProductDisplayLabel(cp)
const q = Number(cp.quantity) || 0
const existing = map.get(key)
if (existing) {
existing.qty += q
} else {
map.set(key, { label, qty: q })
}
}
}
const items = Array.from(map.values()).map((i) => `${i.label} * ${i.qty}`)
return items.length ? items.join(',') : '—'
})
const getCellProductProjectKey = (cp: any): string =>
(cp?.projectCode || cp?.priceId || '').trim()
const getCellProductPriceOptions = (scheduleIdx: number, productIdx: number) => {
const schedule = infusionSchedules.value[scheduleIdx]
if (!schedule) return agreementPriceOptions.value
const usedKeys = new Set(
schedule.cellProducts
.map((cp: any, idx: number) => (idx !== productIdx ? getCellProductProjectKey(cp) : ''))
.filter(Boolean)
)
const currentKey = getCellProductProjectKey(schedule.cellProducts[productIdx])
return agreementPriceOptions.value.filter((p: any) => {
const key = (p.projectCode || p.priceId || '').trim()
if (currentKey && key === currentKey) return true
return !usedKeys.has(key)
})
}
// ── 服务订单查看弹窗 ───────────────────────────────────────────────────
const serviceOrderViewVisible = ref(false)
const serviceOrderViewLoading = ref(false)
const serviceOrderViewData = ref<any>({})
const serviceOrderViewAgreementLabel = ref('')
const serviceOrderViewSections = computed((): ViewSection[] => {
const d = serviceOrderViewData.value || {}
const sections: ViewSection[] = [
{
title: '基本信息',
fields: [
{ label: '订单名称', value: d.orderName },
{ label: '渠道名称', value: d.channelName },
{ label: '部门名称', value: d.deptName },
{
label: '订单类型',
value: channelServiceOrderTypeLabel(d.orderType),
tagType: channelServiceOrderTypeTagType(d.orderType)
},
{
label: '订单状态',
value: channelServiceOrderStatusLabel(d.orderStatus),
tagType: channelServiceOrderStatusTagType(d.orderStatus)
},
{ label: '创建人', value: d.createByName || d.createBy },
{ label: '创建时间', value: d.createTime },
{ label: '备注', value: d.remark, span: true }
]
}
]
if (d.orderType === 'yfk') {
sections.push({
title: '金额汇总',
fields: [
{ label: '累计充值(元)', value: `¥${formatOrderBalance(d.totalRechargeAmount)}` },
{ label: '累计退款(元)', value: `¥${formatOrderBalance(d.totalRefundAmount)}` },
{ label: '当前余额(元)', value: `¥${formatOrderBalance(d.currentBalance)}` },
{
label: '渠道协议',
value: serviceOrderViewAgreementLabel.value || d.channelAgreementId
}
]
})
}
if (d.orderType === 'gmcp') {
sections.push({
title: '金额信息',
fields: [
{ label: '购买总额(元)', value: `¥${formatOrderBalance(d.purchaseTotalAmount)}` },
{
label: '渠道协议',
value: serviceOrderViewAgreementLabel.value || d.channelAgreementId
}
]
})
sections.push({
title: '产品持仓',
fields: [],
slot: 'order-products'
})
}
sections.push({
title: '操作记录',
fields: [],
slot: 'txn-timeline'
})
return sections
})
const resolveServiceOrderViewAgreementLabel = async (detail: any) => {
const agreementId = detail?.channelAgreementId || ''
if (!agreementId || !detail?.channelId) {
serviceOrderViewAgreementLabel.value = ''
return
}
try {
const res: any = await fetchChannelAgreements(detail.channelId)
const rows = unwrapApiList(res).map(normalizeAgreementRow)
const hit = rows.find((ag: any) => ag.id === agreementId)
serviceOrderViewAgreementLabel.value = hit ? formatAgreementLabel(hit) : agreementId
} catch {
serviceOrderViewAgreementLabel.value = agreementId
}
}
const openServiceOrderView = async () => {
if (!formData.channelServiceOrderId) return
serviceOrderViewVisible.value = true
serviceOrderViewLoading.value = true
serviceOrderViewAgreementLabel.value = ''
try {
const res: any = await fetchChannelServiceOrderDetail(formData.channelServiceOrderId)
const detail = res?.data ?? res ?? {}
serviceOrderViewData.value = detail
if (detail.channelAgreementId && detail.channelId) {
await resolveServiceOrderViewAgreementLabel(detail)
}
} catch (e: any) {
ElMessage.error(e?.message || '加载订单详情失败')
serviceOrderViewData.value = {}
} finally {
serviceOrderViewLoading.value = false
}
}
// ── 渠道协议查看弹窗 ───────────────────────────────────────────────────
const agreementViewVisible = ref(false)
const agreementViewLoading = ref(false)
const agreementViewData = ref<any>({})
const agreementViewPrices = ref<any[]>([])
const agreementViewTitle = computed(() => {
const label = formatAgreementLabel(agreementViewData.value)
return label && label !== '—' ? label : '渠道协议详情'
})
const agreementViewSections = computed((): ViewSection[] => {
const agr = agreementViewData.value || {}
const fields: ViewSection['fields'] = [
{
label: '协议类型',
value: agreementTypeLabel(agr.agreementType),
tagType: agreementTypeTagType(agr.agreementType)
},
{
label: '协议状态',
value: agreementStatusLabel(agr.status),
tagType: agreementStatusTagType(agr.status)
}
]
if (agr.settlementType) {
fields.push({
label: '结算方式',
value: settlementTypeLabel(agr.settlementType),
tagType: settlementTypeTagType(agr.settlementType)
})
}
if (agr.creditLimit != null && agr.creditLimit !== '') {
fields.push({
label: '授信额度(元)',
value: `¥${formatOrderBalance(agr.creditLimit)}`
})
}
if (agr.effectiveDate) {
fields.push({ label: '生效日期', value: agr.effectiveDate })
}
if (agr.expiryDate) {
fields.push({ label: '到期日期', value: agr.expiryDate })
}
if (agr.remark) {
fields.push({ label: '备注', value: agr.remark, span: true })
}
return [
{ title: '协议信息', fields },
{
title: '细胞产品价格',
fields: [],
slot: 'agreement-prices'
}
]
})
const openAgreementView = async () => {
const agreementId = infusionPlan.channelAgreementId
if (!agreementId) return
agreementViewVisible.value = true
agreementViewLoading.value = true
agreementViewPrices.value = []
try {
if (formData.channelId && !agreementOptions.value.length) {
await loadAgreements(formData.channelId)
}
let ag = agreementOptions.value.find((a: any) => a.id === agreementId)
if (!ag && formData.channelId) {
const res: any = await fetchChannelAgreements(formData.channelId)
const rows = unwrapApiList(res).map(normalizeAgreementRow)
ag = rows.find((a: any) => a.id === agreementId)
}
agreementViewData.value = ag || { id: agreementId }
const priceRes: any = await fetchChannelAgreementPrices(agreementId)
agreementViewPrices.value = normalizeAgreementPriceRows(unwrapApiList(priceRes))
} catch (e: any) {
ElMessage.error(e?.message || '加载协议详情失败')
agreementViewData.value = { id: agreementId }
} finally {
agreementViewLoading.value = false
}
}
const formatServiceOrderLabel = (so: any): string => {
const name = (so.orderName || '').trim()
const typeLabel = channelServiceOrderTypeLabel(so.orderType)
if (name) return `${name}(${typeLabel})`
const no = so.id ? String(so.id).slice(-8) : ''
return `${no}(${typeLabel})`
}
// ── 远程客户搜索 ───────────────────────────────────────────────────────
const customerLoading = ref(false)
const customerOptions = ref<any[]>([])
const remoteSearchCustomers = async (keyword: string) => {
customerLoading.value = true
try {
const res: any = await fetchCustomerList({ page: 1, pageSize: 50, keyword: keyword || undefined })
const data = res?.data ?? res
customerOptions.value = data?.list ?? data?.records ?? []
} catch {
customerOptions.value = []
} finally {
customerLoading.value = false
}
}
const formatCustomerLabel = (c: any): string => {
const parts = [c.customerName || c.name || '']
if (c.phoneNo) parts.push(c.phoneNo)
if (c.idCardNo) parts.push(c.idCardNo)
return parts.join(' · ')
}
const onCustomerSelect = (id: string) => {
if (!id || id === '__add_customer__') return
const c = customerOptions.value.find((item) => item.id === id)
if (!c) return
patientForm.customerId = c.id || ''
patientForm.patientName = c.customerName || c.name || ''
patientForm.sex = c.sex != null ? String(c.sex) : ''
patientForm.age = c.age ?? null
patientForm.idCardNo = c.idCardNo || ''
patientForm.phoneNo = c.phoneNo || ''
}
// ── 新增客户 ───────────────────────────────────────────────────────────
const newCustomerDialogVisible = ref(false)
const newCustomerSaving = ref(false)
const newCustomerFormRef = ref<FormInstance>()
const newCustomerForm = reactive({
customerName: '',
sex: '',
age: null as number | null,
phoneNo: '',
idCardNo: ''
})
const newCustomerRules = reactive({
customerName: [{ required: true, message: '请输入客户姓名', trigger: 'blur' }]
})
const openNewCustomerDialog = () => {
newCustomerForm.customerName = ''
newCustomerForm.sex = ''
newCustomerForm.age = null
newCustomerForm.phoneNo = ''
newCustomerForm.idCardNo = ''
newCustomerDialogVisible.value = true
}
const handleCreateCustomer = async () => {
try {
await newCustomerFormRef.value?.validate()
} catch {
return
}
newCustomerSaving.value = true
try {
const res: any = await fetchCreateCustomer({
customerName: newCustomerForm.customerName,
sex: newCustomerForm.sex || undefined,
age: newCustomerForm.age ?? undefined,
phoneNo: newCustomerForm.phoneNo || undefined,
idCardNo: newCustomerForm.idCardNo || undefined
})
const data = res?.data ?? res
const newId = data?.id || data?.customerId || data
if (newId) {
patientForm.customerId = String(newId)
patientForm.patientName = newCustomerForm.customerName
patientForm.sex = newCustomerForm.sex || ''
patientForm.age = newCustomerForm.age
patientForm.idCardNo = newCustomerForm.idCardNo || ''
patientForm.phoneNo = newCustomerForm.phoneNo || ''
}
ElMessage.success('客户创建成功')
newCustomerDialogVisible.value = false
} catch (e: any) {
ElMessage.error(e?.message || '创建客户失败')
} finally {
newCustomerSaving.value = false
}
}
// ── 附件工具 ───────────────────────────────────────────────────────────
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
function fullFileUrl(suffix: string) {
if (!suffix) return ''
if (suffix.startsWith('http://') || suffix.startsWith('https://') || suffix.startsWith('/'))
return suffix
return `${minioBaseUrl}/${suffix}`
}
const isImageFile = (file: any): boolean => {
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/')
)
}
const isVideoFile = (file: any): boolean => {
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/')
)
}
const diagnosisUploadRef = ref()
const handleDiagnosisFileChange = async (uploadFile: any) => {
const file = uploadFile.raw || uploadFile
if (!file) return
const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
diagnosisFileList.value.push({
id: tempId,
fileName: file.name,
fileUrl: '',
fileSize: file.size,
fileSizeDisplay: `${(file.size / 1024).toFixed(1)} KB`,
fileType: file.type || '',
_uploading: true,
})
const idx = diagnosisFileList.value.length - 1
try {
const fd = new FormData()
fd.append('file', file)
const res: any = await fetchUploadAttachment(fd)
diagnosisFileList.value[idx] = {
...diagnosisFileList.value[idx],
id: res.id || tempId,
fileName: file.name || res.fileName,
fileUrl: res.fileUrl || res?.data?.fileUrl || res?.data?.url || '',
fileSize: res.fileSize || file.size,
fileSizeDisplay: res.fileSizeDisplay || `${(file.size / 1024).toFixed(1)} KB`,
fileType: res.fileType || file.type || '',
_uploading: false,
}
} catch (e: any) {
ElMessage.error(e?.message || '上传失败')
diagnosisFileList.value.splice(idx, 1)
} finally {
nextTick(() => diagnosisUploadRef.value?.clearFiles())
}
}
const removeDiagnosisFile = (idx: number) => {
diagnosisFileList.value.splice(idx, 1)
}
const downloadFile = (file: any) => {
const link = document.createElement('a')
link.href = fullFileUrl(file.fileUrl || file.url)
link.download = file.fileName || file.name
link.target = '_blank'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
// ── 输注方案(inline,一个申请单一个方案) ──────────────────────────────
const infusionPlan = reactive({
totalCourseCount: 0,
courseInterval: '',
infusionStartDate: '',
channelAgreementId: ''
})
// 输注计划列表
let scheduleSeq = 0
const infusionSchedules = ref<any[]>([])
// 渠道协议
const agreementOptions = ref<any[]>([])
const agreementLoading = ref(false)
// 协议产品价格(用于细胞产品下拉)
const agreementPriceOptions = ref<any[]>([])
const agreementPriceLoading = ref(false)
// 渠道协议展示:取自服务订单关联协议(与渠道服务订单页一致,不限 zsxy)
const currentAgreementLabel = computed(() => {
const agreementId = infusionPlan.channelAgreementId
if (!agreementId) return ''
const ag = agreementOptions.value.find((a: any) => a.id === agreementId)
if (ag) return formatAgreementLabel(ag)
return agreementId
})
const loadAgreements = async (channelId: string) => {
if (!channelId) {
agreementOptions.value = []
return
}
agreementLoading.value = true
try {
const res: any = await fetchChannelAgreements(channelId)
agreementOptions.value = unwrapApiList(res)
.map(normalizeAgreementRow)
.filter((a: any) => a.status === 'sxz')
} catch {
agreementOptions.value = []
} finally {
agreementLoading.value = false
}
}
// 协议选中 → 加载协议价格
const onAgreementChange = async (agreementId: string) => {
agreementPriceOptions.value = []
if (!agreementId) return
agreementPriceLoading.value = true
try {
const res: any = await fetchChannelAgreementPrices(agreementId)
const list = res?.data ?? res
const arr = Array.isArray(list) ? list : (list?.list ?? list?.records ?? [])
agreementPriceOptions.value = normalizeAgreementPriceRows(arr)
} catch {
agreementPriceOptions.value = []
} finally {
agreementPriceLoading.value = false
}
}
const normalizeAgreementPriceRows = (rows: any[]) => {
return (Array.isArray(rows) ? rows : []).map((p) => ({
priceId: p.id,
projectCode: p.projectCode || '',
projectName: p.projectName || '',
cellTypeCode: p.cellTypeCode || '',
cellTypeName: p.cellTypeName || '',
specText: p.specText || '',
unitPrice: p.agreementPrice ?? p.unitPrice ?? 0
}))
}
// 疗程数变化 → 自动增减输注计划 + 自动计算输注时间
const computeScheduleTime = (index: number): string => {
if (!infusionPlan.infusionStartDate || !infusionPlan.courseInterval) return ''
const months = parseInt(infusionPlan.courseInterval, 10)
if (isNaN(months) || months < 0) return ''
const startDate = new Date(infusionPlan.infusionStartDate)
if (isNaN(startDate.getTime())) return ''
startDate.setMonth(startDate.getMonth() + months * index)
const y = startDate.getFullYear()
const m = String(startDate.getMonth() + 1).padStart(2, '0')
const d = String(startDate.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
const recalcAllScheduleTimes = () => {
infusionSchedules.value.forEach((s: any, i: number) => {
s.infusionTime = computeScheduleTime(i)
})
}
// 输注开始日期或疗程间隔变化时,重新计算所有计划时间
watch(
() => [infusionPlan.infusionStartDate, infusionPlan.courseInterval],
() => {
recalcAllScheduleTimes()
}
)
watch(
() => infusionPlan.totalCourseCount,
(newCount: number) => {
if (!newCount || newCount < 0) {
infusionSchedules.value = []
return
}
const currentCount = infusionSchedules.value.length
if (newCount > currentCount) {
for (let i = currentCount; i < newCount; i++) {
infusionSchedules.value.push({
_localId: ++scheduleSeq,
infusionTime: computeScheduleTime(i),
infusionLocation: '',
infusionMethod: '',
_expanded: i === 0,
cellProducts: [] as any[]
})
}
} else if (newCount < currentCount) {
infusionSchedules.value.splice(newCount)
}
}
)
const toggleScheduleExpand = (idx: number) => {
const s = infusionSchedules.value[idx]
if (s) s._expanded = !s._expanded
}
/** 输注计划条数与疗程数保持一致(按钮增删时反向同步) */
const syncTotalCourseCountWithSchedules = () => {
infusionPlan.totalCourseCount = infusionSchedules.value.length
}
const addSchedule = () => {
const idx = infusionSchedules.value.length
infusionSchedules.value.push({
_localId: ++scheduleSeq,
infusionTime: computeScheduleTime(idx),
infusionLocation: '',
infusionMethod: '',
_expanded: true,
cellProducts: [] as any[]
})
syncTotalCourseCountWithSchedules()
}
const removeSchedule = (idx: number) => {
infusionSchedules.value.splice(idx, 1)
recalcAllScheduleTimes()
syncTotalCourseCountWithSchedules()
}
// 细胞产品 CRUD
const addCellProduct = (scheduleIdx: number) => {
const s = infusionSchedules.value[scheduleIdx]
if (!s) return
const usedKeys = new Set(
(s.cellProducts || []).map((cp: any) => getCellProductProjectKey(cp)).filter(Boolean)
)
const hasUnused = agreementPriceOptions.value.some((p: any) => {
const key = (p.projectCode || p.priceId || '').trim()
return key && !usedKeys.has(key)
})
if (!hasUnused) {
ElMessage.warning('已添加全部可选细胞项目')
return
}
s.cellProducts.push({
_localId: ++scheduleSeq,
priceId: '',
projectCode: '',
projectName: '',
cellTypeName: '',
specText: '',
agreementPrice: 0,
actualPrice: 0,
quantity: 1,
remark: '',
status: '待输注'
})
}
const removeCellProduct = (scheduleIdx: number, productIdx: number) => {
const s = infusionSchedules.value[scheduleIdx]
if (!s) return
s.cellProducts.splice(productIdx, 1)
}
const onCellProductSelect = (scheduleIdx: number, productIdx: number, priceId: string) => {
const s = infusionSchedules.value[scheduleIdx]
if (!s) return
const product = s.cellProducts[productIdx]
if (!product) return
if (!priceId) {
product.priceId = ''
product.projectCode = ''
product.projectName = ''
product.cellTypeName = ''
product.specText = ''
product.agreementPrice = 0
product.actualPrice = 0
return
}
const price = agreementPriceOptions.value.find((p: any) => p.priceId === priceId)
if (!price) return
const projectKey = (price.projectCode || price.priceId || '').trim()
const duplicated = s.cellProducts.some(
(cp: any, idx: number) =>
idx !== productIdx && projectKey && getCellProductProjectKey(cp) === projectKey
)
if (duplicated) {
ElMessage.warning('同一输注计划中不能重复添加相同细胞项目')
product.priceId = ''
product.projectCode = ''
product.projectName = ''
product.cellTypeName = ''
product.specText = ''
product.agreementPrice = 0
product.actualPrice = 0
return
}
product.priceId = priceId
product.projectCode = price.projectCode || ''
product.projectName = price.projectName
product.cellTypeName = price.cellTypeName
product.specText = price.specText
product.agreementPrice = price.unitPrice
product.actualPrice = price.unitPrice
}
const formatAgreementLabel = (ag: any) => {
const type = agreementTypeLabel(ag?.agreementType || '')
const period = [ag?.effectiveDate, ag?.expiryDate].filter(Boolean).join(' ~ ')
return period ? `${type} · ${period}` : type
}
const formatProjectOptionLabel = (p: any) => {
const name = p?.projectName || p?.projectCode || '-'
const cellType = p?.cellTypeName || '-'
const spec = p?.specText || '-'
const price = formatMoneyDisplay(p?.unitPrice, 2)
if (p?.source === 'order' && p?.currentQty != null) {
return `${name}(${cellType}、${spec}、¥${price}、剩余${p.currentQty}份)`
}
return `${name}(${cellType}、${spec}、¥${price})`
}
// ── 弹窗打开 ───────────────────────────────────────────────────────────
const handleOpen = () => {
resetForm()
loadChannels()
loadInfusionMethodOptions()
remoteSearchCustomers('')
if (isEdit.value && props.editId) {
loadEditData(props.editId)
}
}
const resetForm = () => {
formData.applicationName = ''
formData.channelId = ''
formData.channelServiceOrderId = ''
formData.remark = ''
patientForm.customerId = ''
patientForm.patientName = ''
patientForm.sex = ''
patientForm.age = null
patientForm.idCardNo = ''
patientForm.phoneNo = ''
patientForm.emergencyContact = ''
patientForm.emergencyPhone = ''
patientForm.clinicalDiagnosis = ''
patientForm.needCheckup = 0
diagnosisFileList.value = []
infusionPlan.totalCourseCount = 0
infusionPlan.courseInterval = ''
infusionPlan.infusionStartDate = ''
infusionPlan.channelAgreementId = ''
infusionSchedules.value = []
scheduleSeq = 0
selectedServiceOrder.value = null
agreementOptions.value = []
agreementPriceOptions.value = []
activeTab.value = 'application'
channelOptions.value = []
serviceOrderOptions.value = []
customerOptions.value = []
}
// ── 编辑模式加载 ───────────────────────────────────────────────────────
const loadEditData = async (id: string) => {
formLoading.value = true
try {
// 1. 加载申请单详情
const res: any = await fetchPilotApplicationDetail({ id })
const detail = res?.data ?? res
if (detail) {
formData.applicationName = detail.applicationName || ''
formData.channelId = detail.channelId || ''
formData.channelServiceOrderId = detail.channelServiceOrderId || ''
formData.remark = detail.remark || ''
// 加载渠道后加载服务订单
if (formData.channelId) {
try {
serviceOrderOptions.value = await fetchAllPagedRecords((page, pageSize) =>
fetchChannelServiceOrderList({
channelId: formData.channelId,
page,
pageSize
})
)
} catch {
serviceOrderOptions.value = []
}
}
}
// 2. 加载患者信息
try {
const patientRes: any = await fetchPilotPatient({ pilotApplicationId: id })
const patient = patientRes?.data ?? patientRes
if (patient) {
patientForm.customerId = patient.customerId || ''
patientForm.patientName = patient.patientName || ''
patientForm.sex = patient.sex != null ? String(patient.sex) : ''
patientForm.age = patient.age ?? null
patientForm.idCardNo = patient.idCardNo || ''
patientForm.phoneNo = patient.phoneNo || ''
patientForm.emergencyContact = patient.emergencyContact || ''
patientForm.emergencyPhone = patient.emergencyPhone || ''
patientForm.clinicalDiagnosis = patient.clinicalDiagnosis || ''
patientForm.needCheckup = patient.needCheckup ?? 0
// 解析诊断附件
if (patient.diagnosisAttachments) {
diagnosisFileList.value = patient.diagnosisAttachments
.split(',')
.filter(Boolean)
.map((url: string) => {
const parts = url.split('/')
const name = decodeURIComponent(parts[parts.length - 1] || '附件')
return {
id: `existing_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
fileName: name,
fileUrl: url,
fileSize: 0,
fileSizeDisplay: '',
fileType: '',
_uploading: false,
}
})
}
}
} catch {
// 患者信息可能不存在
}
// 3. 加载输注方案(编辑模式:从申请单详情中获取)
try {
if (detail.infusionPlan) {
const ip = detail.infusionPlan
infusionPlan.totalCourseCount = ip.totalCourseCount || 0
infusionPlan.courseInterval = ip.courseInterval || ''
infusionPlan.infusionStartDate = ip.infusionStartDate || ''
infusionPlan.channelAgreementId = ip.channelAgreementId || ''
}
if (detail.infusionSchedules && Array.isArray(detail.infusionSchedules)) {
infusionSchedules.value = detail.infusionSchedules.map((s: any, i: number) => ({
_localId: ++scheduleSeq,
infusionTime: s.infusionTime || '',
infusionLocation: s.infusionLocation || '',
infusionMethod: s.infusionMethod || '',
_expanded: i === 0,
cellProducts: (s.cellProducts || []).map((cp: any) => ({
_localId: ++scheduleSeq,
priceId: cp.priceId || '',
projectCode: cp.projectCode || '',
projectName: cp.projectName || '',
cellTypeName: cp.cellTypeName || '',
specText: cp.specText || '',
agreementPrice: cp.agreementPrice ?? 0,
actualPrice: cp.actualPrice ?? 0,
quantity: cp.quantity || 1,
remark: cp.remark || '',
status: cp.status || '待输注'
}))
}))
}
if (formData.channelId) {
await loadAgreements(formData.channelId)
}
if (formData.channelServiceOrderId) {
await onServiceOrderChange(formData.channelServiceOrderId, { preserveCellProducts: true })
} else if (infusionPlan.channelAgreementId) {
await onAgreementChange(infusionPlan.channelAgreementId)
}
} catch {
// 输注方案数据可能不存在
}
} catch (e: any) {
ElMessage.error(e?.message || '加载数据失败')
} finally {
formLoading.value = false
}
}
// ── 提交 ───────────────────────────────────────────────────────────────
const handleSubmit = async () => {
try {
await formRef.value?.validate()
} catch {
return
}
submitting.value = true
try {
const payload: Record<string, any> = {
applicationName: formData.applicationName || undefined,
channelServiceOrderId: formData.channelServiceOrderId,
remark: formData.remark || undefined,
patient: {
customerId: patientForm.customerId || undefined,
patientName: patientForm.patientName || undefined,
sex: patientForm.sex || undefined,
age: patientForm.age ?? undefined,
idCardNo: patientForm.idCardNo || undefined,
phoneNo: patientForm.phoneNo || undefined,
emergencyContact: patientForm.emergencyContact || undefined,
emergencyPhone: patientForm.emergencyPhone || undefined,
clinicalDiagnosis: patientForm.clinicalDiagnosis || undefined,
diagnosisAttachments: diagnosisFileList.value.map((f) => f.fileUrl).filter(Boolean).join(',') || undefined,
needCheckup: patientForm.needCheckup
},
infusionPlan: {
totalCourseCount: infusionPlan.totalCourseCount,
courseInterval: infusionPlan.courseInterval || undefined,
infusionStartDate: infusionPlan.infusionStartDate || undefined,
channelAgreementId: infusionPlan.channelAgreementId || undefined
},
infusionSchedules: infusionSchedules.value.map((s, idx) => ({
sortNo: idx + 1,
infusionTime: s.infusionTime || undefined,
infusionLocation: s.infusionLocation || undefined,
infusionMethod: s.infusionMethod || undefined,
cellProducts: (s.cellProducts || []).map((cp: any) => ({
priceId: cp.priceId || undefined,
projectCode: cp.projectCode || undefined,
projectName: cp.projectName || undefined,
cellTypeName: cp.cellTypeName || undefined,
specText: cp.specText || undefined,
agreementPrice: cp.agreementPrice ?? undefined,
actualPrice: cp.actualPrice ?? undefined,
quantity: cp.quantity || 1,
remark: cp.remark || undefined,
status: cp.status || '待输注'
}))
}))
}
if (isEdit.value && props.editId) {
payload.id = props.editId
}
await savePilotApplication(payload)
ElMessage.success(isEdit.value ? '保存成功' : '创建成功')
emit('update:modelValue', false)
emit('saved')
} catch (e: any) {
ElMessage.error(e?.message || '保存失败')
} finally {
submitting.value = false
}
}
</script>
<style scoped lang="scss">
/* ---- Sticky Tab 导航 ---- */
.detail-tabs {
position: sticky;
top: 0;
z-index: 1000;
display: flex;
gap: 4px;
background: #f0fdf4;
padding: 4px;
border-radius: 8px;
margin: 0 28px 16px;
width: fit-content;
isolation: isolate;
box-shadow: 0 1px 0 #f0fdf4;
}
.detail-tab {
padding: 10px 20px;
cursor: pointer;
border-radius: 6px;
font-size: 14px;
color: #606266;
transition: all 0.3s;
&.active {
background: #fff;
color: #059669;
font-weight: 500;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
&:hover:not(.active) {
color: #059669;
}
}
.form-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px 20px;
&--col3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
&--col4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.form-col-full {
grid-column: 1 / -1;
}
.form-col-span-2-cols {
grid-column: span 2;
}
:deep(.el-form-item) {
margin-bottom: 0;
}
}
.field-with-action {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
&__main {
flex: 1;
min-width: 0;
}
&__btn {
flex-shrink: 0;
}
}
.view-inner-table-wrap {
margin-top: 4px;
}
.form-tab-pane {
padding: 0 28px 4px;
& + .form-tab-pane {
margin-top: 0;
padding-top: 0;
}
}
.section-card {
border-radius: 12px;
padding: 22px 24px;
margin-bottom: 20px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
transition:
transform 0.2s,
box-shadow 0.2s;
position: relative;
background: #fff;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
&--white {
background: #fff;
}
}
.section-title {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 18px;
padding-bottom: 12px;
border-bottom: 1px solid #e2e8f0;
}
.section-title-left {
display: flex;
align-items: center;
&--with-hint {
flex: 1;
min-width: 0;
gap: 8px;
h3 {
flex-shrink: 0;
margin: 0;
}
}
&::before {
content: '';
width: 3px;
height: 18px;
background: #10b981;
border-radius: 2px;
margin-right: 10px;
flex-shrink: 0;
}
}
.section-title h3 {
font-size: 15px;
font-weight: 600;
color: #1e293b;
}
.section-title-hint {
margin: 0;
padding: 0;
font-size: 12px;
font-weight: 400;
color: #94a3b8;
line-height: 1.5;
flex: 1;
min-width: 0;
}
.patient-checkup-field {
grid-column: span 1;
}
.patient-diagnosis-field {
grid-column: span 3;
}
@media (max-width: 900px) {
.patient-diagnosis-field {
grid-column: span 1;
}
}
.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;
&--doc {
background: #e0f2fe;
color: #0284c7;
}
&--video {
background: #ede9fe;
color: #7c3aed;
}
}
.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;
}
}
/* ---- 输注计划卡片 ---- */
.schedule-details {
display: flex;
flex-direction: column;
gap: 12px;
}
.infusion-order-meta {
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px dashed #e2e8f0;
}
.infusion-plan-fields {
margin-top: 0;
}
.infusion-amount-summary {
display: inline-flex;
align-items: center;
min-height: 32px;
font-size: 15px;
font-weight: 600;
color: #059669;
}
.infusion-qty-summary {
font-size: 13px;
color: #334155;
line-height: 1.6;
min-height: 32px;
display: flex;
align-items: center;
}
.schedule-amount-summary {
margin-top: 12px;
padding: 10px 12px;
font-size: 13px;
color: #64748b;
background: #f8fafc;
border-radius: 8px;
border: 1px solid #e2e8f0;
&__value {
font-weight: 600;
color: #059669;
}
}
.cell-product-table {
:deep(.cell-product-table__control) {
width: 100%;
}
:deep(.el-select .el-select__wrapper),
:deep(.el-input-number .el-input__wrapper),
:deep(.el-input .el-input__wrapper) {
min-height: 32px;
}
:deep(.el-input-number .el-input__wrapper) {
padding-left: 8px;
padding-right: 8px;
}
}
.cell-product-price-text {
display: inline-flex;
align-items: center;
min-height: 32px;
font-size: 13px;
color: #64748b;
}
.meta-text {
font-size: 13px;
color: #334155;
line-height: 32px;
}
/* 渠道协议只读展示 */
.agreement-display {
display: inline-flex;
align-items: center;
min-height: 32px;
padding: 0 8px;
font-size: 13px;
color: #334155;
background: #f8fafc;
border-radius: 6px;
width: 100%;
}
.schedule-card {
border: 1px solid #e2e8f0;
border-radius: 10px;
overflow: hidden;
transition: box-shadow 0.2s;
&:hover {
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.08);
}
&.expanded {
border-color: #a7f3d0;
}
}
.schedule-header {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 18px;
cursor: pointer;
background: #f8fafc;
transition: background 0.2s;
&:hover {
background: #f0fdf4;
}
}
.schedule-chevron {
font-size: 12px;
color: #94a3b8;
transition: transform 0.3s;
}
.expanded .schedule-chevron {
transform: rotate(90deg);
}
.schedule-title {
font-size: 14px;
font-weight: 500;
color: #1e293b;
}
.schedule-delete-btn {
margin-left: auto;
}
.schedule-body {
border-top: 1px solid #f1f5f9;
}
.schedule-body-inner {
padding: 18px;
}
/* 细胞产品子表 */
.cell-product-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.section-subtitle {
display: flex;
align-items: center;
font-size: 13px;
font-weight: 600;
color: #334155;
&::before {
content: '';
width: 3px;
height: 14px;
background: #10b981;
border-radius: 2px;
margin-right: 8px;
flex-shrink: 0;
}
}
</style>