UploadProgressPopup.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426
  1. <template>
  2. <popup :show="show" round :close-on-click-overlay="false" class="upload-progress-popup"
  3. @update:show="emit('update:show', $event)">
  4. <slot name="header"></slot>
  5. <div class="upload-box">
  6. <div class="box-header">
  7. <div class="upload-title">
  8. <span>{{ t('uploadPopup.uploadPhotos') }}</span>
  9. <span v-if="!uploadRequired" class="optional">{{ t('uploadPopup.optional') }}</span>
  10. </div>
  11. </div>
  12. <uploader class="popup-uploader" :class="{ 'popup-uploader-identify': enableIdentify }" v-model="fileList"
  13. multiple :max-count="10" :after-read="afterReadUpload"
  14. :preview-full-image="!enableIdentify" @delete="onDeleteUpload" @click-preview="openIdentifyPreview">
  15. <template v-if="enableIdentify" #preview-cover="previewItem">
  16. <div v-if="previewItem?.identifyStatus" class="identify-preview-cover"
  17. :class="{ 'is-done': previewItem.identifyStatus === 'done' }">
  18. <div class="identify-status-bar"
  19. @click.stop="previewItem.identifyStatus === 'done' && openIdentifyPreview(previewItem)">
  20. <template v-if="previewItem.identifyStatus === 'identifying'">
  21. <span class="identify-status-text">{{ t('正在识别中..') }}</span>
  22. </template>
  23. <template v-else-if="previewItem.identifyStatus === 'done'">
  24. <el-icon class="identify-done-icon" :size="14">
  25. <CircleCheck />
  26. </el-icon>
  27. <span class="identify-status-text">{{ t('识别完成') }}</span>
  28. <el-icon class="identify-arrow-icon" :size="12">
  29. <ArrowRight />
  30. </el-icon>
  31. </template>
  32. <template v-else-if="previewItem.identifyStatus === 'failed'">
  33. <span class="identify-status-text">{{ t('识别失败') }}</span>
  34. </template>
  35. </div>
  36. </div>
  37. </template>
  38. <img class="plus" src="@/assets/img/home/plus.png" alt="">
  39. </uploader>
  40. </div>
  41. <slot name="footer"></slot>
  42. <div class="upload-action-btns">
  43. <div class="cancel-btn" @click="emit('cancel')">{{ t('取消') }}</div>
  44. <div class="confirm-btn" @click="handleConfirm">{{ confirmText }}</div>
  45. </div>
  46. </popup>
  47. <ImagePreviewPopup v-if="enableIdentify" v-model:show="showIdentifyPreview" :images="identifyPreviewImages">
  48. <template #footer>
  49. <div class="identify-result-panel" v-if="previewResult">
  50. <div class="result-header">
  51. <span class="accent-bar"></span>
  52. <span class="result-name">{{ t('病虫名称:') }}{{ getIdentifyField(previewResult, ['disease_name', 'name', 'pest_name']) }}</span>
  53. </div>
  54. <div class="result-content">{{ getIdentifyField(previewResult, ['kepu', 'harm', 'harm_desc', 'phenotype', 'phenotypic_characteristics']) }}</div>
  55. </div>
  56. </template>
  57. </ImagePreviewPopup>
  58. </template>
  59. <script setup>
  60. import { useI18n } from "@/i18n";
  61. const { t } = useI18n();
  62. import { ref, computed, watch } from 'vue';
  63. import { Popup, Uploader } from 'vant';
  64. import { ElMessage } from 'element-plus';
  65. import { CircleCheck, ArrowRight } from '@element-plus/icons-vue';
  66. import ImagePreviewPopup from '@/components/popup/ImagePreviewPopup.vue';
  67. import UploadFile from "@/utils/upliadFile";
  68. import 'vant/lib/uploader/style';
  69. import { getFileExt } from "@/utils/util";
  70. import { base_img_url2 } from '@/api/config';
  71. const props = defineProps({
  72. show: {
  73. type: Boolean,
  74. default: false,
  75. },
  76. initImgArr: {
  77. type: Array,
  78. default: () => [],
  79. },
  80. confirmText: {
  81. type: String,
  82. default: '确认信息',
  83. },
  84. /** 是否必传图片,默认必传 */
  85. uploadRequired: {
  86. type: Boolean,
  87. default: true,
  88. },
  89. /** 是否开启上传后 AI 病虫识别(物候上传等场景关闭) */
  90. enableIdentify: {
  91. type: Boolean,
  92. default: false,
  93. },
  94. });
  95. const emit = defineEmits(['update:show', 'cancel', 'confirm', 'reset']);
  96. const fileList = ref([]);
  97. const popupInnerImgArr = ref([]);
  98. /** 用户已上传/删除过图片后,以 popupInnerImgArr 为准,不再回退 props.initImgArr */
  99. const imgTouched = ref(false);
  100. const uploadFileObj = new UploadFile();
  101. const miniUserId = localStorage.getItem("MINI_USER_ID");
  102. const showIdentifyPreview = ref(false);
  103. const previewImageUrl = ref('');
  104. const previewResult = ref(null);
  105. const identifyPreviewImages = computed(() => (
  106. previewImageUrl.value ? [previewImageUrl.value] : []
  107. ));
  108. function getIdentifyField(result, fields) {
  109. for (const field of fields) {
  110. const value = result?.[field];
  111. if (value !== undefined && value !== null && value !== '') {
  112. return value;
  113. }
  114. }
  115. return '-';
  116. }
  117. function getConfirmImgArr() {
  118. if (imgTouched.value || popupInnerImgArr.value.length) {
  119. return popupInnerImgArr.value.map((item) => base_img_url2 + item);
  120. }
  121. return [...(props.initImgArr || [])];
  122. }
  123. function onDeleteUpload(_file, detail) {
  124. imgTouched.value = true;
  125. popupInnerImgArr.value.splice(detail.index, 1);
  126. }
  127. const afterReadUpload = async (data) => {
  128. if (!Array.isArray(data)) {
  129. data = [data];
  130. }
  131. for (const file of data) {
  132. const fileVal = file.file;
  133. file.status = "uploading";
  134. file.message = "上传中...";
  135. const ext = getFileExt(fileVal.name);
  136. const key = `birdseye-look-mini/${miniUserId}/${new Date().getTime()}.${ext}`;
  137. const resFilename = await uploadFileObj.put(key, fileVal);
  138. if (resFilename) {
  139. file.status = "done";
  140. file.message = "";
  141. file.resFilename = resFilename;
  142. file.url = base_img_url2 + resFilename;
  143. imgTouched.value = true;
  144. popupInnerImgArr.value.push(resFilename);
  145. if (props.enableIdentify) {
  146. file.identifyStatus = 'identifying';
  147. identifySingleImage(file, resFilename);
  148. }
  149. } else {
  150. file.status = "failed";
  151. file.message = "上传失败";
  152. ElMessage.error("图片上传失败,请稍后再试!");
  153. }
  154. }
  155. };
  156. function patchFileItem(resFilename, patch) {
  157. let index = fileList.value.findIndex((item) => item.resFilename === resFilename);
  158. if (index === -1) {
  159. index = fileList.value.findIndex((item) => item.url?.includes(resFilename));
  160. }
  161. if (index === -1) return;
  162. Object.assign(fileList.value[index], patch);
  163. }
  164. async function identifySingleImage(file, resFilename) {
  165. let farmData = {};
  166. try {
  167. farmData = JSON.parse(localStorage.getItem('selectedFarmData') || '{}');
  168. } catch {
  169. farmData = {};
  170. }
  171. const params = {
  172. image_urls: [base_img_url2 + resFilename],
  173. crop_type: farmData.farm_variety,
  174. };
  175. try {
  176. const res = await VE_API.record.batchPestIdentify(params);
  177. if (res.code === 200 && res.results?.length) {
  178. const patch = { identifyStatus: 'done', identifyResult: res.results[0] };
  179. Object.assign(file, patch);
  180. patchFileItem(resFilename, patch);
  181. } else {
  182. const patch = { identifyStatus: 'failed' };
  183. Object.assign(file, patch);
  184. patchFileItem(resFilename, patch);
  185. ElMessage.error(t('AI识别失败,请稍后再试!'));
  186. }
  187. } catch {
  188. const patch = { identifyStatus: 'failed' };
  189. Object.assign(file, patch);
  190. patchFileItem(resFilename, patch);
  191. ElMessage.error(t('AI识别失败,请稍后再试!'));
  192. }
  193. }
  194. function openIdentifyPreview(file) {
  195. previewImageUrl.value = file.url || base_img_url2 + (file.resFilename || '');
  196. previewResult.value = file.identifyResult || null;
  197. showIdentifyPreview.value = true;
  198. }
  199. function handleConfirm() {
  200. const imgArr = getConfirmImgArr();
  201. if (props.uploadRequired && !imgArr.length) {
  202. ElMessage.warning(t('请先上传照片'));
  203. return;
  204. }
  205. emit('confirm', imgArr);
  206. }
  207. function uploadReset() {
  208. fileList.value = [];
  209. popupInnerImgArr.value = [];
  210. imgTouched.value = false;
  211. showIdentifyPreview.value = false;
  212. previewImageUrl.value = '';
  213. previewResult.value = null;
  214. }
  215. watch(
  216. () => props.initImgArr,
  217. (val) => {
  218. if (imgTouched.value || !val?.length) {
  219. return;
  220. }
  221. fileList.value = val.map((item) => ({
  222. url: base_img_url2 + item,
  223. isImage: true,
  224. resFilename: item,
  225. }));
  226. popupInnerImgArr.value = [...val];
  227. },
  228. { immediate: true, deep: true },
  229. );
  230. watch(
  231. () => props.show,
  232. (val) => {
  233. if (val) {
  234. uploadReset();
  235. emit('reset');
  236. }
  237. },
  238. );
  239. defineExpose({
  240. uploadReset,
  241. });
  242. </script>
  243. <style scoped lang="scss">
  244. .upload-progress-popup {
  245. width: 100%;
  246. padding: 24px 16px;
  247. background: linear-gradient(360deg, #FFFFFF 74.2%, #D1EBFF 100%);
  248. .upload-box {
  249. margin-bottom: 12px;
  250. position: relative;
  251. min-height: 88px;
  252. .box-header {
  253. display: flex;
  254. align-items: center;
  255. justify-content: space-between;
  256. margin-bottom: 12px;
  257. .upload-title {
  258. font-size: 16px;
  259. .optional {
  260. font-size: 12px;
  261. color: rgba(18, 18, 18, 0.2);
  262. }
  263. }
  264. }
  265. .popup-uploader {
  266. .plus {
  267. width: calc((100vw - 68px) / 4);
  268. height: calc((100vw - 68px) / 4);
  269. }
  270. ::v-deep {
  271. .van-uploader__wrapper {
  272. --van-uploader-size: 76.7px;
  273. --van-padding-xs: 6px;
  274. }
  275. }
  276. &.popup-uploader-identify {
  277. ::v-deep {
  278. .van-uploader__preview-cover {
  279. position: absolute;
  280. left: 0;
  281. right: 0;
  282. top: 0;
  283. bottom: 0;
  284. height: 100%;
  285. pointer-events: none;
  286. }
  287. .van-uploader__preview-delete {
  288. z-index: 3;
  289. }
  290. }
  291. }
  292. }
  293. .identify-preview-cover {
  294. display: flex;
  295. flex-direction: column;
  296. justify-content: flex-end;
  297. width: 100%;
  298. height: 100%;
  299. position: relative;
  300. }
  301. .identify-status-bar {
  302. pointer-events: auto;
  303. display: flex;
  304. align-items: center;
  305. justify-content: flex-start;
  306. gap: 4px;
  307. width: 100%;
  308. padding: 4px 6px;
  309. box-sizing: border-box;
  310. background: rgba(0, 0, 0, 0.55);
  311. color: #fff;
  312. font-size: 11px;
  313. text-align: center;
  314. .identify-status-text {
  315. flex: 1;
  316. overflow: hidden;
  317. text-overflow: ellipsis;
  318. white-space: nowrap;
  319. }
  320. .identify-done-icon {
  321. color: #52c41a;
  322. flex-shrink: 0;
  323. }
  324. .identify-arrow-icon {
  325. flex-shrink: 0;
  326. margin-left: auto;
  327. }
  328. }
  329. }
  330. .upload-action-btns {
  331. display: flex;
  332. gap: 10px;
  333. margin-top: 16px;
  334. .cancel-btn,
  335. .confirm-btn {
  336. flex: 1;
  337. border-radius: 4px;
  338. padding: 8px;
  339. text-align: center;
  340. font-size: 16px;
  341. }
  342. .cancel-btn {
  343. border: 1px solid #dcdfe6;
  344. color: #606266;
  345. background: #ffffff;
  346. }
  347. .confirm-btn {
  348. background: #2199f8;
  349. color: #ffffff;
  350. }
  351. }
  352. }
  353. .identify-result-panel {
  354. padding: 8px 10px;
  355. background: rgba(0, 0, 0, 0.4);
  356. border-radius: 6px;
  357. box-sizing: border-box;
  358. margin: 18px 12px;
  359. .result-header {
  360. display: flex;
  361. align-items: center;
  362. gap: 6px;
  363. margin-bottom: 7px;
  364. .accent-bar {
  365. width: 4px;
  366. height: 15px;
  367. border-radius: 2px;
  368. background: #FFD786;
  369. flex-shrink: 0;
  370. }
  371. .result-name {
  372. color: #FFD786;
  373. font-size: 18px;
  374. font-weight: 500;
  375. line-height: 22px;
  376. }
  377. }
  378. .result-content {
  379. color: #ffffff;
  380. font-size: 13px;
  381. }
  382. }
  383. </style>