2411 lines
80 KiB
Vue
2411 lines
80 KiB
Vue
<!-- 中试服务申请单 — 新增/编辑表单弹窗 -->
|
||
<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>
|