吉安感知网项目-前端
罗广辉
3 hours ago e874d1268b4884f5cadef3f1c68579605be2c701
feat: 手机端使用缩略图
2 files modified
191 ■■■■ changed files
uniapps/work-app/src/pages/work/index.vue 170 ●●●● patch | view | raw | blame | history
uniapps/work-app/src/subPackages/workDetail/index.vue 21 ●●●●● patch | view | raw | blame | history
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 = []
uniapps/work-app/src/subPackages/workDetail/index.vue
@@ -180,7 +180,7 @@
const showCompleteModal = ref(false)
const completeContent = ref('')
const completing = ref(false)
const AI_IMG_CACHE_KEY_PREFIX = 'work-detail-ai-img-'
const PREVIEW_IMG_CACHE_KEY_PREFIX = 'work-detail-ai-img-'
// 状态选项列表
const statusOptions = [
@@ -199,28 +199,30 @@
    return option?.name || ''
})
// 页面加载时读取预览图缓存
onLoad(async options => {
    eventNum.value = options.id
    const aiImgCacheKey = `${AI_IMG_CACHE_KEY_PREFIX}${options.id}`
    const aiImg = uni.getStorageSync(aiImgCacheKey)
    uni.removeStorageSync(aiImgCacheKey)
    await getDataList(options.id, aiImg)
    const previewImgCacheKey = `${PREVIEW_IMG_CACHE_KEY_PREFIX}${options.id}`
    const previewUrl = uni.getStorageSync(previewImgCacheKey)
    uni.removeStorageSync(previewImgCacheKey)
    await getDataList(options.id, previewUrl)
    checkEnvironment()
    viewUrl.value = getWebViewUrl('/workDetail', { workDetailData: JSON.stringify(workDetailData.value) })
})
// 页面显示时刷新工单详情
onShow(() => {
    getDataList(eventNum.value)
})
// 获取工单详情并保留 AI 图片
const getDataList = async (val, aiImg) => {
// 获取工单详情并保留预览原图
const getDataList = async (val, previewUrl) => {
    const params = {
        id: val,
    }
    const res = await getGddetailedData(params)
    const response = res.data.data
    workDetailData.value = { ...response, aiImg: aiImg || workDetailData.value.aiImg }
    workDetailData.value = { ...response, previewUrl: previewUrl || workDetailData.value.previewUrl }
}
// 获取状态文本
@@ -299,11 +301,12 @@
        current: currentIndex,
    })
}
// 获取详情预览图片列表
const getImageList = computed(() => {
    const imageArr = []
    const detail = workDetailData.value
    if (detail.eventImageUrl) {
        imageArr.push(detail.aiImg || detail.eventImageUrl)
        imageArr.push(detail.previewUrl || detail.eventImageUrl)
    }
    return imageArr
})