index.vue 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838
  1. <template>
  2. <div class="container">
  3. <van-nav-bar
  4. title="图片编辑"
  5. left-arrow
  6. @click-left="goBack"
  7. @click-right="onReset"
  8. >
  9. <template #left>
  10. <van-icon name="arrow-left" size="25" />
  11. <div class="left-btn">返回</div>
  12. </template>
  13. <template #right>
  14. <div class="nav-right right-btn">重置</div>
  15. </template>
  16. </van-nav-bar>
  17. <div class="init-container">
  18. <div class="workbench-info">
  19. <p class="info-line">
  20. 仓库:<span class="info-value">{{ workbench.warehouseCode }}</span>
  21. </p>
  22. <p class="info-line">
  23. 工作台:<span class="info-value">{{ workbench.workStation }}</span>
  24. </p>
  25. <p class="info-line hint-text">支持png/jpeg/jpg/webp/heic/heif(heic/heif自动转webp)</p>
  26. <van-button
  27. size="mini"
  28. type="primary"
  29. plain
  30. icon="replay"
  31. class="refresh-btn"
  32. @click.stop="getWorkbench"
  33. ></van-button>
  34. </div>
  35. <div class="scan-returned-content">
  36. <div class="input-group">
  37. <van-cell title="外箱图上传" />
  38. <van-uploader
  39. v-model="outerImages"
  40. :max-count="5"
  41. :max-size="15 * 1024 * 1024"
  42. :before-read="beforeReadImage"
  43. :preview-full-image="true"
  44. :deletable="true"
  45. >
  46. <template #preview-cover="{ file, index }">
  47. <div class="custom-cover">
  48. <!-- 状态指示器 -->
  49. <div class="upload-status" :class="file.status">
  50. <span v-if="file.status === 'uploading'">上传中...</span>
  51. <span v-else-if="file.status === 'retrying'">重试中...</span>
  52. <span v-else-if="file.status === 'success'">✓ 成功</span>
  53. <span v-else-if="file.status === 'failed'">
  54. ✗ 失败
  55. <van-button
  56. v-if="file.retryCount < 1"
  57. size="mini"
  58. @click.stop="retrySingleImage(file, 'RETURNED_BOX_IMAGE')"
  59. style="margin-left: 5px; background: #fff; color: #ee0a24"
  60. >
  61. 重试
  62. </van-button>
  63. <span v-else style="margin-left: 5px">(已达上限)</span>
  64. </span>
  65. </div>
  66. <van-button
  67. class="cover-button"
  68. size="mini"
  69. icon="edit"
  70. @click.stop="handleOuterImages(file, index)"
  71. >
  72. 编辑
  73. </van-button>
  74. </div>
  75. </template>
  76. </van-uploader>
  77. </div>
  78. <div class="input-group">
  79. <van-cell title="内物图上传" />
  80. <van-uploader
  81. v-model="innerImages"
  82. :max-count="5"
  83. :max-size="15 * 1024 * 1024"
  84. :before-read="beforeReadImage"
  85. :preview-full-image="true"
  86. :deletable="true"
  87. >
  88. <template #preview-cover="{ file, index }">
  89. <div class="custom-cover">
  90. <!-- 状态指示器 -->
  91. <div class="upload-status" :class="file.status">
  92. <span v-if="file.status === 'uploading'">上传中...</span>
  93. <span v-else-if="file.status === 'retrying'">重试中...</span>
  94. <span v-else-if="file.status === 'success'">✓ 成功</span>
  95. <span v-else-if="file.status === 'failed'">
  96. ✗ 失败
  97. <van-button
  98. v-if="file.retryCount < 1"
  99. size="mini"
  100. @click.stop="
  101. retrySingleImage(file, 'RETURNED_INNER_IMAGE')
  102. "
  103. style="margin-left: 5px; background: #fff; color: #ee0a24"
  104. >
  105. 重试
  106. </van-button>
  107. <span v-else style="margin-left: 5px">(已达上限)</span>
  108. </span>
  109. </div>
  110. <van-button
  111. class="cover-button"
  112. size="mini"
  113. icon="edit"
  114. @click.stop="handleInnerImages(file, index)"
  115. >
  116. 编辑
  117. </van-button>
  118. </div>
  119. </template>
  120. </van-uploader>
  121. </div>
  122. <div class="button-group">
  123. <van-button class="action-btn" type="warning" plain @click="previewAll">预览全部</van-button>
  124. <van-button class="action-btn action-btn--danger" type="danger" plain @click="onReset">重置图片</van-button>
  125. <van-button
  126. class="action-btn"
  127. type="primary"
  128. plain
  129. @click="retryAllFailedImages"
  130. :disabled="!hasFailedImages()"
  131. >重传失败图片</van-button>
  132. <van-button class="action-btn" type="primary" @click="onSubmit">提交</van-button>
  133. </div>
  134. </div>
  135. </div>
  136. <!-- 自定义图片预览组件 -->
  137. <van-image-preview
  138. v-model:show="previewVisible"
  139. :images="previewImages"
  140. :start-position="previewStartPosition"
  141. @change="handlePreviewChange"
  142. >
  143. <!-- 自定义操作按钮 -->
  144. <template #index>
  145. <div class="preview-toolbar">
  146. <van-button
  147. type="primary"
  148. size="small"
  149. @click="onEditImage(previewCurrentIndex)"
  150. :disabled="
  151. !canEditImage(getPreviewImage(previewCurrentIndex))
  152. "
  153. >
  154. 编辑
  155. </van-button>
  156. </div>
  157. </template>
  158. </van-image-preview>
  159. <edit-image ref="editImageRef" @save-image="changeFile" />
  160. </div>
  161. </template>
  162. <script setup lang="ts">
  163. import { ref, onMounted } from 'vue'
  164. // Type declarations for image upload handling
  165. enum UPLOAD_STATUS {
  166. PENDING = 'pending',
  167. UPLOADING = 'uploading',
  168. SUCCESS = 'success',
  169. FAILED = 'failed',
  170. RETRYING = 'retrying',
  171. }
  172. interface UploadImage {
  173. file: File
  174. status: UPLOAD_STATUS
  175. url: string | null
  176. error: string | null
  177. retryCount: number
  178. originalFile: File
  179. // Optional; some flows may carry raw content for previews
  180. content?: string
  181. }
  182. interface Workbench {
  183. warehouseCode: string
  184. workStation: string
  185. }
  186. interface EditImageExposed {
  187. editImage: (image: UploadImage, type: 'outer' | 'inner') => void
  188. }
  189. import {
  190. showFailToast,
  191. showNotify,
  192. showLoadingToast,
  193. closeToast,
  194. showConfirmDialog,
  195. } from 'vant'
  196. import { getHeader, goBack } from '@/utils/android'
  197. import { detailImageUpload, returnedWorkbench } from '@/api/returned/index.ts'
  198. import { compressImage } from '@/utils/imageCompression'
  199. import { convertHeicHeifToWebp, isHeicOrHeif } from '@/utils/imageFormat'
  200. import EditImage from '@/components/EditImage.vue'
  201. const workbench = ref<Workbench>({ warehouseCode: '', workStation: '' })
  202. onMounted(async () => {
  203. try {
  204. await getHeader()
  205. } catch (error: any) {
  206. if (typeof showFailToast === 'function') {
  207. showFailToast(error?.message || '获取头信息失败,请稍后重试')
  208. }
  209. console.error(error)
  210. }
  211. getWorkbench()
  212. })
  213. // 图片上传相关 - 增强数据结构
  214. const outerImages = ref<UploadImage[]>([]) // 外箱图 [{file, status, url, error, retryCount, originalFile}]
  215. const innerImages = ref<UploadImage[]>([]) // 内物图 [{file, status, url, error, retryCount, originalFile}]
  216. // 预览相关变量
  217. const previewVisible = ref<boolean>(false)
  218. const previewImages = ref<string[]>([])
  219. const previewStartPosition = ref<number>(0)
  220. const previewCurrentIndex = ref<number>(0)
  221. // 编辑权限检查
  222. const canEditImage = (imageObj: UploadImage | undefined): boolean => {
  223. if (!imageObj) return false
  224. // 只有上传中和重试中的图片不能编辑
  225. return (
  226. imageObj.status !== UPLOAD_STATUS.UPLOADING &&
  227. imageObj.status !== UPLOAD_STATUS.RETRYING
  228. )
  229. }
  230. // 预览索引变更处理
  231. const handlePreviewChange = (index: number): void => {
  232. previewCurrentIndex.value = index
  233. }
  234. function getWorkbench(): void {
  235. returnedWorkbench()
  236. .then((res) => {
  237. workbench.value = res.data
  238. })
  239. .catch((error: any) => {
  240. // 记录错误并提示用户,避免静默失败
  241. console.error('getWorkbench error', error)
  242. showFailToast('获取工作台信息失败,请稍后重试')
  243. })
  244. }
  245. const beforeReadImage = async (file: File | File[]): Promise<UploadImage[] | false> => {
  246. const files = Array.isArray(file) ? file : [file]
  247. const normalizedFiles: File[] = []
  248. for (const f of files) {
  249. const isHeicFile = isHeicOrHeif(f)
  250. const isImage = /^image\//.test(f.type) || isHeicFile
  251. if (!isImage) {
  252. showFailToast('仅支持图片文件')
  253. return false
  254. }
  255. if (f.size > 15 * 1024 * 1024) {
  256. showFailToast('图片大小不能超过15MB')
  257. return false
  258. }
  259. if (isHeicFile) {
  260. try {
  261. const convertedFile = await convertHeicHeifToWebp(f)
  262. normalizedFiles.push(convertedFile)
  263. } catch (error) {
  264. console.error('HEIC/HEIF 转换失败:', error)
  265. showFailToast('HEIC/HEIF 转换失败,请使用 JPG/PNG/WEBP 格式')
  266. return false
  267. }
  268. continue
  269. }
  270. normalizedFiles.push(f)
  271. }
  272. // 返回增强的图片对象数组,包含状态信息
  273. return normalizedFiles.map((f) => ({
  274. file: f, // 原始File对象
  275. status: UPLOAD_STATUS.PENDING,
  276. url: null,
  277. error: null,
  278. retryCount: 0,
  279. originalFile: f, // 保留用于重传
  280. }))
  281. }
  282. // 单张图片上传函数
  283. const uploadSingleImage = async (
  284. imageObj: UploadImage,
  285. category: string,
  286. ): Promise<boolean> => {
  287. imageObj.status = UPLOAD_STATUS.UPLOADING
  288. try {
  289. const compressedFile = await compressImage(imageObj.file, 0.5)
  290. const data = new FormData()
  291. data.set('file', compressedFile)
  292. data.set('type', category)
  293. // detailImageUpload成功执行即成功,catch到异常即失败
  294. await detailImageUpload(data)
  295. imageObj.status = UPLOAD_STATUS.SUCCESS
  296. imageObj.url = URL.createObjectURL(imageObj.file)
  297. imageObj.error = null
  298. return true
  299. } catch (error) {
  300. // 标记失败并记录错误,但不要将内部错误信息暴露给用户
  301. imageObj.status = UPLOAD_STATUS.FAILED
  302. imageObj.error = '上传失败,请稍后再试'
  303. // retryCount 不在此处修改,由重试调用方负责递增
  304. // 记录详细错误以供调试
  305. console.error('uploadSingleImage error', error)
  306. return false
  307. }
  308. }
  309. const uploadCategoryWithStatus = async (
  310. list: UploadImage[],
  311. categoryName: string,
  312. ): Promise<{ success: boolean; item: UploadImage }[]> => {
  313. const results: { success: boolean; item: UploadImage }[] = []
  314. for (const item of list) {
  315. // 跳过已成功的图片
  316. if (item.status === UPLOAD_STATUS.SUCCESS) {
  317. results.push({ success: true, item })
  318. continue
  319. }
  320. console.log(item,categoryName)
  321. const success = await uploadSingleImage(item, categoryName)
  322. results.push({ success, item })
  323. }
  324. return results
  325. }
  326. // 获取预览图片对象
  327. const getPreviewImage = (index: number): UploadImage => {
  328. const allImages = [...outerImages.value, ...innerImages.value]
  329. return allImages[index] as UploadImage
  330. }
  331. const onEditImage = (index: number): void => {
  332. // 获取图片对象
  333. const imageObj = getPreviewImage(index)
  334. if (!imageObj) return
  335. // 检查是否可以编辑
  336. if (!canEditImage(imageObj)) {
  337. showNotify({
  338. type: 'warning',
  339. message: '图片正在上传中,请稍后再编辑',
  340. })
  341. return
  342. }
  343. // 关闭预览
  344. previewVisible.value = false
  345. // 确定图片类型和索引
  346. let type: 'outer' | 'inner'
  347. if (index < outerImages.value.length) {
  348. // 是外箱图
  349. type = 'outer'
  350. outerImagesIndex.value = index
  351. } else {
  352. // 是内物图
  353. type = 'inner'
  354. innerImagesIndex.value = index - outerImages.value.length
  355. }
  356. // 打开编辑界面
  357. setTimeout(() => {
  358. editImageRef.value?.editImage(imageObj, type)
  359. }, 300)
  360. }
  361. const previewAll = (): void => {
  362. const images = [...outerImages.value, ...innerImages.value]
  363. .map(
  364. (it) => it.url || (it.file ? URL.createObjectURL(it.file) : it.content),
  365. )
  366. .filter(Boolean)
  367. if (!images.length) {
  368. showNotify({ type: 'warning', message: '暂无可预览的图片' })
  369. return
  370. }
  371. previewImages.value = images
  372. previewStartPosition.value = 0
  373. previewCurrentIndex.value = 0
  374. previewVisible.value = true
  375. }
  376. const onReset = (): void => {
  377. outerImages.value = []
  378. innerImages.value = []
  379. showNotify({ type: 'primary', message: '已重置图片' })
  380. }
  381. const onSubmit = async (): Promise<void> => {
  382. if (!outerImages.value.length && !innerImages.value.length) {
  383. showFailToast('请先上传外箱图或内物图')
  384. return
  385. }
  386. try {
  387. const outerCount = outerImages.value.length
  388. const innerCount = innerImages.value.length
  389. await showConfirmDialog({
  390. title: '确认提交',
  391. message: `外箱图:${outerCount} 张\n内物图:${innerCount} 张\n是否确认提交?`,
  392. })
  393. } catch (e) {
  394. showNotify({ type: 'warning', message: '已取消提交' })
  395. return
  396. }
  397. const toast = showLoadingToast({ duration: 0, message: '上传中...' })
  398. try {
  399. // 使用新的上传函数
  400. const outerResults = await uploadCategoryWithStatus(
  401. outerImages.value,
  402. 'RETURNED_BOX_IMAGE',
  403. )
  404. const innerResults = await uploadCategoryWithStatus(
  405. innerImages.value,
  406. 'RETURNED_INNER_IMAGE',
  407. )
  408. closeToast()
  409. // 统计结果
  410. const allResults = [...outerResults, ...innerResults]
  411. const successCount = allResults.filter((r) => r.success).length
  412. const failedCount = allResults.filter((r) => !r.success).length
  413. if (failedCount === 0) {
  414. // 全部成功:清空所有图片
  415. showNotify({ type: 'success', message: '提交成功' })
  416. outerImages.value = []
  417. innerImages.value = []
  418. } else {
  419. // 有失败图片:只清空成功的,保留失败的
  420. showNotify({
  421. type: 'warning',
  422. duration: 5000,
  423. message: `上传完成,成功 ${successCount} 张,失败 ${failedCount} 张。失败图片已保留,可手动重传。`,
  424. })
  425. // 只清空成功的图片
  426. outerImages.value = outerImages.value.filter(
  427. (img) => img.status !== UPLOAD_STATUS.SUCCESS,
  428. )
  429. innerImages.value = innerImages.value.filter(
  430. (img) => img.status !== UPLOAD_STATUS.SUCCESS,
  431. )
  432. }
  433. } catch (error) {
  434. // 保证用户看到的错误信息不包含内部实现细节
  435. closeToast()
  436. console.error('onSubmit error', error)
  437. showFailToast('提交失败,请稍后再试')
  438. }
  439. }
  440. // 手动重传相关函数
  441. // 单张图片重传
  442. const retrySingleImage = async (
  443. imageObj: UploadImage,
  444. category: string,
  445. ): Promise<void> => {
  446. // 检查重传次数
  447. if (imageObj.retryCount >= 1) {
  448. showNotify({ type: 'warning', message: '已达到最大重传次数' })
  449. return
  450. }
  451. imageObj.status = UPLOAD_STATUS.RETRYING
  452. const success = await uploadSingleImage(imageObj, category)
  453. imageObj.retryCount++ // 无论成功失败,重试次数+1
  454. if (success) {
  455. showNotify({ type: 'success', message: '重传成功' })
  456. // 成功后会从列表中移除(在下次提交时)
  457. } else {
  458. showNotify({
  459. type: 'danger',
  460. message: `重传失败`,
  461. })
  462. }
  463. }
  464. // 批量重传失败图片
  465. const retryAllFailedImages = async (): Promise<void> => {
  466. const allImages = [...outerImages.value, ...innerImages.value]
  467. const failedImages = allImages.filter(
  468. (img) => img.status === UPLOAD_STATUS.FAILED && img.retryCount < 1,
  469. )
  470. if (failedImages.length === 0) {
  471. showNotify({ type: 'primary', message: '没有需要重传的图片' })
  472. return
  473. }
  474. const toast = showLoadingToast({
  475. duration: 0,
  476. message: `重传 ${failedImages.length} 张图片...`,
  477. })
  478. let successCount = 0
  479. for (const img of failedImages) {
  480. const category = outerImages.value.includes(img)
  481. ? 'RETURNED_BOX_IMAGE'
  482. : 'RETURNED_INNER_IMAGE'
  483. const success = await uploadSingleImage(img, category)
  484. img.retryCount++ // 无论成功失败,重试次数+1
  485. if (success) successCount++
  486. }
  487. closeToast()
  488. showNotify({
  489. type: successCount === failedImages.length ? 'success' : 'warning',
  490. message: `重传完成,成功 ${successCount}/${failedImages.length} 张`,
  491. })
  492. }
  493. // 计算是否有失败图片
  494. const hasFailedImages = (): boolean => {
  495. const allImages = [
  496. ...outerImages.value,
  497. ...innerImages.value,
  498. ] as UploadImage[]
  499. return allImages.some(
  500. (img: UploadImage) =>
  501. img.status === UPLOAD_STATUS.FAILED && img.retryCount < 1,
  502. )
  503. }
  504. // 外箱图
  505. const outerImagesIndex = ref<number>(-1)
  506. // 内物图
  507. const innerImagesIndex = ref<number>(-1)
  508. const editImageRef = ref<EditImageExposed | null>(null)
  509. /**
  510. * 处理图片编辑操作
  511. * @param file 文件对象
  512. * @param index 文件下标
  513. */
  514. const handleInnerImages = (_slotFile: unknown, index: number): void => {
  515. // 直接从响应式列表取原始对象,避免 Vant 插槽包装层导致 file 字段异常
  516. const imageObj = innerImages.value[index]
  517. if (!imageObj) return
  518. if (!canEditImage(imageObj)) {
  519. showNotify({
  520. type: 'warning',
  521. message: '图片正在上传中,请稍后再编辑',
  522. })
  523. return
  524. }
  525. // 保证传入 EditImage 时有可用的预览 URL
  526. if (!imageObj.url && !imageObj.content && imageObj.file instanceof File) {
  527. imageObj.content = URL.createObjectURL(imageObj.file)
  528. }
  529. innerImagesIndex.value = index
  530. editImageRef.value?.editImage(imageObj, 'inner')
  531. }
  532. /**
  533. * 处理图片编辑操作
  534. * @param _slotFile 插槽传来的对象(Vant 包装层,不可靠,弃用)
  535. * @param index 文件下标
  536. */
  537. const handleOuterImages = (_slotFile: unknown, index: number): void => {
  538. // 直接从响应式列表取原始对象,避免 Vant 插槽包装层导致 file 字段异常
  539. const imageObj = outerImages.value[index]
  540. if (!imageObj) return
  541. if (!canEditImage(imageObj)) {
  542. showNotify({
  543. type: 'warning',
  544. message: '图片正在上传中,请稍后再编辑',
  545. })
  546. return
  547. }
  548. // 保证传入 EditImage 时有可用的预览 URL
  549. if (!imageObj.url && !imageObj.content && imageObj.file instanceof File) {
  550. imageObj.content = URL.createObjectURL(imageObj.file)
  551. }
  552. outerImagesIndex.value = index
  553. editImageRef.value?.editImage(imageObj, 'outer')
  554. }
  555. const changeFile = async (
  556. editedDataURL: string,
  557. type: 'inner' | 'outer',
  558. ): Promise<void> => {
  559. try {
  560. // 根据type找到对应的图片索引和列表
  561. const index =
  562. type === 'inner' ? innerImagesIndex.value : outerImagesIndex.value
  563. const imageList = type === 'inner' ? innerImages.value : outerImages.value
  564. if (index === -1 || !imageList[index]) {
  565. showNotify({ type: 'warning', message: '未找到对应的图片' })
  566. return
  567. }
  568. const imageObj = imageList[index]
  569. // 将base64转换为File对象
  570. const editedFile = dataURLtoFile(
  571. editedDataURL,
  572. getFileNameByMime(imageObj.file.name, editedDataURL),
  573. )
  574. // 压缩图片(EditImage保存的是PNG,可能很大)
  575. const compressedFile = await compressImage(editedFile)
  576. // 释放旧的预览URL(内存管理)
  577. if (imageObj.url && imageObj.url.startsWith('blob:')) {
  578. URL.revokeObjectURL(imageObj.url)
  579. }
  580. // 只更新文件内容和预览URL
  581. imageObj.file = compressedFile
  582. imageObj.originalFile = compressedFile
  583. imageObj.url = editedDataURL // 使用原始的base64作为预览(质量更好)
  584. // 若图片已成功上传,编辑后需重置为 PENDING,确保提交时重新上传编辑后的版本
  585. if (imageObj.status === UPLOAD_STATUS.SUCCESS) {
  586. imageObj.status = UPLOAD_STATUS.PENDING
  587. imageObj.error = null
  588. }
  589. // 其余状态(PENDING / FAILED)保持不变,由正常上传流程处理
  590. showNotify({ type: 'success', message: '图片编辑完成' })
  591. } catch (error) {
  592. console.error('图片编辑保存失败:', error)
  593. showNotify({
  594. type: 'danger',
  595. message: '图片编辑保存失败,请重试',
  596. })
  597. }
  598. }
  599. // base64转File函数
  600. function dataURLtoFile(dataurl: string, filename: string): File {
  601. const arr = dataurl.split(',')
  602. const mimeMatch = arr[0].match(/:(.*?);/)
  603. if (!mimeMatch) throw new Error('无效的 dataURL 格式')
  604. const mime = mimeMatch[1]
  605. const bstr = atob(arr[1])
  606. let n = bstr.length
  607. const u8arr = new Uint8Array(n)
  608. while (n--) {
  609. u8arr[n] = bstr.charCodeAt(n)
  610. }
  611. return new File([u8arr], filename, { type: mime })
  612. }
  613. function getFileNameByMime(fileName: string, dataUrl: string): string {
  614. const mimeMatch = dataUrl.match(/^data:(.*?);/)
  615. const mime = mimeMatch?.[1] || ''
  616. const ext = mime.split('/')[1]
  617. if (!ext) return fileName
  618. const normalizedExt = ext.split('+')[0].toLowerCase()
  619. const dotIndex = fileName.lastIndexOf('.')
  620. if (dotIndex <= 0) return `${fileName}.${normalizedExt}`
  621. return `${fileName.slice(0, dotIndex)}.${normalizedExt}`
  622. }
  623. </script>
  624. <style scoped lang="sass">
  625. .van-nav-bar
  626. .left-btn
  627. color: #fff
  628. height: 46px
  629. padding-right: 20px
  630. line-height: 46px
  631. .right-btn
  632. color: #fff
  633. // ── 仓库信息区 ──
  634. .workbench-info
  635. background: #fff
  636. padding: 10px 14px 8px
  637. border-bottom: 1px solid #f0f0f0
  638. .info-line
  639. margin: 0 0 4px
  640. font-size: 14px
  641. color: #666
  642. .info-value
  643. color: #1989fa
  644. font-weight: 500
  645. .hint-text
  646. color: #999
  647. font-size: 12px
  648. .refresh-btn
  649. margin-top: 4px
  650. // ── 主内容区 ──
  651. .container
  652. .init-container
  653. width: 100%
  654. .scan-returned-content
  655. padding: 10px
  656. .input-group
  657. padding: 5px
  658. .button-group
  659. display: flex
  660. gap: 8px
  661. padding: 8px 5px
  662. flex-wrap: wrap
  663. .action-btn
  664. flex: 1
  665. min-width: 70px
  666. height: 38px
  667. font-size: 13px
  668. border-radius: 8px
  669. .content
  670. width: 100%
  671. .scan-returned-no
  672. align-items: center
  673. padding: 15px
  674. .returned-detail-list
  675. .card-div
  676. background: #fff
  677. border-radius: 12px
  678. overflow: hidden
  679. box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05)
  680. margin: 5px 0
  681. padding: 5px 0
  682. .card-div-content
  683. padding: 3px
  684. .info-row
  685. display: flex
  686. margin-bottom: 12px
  687. font-size: 12px
  688. // 图片预览工具栏样式
  689. .preview-toolbar
  690. display: flex
  691. justify-content: flex-end
  692. margin-top: 10px
  693. padding: 0 15px
  694. .van-button
  695. margin-left: 10px
  696. .custom-cover
  697. position: absolute
  698. top: 0
  699. left: 0
  700. box-sizing: border-box
  701. width: 100%
  702. height: 100%
  703. display: flex
  704. align-items: flex-end
  705. /* 按钮通常放在底部 */
  706. justify-content: center
  707. padding: 8px
  708. /* 留出边距 */
  709. background: linear-gradient(to top, rgba(0, 0, 0, 0.7), transparent)
  710. /* 可选:底部渐变背景,让按钮更清晰 */
  711. pointer-events: none
  712. /* 关键:避免覆盖层拦截图片的点击预览事件 */
  713. .cover-button
  714. pointer-events: auto
  715. /* 关键:允许按钮本身接收点击事件 */
  716. // 上传状态样式
  717. .upload-status
  718. position: absolute
  719. top: 5px
  720. left: 5px
  721. padding: 2px 6px
  722. border-radius: 3px
  723. font-size: 12px
  724. color: white
  725. background-color: rgba(0, 0, 0, 0.7)
  726. .upload-status.success
  727. background-color: #07c160
  728. .upload-status.failed
  729. background-color: #ee0a24
  730. .upload-status.uploading
  731. background-color: #1989fa
  732. .upload-status.retrying
  733. background-color: #ff976a
  734. .upload-status.pending
  735. background-color: #969799
  736. </style>