Application.vue 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921
  1. <template>
  2. <div class="application-dialog-container">
  3. <van-popup v-model:show="state.dialog.isShowDialog" position="bottom" :style="{ height: '90vh' }" round
  4. :closeable="true" @close="onCancel" :close-on-click-overlay="false">
  5. <div class="popup-wrapper">
  6. <h3 class="popup-title">{{ state.dialog.title }}</h3>
  7. <div class="popup-content">
  8. <van-form ref="expertDialogFormRef" @submit="onSubmit">
  9. <h4 class="mb8 mt8">基本信息</h4>
  10. <van-cell-group>
  11. <van-field v-model="state.form.projectName" label="课题名称" placeholder="请选择" readonly is-link required
  12. @click="showProjectPicker = true" :rules="rules.projectGroupId" />
  13. <van-field v-model="userInfos.nickName" label="姓名" placeholder="姓名" readonly />
  14. <van-field v-model="userInfos.deptName" label="部门" placeholder="部门" readonly />
  15. <van-field v-model="userInfos.phone" label="联系方式" placeholder="联系方式" readonly />
  16. </van-cell-group>
  17. <h4 class="mb8 mt10">实验动物笼位预约信息</h4>
  18. <van-cell-group>
  19. <van-field v-model="state.form.number" label="笼位数量" placeholder="笼位数量" type="digit" required
  20. :rules="rules.number">
  21. <template #button>
  22. <van-stepper v-model="state.form.number" :min="1" integer />
  23. </template>
  24. </van-field>
  25. <van-field v-model="state.form.startDate" label="开始使用时间" placeholder="请选择时间" readonly is-link required
  26. @click="showStartDatePicker = true" :rules="rules.startDate" />
  27. <van-field v-model="state.form.categoryName" label="动物类别" placeholder="请选择" readonly is-link required
  28. @click="showCategoryPicker = true" :rules="rules.categoryId" />
  29. <van-field v-model="state.form.variety" label="品种品系" placeholder="请输入品种品系" required
  30. :rules="rules.variety" />
  31. <van-field v-model="state.form.levelName" label="饲养区域" placeholder="请选择" readonly is-link required :rules="rules.levelName"
  32. @click="showLevelPicker = true" />
  33. <van-field label="周龄(w)" :rules="rules.age">
  34. <template #input>
  35. <div class="range-input-wrapper">
  36. <van-field v-model="state.form.age.min" placeholder="请输入" type="digit" class="range-input" />
  37. <span class="range-separator">至</span>
  38. <van-field v-model="state.form.age.max" placeholder="请输入" type="digit" class="range-input" />
  39. </div>
  40. </template>
  41. </van-field>
  42. <van-field label="体重(g)" :rules="rules.weight">
  43. <template #input>
  44. <div class="range-input-wrapper">
  45. <van-field v-model="state.form.weight.min" placeholder="请输入" type="number" class="range-input" />
  46. <span class="range-separator">至</span>
  47. <van-field v-model="state.form.weight.max" placeholder="请输入" type="number" class="range-input" />
  48. </div>
  49. </template>
  50. </van-field>
  51. <van-field
  52. v-model="state.form.maleNumber"
  53. label="雄性"
  54. placeholder="雄性数量"
  55. type="digit"
  56. :rules="rules.sexNumber"
  57. >
  58. <template #button>
  59. <van-stepper v-model="state.form.maleNumber" :min="0" integer />
  60. </template>
  61. </van-field>
  62. <van-field v-model="state.form.famaleNumber" label="雌性" placeholder="雌性数量" type="digit">
  63. <template #button>
  64. <van-stepper v-model="state.form.famaleNumber" :min="0" integer />
  65. </template>
  66. </van-field>
  67. <van-field v-model="state.form.totalNumber" label="合计" placeholder="合计" readonly type="digit">
  68. <!-- <template #button>
  69. <van-stepper v-model="state.form.totalNumber" :min="0" integer disabled />
  70. </template> -->
  71. </van-field>
  72. <van-field v-model="state.form.feedingDay" label="饲养总天数" placeholder="饲养总天数" type="digit" required
  73. :rules="rules.feedingDay">
  74. <template #button>
  75. <van-stepper v-model="state.form.feedingDay" :min="1" integer />
  76. </template>
  77. </van-field>
  78. </van-cell-group>
  79. <h4 class="mb8 mt20">采购渠道</h4>
  80. <van-cell-group>
  81. <van-field label="采购渠道" required :rules="rules.buyFrom">
  82. <template #input>
  83. <van-radio-group v-model="state.form.buyFrom" direction="horizontal">
  84. <van-radio style="margin-bottom: 10px" :name="ProcurementChannels.PURCHASED_BY_OTHERS">动物房代购</van-radio>
  85. <van-radio style="margin-bottom: 10px" :name="ProcurementChannels.PURCHASED_BY_MYSELF">自行购买</van-radio>
  86. <van-radio style="margin-bottom: 10px" :name="ProcurementChannels.PURCHASED_BY_SPLIT_CAGE">动物分笼</van-radio>
  87. </van-radio-group>
  88. </template>
  89. </van-field>
  90. <van-field v-if="state.form.buyFrom === ProcurementChannels.PURCHASED_BY_MYSELF"
  91. v-model="state.form.comeFromUnit" label="外购来源单位" placeholder="请输入外购来源单位" required
  92. :rules="rules.comeFromUnit" />
  93. <van-field v-model="state.form.comeTime" label="动物到达时间" placeholder="请选择到达时间" readonly is-link
  94. @click="showComeTimePicker = true" />
  95. </van-cell-group>
  96. <!-- 自行购买时的文件上传 -->
  97. <template v-if="state.form.buyFrom === ProcurementChannels.PURCHASED_BY_MYSELF">
  98. <h4 class="mb8 mt20">文件上传</h4>
  99. <van-cell-group>
  100. <van-cell title="生产许可证副本" required :rules="rules.licenseNumberFile">
  101. <van-uploader v-model="licenseNumberFileList"
  102. :after-read="(file) => handleFileUpload(file, UploadFileType.LICENSE_NUMBER)"
  103. :before-delete="() => handleRemove(UploadFileType.LICENSE_NUMBER)" :max-count="1"
  104. :accept="state.SUPPORT_FILE_UPLOAD_TYPE_MAX" />
  105. <div class="upload-tip">支持格式:jpg png pdf等,单个文件不超过20MB</div>
  106. </van-cell>
  107. <van-cell title="近三个月动物质量检测证明" required :rules="rules.animalTestDateFile">
  108. <van-uploader v-model="animalTestDateFileList"
  109. :after-read="(file) => handleFileUpload(file, UploadFileType.ANIMAL_TEST_DATE)"
  110. :before-delete="() => handleRemove(UploadFileType.ANIMAL_TEST_DATE)" :max-count="1"
  111. :accept="state.SUPPORT_FILE_UPLOAD_TYPE_MAX" />
  112. <div class="upload-tip">支持格式:jpg png pdf等,单个文件不超过20MB</div>
  113. </van-cell>
  114. <van-cell title="基因鉴定报告">
  115. <van-uploader v-model="geneIdentificationFileList"
  116. :after-read="(file) => handleFileUpload(file, UploadFileType.ENV_TEST_DATE)"
  117. :before-delete="() => handleRemove(UploadFileType.ENV_TEST_DATE)" :max-count="1"
  118. :accept="state.SUPPORT_FILE_UPLOAD_TYPE_MAX" />
  119. <div class="upload-tip">支持格式:jpg png pdf等,单个文件不超过20MB</div>
  120. </van-cell>
  121. </van-cell-group>
  122. </template>
  123. <h4 class="mb8 mt20">特殊要求</h4>
  124. <van-cell-group>
  125. <van-field label="是否有特殊饲养要求">
  126. <template #input>
  127. <van-radio-group v-model="state.form.hasFeedingSpecial" direction="horizontal">
  128. <van-radio :name="FeedingSpecial.HAVE_FEEDING_SPECIAL">有</van-radio>
  129. <van-radio :name="FeedingSpecial.NO_FEEDING_SPECIAL">无</van-radio>
  130. </van-radio-group>
  131. </template>
  132. </van-field>
  133. <van-field v-if="state.form.hasFeedingSpecial === FeedingSpecial.HAVE_FEEDING_SPECIAL"
  134. v-model="state.form.feedingSpecialDesc" label="特殊饲养要求" placeholder="输入特殊饲养要求,如每天更换垫料等" required
  135. :rules="rules.feedingSpecialDesc" />
  136. </van-cell-group>
  137. <h4 class="mb8 mt20">备注</h4>
  138. <van-cell-group>
  139. <van-field
  140. v-model="state.form.remark" label="备注" placeholder="输入备注"
  141. />
  142. </van-cell-group>
  143. <h4 class="mb8 mt20">附件上传</h4>
  144. <van-cell-group>
  145. <van-cell title="实验动物福利伦理审查申请表" required :rules="rules.ethicsCheckFile">
  146. <van-uploader v-model="ethicsCheckFileList"
  147. :after-read="(file) => handleFileUpload(file, UploadFileType.ETHICS_CHECK_FILE)"
  148. :before-delete="() => handleRemove(UploadFileType.ETHICS_CHECK_FILE)" :max-count="1"
  149. :accept="state.SUPPORT_FILE_UPLOAD_TYPE_MAX" />
  150. <div class="upload-tip">支持格式:jpg png pdf等,单个文件不超过20MB</div>
  151. </van-cell>
  152. <van-cell title="实验动物福利伦理审查意见表" required :rules="rules.ethicsAdviceFile">
  153. <van-uploader v-model="ethicsAdviceFileList"
  154. :after-read="(file) => handleFileUpload(file, UploadFileType.ETHICS_ADVICE_FILE)"
  155. :before-delete="() => handleRemove(UploadFileType.ETHICS_ADVICE_FILE)" :max-count="1"
  156. :accept="state.SUPPORT_FILE_UPLOAD_TYPE_MAX" />
  157. <div class="upload-tip">支持格式:jpg png pdf等,单个文件不超过20MB</div>
  158. </van-cell>
  159. </van-cell-group>
  160. <div class="mt30 mb30 checkbox-wrapper">
  161. <van-checkbox v-model="safePromise">
  162. <div class="safePromise">
  163. 本人(以上所述课题的负责人)谨此声明:本项目所包含的实验动物、实验方法、实验材料及试剂无放射性、感染性和化学毒性,所有参与实验人员在实验过程中自愿遵守遵义医科大学附属医院实验动物房的管理制度和操作流程,愿意根据其规定的付费方式向遵义医科大学附属医院实验动物房支付所有的费用。
  164. </div>
  165. </van-checkbox>
  166. </div>
  167. </van-form>
  168. </div>
  169. <div class="dialog-footer">
  170. <van-button type="primary" @click="onSubmit" block native-type="submit" :loading="submitting">
  171. 提交
  172. </van-button>
  173. </div>
  174. </div>
  175. </van-popup>
  176. <!-- 课题选择器 -->
  177. <van-popup v-model:show="showProjectPicker" position="bottom">
  178. <van-picker :columns="projects" :columns-field-names="{ text: 'projectName', value: 'id' }"
  179. @confirm="onProjectConfirm" @cancel="showProjectPicker = false" />
  180. </van-popup>
  181. <!-- 动物类别选择器 -->
  182. <van-popup v-model:show="showCategoryPicker" position="bottom">
  183. <van-picker :columns="animalTypeList" :columns-field-names="{ text: 'name', value: 'id' }"
  184. @confirm="onCategoryConfirm" @cancel="showCategoryPicker = false" />
  185. </van-popup>
  186. <!-- 饲养区域选择器 -->
  187. <van-popup v-model:show="showLevelPicker" position="bottom">
  188. <van-picker :columns="LeavelList" :columns-field-names="{ text: 'name', value: 'id' }" @confirm="onLevelConfirm"
  189. @cancel="showLevelPicker = false" />
  190. </van-popup>
  191. <!-- 开始使用时间选择器 -->
  192. <van-popup v-model:show="showStartDatePicker" position="bottom" :style="{ height: '80vh' }" round>
  193. <van-calendar v-model:show="showStartDatePicker" @confirm="onStartDateConfirm" :min-date="new Date()" />
  194. </van-popup>
  195. <!-- 动物到达时间选择器 -->
  196. <van-popup v-model:show="showComeTimePicker" position="bottom" :style="{ height: '80vh' }" round>
  197. <van-calendar v-model:show="showComeTimePicker" @confirm="onComeTimeConfirm" />
  198. </van-popup>
  199. </div>
  200. </template>
  201. <script setup lang="ts" name="systemProDialog">
  202. import { reactive, ref, watch } from 'vue'
  203. import to from 'await-to-js'
  204. import { showToast, showNotify } from 'vant'
  205. import type { FormInstance } from 'vant/es'
  206. import dayjs from 'dayjs'
  207. import { storeToRefs } from 'pinia'
  208. import { usePlatAnimalCageApplicationApi } from '/@/api/platform/animal'
  209. import { LeavelList, SUPPORT_FILE_UPLOAD_TYPE_MAX ,AnimalPurpose} from '/@/constants/pageConstants'
  210. import { deepClone } from '/@/utils/other'
  211. import { useUserInfo } from '/@/stores/userInfo'
  212. import { ProcurementChannels, FeedingSpecial, UploadFileType } from '/@/constants/pageConstants'
  213. import { handleUpload } from '/@/utils/upload'
  214. import { getFileUrl } from '/@/utils/url'
  215. import { formatDate } from '/@/utils/formatTime'
  216. const stores = useUserInfo()
  217. const { userInfos } = storeToRefs(stores)
  218. // 定义子组件向父组件传值/事件
  219. const emit = defineEmits(['refresh'])
  220. const platAnimalCageApplicationApi = usePlatAnimalCageApplicationApi()
  221. const expertDialogFormRef = ref<FormInstance>()
  222. const projectGroupList = ref<any[]>([])
  223. const projects = ref<any[]>([])
  224. const showProjectPicker = ref(false)
  225. const showCategoryPicker = ref(false)
  226. const showLevelPicker = ref(false)
  227. const showStartDatePicker = ref(false)
  228. const showComeTimePicker = ref(false)
  229. const rules = {
  230. projectGroupId: [{ required: true, message: '课题名称不能为空' }],
  231. categoryId: [{ required: true, message: '动物类别不能为空' }],
  232. variety: [{ required: true, message: '品种品系不能为空' }],
  233. levelName: [{ required: true, message: '饲养区域不能为空' }],
  234. number: [{ required: true, message: '笼位数量不能为空' }],
  235. startDate: [{ required: true, message: '开始使用时间不能为空' }],
  236. buyFrom: [{ required: true, message: '采购渠道不能为空' }],
  237. feedingDay: [{ required: true, message: '饲养总天数不能为空' }],
  238. comeFromUnit: [{ required: true, message: '外购来源单位不能为空' }],
  239. feedingSpecialDesc: [{ required: true, message: '特殊饲养要求不能为空' }],
  240. ethicsCheckFile: [{ required: true, message: '实验动物福利伦理审查申请表不能为空' }],
  241. ethicsAdviceFile: [{ required: true, message: '实验动物福利伦理审查意见表不能为空' }],
  242. licenseNumberFile: [{ required: true, message: '生产许可证副本不能为空' }],
  243. animalTestDateFile: [{ required: true, message: '近三个月动物质量检测证明不能为空' }],
  244. age: [
  245. {
  246. validator: () => {
  247. const age = state.form.age
  248. const weight = state.form.weight
  249. const ageMin = age?.min
  250. const ageMax = age?.max
  251. const weightMin = weight?.min
  252. const weightMax = weight?.max
  253. const hasAgeMin = ageMin !== null && ageMin !== ''
  254. const hasAgeMax = ageMax !== null && ageMax !== ''
  255. const hasWeightMin = weightMin !== null && weightMin !== ''
  256. const hasWeightMax = weightMax !== null && weightMax !== ''
  257. const isAgeFilled = hasAgeMin && hasAgeMax
  258. const isWeightFilled = hasWeightMin && hasWeightMax
  259. const hasAgeAny = hasAgeMin || hasAgeMax
  260. if (!hasAgeAny && !isWeightFilled) {
  261. return '周龄或体重至少填写一个'
  262. }
  263. if (hasAgeAny && !isAgeFilled) {
  264. return '周龄范围不能为空'
  265. }
  266. if (!isAgeFilled) {
  267. return true
  268. }
  269. const min = Number(ageMin)
  270. const max = Number(ageMax)
  271. if (isNaN(min) || isNaN(max)) {
  272. return '周龄范围不能为空'
  273. }
  274. if (max === 0) {
  275. return '最大周龄不能为0'
  276. }
  277. if (min > max) {
  278. return '最小周龄不能大于最大周龄'
  279. }
  280. return true
  281. },
  282. },
  283. ],
  284. weight: [
  285. {
  286. validator: () => {
  287. const age = state.form.age
  288. const weight = state.form.weight
  289. const ageMin = age?.min
  290. const ageMax = age?.max
  291. const weightMin = weight?.min
  292. const weightMax = weight?.max
  293. const hasAgeMin = ageMin !== null && ageMin !== ''
  294. const hasAgeMax = ageMax !== null && ageMax !== ''
  295. const hasWeightMin = weightMin !== null && weightMin !== ''
  296. const hasWeightMax = weightMax !== null && weightMax !== ''
  297. const isAgeFilled = hasAgeMin && hasAgeMax
  298. const isWeightFilled = hasWeightMin && hasWeightMax
  299. const hasWeightAny = hasWeightMin || hasWeightMax
  300. if (!hasWeightAny && !isAgeFilled) {
  301. return '周龄或体重至少填写一个'
  302. }
  303. if (hasWeightAny && !isWeightFilled) {
  304. return '体重范围不能为空'
  305. }
  306. if (!isWeightFilled) {
  307. return true
  308. }
  309. const min = Number(weightMin)
  310. const max = Number(weightMax)
  311. if (isNaN(min) || isNaN(max)) {
  312. return '体重范围不能为空'
  313. }
  314. if (max === 0) {
  315. return '最大体重不能为0'
  316. }
  317. if (min > max) {
  318. return '最小体重不能大于最大体重'
  319. }
  320. return true
  321. },
  322. },
  323. ],
  324. sexNumber: [
  325. {
  326. validator: () => {
  327. const male = Number(state.form.maleNumber) || 0
  328. const female = Number(state.form.famaleNumber) || 0
  329. if (!male && !female) {
  330. return '雌性或雄性数量至少填写一个'
  331. }
  332. return true
  333. },
  334. },
  335. ],
  336. }
  337. const licenseNumberFileList = ref<any[]>([])
  338. const animalTestDateFileList = ref<any[]>([])
  339. const geneIdentificationFileList = ref<any[]>([])
  340. const ethicsCheckFileList = ref<any[]>([])
  341. const ethicsAdviceFileList = ref<any[]>([])
  342. const safePromise = ref<boolean>(false)
  343. const animalTypeList = ref<any[]>([])
  344. const submitting = ref<boolean>(false) // 提交状态,用于控制按钮加载状态
  345. const defaultFormData = {
  346. id: 0,
  347. projectGroupName: '',
  348. projectGroupId: null,
  349. projectName: '',
  350. categoryName: '',
  351. variety: '',
  352. categoryId: null,
  353. level: null,
  354. levelName: '',
  355. number: 1,
  356. startDate: '',
  357. maleNumber: 0,
  358. famaleNumber: 0,
  359. weight: { min: null, max: null },
  360. age: { min: null, max: null },
  361. feedingDay: 0,
  362. buyFrom: ProcurementChannels.PURCHASED_BY_OTHERS,
  363. comeTime: '',
  364. comeFromUnit: '',
  365. licenseNumberFile: [],
  366. animalTestDateFile: [],
  367. geneIdentificationFile: [],
  368. animalPurpose: AnimalPurpose.NO_NEED_SPLIT_CAGE,
  369. hasFeedingSpecial: FeedingSpecial.HAVE_FEEDING_SPECIAL,
  370. feedingSpecialDesc: '',
  371. ethicsCheckFile: [],
  372. ethicsAdviceFile: [],
  373. deptName: '',
  374. deptId: '',
  375. phone: '',
  376. totalNumber: 0,
  377. remark: '',
  378. }
  379. const state = reactive({
  380. SUPPORT_FILE_UPLOAD_TYPE_MAX,
  381. form: { ...defaultFormData },
  382. dialog: {
  383. isShowDialog: false,
  384. type: '',
  385. title: '',
  386. submitTxt: '',
  387. },
  388. })
  389. const getDicts = () => {
  390. return Promise.all([
  391. platAnimalCageApplicationApi.getAnimalTypeList({}),
  392. platAnimalCageApplicationApi.getProjectGroup({}),
  393. ]).then(([animalType, projectGroup]) => {
  394. animalTypeList.value = animalType.data
  395. if (projectGroup && projectGroup.data) {
  396. projectGroupList.value = projectGroup.data
  397. const currentProject = projectGroup.data[0]?.projects
  398. if (currentProject) {
  399. projects.value = currentProject
  400. }
  401. }
  402. })
  403. }
  404. // 重置表单数据
  405. const resetForm = () => {
  406. Object.assign(state.form, deepClone(defaultFormData));
  407. // 3. 特别注意:你在 onSubmit 里把 age 和 weight 转成了字符串
  408. // 这里必须手动确保它们恢复成对象结构,否则 v-model 会报错
  409. state.form.age = { min: null, max: null };
  410. state.form.weight = { min: null, max: null };
  411. // state.form = { ...defaultFormData }
  412. expertDialogFormRef.value?.resetValidation()
  413. licenseNumberFileList.value = []
  414. animalTestDateFileList.value = []
  415. geneIdentificationFileList.value = []
  416. ethicsCheckFileList.value = []
  417. ethicsAdviceFileList.value = []
  418. safePromise.value = false
  419. }
  420. const isValidJsonArray = (value: any) => {
  421. if (Array.isArray(value)) return true
  422. if (typeof value !== 'string') return false
  423. try {
  424. const parsed = JSON.parse(value)
  425. return Array.isArray(parsed)
  426. } catch {
  427. return false
  428. }
  429. }
  430. const parseRangeField = (value: any) => {
  431. if (!value) return { min: null, max: null }
  432. if (typeof value === 'object' && value.min !== undefined && value.max !== undefined) {
  433. return { min: value.min ?? null, max: value.max ?? null }
  434. }
  435. if (typeof value === 'string') {
  436. try {
  437. const cleanStr = value.replace(/\\"/g, '"').replace(/^"|"$/g, '')
  438. const parsed = JSON.parse(cleanStr)
  439. return { min: parsed?.min ?? null, max: parsed?.max ?? null }
  440. } catch {
  441. return { min: null, max: null }
  442. }
  443. }
  444. return { min: null, max: null }
  445. }
  446. // 打开弹窗
  447. const openDialog = async (type: 'add' | 'edit', row?: any) => {
  448. resetForm()
  449. await getDicts()
  450. state.dialog.type = type
  451. state.dialog.title = type === 'edit' ? '重新提交实验动物笼位申请' : '新增实验动物笼位申请'
  452. if (type === 'edit' && row) {
  453. const categoryId = row.categoryId ?? row.category_id ?? null
  454. const categoryName = row.category_name ?? row.categoryName ?? ''
  455. const projectGroupName = row.projectGroupName ?? row.project_name ?? row.projectName ?? ''
  456. const projectGroupId = row.projectGroupId ?? row.projectGroup_id ?? null
  457. const level = row.level ?? row.level_id ?? row.levelId ?? null
  458. const levelNameFromRow = row.levelName ?? row.level_name ?? ''
  459. const normalizedLevel = level != null ? Number(level) : null
  460. const normalizedLevelName =
  461. levelNameFromRow || (normalizedLevel != null ? LeavelList.find((item) => item.id === normalizedLevel)?.name || '' : '')
  462. state.form = {
  463. ...deepClone(defaultFormData),
  464. ...row,
  465. id: Number(row.id) || 0,
  466. categoryId: categoryId != null ? Number(categoryId) : null,
  467. categoryName,
  468. projectGroupId: projectGroupId != null ? Number(projectGroupId) : null,
  469. projectGroupName,
  470. projectName: row.projectName || projectGroupName,
  471. level: normalizedLevel,
  472. levelName: normalizedLevelName,
  473. age: parseRangeField(row.age),
  474. weight: parseRangeField(row.weight),
  475. }
  476. state.form.licenseNumberFile = isValidJsonArray(row.licenseNumberFile) ? JSON.parse(row.licenseNumberFile) : []
  477. state.form.animalTestDateFile = isValidJsonArray(row.animalTestDateFile) ? JSON.parse(row.animalTestDateFile) : []
  478. state.form.geneIdentificationFile = isValidJsonArray(row.geneIdentificationFile)
  479. ? JSON.parse(row.geneIdentificationFile)
  480. : []
  481. state.form.ethicsCheckFile = isValidJsonArray(row.ethicsCheckFile) ? JSON.parse(row.ethicsCheckFile) : []
  482. state.form.ethicsAdviceFile = isValidJsonArray(row.ethicsAdviceFile) ? JSON.parse(row.ethicsAdviceFile) : []
  483. licenseNumberFileList.value = (state.form.licenseNumberFile || []).map((f: any) => ({ url: getFileUrl(f.url), name: f.name }))
  484. animalTestDateFileList.value = (state.form.animalTestDateFile || []).map((f: any) => ({ url: getFileUrl(f.url), name: f.name }))
  485. geneIdentificationFileList.value = (state.form.geneIdentificationFile || []).map((f: any) => ({ url: getFileUrl(f.url), name: f.name }))
  486. ethicsCheckFileList.value = (state.form.ethicsCheckFile || []).map((f: any) => ({ url: getFileUrl(f.url), name: f.name }))
  487. ethicsAdviceFileList.value = (state.form.ethicsAdviceFile || []).map((f: any) => ({ url: getFileUrl(f.url), name: f.name }))
  488. }
  489. safePromise.value = false
  490. state.dialog.isShowDialog = true
  491. }
  492. // 关闭弹窗
  493. const closeDialog = () => {
  494. expertDialogFormRef.value?.resetValidation()
  495. state.dialog.isShowDialog = false
  496. licenseNumberFileList.value = []
  497. animalTestDateFileList.value = []
  498. geneIdentificationFileList.value = []
  499. ethicsCheckFileList.value = []
  500. ethicsAdviceFileList.value = []
  501. safePromise.value = false
  502. }
  503. // 取消
  504. const onCancel = () => {
  505. closeDialog()
  506. }
  507. const handleFileUpload = async (file: any, type: UploadFileType) => {
  508. // 处理单个文件或文件数组
  509. const files = Array.isArray(file) ? file : [file]
  510. for (const item of files) {
  511. // 检查文件大小
  512. if (item.file && item.file.size / 1024 / 1024 > 20) {
  513. showNotify({
  514. type: 'warning',
  515. message: '上传文件大小不能超过 20MB!',
  516. })
  517. // 移除文件
  518. if (type === UploadFileType.LICENSE_NUMBER) {
  519. licenseNumberFileList.value = licenseNumberFileList.value.filter((f) => f !== item)
  520. } else if (type === UploadFileType.ANIMAL_TEST_DATE) {
  521. animalTestDateFileList.value = animalTestDateFileList.value.filter((f) => f !== item)
  522. } else if (type === UploadFileType.ENV_TEST_DATE) {
  523. geneIdentificationFileList.value = geneIdentificationFileList.value.filter((f) => f !== item)
  524. } else if (type === UploadFileType.ETHICS_CHECK_FILE) {
  525. ethicsCheckFileList.value = ethicsCheckFileList.value.filter((f) => f !== item)
  526. } else if (type === UploadFileType.ETHICS_ADVICE_FILE) {
  527. ethicsAdviceFileList.value = ethicsAdviceFileList.value.filter((f) => f !== item)
  528. }
  529. return
  530. }
  531. if (item.file) {
  532. item.status = 'uploading'
  533. const [err, res]: ToResponse = await to(handleUpload(item.file))
  534. if (err) {
  535. item.status = 'failed'
  536. showNotify({
  537. type: 'danger',
  538. message: '上传失败',
  539. })
  540. return
  541. }
  542. item.status = 'done'
  543. item.url = getFileUrl(res as string)
  544. item.name = item.file.name
  545. // 保存到 form
  546. if (type === UploadFileType.LICENSE_NUMBER) {
  547. state.form.licenseNumberFile = [{ name: item.name, url: res }]
  548. } else if (type === UploadFileType.ANIMAL_TEST_DATE) {
  549. state.form.animalTestDateFile = [{ name: item.name, url: res }]
  550. } else if (type === UploadFileType.ENV_TEST_DATE) {
  551. state.form.geneIdentificationFile = [{ name: item.name, url: res }]
  552. } else if (type === UploadFileType.ETHICS_CHECK_FILE) {
  553. state.form.ethicsCheckFile = [{ name: item.name, url: res }]
  554. } else if (type === UploadFileType.ETHICS_ADVICE_FILE) {
  555. state.form.ethicsAdviceFile = [{ name: item.name, url: res }]
  556. }
  557. }
  558. }
  559. }
  560. const handleRemove = (type: UploadFileType) => {
  561. if (type === UploadFileType.LICENSE_NUMBER) {
  562. licenseNumberFileList.value = []
  563. state.form.licenseNumberFile = []
  564. } else if (type === UploadFileType.ANIMAL_TEST_DATE) {
  565. animalTestDateFileList.value = []
  566. state.form.animalTestDateFile = []
  567. } else if (type === UploadFileType.ENV_TEST_DATE) {
  568. geneIdentificationFileList.value = []
  569. state.form.geneIdentificationFile = []
  570. } else if (type === UploadFileType.ETHICS_CHECK_FILE) {
  571. ethicsCheckFileList.value = []
  572. state.form.ethicsCheckFile = []
  573. } else if (type === UploadFileType.ETHICS_ADVICE_FILE) {
  574. ethicsAdviceFileList.value = []
  575. state.form.ethicsAdviceFile = []
  576. }
  577. return true
  578. }
  579. const onProjectConfirm = ({ selectedOptions }: { selectedOptions: any[] }) => {
  580. if (selectedOptions.length > 0) {
  581. const selected = selectedOptions[0]
  582. state.form.projectGroupId = selected.id
  583. state.form.projectName = selected.projectName
  584. }
  585. showProjectPicker.value = false
  586. }
  587. const onCategoryConfirm = ({ selectedOptions }: { selectedOptions: any[] }) => {
  588. if (selectedOptions.length > 0) {
  589. const selected = selectedOptions[0]
  590. state.form.categoryId = selected.id
  591. state.form.categoryName = selected.name
  592. }
  593. showCategoryPicker.value = false
  594. }
  595. const onLevelConfirm = ({ selectedOptions }: { selectedOptions: any[] }) => {
  596. if (selectedOptions.length > 0) {
  597. const selected = selectedOptions[0]
  598. state.form.level = selected.id
  599. state.form.levelName = selected.name
  600. }
  601. showLevelPicker.value = false
  602. }
  603. const onStartDateConfirm = (date: Date) => {
  604. state.form.startDate = formatDate(date, 'YYYY-mm-dd')
  605. showStartDatePicker.value = false
  606. }
  607. const onComeTimeConfirm = (date: Date) => {
  608. state.form.comeTime = formatDate(date, 'YYYY-mm-dd')
  609. showComeTimePicker.value = false
  610. }
  611. // 提交
  612. const onSubmit = async () => {
  613. // 设置提交状态为加载中
  614. submitting.value = true
  615. try {
  616. await expertDialogFormRef.value?.validate()
  617. } catch (error: any) {
  618. // 显示表单验证错误
  619. let errorMessage = '请完善必填信息'
  620. if (error) {
  621. // Vant 表单验证错误可能是数组格式
  622. if (Array.isArray(error) && error.length > 0) {
  623. const firstError = error[0]
  624. if (firstError && firstError.message) {
  625. errorMessage = firstError.message
  626. }
  627. } else if (error.message) {
  628. errorMessage = error.message
  629. }
  630. }
  631. showNotify({
  632. type: 'warning',
  633. message: errorMessage,
  634. })
  635. submitting.value = false // 重置提交状态
  636. return
  637. }
  638. if (!safePromise.value) {
  639. showNotify({
  640. type: 'warning',
  641. message: '请阅读并勾选安全承诺!',
  642. })
  643. submitting.value = false // 重置提交状态
  644. return
  645. }
  646. // 验证自行购买时的必填项
  647. if (state.form.buyFrom === ProcurementChannels.PURCHASED_BY_MYSELF) {
  648. if (!state.form.licenseNumberFile.length) {
  649. showNotify({
  650. type: 'warning',
  651. message: '请上传生产许可证副本!',
  652. })
  653. submitting.value = false // 重置提交状态
  654. return
  655. }
  656. if (!state.form.animalTestDateFile.length) {
  657. showNotify({
  658. type: 'warning',
  659. message: '请上传近三个月动物质量检测证明!',
  660. })
  661. submitting.value = false // 重置提交状态
  662. return
  663. }
  664. }
  665. // 验证必填文件
  666. if (!state.form.ethicsCheckFile.length) {
  667. showNotify({
  668. type: 'warning',
  669. message: '请上传实验动物福利伦理审查申请表!',
  670. })
  671. submitting.value = false // 重置提交状态
  672. return
  673. }
  674. if (!state.form.ethicsAdviceFile.length) {
  675. showNotify({
  676. type: 'warning',
  677. message: '请上传实验动物福利伦理审查意见表!',
  678. })
  679. submitting.value = false // 重置提交状态
  680. return
  681. }
  682. // json 字符串化
  683. state.form.age= JSON.stringify(state.form.age);
  684. state.form.weight = JSON.stringify(state.form.weight);
  685. state.form.deptName = userInfos.value.deptName;
  686. state.form.deptId = userInfos.value.deptId;
  687. state.form.phone = userInfos.value.phone;
  688. const params = {
  689. ...deepClone(state.form),
  690. categoryId: state.form.categoryId != null ? String(state.form.categoryId) : null,
  691. categoryName: animalTypeList.value.find((item) => item.id == state.form.categoryId)?.name || state.form.categoryName,
  692. projectGroupName:
  693. projects.value.find((item) => item.id == state.form.projectGroupId)?.projectName || state.form.projectGroupName,
  694. startDate: dayjs(state.form.startDate).format('YYYY-MM-DD'),
  695. comeTime: state.form.comeTime ? dayjs(state.form.comeTime).format('YYYY-MM-DD') : '',
  696. licenseNumberFile: JSON.stringify(state.form.licenseNumberFile),
  697. animalTestDateFile: JSON.stringify(state.form.animalTestDateFile),
  698. geneIdentificationFile: JSON.stringify(state.form.geneIdentificationFile),
  699. ethicsCheckFile: JSON.stringify(state.form.ethicsCheckFile),
  700. ethicsAdviceFile: JSON.stringify(state.form.ethicsAdviceFile),
  701. maleNumber: Number(state.form.maleNumber) || 0,
  702. famaleNumber: Number(state.form.famaleNumber) || 0,
  703. number: Number(state.form.number) || 1,
  704. feedingDay: Number(state.form.feedingDay) || 0,
  705. totalNumber: Number(state.form.totalNumber) || 0,
  706. }
  707. Object.entries(params).forEach(([key, value]) => {
  708. if (value === '' || value === null) {
  709. delete params[key as keyof typeof params]
  710. }
  711. })
  712. const post = state.dialog.type === 'edit' ? platAnimalCageApplicationApi.reSubmit : platAnimalCageApplicationApi.create
  713. const [err]: ToResponse = await to(post(params))
  714. if (err) {
  715. submitting.value = false // 重置提交状态
  716. return
  717. }
  718. showToast({
  719. type: 'success',
  720. message: '操作成功',
  721. })
  722. // 接口成功后继续保持加载状态2秒,提供更好的用户体验
  723. setTimeout(() => {
  724. closeDialog()
  725. emit('refresh')
  726. submitting.value = false // 5秒后重置提交状态
  727. }, 5000)
  728. }
  729. watch(
  730. () => [state.form.maleNumber, state.form.famaleNumber],
  731. ([maleNumber, famaleNumber]) => {
  732. state.form.totalNumber = (Number(maleNumber) || 0) + (Number(famaleNumber) || 0)
  733. },
  734. { immediate: true },
  735. )
  736. // 暴露变量
  737. defineExpose({
  738. openDialog,
  739. })
  740. </script>
  741. <style lang="scss" scoped>
  742. .application-dialog-container {
  743. .popup-wrapper {
  744. display: flex;
  745. flex-direction: column;
  746. height: 100%;
  747. overflow: hidden;
  748. }
  749. .popup-title {
  750. font-size: 18px;
  751. font-weight: 600;
  752. text-align: center;
  753. padding: 16px 0;
  754. margin: 0;
  755. border-bottom: 1px solid #ebedf0;
  756. flex-shrink: 0;
  757. background-color: #fff;
  758. position: sticky;
  759. top: 0;
  760. z-index: 1;
  761. padding-right: 40px;
  762. }
  763. .popup-content {
  764. flex: 1;
  765. padding: 16px;
  766. overflow-y: auto;
  767. -webkit-overflow-scrolling: touch;
  768. }
  769. h4 {
  770. font-size: 16px;
  771. font-weight: 600;
  772. margin: 16px 0 8px;
  773. padding-left: 8px;
  774. position: relative;
  775. &::before {
  776. content: '';
  777. position: absolute;
  778. left: 0;
  779. top: 50%;
  780. transform: translateY(-50%);
  781. width: 3px;
  782. height: 16px;
  783. background-color: #1c9bfd;
  784. }
  785. }
  786. .checkbox-wrapper {
  787. padding: 16px;
  788. background-color: #f7f8fa;
  789. border-radius: 8px;
  790. margin: 16px 0;
  791. .safePromise {
  792. white-space: pre-wrap;
  793. line-height: 1.6;
  794. }
  795. }
  796. .dialog-footer {
  797. padding: 16px;
  798. padding-bottom: calc(16px + env(safe-area-inset-bottom));
  799. flex-shrink: 0;
  800. background-color: #fff;
  801. border-top: 1px solid #ebedf0;
  802. position: sticky;
  803. bottom: 0;
  804. z-index: 10;
  805. }
  806. .upload-tip {
  807. font-size: 12px;
  808. color: #969799;
  809. margin-top: 8px;
  810. }
  811. }
  812. .range-input-wrapper {
  813. display: flex;
  814. align-items: center;
  815. width: 100%;
  816. .range-input {
  817. flex: 1;
  818. :deep(.van-field__body) {
  819. padding: 0;
  820. }
  821. :deep(.van-field__control) {
  822. text-align: center;
  823. }
  824. }
  825. .range-separator {
  826. margin: 0 8px;
  827. color: #969799;
  828. font-size: 14px;
  829. flex-shrink: 0;
  830. }
  831. }
  832. :deep(.van-checkbox) {
  833. white-space: pre-wrap;
  834. line-height: 1.6;
  835. }
  836. :deep(.van-field__label) {
  837. width: 120px;
  838. }
  839. :deep(.van-popup__close-icon) {
  840. z-index: 100;
  841. top: 16px;
  842. right: 16px;
  843. }
  844. </style>