Application.vue 32 KB

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