| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375 |
- <template>
- <view class="task-detail-page">
- <view v-if="loading" class="task-detail-state">
- <wd-loading color="#3b9bed" />
- <text class="task-detail-state__text">正在加载任务详情</text>
- </view>
- <view v-else-if="errorMessage" class="task-detail-state">
- <view class="task-detail-state__icon task-detail-state__icon--error">!</view>
- <text class="task-detail-state__title">详情加载失败</text>
- <text class="task-detail-state__text">{{ errorMessage }}</text>
- <button class="task-detail-state__retry" @click="loadTaskDetail">重新加载</button>
- </view>
- <template v-else-if="taskDetail">
- <SignTaskDetail v-if="signTaskDetail" :task-detail="signTaskDetail" :records="signRecords" />
- <ShareTaskDetail
- v-else-if="shareTaskDetail"
- :task-detail="shareTaskDetail"
- @qr-state-change="handleQrStateChange"
- />
- <NormalTaskDetail
- v-else-if="normalTaskDetail"
- :task-detail="normalTaskDetail"
- :task-config="taskContentConfig"
- />
- </template>
- <view v-else class="task-detail-state">
- <view class="task-detail-state__icon">⌕</view>
- <text class="task-detail-state__title">暂无任务详情</text>
- </view>
- </view>
- </template>
- <script setup lang="ts">
- import { computed, nextTick, ref } from 'vue'
- import { onLoad, onUnload } from '@dcloudio/uni-app'
- 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'
- import { getSignInfoByTaskIdApi } from '@/services/modules/task/taskVisit'
- import type { VisitSignRecordItem } from '@/services/modules/task/taskVisit/type'
- import NormalTaskDetail from './components/NormalTaskDetail.vue'
- import ShareTaskDetail from './components/ShareTaskDetail.vue'
- 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))
- }
- const isSignTaskDetail = (detail: TaskDetailResponse): detail is SignTaskDetailResponse => {
- return SIGN_TASK_TYPES.has(String(detail.taskType))
- }
- const signTaskDetail = computed<SignTaskDetailResponse | null>(() => {
- const detail = taskDetail.value
- return detail && isSignTaskDetail(detail) ? detail : null
- })
- const shareTaskDetail = computed<ShareTaskDetailResponse | null>(() => {
- const detail = taskDetail.value
- return detail && isShareTaskDetail(detail) ? detail : null
- })
- const normalTaskDetail = computed<TaskFormDetailResponse | null>(() => {
- const detail = taskDetail.value
- if (!detail || isShareTaskDetail(detail) || isSignTaskDetail(detail)) return 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
- loading.value = true
- errorMessage.value = ''
- taskContentConfig.value = null
- signRecords.value = []
- try {
- const res = await getTaskInfoByIdApi(taskId.value)
- const detail = res.data ?? null
- taskDetail.value = detail
- if (!detail) {
- errorMessage.value = '接口未返回任务详情数据'
- return
- }
- if (isSignTaskDetail(detail)) {
- const signRes = await getSignInfoByTaskIdApi({ id: taskId.value })
- signRecords.value = Array.isArray(signRes.data) ? signRes.data : []
- } else if (!isShareTaskDetail(detail)) {
- try {
- const configRes = await getTaskContentConfigByTaskTypeIdApi(String(detail.taskType))
- taskContentConfig.value = configRes.data ?? null
- } catch (error) {
- console.warn('[task-detail] 任务表单配置加载失败,将使用详情原始值', error)
- }
- }
- } catch (error) {
- console.error('[task-detail] 任务详情加载失败', error)
- taskDetail.value = null
- taskContentConfig.value = null
- signRecords.value = []
- errorMessage.value = '请检查网络后重试'
- } finally {
- loading.value = false
- }
- }
- 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 (async () => {
- await loadTaskDetail()
- // #ifdef H5
- if (exportToken.value) await waitAndNotifyExportReady(flowVersion)
- // #endif
- })()
- })
- onUnload(() => {
- exportFlowVersion += 1
- })
- </script>
- <style lang="scss" scoped>
- .task-detail-page {
- min-height: 100vh;
- padding: 24rpx 24rpx 48rpx;
- background: linear-gradient(180deg, #edf6ff 0%, #f5f7fa 360rpx, #f5f7fa 100%);
- box-sizing: border-box;
- }
- .task-detail-state {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- min-height: 620rpx;
- padding: 40rpx;
- color: #8491a2;
- text-align: center;
- }
- .task-detail-state__icon {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 92rpx;
- height: 92rpx;
- color: #3b9bed;
- font-size: 48rpx;
- background: #eaf5ff;
- border-radius: 50%;
- }
- .task-detail-state__icon--error {
- color: #d97706;
- background: #fff5df;
- }
- .task-detail-state__title {
- margin-top: 24rpx;
- color: #334155;
- font-size: 30rpx;
- font-weight: 650;
- }
- .task-detail-state__text {
- margin-top: 16rpx;
- font-size: 24rpx;
- line-height: 36rpx;
- }
- .task-detail-state__retry {
- width: 220rpx;
- height: 68rpx;
- margin: 28rpx 0 0;
- color: #fff;
- font-size: 25rpx;
- line-height: 68rpx;
- background: #3b9bed;
- border: 0;
- border-radius: 34rpx;
- }
- .task-detail-state__retry::after {
- border: 0;
- }
- </style>
|