| | |
| | | <scroll-view class="eventBox" scroll-y :lower-threshold="80" @scrolltolower="loadMore"> |
| | | <div class="eventList"> |
| | | <div class="eventItem" v-for="(item, index) in dataList" :key="index"> |
| | | <image |
| | | v-if="[1, 2].includes(item.attachmentType)" |
| | | :src="item?.aiImg || item?.eventImageUrl" |
| | | mode="aspectFill" |
| | | @click="detailHandle(item)" |
| | | /> |
| | | <div v-if="item.attachmentType === 3" class="videoBox" @click="detailHandle(item)"> |
| | | <image v-if="item.isImage" :src="item.thumbnail" mode="aspectFill" @click="detailHandle(item)" /> |
| | | <div v-else-if="item.isVideo" class="videoBox" @click="detailHandle(item)"> |
| | | <div class="playIcon"></div> |
| | | </div> |
| | | <div class="informationDisplay"> |
| | |
| | | const currentTab = ref('all') |
| | | const loading = ref(false) |
| | | const hasMore = ref(true) |
| | | const AI_IMG_CACHE_KEY_PREFIX = 'work-detail-ai-img-' |
| | | const PREVIEW_IMG_CACHE_KEY_PREFIX = 'work-detail-ai-img-' |
| | | const imageTaskMap = new Map() |
| | | let imageRenderQueue = Promise.resolve() |
| | | const tabList = ref([ |
| | | { |
| | | name: '全部工单', |
| | |
| | | eventStatus: '', |
| | | }) |
| | | let requestSeq = 0 // 请求序列号,用于丢弃快速切换tab时的过期响应 |
| | | const mockGeojson = |
| | | '[{"score":0.89990234375,"bbox":{"x_cen":195.5,"y_cen":326.5,"width":117.0,"height":265.0},"class_name":"car","algorithmId":"e71116098eeb1d60cfebd04d30653b151"},{"score":0.89306640625,"bbox":{"x_cen":1194.5,"y_cen":559.5,"width":115.0,"height":261.0},"class_name":"car","algorithmId":"e71116098eeb1d60cfebd04d30653b151"},{"score":0.88720703125,"bbox":{"x_cen":179.0,"y_cen":955.5,"width":124.0,"height":249.0},"class_name":"car","algorithmId":"e71116098eeb1d60cfebd04d30653b151"},{"score":0.88330078125,"bbox":{"x_cen":1198.5,"y_cen":260.5,"width":115.0,"height":285.0},"class_name":"car","algorithmId":"e71116098eeb1d60cfebd04d30653b151"},{"score":0.84716796875,"bbox":{"x_cen":204.5,"y_cen":71.5,"width":115.0,"height":143.0},"class_name":"car","algorithmId":"e71116098eeb1d60cfebd04d30653b151"},{"score":0.83203125,"bbox":{"x_cen":186.0,"y_cen":657.5,"width":114.0,"height":269.0},"class_name":"car","algorithmId":"e71116098eeb1d60cfebd04d30653b151"},{"score":0.78662109375,"bbox":{"x_cen":1205.5,"y_cen":49.5,"width":117.0,"height":99.0},"class_name":"car","algorithmId":"e71116098eeb1d60cfebd04d30653b151"}]' |
| | | let imageGeneration = 0 // 图片任务批次,用于取消已离开列表的绘制任务 |
| | | const canvasSize = ref({ |
| | | width: 1, |
| | | height: 1, |
| | | }) |
| | | |
| | | // 解析算法框数据 |
| | | const parseAiFrame = aiFrameSource => { |
| | | if (!aiFrameSource) return [] |
| | | if (Array.isArray(aiFrameSource)) return aiFrameSource |
| | |
| | | } |
| | | } |
| | | |
| | | // 获取图片信息 |
| | | const getImageInfo = src => |
| | | new Promise(resolve => { |
| | | uni.getImageInfo({ |
| | |
| | | }) |
| | | }) |
| | | |
| | | // 将画布导出为临时图片 |
| | | const canvasToTempFilePath = (width, height) => |
| | | new Promise(resolve => { |
| | | uni.canvasToTempFilePath( |
| | |
| | | ) |
| | | }) |
| | | |
| | | const drawAiImage = async (url, aiFrameSource) => { |
| | | if (!url) return url |
| | | |
| | | const aiFrame = parseAiFrame(aiFrameSource) |
| | | if (!aiFrame.length) return url |
| | | |
| | | const imageInfo = await getImageInfo(url) |
| | | if (!imageInfo?.path || !imageInfo.width || !imageInfo.height) return url |
| | | |
| | | // 根据图片信息绘制算法框 |
| | | const drawAiImage = async (imageInfo, aiFrame, scaleX = 1, scaleY = 1) => { |
| | | if (!imageInfo?.path || !imageInfo.width || !imageInfo.height || !aiFrame.length) return '' |
| | | const width = Math.round(imageInfo.width) |
| | | const height = Math.round(imageInfo.height) |
| | | canvasSize.value = { width, height } |
| | |
| | | const { x_cen, y_cen, width: boxWidth, height: boxHeight } = item.bbox || {} |
| | | if ([x_cen, y_cen, boxWidth, boxHeight].some(value => typeof value !== 'number')) return |
| | | |
| | | const x = x_cen - boxWidth / 2 |
| | | const y = y_cen - boxHeight / 2 |
| | | const scaledWidth = boxWidth * scaleX |
| | | const scaledHeight = boxHeight * scaleY |
| | | const x = x_cen * scaleX - scaledWidth / 2 |
| | | const y = y_cen * scaleY - scaledHeight / 2 |
| | | const label = item.class_name || '' |
| | | const fontSize = Math.max(18, Math.round(width / 80)) |
| | | const labelHeight = fontSize + 10 |
| | |
| | | |
| | | ctx.setStrokeStyle('#FF3B30') |
| | | ctx.setLineWidth(lineWidth) |
| | | ctx.strokeRect(x, y, boxWidth, boxHeight) |
| | | ctx.strokeRect(x, y, scaledWidth, scaledHeight) |
| | | |
| | | if (label) { |
| | | ctx.setFontSize(fontSize) |
| | |
| | | return new Promise(resolve => { |
| | | ctx.draw(false, async () => { |
| | | const tempFilePath = await canvasToTempFilePath(width, height) |
| | | resolve(tempFilePath || url) |
| | | resolve(tempFilePath) |
| | | }) |
| | | }) |
| | | } |
| | | |
| | | // 生成缩略图地址 |
| | | const getThumbnailUrl = url => { |
| | | if (!url) return '' |
| | | return url.replace(/(\.[^./?]+)(\?.*)?$/, '_thumbnail$1$2') |
| | | } |
| | | |
| | | // 前置整理列表媒体字段 |
| | | const normalizeWorkItem = item => { |
| | | const isImage = [1, 2].includes(item.attachmentType) |
| | | const aiFrame = item.attachmentType === 2 ? parseAiFrame(item.geojson) : [] |
| | | return { |
| | | ...item, |
| | | isImage, |
| | | isVideo: item.attachmentType === 3, |
| | | thumbnail: item.eventImageUrl, |
| | | previewUrl: item.eventImageUrl, |
| | | thumbnailSource: isImage ? getThumbnailUrl(item.eventImageUrl) : '', |
| | | aiFrame, |
| | | imageReady: !aiFrame.length, |
| | | } |
| | | } |
| | | |
| | | // 串行绘制单条 AI 原图和缩略图 |
| | | const enqueueAiImages = (item, originalImageInfo, thumbnailImageInfo, generation) => { |
| | | const task = imageRenderQueue.then(async () => { |
| | | if (generation !== imageGeneration) return |
| | | const previewUrl = (await drawAiImage(originalImageInfo, item.aiFrame)) || item.eventImageUrl |
| | | if (generation !== imageGeneration) return |
| | | item.previewUrl = previewUrl |
| | | if (!thumbnailImageInfo) { |
| | | item.thumbnail = previewUrl |
| | | return |
| | | } |
| | | const scaleX = thumbnailImageInfo.width / originalImageInfo.width |
| | | const scaleY = thumbnailImageInfo.height / originalImageInfo.height |
| | | item.thumbnail = (await drawAiImage(thumbnailImageInfo, item.aiFrame, scaleX, scaleY)) || previewUrl |
| | | }) |
| | | imageRenderQueue = task.catch(() => {}) |
| | | return task |
| | | } |
| | | |
| | | // 异步准备单条记录的缩略图和 AI 图片 |
| | | const prepareItemImages = async (item, generation) => { |
| | | if (!item.isImage || !item.eventImageUrl) return |
| | | const thumbnailImagePromise = getImageInfo(item.thumbnailSource) |
| | | if (!item.aiFrame.length) { |
| | | const thumbnailImageInfo = await thumbnailImagePromise |
| | | if (generation === imageGeneration && thumbnailImageInfo) item.thumbnail = item.thumbnailSource |
| | | return |
| | | } |
| | | |
| | | const [originalImageInfo, thumbnailImageInfo] = await Promise.all([ |
| | | getImageInfo(item.eventImageUrl), |
| | | thumbnailImagePromise, |
| | | ]) |
| | | if (generation !== imageGeneration) return |
| | | if (thumbnailImageInfo) item.thumbnail = item.thumbnailSource |
| | | if (!originalImageInfo) return |
| | | return enqueueAiImages(item, originalImageInfo, thumbnailImageInfo, generation) |
| | | } |
| | | |
| | | // 启动当前页图片后台处理任务 |
| | | const startImageTasks = (list, generation) => { |
| | | list.forEach(item => { |
| | | const task = prepareItemImages(item, generation) |
| | | .catch(() => {}) |
| | | .finally(() => { |
| | | item.imageReady = true |
| | | }) |
| | | if (item.id) imageTaskMap.set(item.id, task) |
| | | }) |
| | | } |
| | | |
| | | // 获取工单列表 |
| | | const getDataList = () => { |
| | | if (!hasMore.value) return |
| | | |
| | | // 每次请求自增序列号,响应回来时校验是否过期 |
| | | const seq = ++requestSeq |
| | | const generation = imageGeneration |
| | | loading.value = true |
| | | const params = { |
| | | current: listParams.value.current, |
| | |
| | | eventStatus: listParams.value.eventStatus, |
| | | } |
| | | getGdList(params) |
| | | .then(async res => { |
| | | .then(res => { |
| | | // 已有新请求发出,丢弃过期响应 |
| | | if (seq !== requestSeq) return |
| | | const resData = res.data.data |
| | | const response = resData.records |
| | | const list = [] |
| | | for (const item of response) { |
| | | // const aiImg = await drawAiImage(item.eventImageUrl, mockGeojson) |
| | | if (item.attachmentType !== 2) { |
| | | list.push(item) |
| | | } else { |
| | | const aiImg = await drawAiImage(item.eventImageUrl, item.geojson) |
| | | list.push({ ...item, aiImg }) |
| | | } |
| | | } |
| | | // 画图为异步耗时操作,完成后再次校验是否过期 |
| | | if (seq !== requestSeq) return |
| | | const list = response.map(normalizeWorkItem) |
| | | let currentPageList = [] |
| | | // 根据当前页码决定是替换还是追加数据 |
| | | if (listParams.value.current === 1) { |
| | | dataList.value = list |
| | | currentPageList = dataList.value |
| | | } else { |
| | | const startIndex = dataList.value.length |
| | | dataList.value = [...dataList.value, ...list] |
| | | currentPageList = dataList.value.slice(startIndex) |
| | | } |
| | | // 判断是否还有更多数据 |
| | | if (list.length < listParams.value.size || resData.current >= resData.pages) { |
| | |
| | | } else { |
| | | hasMore.value = true |
| | | } |
| | | startImageTasks(currentPageList, generation) |
| | | }) |
| | | .finally(() => { |
| | | // 仅在最新一次请求结束时关闭loading,避免过早关闭 |
| | | if (seq === requestSeq) loading.value = false |
| | | }) |
| | | } |
| | | // 获取工单状态数量 |
| | | const getstatusCountData = () => { |
| | | const params = { |
| | | keyword: listParams.value.keyword, |
| | |
| | | }) |
| | | } |
| | | |
| | | // 切换工单状态 |
| | | const handleClick = item => { |
| | | imageGeneration++ |
| | | imageTaskMap.clear() |
| | | currentTab.value = item.key |
| | | listParams.value.eventStatus = tabStatusMap[item.key] ?? '' |
| | | |
| | |
| | | dataList.value = [] |
| | | getDataList() |
| | | } |
| | | // 加载更多工单 |
| | | const loadMore = () => { |
| | | if (loading.value || !hasMore.value) return |
| | | listParams.value.current++ |
| | | getDataList() |
| | | } |
| | | |
| | | // 缓存 AI 图片后跳转详情页 |
| | | const detailHandle = val => { |
| | | if (val.aiImg) { |
| | | uni.setStorageSync(`${AI_IMG_CACHE_KEY_PREFIX}${val.id}`, val.aiImg) |
| | | // 缓存预览原图后跳转详情页 |
| | | const detailHandle = async val => { |
| | | const imageTask = imageTaskMap.get(val.id) |
| | | if (val.aiFrame.length && !val.imageReady && imageTask) { |
| | | uni.showLoading({ title: '图片处理中', mask: true }) |
| | | try { |
| | | await imageTask |
| | | } finally { |
| | | uni.hideLoading() |
| | | } |
| | | } |
| | | |
| | | uni.navigateTo({ |
| | | url: `/subPackages/workDetail/index?id=${val.id}`, |
| | | }) |
| | | if (val.aiFrame.length) { |
| | | uni.setStorageSync(`${PREVIEW_IMG_CACHE_KEY_PREFIX}${val.id}`, val.previewUrl) |
| | | } |
| | | uni.navigateTo({ url: `/subPackages/workDetail/index?id=${val.id}` }) |
| | | } |
| | | |
| | | // 搜索功能 |
| | | const handleSearch = () => { |
| | | imageGeneration++ |
| | | imageTaskMap.clear() |
| | | listParams.value.current = 1 |
| | | hasMore.value = true |
| | | dataList.value = [] |
| | |
| | | } |
| | | // 清除搜索 |
| | | const handleClear = () => { |
| | | imageGeneration++ |
| | | imageTaskMap.clear() |
| | | listParams.value.keyword = '' |
| | | listParams.value.current = 1 |
| | | hasMore.value = true |
| | |
| | | getstatusCountData() |
| | | } |
| | | const topMargin = getStatusBarHeight() |
| | | // 页面显示时刷新工单列表 |
| | | onShow(() => { |
| | | imageGeneration++ |
| | | imageTaskMap.clear() |
| | | listParams.value.current = 1 |
| | | hasMore.value = true |
| | | dataList.value = [] |