From e874d1268b4884f5cadef3f1c68579605be2c701 Mon Sep 17 00:00:00 2001
From: 罗广辉 <guanghui.luo@foxmail.com>
Date: Wed, 12 Aug 2026 15:07:47 +0800
Subject: [PATCH] feat: 手机端使用缩略图

---
 uniapps/work-app/src/pages/work/index.vue |  170 ++++++++++++++++++++++++++++++++++++++++++--------------
 1 files changed, 126 insertions(+), 44 deletions(-)

diff --git a/uniapps/work-app/src/pages/work/index.vue b/uniapps/work-app/src/pages/work/index.vue
index 4b42810..ad54193 100644
--- a/uniapps/work-app/src/pages/work/index.vue
+++ b/uniapps/work-app/src/pages/work/index.vue
@@ -17,13 +17,8 @@
 			<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">
@@ -68,7 +63,9 @@
 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: '全部工单',
@@ -129,13 +126,13 @@
 	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
@@ -148,6 +145,7 @@
 	}
 }
 
+// 获取图片信息
 const getImageInfo = src =>
 	new Promise(resolve => {
 		uni.getImageInfo({
@@ -160,6 +158,7 @@
 		})
 	})
 
+// 将画布导出为临时图片
 const canvasToTempFilePath = (width, height) =>
 	new Promise(resolve => {
 		uni.canvasToTempFilePath(
@@ -181,15 +180,9 @@
 		)
 	})
 
-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 }
@@ -202,8 +195,10 @@
 		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
@@ -212,7 +207,7 @@
 
 		ctx.setStrokeStyle('#FF3B30')
 		ctx.setLineWidth(lineWidth)
-		ctx.strokeRect(x, y, boxWidth, boxHeight)
+		ctx.strokeRect(x, y, scaledWidth, scaledHeight)
 
 		if (label) {
 			ctx.setFontSize(fontSize)
@@ -228,16 +223,91 @@
 	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,
@@ -247,28 +317,21 @@
 		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) {
@@ -276,12 +339,14 @@
 			} else {
 				hasMore.value = true
 			}
+			startImageTasks(currentPageList, generation)
 		})
 		.finally(() => {
 			// 仅在最新一次请求结束时关闭loading,避免过早关闭
 			if (seq === requestSeq) loading.value = false
 		})
 }
+// 获取工单状态数量
 const getstatusCountData = () => {
 	const params = {
 		keyword: listParams.value.keyword,
@@ -310,7 +375,10 @@
 	})
 }
 
+// 切换工单状态
 const handleClick = item => {
+	imageGeneration++
+	imageTaskMap.clear()
 	currentTab.value = item.key
 	listParams.value.eventStatus = tabStatusMap[item.key] ?? ''
 
@@ -319,25 +387,34 @@
 	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 = []
@@ -346,6 +423,8 @@
 }
 // 清除搜索
 const handleClear = () => {
+	imageGeneration++
+	imageTaskMap.clear()
 	listParams.value.keyword = ''
 	listParams.value.current = 1
 	hasMore.value = true
@@ -354,7 +433,10 @@
 	getstatusCountData()
 }
 const topMargin = getStatusBarHeight()
+// 页面显示时刷新工单列表
 onShow(() => {
+	imageGeneration++
+	imageTaskMap.clear()
 	listParams.value.current = 1
 	hasMore.value = true
 	dataList.value = []

--
Gitblit v1.9.3