StorageCertTab.vue 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866
  1. <!-- 采集协议单管理 · 储存证 Tab -->
  2. <!-- eslint-disable vue/no-v-model-argument -->
  3. <template>
  4. <div class="storage-cert-tab">
  5. <div class="filter-block">
  6. <el-form :model="searchForm" class="filter-form" @submit.prevent>
  7. <div class="filter-grid">
  8. <el-form-item label="关键词">
  9. <el-input v-model="searchForm.keyword" placeholder="姓名" clearable />
  10. </el-form-item>
  11. <el-form-item label="制作状态">
  12. <el-select
  13. v-model="searchForm.makeStatus"
  14. placeholder="全部"
  15. clearable
  16. style="width: 100%"
  17. >
  18. <el-option label="未制作" value="0" />
  19. <el-option label="已制作" value="1" />
  20. </el-select>
  21. </el-form-item>
  22. <el-form-item label="入库日期">
  23. <el-date-picker
  24. v-model="storageInDateRange"
  25. type="daterange"
  26. range-separator="至"
  27. start-placeholder="开始日期"
  28. end-placeholder="结束日期"
  29. value-format="YYYY-MM-DD"
  30. style="width: 100%"
  31. />
  32. </el-form-item>
  33. </div>
  34. <div class="filter-actions-row">
  35. <div class="filter-actions-end">
  36. <el-button class="btn-ghost" @click="handleReset">
  37. <el-icon><RefreshLeft /></el-icon>重置
  38. </el-button>
  39. <el-button class="btn-main" type="primary" @click="handleSearch">
  40. <el-icon><Search /></el-icon>搜索
  41. </el-button>
  42. </div>
  43. </div>
  44. </el-form>
  45. </div>
  46. <div class="list-body list-wrap">
  47. <el-table
  48. class="list-table"
  49. :data="tableData"
  50. v-loading="loading"
  51. stripe
  52. row-key="id"
  53. :row-class-name="storageCertRowClassName"
  54. @row-dblclick="handleView"
  55. >
  56. <el-table-column
  57. type="index"
  58. label="序号"
  59. min-width="68"
  60. align="center"
  61. header-align="center"
  62. :index="(i) => (pagination.page - 1) * pagination.pageSize + i + 1"
  63. />
  64. <el-table-column
  65. prop="protocolStatus"
  66. label="协议状态"
  67. min-width="100"
  68. align="center"
  69. header-align="center"
  70. >
  71. <template #default="{ row }">
  72. <el-tag
  73. :type="protocolStatusTagType(String(row.protocolStatus || ''))"
  74. size="small"
  75. >{{ protocolStatusDisplayLabel(String(row.protocolStatus || ''), String(row.voidType || '')) }}</el-tag
  76. >
  77. </template>
  78. </el-table-column>
  79. <el-table-column
  80. prop="motherName"
  81. label="母亲姓名"
  82. min-width="100"
  83. align="center"
  84. header-align="center"
  85. />
  86. <el-table-column
  87. prop="motherPhone"
  88. label="母亲联系方式"
  89. min-width="130"
  90. align="center"
  91. header-align="center"
  92. >
  93. <template #default="{ row }">{{ row.motherPhone || '—' }}</template>
  94. </el-table-column>
  95. <el-table-column
  96. prop="motherIdCard"
  97. label="身份证号"
  98. min-width="170"
  99. align="center"
  100. header-align="center"
  101. >
  102. <template #default="{ row }">{{ maskIdCard(row.motherIdCard) }}</template>
  103. </el-table-column>
  104. <el-table-column
  105. prop="newbornInfo"
  106. label="新生儿信息"
  107. min-width="200"
  108. align="center"
  109. header-align="center"
  110. show-overflow-tooltip
  111. />
  112. <el-table-column
  113. prop="sampleInfo"
  114. label="样本信息"
  115. min-width="200"
  116. align="center"
  117. header-align="center"
  118. show-overflow-tooltip
  119. />
  120. <el-table-column
  121. prop="storageInDate"
  122. label="入库日期"
  123. min-width="120"
  124. align="center"
  125. header-align="center"
  126. >
  127. <template #default="{ row }">{{ formatStorageInDate(row.storageInDate) }}</template>
  128. </el-table-column>
  129. <el-table-column
  130. prop="makeStatus"
  131. label="制作状态"
  132. min-width="100"
  133. align="center"
  134. header-align="center"
  135. >
  136. <template #default="{ row }">
  137. <el-tag :type="row.makeStatus == '1' ? 'success' : 'warning'" size="small">
  138. {{ row.makeStatus == '1' ? '已制作' : '未制作' }}
  139. </el-tag>
  140. </template>
  141. </el-table-column>
  142. <el-table-column
  143. prop="makeDate"
  144. label="制作日期"
  145. min-width="120"
  146. align="center"
  147. header-align="center"
  148. >
  149. <template #default="{ row }">{{ formatStorageInDate(row.makeDate) }}</template>
  150. </el-table-column>
  151. <el-table-column
  152. label="操作"
  153. min-width="260"
  154. fixed="right"
  155. align="center"
  156. header-align="center"
  157. >
  158. <template #default="{ row }">
  159. <el-button class="btn-op-item primary" @click="handleUpload(row)">
  160. <el-icon><Upload /></el-icon>上传储存证
  161. </el-button>
  162. <el-button class="btn-op-item" @click="handleView(row)">
  163. <el-icon><View /></el-icon>查看
  164. </el-button>
  165. </template>
  166. </el-table-column>
  167. </el-table>
  168. <div class="pagination list-pager-bar">
  169. <el-pagination
  170. :current-page="pagination.page"
  171. :page-size="pagination.pageSize"
  172. :total="pagination.total"
  173. :page-sizes="[10, 20, 50]"
  174. layout="total, sizes, prev, pager, next"
  175. @size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
  176. @current-change="(val) => ((pagination.page = val), loadData())"
  177. />
  178. </div>
  179. </div>
  180. <!-- 上传储存证弹窗 -->
  181. <el-dialog
  182. v-model="uploadDialogVisible"
  183. title="上传储存证"
  184. width="720px"
  185. class="dialog-form-layout"
  186. align-center
  187. append-to-body
  188. modal-class="protocol-dialog-mask"
  189. destroy-on-close
  190. :close-on-click-modal="false"
  191. >
  192. <el-form
  193. ref="uploadFormRef"
  194. :model="uploadForm"
  195. :rules="uploadFormRules"
  196. label-position="top"
  197. class="storage-cert-dialog-form"
  198. scroll-to-error
  199. :scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
  200. >
  201. <el-form-item label="母亲姓名">
  202. <el-input :model-value="uploadRow?.motherName" disabled />
  203. </el-form-item>
  204. <el-form-item label="储存证附件" class="form-col-span-2">
  205. <div
  206. class="attachment-upload"
  207. :class="{ 'is-dragover': isCertAttachDragover }"
  208. v-bind="certAttachDropHandlers"
  209. >
  210. <div class="attachment-list">
  211. <el-upload
  212. ref="certAttachmentUploadRef"
  213. :auto-upload="false"
  214. :show-file-list="false"
  215. :multiple="true"
  216. accept="image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar"
  217. @change="handleCertAttachmentChange"
  218. >
  219. <div class="upload-card">
  220. <el-icon :size="28" color="#047857"><Plus /></el-icon>
  221. <span class="upload-card-text">上传附件</span>
  222. </div>
  223. </el-upload>
  224. <div
  225. v-for="(file, index) in certAttachments"
  226. :key="file.id || index"
  227. class="attachment-item"
  228. :class="{ 'is-uploading': file._uploading }"
  229. >
  230. <div
  231. class="attachment-preview"
  232. @click="
  233. isImageFile(file) && file.fileUrl ? handlePreviewAttachment(file) : undefined
  234. "
  235. >
  236. <template v-if="isImageFile(file) && file.fileUrl">
  237. <img :src="fullFileUrl(file.fileUrl)" :alt="file.fileName" />
  238. </template>
  239. <template v-else-if="isImageFile(file) && !file.fileUrl">
  240. <el-icon :size="24" color="#94a3b8"><Loading /></el-icon>
  241. </template>
  242. <template v-else-if="isVideoFile(file)">
  243. <div class="attachment-preview__icon attachment-preview__icon--video">
  244. <el-icon :size="28"><VideoPlay /></el-icon>
  245. </div>
  246. </template>
  247. <template v-else>
  248. <div class="attachment-preview__icon attachment-preview__icon--doc">
  249. <el-icon :size="28"><Document /></el-icon>
  250. </div>
  251. </template>
  252. </div>
  253. <div class="attachment-footer">
  254. <span class="attachment-name" :title="file.fileName">{{ file.fileName }}</span>
  255. <div class="attachment-actions">
  256. <el-icon
  257. class="action-btn"
  258. title="下载"
  259. @click="handleDownloadAttachment(file)"
  260. ><Download
  261. /></el-icon>
  262. <el-icon
  263. class="action-btn action-btn--danger"
  264. title="删除"
  265. @click="removeCertAttachment(index)"
  266. ><Delete
  267. /></el-icon>
  268. </div>
  269. </div>
  270. </div>
  271. </div>
  272. <span class="upload-hint"
  273. >支持拖拽或点击上传图片、视频、PDF、Word、Excel等文件,单文件不超过 20MB</span
  274. >
  275. </div>
  276. </el-form-item>
  277. <div class="form-grid-2">
  278. <el-form-item label="制作日期" prop="makeDate">
  279. <el-date-picker
  280. v-model="uploadForm.makeDate"
  281. type="date"
  282. format="YYYYMMDD"
  283. value-format="YYYYMMDD"
  284. placeholder="选择日期"
  285. style="width: 100%"
  286. />
  287. </el-form-item>
  288. <el-form-item label="备注">
  289. <el-input v-model="uploadForm.remark" type="textarea" :rows="2" placeholder="可选" />
  290. </el-form-item>
  291. </div>
  292. </el-form>
  293. <template #footer>
  294. <el-button class="btn-ghost" @click="uploadDialogVisible = false">
  295. <el-icon><Close /></el-icon>取消
  296. </el-button>
  297. <el-button
  298. class="btn-main"
  299. type="primary"
  300. :loading="submitLoading"
  301. @click="handleSubmitUpload"
  302. >
  303. <el-icon><Check /></el-icon>确定
  304. </el-button>
  305. </template>
  306. </el-dialog>
  307. <el-dialog
  308. v-model="previewVisible"
  309. title="文件预览"
  310. width="800px"
  311. align-center
  312. append-to-body
  313. destroy-on-close
  314. >
  315. <div v-if="previewType === 'image'" class="preview-container">
  316. <img
  317. :src="previewUrl"
  318. alt="预览"
  319. style="max-width: 100%; max-height: 70vh; display: block; margin: 0 auto"
  320. />
  321. </div>
  322. <div v-else class="preview-container">
  323. <video
  324. :src="previewUrl"
  325. controls
  326. style="max-width: 100%; max-height: 70vh; display: block; margin: 0 auto"
  327. />
  328. </div>
  329. </el-dialog>
  330. <!-- 查看弹窗(与满月待办管理等一致:审核+储存证+保险) -->
  331. <ProtocolViewDialog
  332. v-model="viewVisible"
  333. :protocol-id="viewProtocolId"
  334. :list-row="viewRow"
  335. show-audit-history
  336. show-storage-cert
  337. show-fullmoon-insurance
  338. />
  339. </div>
  340. </template>
  341. <script setup lang="ts">
  342. import { ref, reactive, onMounted, nextTick } from 'vue'
  343. import { ElMessage } from 'element-plus'
  344. import {
  345. Plus,
  346. Close,
  347. Check,
  348. Search,
  349. RefreshLeft,
  350. View,
  351. Upload,
  352. Delete,
  353. Document,
  354. VideoPlay,
  355. Loading,
  356. Download
  357. } from '@element-plus/icons-vue'
  358. import ProtocolViewDialog from '../protocol/ProtocolViewDialog.vue'
  359. import { fetchStorageCertList } from '@/api/admin'
  360. import {
  361. fetchUpdateCollectionProtocol,
  362. type CollectionProtocolRow
  363. } from '@/api/collection-protocol'
  364. import { fetchUploadAttachment } from '@/api/business'
  365. import { downloadAttachmentFile } from '@/utils/download-attachment'
  366. import { useAttachmentDropZone } from '@/composables/useAttachmentDropZone'
  367. import { maskIdCard } from '@/utils/mask-sensitive'
  368. import { protocolStatusDisplayLabel, protocolStatusTagType } from '@/utils/status-tag-registry'
  369. interface CertAttachmentItem {
  370. id: string
  371. fileName: string
  372. fileUrl: string
  373. fileSize?: number
  374. fileSizeDisplay?: string
  375. fileType?: string
  376. _uploading?: boolean
  377. }
  378. defineOptions({ name: 'CellStorageStorageCertTab' })
  379. const searchForm = reactive({ keyword: '', makeStatus: '0' })
  380. const storageInDateRange = ref<[string, string] | null>(null)
  381. const tableData = ref<any[]>([])
  382. const loading = ref(false)
  383. const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
  384. // ---------------------------------------------------------------------------
  385. // 上传弹窗
  386. // ---------------------------------------------------------------------------
  387. const uploadDialogVisible = ref(false)
  388. const uploadRow = ref<any>(null)
  389. const uploadFormRef = ref()
  390. const certAttachmentUploadRef = ref()
  391. const certAttachments = ref<CertAttachmentItem[]>([])
  392. const submitLoading = ref(false)
  393. const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
  394. const uploadForm = reactive({
  395. makeDate: '',
  396. remark: ''
  397. })
  398. const uploadFormRules = {
  399. makeDate: [{ required: true, message: '请选择制作日期', trigger: 'change' }]
  400. }
  401. const previewVisible = ref(false)
  402. const previewUrl = ref('')
  403. const previewType = ref<'image' | 'video'>('image')
  404. // ---------------------------------------------------------------------------
  405. // 查看弹窗(与满月待办管理等一致)
  406. // ---------------------------------------------------------------------------
  407. const viewVisible = ref(false)
  408. const viewProtocolId = ref<string | null>(null)
  409. const viewRow = ref<CollectionProtocolRow | null>(null)
  410. /** 新生儿已满月且储存证未制作时标红(列表仅含已生效协议) */
  411. function isStorageCertMakePendingRow(row: Record<string, unknown>) {
  412. if (String(row.makeStatus ?? '0') === '1') return false
  413. const delivery = String(row.newbornDeliveryTime || '').trim()
  414. if (!delivery) return false
  415. const deliveryDate = new Date(delivery.replace(/-/g, '/'))
  416. if (Number.isNaN(deliveryDate.getTime())) return false
  417. const fullmoon = new Date(deliveryDate)
  418. fullmoon.setDate(fullmoon.getDate() + 30)
  419. const today = new Date()
  420. today.setHours(0, 0, 0, 0)
  421. fullmoon.setHours(0, 0, 0, 0)
  422. return fullmoon <= today
  423. }
  424. function storageCertRowClassName({ row }: { row: Record<string, unknown> }) {
  425. return isStorageCertMakePendingRow(row) ? 'row-storage-cert-pending' : ''
  426. }
  427. function formatStorageInDate(val: unknown) {
  428. const s = String(val || '').trim()
  429. if (!s) return '—'
  430. const digits = s.replace(/\D/g, '')
  431. if (digits.length >= 8) {
  432. return `${digits.slice(0, 4)}-${digits.slice(4, 6)}-${digits.slice(6, 8)}`
  433. }
  434. return s
  435. }
  436. // ---------------------------------------------------------------------------
  437. // 数据加载
  438. // ---------------------------------------------------------------------------
  439. async function loadData() {
  440. loading.value = true
  441. try {
  442. const res = await fetchStorageCertList({
  443. page: pagination.page,
  444. pageSize: pagination.pageSize,
  445. keyword: searchForm.keyword || undefined,
  446. makeStatus: searchForm.makeStatus || undefined,
  447. storageInDateStart: storageInDateRange.value?.[0] || undefined,
  448. storageInDateEnd: storageInDateRange.value?.[1] || undefined
  449. })
  450. tableData.value = (res as any)?.list || []
  451. pagination.total = (res as any)?.total ?? 0
  452. } finally {
  453. loading.value = false
  454. }
  455. }
  456. function handleSearch() {
  457. pagination.page = 1
  458. loadData()
  459. }
  460. function handleReset() {
  461. searchForm.keyword = ''
  462. searchForm.makeStatus = '0'
  463. storageInDateRange.value = null
  464. pagination.page = 1
  465. loadData()
  466. }
  467. // ---------------------------------------------------------------------------
  468. // 上传储存证
  469. // ---------------------------------------------------------------------------
  470. function fullFileUrl(suffix: string) {
  471. if (!suffix) return ''
  472. if (suffix.startsWith('http://') || suffix.startsWith('https://') || suffix.startsWith('/')) {
  473. return suffix
  474. }
  475. return `${minioBaseUrl}/${suffix}`
  476. }
  477. function isImageFile(file: CertAttachmentItem) {
  478. const url = (file.fileUrl || '').toLowerCase()
  479. const name = (file.fileName || '').toLowerCase()
  480. return (
  481. /\.(jpg|jpeg|png|gif|bmp|webp|svg)(\?|$)/.test(url) ||
  482. /\.(jpg|jpeg|png|gif|bmp|webp|svg)$/.test(name) ||
  483. (file.fileType || '').startsWith('image/')
  484. )
  485. }
  486. function isVideoFile(file: CertAttachmentItem) {
  487. const url = (file.fileUrl || '').toLowerCase()
  488. const name = (file.fileName || '').toLowerCase()
  489. return (
  490. /\.(mp4|webm|ogg|mov|avi)(\?|$)/.test(url) ||
  491. /\.(mp4|webm|ogg|mov|avi)$/.test(name) ||
  492. (file.fileType || '').startsWith('video/')
  493. )
  494. }
  495. function parseCertUrlsToAttachments(urls: string): CertAttachmentItem[] {
  496. return urls
  497. .split(',')
  498. .map((u) => u.trim())
  499. .filter(Boolean)
  500. .map((url, index) => {
  501. const rawName = url.split('/').pop() || ''
  502. const fileName = decodeURIComponent(rawName) || `附件${index + 1}`
  503. return {
  504. id: `existing_${index}`,
  505. fileName,
  506. fileUrl: url,
  507. fileType: '',
  508. _uploading: false
  509. }
  510. })
  511. }
  512. function certAttachmentsToUrls() {
  513. return certAttachments.value
  514. .filter((f) => f.fileUrl && !f._uploading)
  515. .map((f) => f.fileUrl)
  516. .join(',')
  517. }
  518. async function handleCertAttachmentChange(uploadFile: any) {
  519. const file = uploadFile.raw || uploadFile
  520. if (!file) return
  521. const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
  522. certAttachments.value.push({
  523. id: tempId,
  524. fileName: file.name,
  525. fileUrl: '',
  526. fileSize: file.size,
  527. fileSizeDisplay: `${(file.size / 1024).toFixed(1)} KB`,
  528. fileType: file.type || '',
  529. _uploading: true
  530. })
  531. const idx = certAttachments.value.length - 1
  532. try {
  533. const fd = new FormData()
  534. fd.append('folder', 'storage-cert')
  535. fd.append('file', file)
  536. const res = await fetchUploadAttachment(fd)
  537. certAttachments.value[idx] = {
  538. ...certAttachments.value[idx],
  539. id: res.id || tempId,
  540. fileName: file.name || res.fileName,
  541. fileUrl: res.fileUrl,
  542. fileSize: res.fileSize || file.size,
  543. fileSizeDisplay: res.fileSizeDisplay || `${(file.size / 1024).toFixed(1)} KB`,
  544. fileType: res.fileType || file.type || '',
  545. _uploading: false
  546. }
  547. } catch {
  548. ElMessage.error('文件上传失败')
  549. certAttachments.value.splice(idx, 1)
  550. } finally {
  551. nextTick(() => certAttachmentUploadRef.value?.clearFiles())
  552. }
  553. }
  554. function removeCertAttachment(index: number) {
  555. certAttachments.value.splice(index, 1)
  556. }
  557. const { isDragover: isCertAttachDragover, dropZoneHandlers: certAttachDropHandlers } =
  558. useAttachmentDropZone({
  559. onFiles: (files) => {
  560. for (const file of files) {
  561. void handleCertAttachmentChange({ raw: file })
  562. }
  563. }
  564. })
  565. function handlePreviewAttachment(file: CertAttachmentItem) {
  566. if (file._uploading) return
  567. previewUrl.value = fullFileUrl(file.fileUrl)
  568. previewType.value = isVideoFile(file) ? 'video' : 'image'
  569. previewVisible.value = true
  570. }
  571. function handleDownloadAttachment(file: CertAttachmentItem) {
  572. downloadAttachmentFile(file, { fullFileUrl })
  573. }
  574. function handleUpload(row: any) {
  575. uploadRow.value = row
  576. certAttachments.value = parseCertUrlsToAttachments(String(row.certImageUrls || ''))
  577. uploadForm.makeDate = String(row.makeDate || '').trim() || getTodayStr()
  578. uploadForm.remark = String(row.remark || '').trim()
  579. uploadDialogVisible.value = true
  580. }
  581. async function handleSubmitUpload() {
  582. if (certAttachments.value.some((f) => f._uploading)) {
  583. ElMessage.warning('附件上传中,请稍候')
  584. return
  585. }
  586. const uploaded = certAttachments.value.filter((f) => f.fileUrl && !f._uploading)
  587. const hasAttachments = uploaded.length > 0
  588. if (hasAttachments) {
  589. const valid = await uploadFormRef.value?.validate?.().catch(() => false)
  590. if (!valid) return
  591. }
  592. submitLoading.value = true
  593. try {
  594. const protocolId = uploadRow.value?.protocolId || uploadRow.value?.id
  595. await fetchUpdateCollectionProtocol(protocolId, {
  596. storageCert: {
  597. makeStatus: hasAttachments ? '1' : '0',
  598. certImageUrls: hasAttachments ? certAttachmentsToUrls() : '',
  599. makeDate: hasAttachments ? uploadForm.makeDate : '',
  600. remark: uploadForm.remark?.trim() || undefined
  601. }
  602. })
  603. ElMessage.success(hasAttachments ? '上传成功' : '保存成功')
  604. uploadDialogVisible.value = false
  605. await loadData()
  606. } catch {
  607. /* 全局已处理 */
  608. } finally {
  609. submitLoading.value = false
  610. }
  611. }
  612. function handleView(row: any) {
  613. viewProtocolId.value = String(row.protocolId || row.id)
  614. viewRow.value = row as CollectionProtocolRow
  615. viewVisible.value = true
  616. }
  617. // ---------------------------------------------------------------------------
  618. // 工具函数
  619. // ---------------------------------------------------------------------------
  620. function getTodayStr(): string {
  621. const d = new Date()
  622. const yyyy = d.getFullYear()
  623. const mm = String(d.getMonth() + 1).padStart(2, '0')
  624. const dd = String(d.getDate()).padStart(2, '0')
  625. return `${yyyy}${mm}${dd}`
  626. }
  627. // 输入检索条件时自动搜索(防抖 300ms)
  628. watchDebounced(
  629. [searchForm, storageInDateRange],
  630. () => {
  631. pagination.page = 1
  632. loadData()
  633. },
  634. { debounce: 300, deep: true }
  635. )
  636. onMounted(() => {
  637. loadData()
  638. })
  639. </script>
  640. <style scoped lang="scss">
  641. .storage-cert-dialog-form {
  642. :deep(.el-form-item) {
  643. margin-bottom: 0;
  644. }
  645. }
  646. .form-col-span-2 {
  647. grid-column: 1 / -1;
  648. }
  649. .attachment-upload {
  650. width: 100%;
  651. }
  652. .upload-card {
  653. width: 140px;
  654. height: 96px;
  655. border: 1px dashed #a8d4c4;
  656. border-radius: 8px;
  657. background: #f0fdf4;
  658. display: flex;
  659. flex-direction: column;
  660. align-items: center;
  661. justify-content: center;
  662. gap: 6px;
  663. cursor: pointer;
  664. transition:
  665. border-color 0.15s,
  666. background 0.15s;
  667. &:hover {
  668. border-color: #047857;
  669. border-style: solid;
  670. background: #e8f4ee;
  671. }
  672. }
  673. .upload-card-text {
  674. font-size: 12px;
  675. color: #047857;
  676. font-weight: 500;
  677. line-height: 1;
  678. }
  679. .upload-hint {
  680. display: block;
  681. font-size: 11px;
  682. color: var(--mint-text-muted, #334155);
  683. line-height: 1;
  684. margin-top: 8px;
  685. }
  686. .attachment-list {
  687. display: flex;
  688. flex-wrap: wrap;
  689. gap: 12px;
  690. }
  691. .attachment-item {
  692. width: 140px;
  693. display: flex;
  694. flex-direction: column;
  695. border: 1px solid #e2e8f0;
  696. border-radius: 8px;
  697. background: #fff;
  698. overflow: hidden;
  699. transition:
  700. border-color 0.15s,
  701. box-shadow 0.15s;
  702. &:hover {
  703. border-color: #047857;
  704. box-shadow: 0 2px 8px rgba(4, 120, 87, 0.1);
  705. }
  706. &.is-uploading {
  707. opacity: 0.6;
  708. pointer-events: none;
  709. }
  710. }
  711. .attachment-preview {
  712. width: 100%;
  713. height: 96px;
  714. flex-shrink: 0;
  715. display: flex;
  716. align-items: center;
  717. justify-content: center;
  718. background: #f8fafc;
  719. overflow: hidden;
  720. cursor: pointer;
  721. img {
  722. width: 100%;
  723. height: 100%;
  724. object-fit: cover;
  725. display: block;
  726. }
  727. }
  728. .attachment-preview__icon {
  729. width: 48px;
  730. height: 48px;
  731. border-radius: 10px;
  732. display: flex;
  733. align-items: center;
  734. justify-content: center;
  735. &--video {
  736. background: #ede9fe;
  737. color: #7c3aed;
  738. }
  739. &--doc {
  740. background: #e0f2fe;
  741. color: #0284c7;
  742. }
  743. }
  744. .attachment-footer {
  745. display: flex;
  746. flex-direction: column;
  747. flex: 1;
  748. padding: 6px 8px;
  749. }
  750. .attachment-name {
  751. display: -webkit-box;
  752. -webkit-line-clamp: 2;
  753. -webkit-box-orient: vertical;
  754. overflow: hidden;
  755. text-overflow: ellipsis;
  756. font-size: 11px;
  757. color: #334155;
  758. line-height: 1.4;
  759. word-break: break-all;
  760. min-height: calc(11px * 1.4 * 2);
  761. }
  762. .attachment-actions {
  763. display: flex;
  764. justify-content: center;
  765. align-items: center;
  766. gap: 8px;
  767. margin-top: 4px;
  768. flex-shrink: 0;
  769. }
  770. .action-btn {
  771. width: 22px;
  772. height: 22px;
  773. border-radius: 4px;
  774. display: flex;
  775. align-items: center;
  776. justify-content: center;
  777. font-size: 12px;
  778. color: var(--mint-text-muted, #334155);
  779. cursor: pointer;
  780. background: #f1f5f9;
  781. transition:
  782. background 0.15s,
  783. color 0.15s;
  784. &:hover {
  785. background: #047857;
  786. color: #fff;
  787. }
  788. &--danger:hover {
  789. background: #ef4444;
  790. color: #fff;
  791. }
  792. }
  793. .preview-container {
  794. text-align: center;
  795. }
  796. .list-table :deep(tr.row-storage-cert-pending > td.el-table__cell) {
  797. background-color: #fee2e2 !important;
  798. color: #991b1b;
  799. }
  800. .list-table :deep(tr.row-storage-cert-pending:hover > td.el-table__cell) {
  801. background-color: #fecaca !important;
  802. }
  803. </style>