Przeglądaj źródła

h5新增通知截图

yuanmingze 3 tygodni temu
rodzic
commit
cac6803949

+ 56 - 2
src/pages-task/task-detail/components/ShareTaskDetail.vue

@@ -76,9 +76,40 @@ const props = defineProps<{
   taskDetail: ShareTaskDetailResponse
 }>()
 
+interface QrExportState {
+  ercodeUrl: string
+  qrLoadOk: boolean
+  qrLoadErr: boolean
+  qrErrorMsg: string
+}
+
+const emit = defineEmits<{
+  'qr-state-change': [state: QrExportState]
+}>()
+
 const qrCodeUrl = ref('')
 const qrError = ref('')
 const qrLoading = ref(false)
+const qrLoadOk = ref(false)
+const qrLoadErr = ref(false)
+const qrErrorMsg = ref('')
+
+const emitQrState = (ercodeUrl = qrCodeUrl.value) => {
+  emit('qr-state-change', {
+    ercodeUrl,
+    qrLoadOk: qrLoadOk.value,
+    qrLoadErr: qrLoadErr.value,
+    qrErrorMsg: qrErrorMsg.value,
+  })
+}
+
+const safeJsonStringify = (value: unknown): string => {
+  try {
+    return JSON.stringify(value)
+  } catch {
+    return String(value)
+  }
+}
 
 const forwardTime = computed(() => normalizeText(props.taskDetail.taskInfo.createTime))
 const shareTitle = computed(() => normalizeText(props.taskDetail.title))
@@ -109,9 +140,15 @@ const loadQrCode = async () => {
   const shareUrl = buildShareUrl()
   qrCodeUrl.value = ''
   qrError.value = ''
+  qrLoadOk.value = false
+  qrLoadErr.value = false
+  qrErrorMsg.value = ''
+  emitQrState('')
 
   if (!shareUrl) {
     qrError.value = '缺少分享内容 ID,无法生成二维码'
+    qrErrorMsg.value = 'qr-api-error:share-id-empty'
+    emitQrState('')
     return
   }
 
@@ -128,21 +165,38 @@ const loadQrCode = async () => {
 
     const fullUrl = toFullUrl(imagePath)
     qrCodeUrl.value = `${fullUrl}${fullUrl.includes('?') ? '&' : '?'}t=${Date.now()}`
+    qrErrorMsg.value = `qr-api-return:${imagePath}`
+    emitQrState()
   } catch (error) {
     console.error('[task-detail] 分享二维码生成失败', error)
     qrLoading.value = false
     qrError.value = error instanceof Error ? error.message : '二维码生成失败'
+    qrErrorMsg.value = `qr-api-error:${safeJsonStringify(error)}`
+    emitQrState('')
   }
 }
 
 const onQrImgLoad = () => {
   qrLoading.value = false
+  qrLoadOk.value = true
+  qrLoadErr.value = false
+  emitQrState()
 }
 
-const onQrImgError = () => {
+const onQrImgError = (event: unknown) => {
+  const failedUrl = qrCodeUrl.value
+  const detail =
+    event && typeof event === 'object' && 'detail' in event
+      ? (event as { detail?: unknown }).detail
+      : undefined
+
   qrLoading.value = false
-  qrCodeUrl.value = ''
   qrError.value = '二维码图片加载失败'
+  qrLoadOk.value = false
+  qrLoadErr.value = true
+  qrErrorMsg.value = `qr-img-error:${detail ? safeJsonStringify(detail) : 'unknown'}`
+  emitQrState(failedUrl)
+  qrCodeUrl.value = ''
 }
 
 onMounted(() => {

+ 171 - 5
src/pages-task/task-detail/index.vue

@@ -14,7 +14,11 @@
 
     <template v-else-if="taskDetail">
       <SignTaskDetail v-if="signTaskDetail" :task-detail="signTaskDetail" :records="signRecords" />
-      <ShareTaskDetail v-else-if="shareTaskDetail" :task-detail="shareTaskDetail" />
+      <ShareTaskDetail
+        v-else-if="shareTaskDetail"
+        :task-detail="shareTaskDetail"
+        @qr-state-change="handleQrStateChange"
+      />
       <NormalTaskDetail
         v-else-if="normalTaskDetail"
         :task-detail="normalTaskDetail"
@@ -30,16 +34,17 @@
 </template>
 
 <script setup lang="ts">
-import { computed, ref } from 'vue'
+import { computed, nextTick, ref } from 'vue'
 
-import { onLoad } from '@dcloudio/uni-app'
+import { onLoad, onUnload } from '@dcloudio/uni-app'
 
-import { getTaskInfoByIdApi } from '@/services/modules/task/taskDetail'
+import { getTaskInfoByIdApi, updateScreenShotStatusApi } from '@/services/modules/task/taskDetail'
 import type {
   ShareTaskDetailResponse,
   SignTaskDetailResponse,
   TaskDetailResponse,
   TaskFormDetailResponse,
+  UpdateScreenShotStatusParams,
 } from '@/services/modules/task/taskDetail/type'
 import { getTaskContentConfigByTaskTypeIdApi } from '@/services/modules/task/taskFrom'
 import type { TaskContentConfigByTaskTypeIdResponse } from '@/services/modules/task/taskFrom/type'
@@ -53,17 +58,46 @@ import SignTaskDetail from './components/SignTaskDetail.vue'
 interface PageLoadOptions {
   id?: string
   taskId?: string
+  exportToken?: string
+}
+
+interface QrExportState {
+  ercodeUrl: string
+  qrLoadOk: boolean
+  qrLoadErr: boolean
+  qrErrorMsg: string
+}
+
+interface ImageReadyState {
+  imgTotal: number
+  imgDone: number
+  imgOk: number
+  imgErr: number
 }
 
 const SHARE_TASK_TYPES = new Set(['8', '9', '10', '11'])
 const SIGN_TASK_TYPES = new Set(['5', '6', '33'])
+const EXPORT_MAX_WAIT_MS = 18_000
+const EXPORT_POLL_INTERVAL_MS = 120
+const QR_IMAGE_SELECTOR = '.share-detail-qrcode__image'
 
 const taskId = ref('')
+const exportToken = ref('')
 const loading = ref(false)
 const errorMessage = ref('')
 const taskDetail = ref<TaskDetailResponse | null>(null)
 const taskContentConfig = ref<TaskContentConfigByTaskTypeIdResponse | null>(null)
 const signRecords = ref<VisitSignRecordItem[]>([])
+const qrExportState = ref<QrExportState>({
+  ercodeUrl: '',
+  qrLoadOk: false,
+  qrLoadErr: false,
+  qrErrorMsg: '',
+})
+
+let exportStartTs = 0
+let exportReadyNotified = false
+let exportFlowVersion = 0
 
 const isShareTaskDetail = (detail: TaskDetailResponse): detail is ShareTaskDetailResponse => {
   return SHARE_TASK_TYPES.has(String(detail.taskType))
@@ -89,6 +123,112 @@ const normalTaskDetail = computed<TaskFormDetailResponse | null>(() => {
   return detail
 })
 
+const handleQrStateChange = (state: QrExportState) => {
+  qrExportState.value = state
+}
+
+const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
+
+const waitForPaint = async () => {
+  await nextTick()
+  await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
+  await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
+}
+
+const getPageRoot = (): Element | null => {
+  return document.querySelector('.task-detail-page')
+}
+
+const getQrNaturalWidth = (): number => {
+  const qrImage = getPageRoot()?.querySelector<HTMLImageElement>(QR_IMAGE_SELECTOR)
+  return qrImage ? qrImage.naturalWidth || 0 : -1
+}
+
+const checkAllImagesOnce = (): ImageReadyState => {
+  const images = Array.from(getPageRoot()?.querySelectorAll<HTMLImageElement>('img') ?? [])
+  let imgDone = 0
+  let imgOk = 0
+  let imgErr = 0
+
+  for (const image of images) {
+    const isQrImage = image.matches(QR_IMAGE_SELECTOR)
+
+    if (image.complete && image.naturalWidth > 0) {
+      imgDone += 1
+      imgOk += 1
+      continue
+    }
+
+    // 二维码必须成功解码;其他图片即使失败也视为加载结束,避免阻塞截图任务。
+    if (!isQrImage && image.complete && image.naturalWidth === 0) {
+      imgDone += 1
+      imgErr += 1
+    }
+  }
+
+  return {
+    imgTotal: images.length,
+    imgDone,
+    imgOk,
+    imgErr,
+  }
+}
+
+const mockNotifyExportReady = async (data: UpdateScreenShotStatusParams) => {
+  try {
+    const res = await updateScreenShotStatusApi(data)
+    console.log('[task-detail] mockNotifyExportReady', res)
+  } catch (error) {
+    console.error('[task-detail] 截图就绪状态通知失败', error)
+  }
+}
+
+const notifyExportReadyOnce = (ok: boolean, timeout: boolean) => {
+  if (!exportToken.value || exportReadyNotified) return
+
+  exportReadyNotified = true
+
+  const data: UpdateScreenShotStatusParams = {
+    exportToken: exportToken.value,
+    ok: String(ok),
+    timeout: String(timeout),
+    costMs: String(Math.max(0, Date.now() - exportStartTs)),
+    qrLoadOk: String(qrExportState.value.qrLoadOk),
+    qrLoadErr: String(qrExportState.value.qrLoadErr),
+    qrErrorMsg: qrExportState.value.qrErrorMsg,
+    qrNaturalWidth: String(getQrNaturalWidth()),
+    ercodeUrl: qrExportState.value.ercodeUrl,
+  }
+
+  void mockNotifyExportReady(data)
+}
+
+const waitAndNotifyExportReady = async (flowVersion: number) => {
+  const deadline = exportStartTs + EXPORT_MAX_WAIT_MS
+
+  while (flowVersion === exportFlowVersion && Date.now() < deadline) {
+    await waitForPaint()
+
+    const imageState = checkAllImagesOnce()
+    const allImagesDone = imageState.imgTotal === 0 || imageState.imgDone >= imageState.imgTotal
+    const qrReady =
+      !shareTaskDetail.value ||
+      (Boolean(qrExportState.value.ercodeUrl) &&
+        (qrExportState.value.qrLoadOk || getQrNaturalWidth() > 0))
+
+    if (qrReady && allImagesDone) {
+      notifyExportReadyOnce(true, false)
+      return
+    }
+
+    await sleep(EXPORT_POLL_INTERVAL_MS)
+  }
+
+  if (flowVersion === exportFlowVersion) {
+    notifyExportReadyOnce(false, true)
+  }
+}
+
 const loadTaskDetail = async () => {
   if (!taskId.value || loading.value) return
 
@@ -130,15 +270,41 @@ const loadTaskDetail = async () => {
 }
 
 onLoad((options: PageLoadOptions = {}) => {
+  exportFlowVersion += 1
+  const flowVersion = exportFlowVersion
+
   taskId.value = String(options.taskId ?? options.id ?? '').trim()
+  exportToken.value = String(options.exportToken ?? '').trim()
+  exportStartTs = Date.now()
+  exportReadyNotified = false
+  qrExportState.value = {
+    ercodeUrl: '',
+    qrLoadOk: false,
+    qrLoadErr: false,
+    qrErrorMsg: '',
+  }
 
   if (!taskId.value) {
     errorMessage.value = '缺少 taskId'
     uni.showToast({ title: errorMessage.value, icon: 'none' })
+
+    // #ifdef H5
+    if (exportToken.value) void waitAndNotifyExportReady(flowVersion)
+    // #endif
     return
   }
 
-  void loadTaskDetail()
+  void (async () => {
+    await loadTaskDetail()
+
+    // #ifdef H5
+    if (exportToken.value) await waitAndNotifyExportReady(flowVersion)
+    // #endif
+  })()
+})
+
+onUnload(() => {
+  exportFlowVersion += 1
 })
 </script>
 

+ 9 - 1
src/services/modules/task/taskDetail/index.ts

@@ -1,5 +1,9 @@
 import http from '../../../index'
-import type { QrCodeResult, TaskDetailResponse } from './type'
+import type {
+  QrCodeResult,
+  TaskDetailResponse,
+  UpdateScreenShotStatusParams,
+} from './type'
 
 export const getTaskInfoByIdApi = (taskId: string) => {
   return http.get<TaskDetailResponse>('/admin/api/getTaskInfoById', { taskId })
@@ -12,3 +16,7 @@ export const getQrCodeByUrlApi = (url: string) => {
     height: 170,
   })
 }
+
+export const updateScreenShotStatusApi = (data: UpdateScreenShotStatusParams) => {
+  return http.post<unknown>('/admin/reportExport/update-screen-shot-status', data)
+}

+ 13 - 0
src/services/modules/task/taskDetail/type.d.ts

@@ -151,3 +151,16 @@ export interface QrCodeResult {
   msg: string | null
   success: boolean
 }
+
+/** 后端截图任务的页面就绪通知参数。 */
+export interface UpdateScreenShotStatusParams {
+  exportToken: string
+  ok: string
+  timeout: string
+  costMs: string
+  qrLoadOk: string
+  qrLoadErr: string
+  qrErrorMsg: string
+  qrNaturalWidth: string
+  ercodeUrl: string
+}