index.vue 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375
  1. <template>
  2. <view class="task-detail-page">
  3. <view v-if="loading" class="task-detail-state">
  4. <wd-loading color="#3b9bed" />
  5. <text class="task-detail-state__text">正在加载任务详情</text>
  6. </view>
  7. <view v-else-if="errorMessage" class="task-detail-state">
  8. <view class="task-detail-state__icon task-detail-state__icon--error">!</view>
  9. <text class="task-detail-state__title">详情加载失败</text>
  10. <text class="task-detail-state__text">{{ errorMessage }}</text>
  11. <button class="task-detail-state__retry" @click="loadTaskDetail">重新加载</button>
  12. </view>
  13. <template v-else-if="taskDetail">
  14. <SignTaskDetail v-if="signTaskDetail" :task-detail="signTaskDetail" :records="signRecords" />
  15. <ShareTaskDetail
  16. v-else-if="shareTaskDetail"
  17. :task-detail="shareTaskDetail"
  18. @qr-state-change="handleQrStateChange"
  19. />
  20. <NormalTaskDetail
  21. v-else-if="normalTaskDetail"
  22. :task-detail="normalTaskDetail"
  23. :task-config="taskContentConfig"
  24. />
  25. </template>
  26. <view v-else class="task-detail-state">
  27. <view class="task-detail-state__icon">⌕</view>
  28. <text class="task-detail-state__title">暂无任务详情</text>
  29. </view>
  30. </view>
  31. </template>
  32. <script setup lang="ts">
  33. import { computed, nextTick, ref } from 'vue'
  34. import { onLoad, onUnload } from '@dcloudio/uni-app'
  35. import { getTaskInfoByIdApi, updateScreenShotStatusApi } from '@/services/modules/task/taskDetail'
  36. import type {
  37. ShareTaskDetailResponse,
  38. SignTaskDetailResponse,
  39. TaskDetailResponse,
  40. TaskFormDetailResponse,
  41. UpdateScreenShotStatusParams,
  42. } from '@/services/modules/task/taskDetail/type'
  43. import { getTaskContentConfigByTaskTypeIdApi } from '@/services/modules/task/taskFrom'
  44. import type { TaskContentConfigByTaskTypeIdResponse } from '@/services/modules/task/taskFrom/type'
  45. import { getSignInfoByTaskIdApi } from '@/services/modules/task/taskVisit'
  46. import type { VisitSignRecordItem } from '@/services/modules/task/taskVisit/type'
  47. import NormalTaskDetail from './components/NormalTaskDetail.vue'
  48. import ShareTaskDetail from './components/ShareTaskDetail.vue'
  49. import SignTaskDetail from './components/SignTaskDetail.vue'
  50. interface PageLoadOptions {
  51. id?: string
  52. taskId?: string
  53. exportToken?: string
  54. }
  55. interface QrExportState {
  56. ercodeUrl: string
  57. qrLoadOk: boolean
  58. qrLoadErr: boolean
  59. qrErrorMsg: string
  60. }
  61. interface ImageReadyState {
  62. imgTotal: number
  63. imgDone: number
  64. imgOk: number
  65. imgErr: number
  66. }
  67. const SHARE_TASK_TYPES = new Set(['8', '9', '10', '11'])
  68. const SIGN_TASK_TYPES = new Set(['5', '6', '33'])
  69. const EXPORT_MAX_WAIT_MS = 18_000
  70. const EXPORT_POLL_INTERVAL_MS = 120
  71. const QR_IMAGE_SELECTOR = '.share-detail-qrcode__image'
  72. const taskId = ref('')
  73. const exportToken = ref('')
  74. const loading = ref(false)
  75. const errorMessage = ref('')
  76. const taskDetail = ref<TaskDetailResponse | null>(null)
  77. const taskContentConfig = ref<TaskContentConfigByTaskTypeIdResponse | null>(null)
  78. const signRecords = ref<VisitSignRecordItem[]>([])
  79. const qrExportState = ref<QrExportState>({
  80. ercodeUrl: '',
  81. qrLoadOk: false,
  82. qrLoadErr: false,
  83. qrErrorMsg: '',
  84. })
  85. let exportStartTs = 0
  86. let exportReadyNotified = false
  87. let exportFlowVersion = 0
  88. const isShareTaskDetail = (detail: TaskDetailResponse): detail is ShareTaskDetailResponse => {
  89. return SHARE_TASK_TYPES.has(String(detail.taskType))
  90. }
  91. const isSignTaskDetail = (detail: TaskDetailResponse): detail is SignTaskDetailResponse => {
  92. return SIGN_TASK_TYPES.has(String(detail.taskType))
  93. }
  94. const signTaskDetail = computed<SignTaskDetailResponse | null>(() => {
  95. const detail = taskDetail.value
  96. return detail && isSignTaskDetail(detail) ? detail : null
  97. })
  98. const shareTaskDetail = computed<ShareTaskDetailResponse | null>(() => {
  99. const detail = taskDetail.value
  100. return detail && isShareTaskDetail(detail) ? detail : null
  101. })
  102. const normalTaskDetail = computed<TaskFormDetailResponse | null>(() => {
  103. const detail = taskDetail.value
  104. if (!detail || isShareTaskDetail(detail) || isSignTaskDetail(detail)) return null
  105. return detail
  106. })
  107. const handleQrStateChange = (state: QrExportState) => {
  108. qrExportState.value = state
  109. }
  110. const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
  111. const waitForPaint = async () => {
  112. await nextTick()
  113. await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
  114. await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
  115. }
  116. const getPageRoot = (): Element | null => {
  117. return document.querySelector('.task-detail-page')
  118. }
  119. const getQrNaturalWidth = (): number => {
  120. const qrImage = getPageRoot()?.querySelector<HTMLImageElement>(QR_IMAGE_SELECTOR)
  121. return qrImage ? qrImage.naturalWidth || 0 : -1
  122. }
  123. const checkAllImagesOnce = (): ImageReadyState => {
  124. const images = Array.from(getPageRoot()?.querySelectorAll<HTMLImageElement>('img') ?? [])
  125. let imgDone = 0
  126. let imgOk = 0
  127. let imgErr = 0
  128. for (const image of images) {
  129. const isQrImage = image.matches(QR_IMAGE_SELECTOR)
  130. if (image.complete && image.naturalWidth > 0) {
  131. imgDone += 1
  132. imgOk += 1
  133. continue
  134. }
  135. // 二维码必须成功解码;其他图片即使失败也视为加载结束,避免阻塞截图任务。
  136. if (!isQrImage && image.complete && image.naturalWidth === 0) {
  137. imgDone += 1
  138. imgErr += 1
  139. }
  140. }
  141. return {
  142. imgTotal: images.length,
  143. imgDone,
  144. imgOk,
  145. imgErr,
  146. }
  147. }
  148. const mockNotifyExportReady = async (data: UpdateScreenShotStatusParams) => {
  149. try {
  150. const res = await updateScreenShotStatusApi(data)
  151. console.log('[task-detail] mockNotifyExportReady', res)
  152. } catch (error) {
  153. console.error('[task-detail] 截图就绪状态通知失败', error)
  154. }
  155. }
  156. const notifyExportReadyOnce = (ok: boolean, timeout: boolean) => {
  157. if (!exportToken.value || exportReadyNotified) return
  158. exportReadyNotified = true
  159. const data: UpdateScreenShotStatusParams = {
  160. exportToken: exportToken.value,
  161. ok: String(ok),
  162. timeout: String(timeout),
  163. costMs: String(Math.max(0, Date.now() - exportStartTs)),
  164. qrLoadOk: String(qrExportState.value.qrLoadOk),
  165. qrLoadErr: String(qrExportState.value.qrLoadErr),
  166. qrErrorMsg: qrExportState.value.qrErrorMsg,
  167. qrNaturalWidth: String(getQrNaturalWidth()),
  168. ercodeUrl: qrExportState.value.ercodeUrl,
  169. }
  170. void mockNotifyExportReady(data)
  171. }
  172. const waitAndNotifyExportReady = async (flowVersion: number) => {
  173. const deadline = exportStartTs + EXPORT_MAX_WAIT_MS
  174. while (flowVersion === exportFlowVersion && Date.now() < deadline) {
  175. await waitForPaint()
  176. const imageState = checkAllImagesOnce()
  177. const allImagesDone = imageState.imgTotal === 0 || imageState.imgDone >= imageState.imgTotal
  178. const qrReady =
  179. !shareTaskDetail.value ||
  180. (Boolean(qrExportState.value.ercodeUrl) &&
  181. (qrExportState.value.qrLoadOk || getQrNaturalWidth() > 0))
  182. if (qrReady && allImagesDone) {
  183. notifyExportReadyOnce(true, false)
  184. return
  185. }
  186. await sleep(EXPORT_POLL_INTERVAL_MS)
  187. }
  188. if (flowVersion === exportFlowVersion) {
  189. notifyExportReadyOnce(false, true)
  190. }
  191. }
  192. const loadTaskDetail = async () => {
  193. if (!taskId.value || loading.value) return
  194. loading.value = true
  195. errorMessage.value = ''
  196. taskContentConfig.value = null
  197. signRecords.value = []
  198. try {
  199. const res = await getTaskInfoByIdApi(taskId.value)
  200. const detail = res.data ?? null
  201. taskDetail.value = detail
  202. if (!detail) {
  203. errorMessage.value = '接口未返回任务详情数据'
  204. return
  205. }
  206. if (isSignTaskDetail(detail)) {
  207. const signRes = await getSignInfoByTaskIdApi({ id: taskId.value })
  208. signRecords.value = Array.isArray(signRes.data) ? signRes.data : []
  209. } else if (!isShareTaskDetail(detail)) {
  210. try {
  211. const configRes = await getTaskContentConfigByTaskTypeIdApi(String(detail.taskType))
  212. taskContentConfig.value = configRes.data ?? null
  213. } catch (error) {
  214. console.warn('[task-detail] 任务表单配置加载失败,将使用详情原始值', error)
  215. }
  216. }
  217. } catch (error) {
  218. console.error('[task-detail] 任务详情加载失败', error)
  219. taskDetail.value = null
  220. taskContentConfig.value = null
  221. signRecords.value = []
  222. errorMessage.value = '请检查网络后重试'
  223. } finally {
  224. loading.value = false
  225. }
  226. }
  227. onLoad((options: PageLoadOptions = {}) => {
  228. exportFlowVersion += 1
  229. const flowVersion = exportFlowVersion
  230. taskId.value = String(options.taskId ?? options.id ?? '').trim()
  231. exportToken.value = String(options.exportToken ?? '').trim()
  232. exportStartTs = Date.now()
  233. exportReadyNotified = false
  234. qrExportState.value = {
  235. ercodeUrl: '',
  236. qrLoadOk: false,
  237. qrLoadErr: false,
  238. qrErrorMsg: '',
  239. }
  240. if (!taskId.value) {
  241. errorMessage.value = '缺少 taskId'
  242. uni.showToast({ title: errorMessage.value, icon: 'none' })
  243. // #ifdef H5
  244. if (exportToken.value) void waitAndNotifyExportReady(flowVersion)
  245. // #endif
  246. return
  247. }
  248. void (async () => {
  249. await loadTaskDetail()
  250. // #ifdef H5
  251. if (exportToken.value) await waitAndNotifyExportReady(flowVersion)
  252. // #endif
  253. })()
  254. })
  255. onUnload(() => {
  256. exportFlowVersion += 1
  257. })
  258. </script>
  259. <style lang="scss" scoped>
  260. .task-detail-page {
  261. min-height: 100vh;
  262. padding: 24rpx 24rpx 48rpx;
  263. background: linear-gradient(180deg, #edf6ff 0%, #f5f7fa 360rpx, #f5f7fa 100%);
  264. box-sizing: border-box;
  265. }
  266. .task-detail-state {
  267. display: flex;
  268. flex-direction: column;
  269. align-items: center;
  270. justify-content: center;
  271. min-height: 620rpx;
  272. padding: 40rpx;
  273. color: #8491a2;
  274. text-align: center;
  275. }
  276. .task-detail-state__icon {
  277. display: flex;
  278. align-items: center;
  279. justify-content: center;
  280. width: 92rpx;
  281. height: 92rpx;
  282. color: #3b9bed;
  283. font-size: 48rpx;
  284. background: #eaf5ff;
  285. border-radius: 50%;
  286. }
  287. .task-detail-state__icon--error {
  288. color: #d97706;
  289. background: #fff5df;
  290. }
  291. .task-detail-state__title {
  292. margin-top: 24rpx;
  293. color: #334155;
  294. font-size: 30rpx;
  295. font-weight: 650;
  296. }
  297. .task-detail-state__text {
  298. margin-top: 16rpx;
  299. font-size: 24rpx;
  300. line-height: 36rpx;
  301. }
  302. .task-detail-state__retry {
  303. width: 220rpx;
  304. height: 68rpx;
  305. margin: 28rpx 0 0;
  306. color: #fff;
  307. font-size: 25rpx;
  308. line-height: 68rpx;
  309. background: #3b9bed;
  310. border: 0;
  311. border-radius: 34rpx;
  312. }
  313. .task-detail-state__retry::after {
  314. border: 0;
  315. }
  316. </style>