perf(mp-weixin): 照片分两层,详情页移入 pages-poi 分包

微信主包上限 2 MB,30 张 800×600 照片单独就 2.6 MB,
整包 3.2 MB,上传被拒(错误码 80051)。

按使用场景把照片拆成两层:
- 主包缩略图 400×300 q75(678 KB),供地图、行程、打卡卡片使用,
  这些场景最大只显示 192rpx(≈288 px @DPR3),800×600 是过剩
- 分包大图 800×600 q66(1832 KB),供详情页 hero 使用,
  随 pages-poi 分包按需下载

两层都保持 4:3,PoiImage 的 aspectFill 行为不变,视觉表现与之前一致。

详情页从 pages/poi/detail 移到 pages-poi/detail:主包页面无法引用
分包资源,hero 要用大图,页面就必须在分包内。新增
coverPhotoFullUrlFor() 供分包内页面取大图,占位图仍指向主包。

verify-weixin-output.mjs 增加防回归检查:分包注册、两层照片数量
一致、主包与各分包实际体积(留 64 KB 余量)。包体再超限会在
verify 阶段失败,不必等上传才发现。

process-poi-photos.mjs 重写为双层产出,幂等,末尾报告包体占用并
在超限时退出非零。原图改从 src/photo-intake/ 读取(不提交仓库)。

改造后:主包 1522 KB、分包 pages-poi 1847 KB,均在 2048 KB 内。
mp-weixin 与 H5 均编译通过,97 个测试通过,vue-tsc 与 ESLint 无错。
This commit is contained in:
周瑞哲
2026-08-03 09:21:23 +08:00
parent 9e8c7bf908
commit f8ad5e1faf
72 changed files with 327 additions and 108 deletions
+414
View File
@@ -0,0 +1,414 @@
<script setup lang="ts">
import type { PoiImageAsset, PoiResolved } from '@/domain/poi'
import PageState from '@/components/poi/PageState.vue'
import PoiHeroGallery from '@/components/poi/PoiHeroGallery.vue'
import PoiTagList from '@/components/poi/PoiTagList.vue'
import { getPoiRepository } from '@/data/poi'
import { coverPhotoFullUrlFor } from '@/data/poi/photos'
import { formatRecommendationLabel } from '@/domain/poi'
import { getCheckInProfile } from '@/services/check-in'
const state = ref<'loading' | 'ready' | 'invalid' | 'error'>('loading')
const poi = ref<PoiResolved | null>(null)
const hasCheckedIn = ref(false)
const checkInStorageError = ref(false)
/**
* hero 用分包大图(800×600)替换主包缩略图(400×300)。
*
* 本页在 pages-poi 分包内,能引用分包 static 资源;主包页面不行。
* 占位图仍指向主包,不做替换。
*/
const heroImages = computed<PoiImageAsset[]>(() => {
const current = poi.value
if (!current)
return []
return current.images.map(image => (
image.id === current.coverImageId && !image.isPlaceholder
? { ...image, url: coverPhotoFullUrlFor(current.id) }
: image
))
})
function loadPoi(poiId: string) {
state.value = 'loading'
poi.value = null
try {
if (!poiId.trim()) {
state.value = 'invalid'
return
}
const result = getPoiRepository().getPoiById(poiId)
if (!result) {
state.value = 'invalid'
return
}
poi.value = result
try {
hasCheckedIn.value = getCheckInProfile().checkIns.some(record => record.poiId === result.id)
checkInStorageError.value = false
}
catch (error) {
console.error('Failed to load POI check-in state', error)
hasCheckedIn.value = false
checkInStorageError.value = true
}
state.value = 'ready'
}
catch (error) {
console.error('Failed to load POI detail', error)
state.value = 'error'
}
}
function openCheckIn() {
if (!poi.value)
return
uni.navigateTo({ url: `/pages/check-in/index?poiId=${encodeURIComponent(poi.value.id)}` })
}
function openCheckInRecords() {
uni.navigateTo({ url: '/pages/check-in/records' })
}
function returnToMap() {
uni.navigateBack({
fail: () => {
uni.reLaunch({ url: '/pages/map/index' })
},
})
}
function formatCoordinate(value: number): string {
return value.toFixed(6)
}
function formatUpdatedAt(value: string): string {
const date = new Date(value)
if (Number.isNaN(date.getTime()))
return '待确认'
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
onLoad((query) => {
const rawPoiId = typeof query?.poiId === 'string' ? query.poiId : ''
try {
loadPoi(decodeURIComponent(rawPoiId))
}
catch {
state.value = 'invalid'
}
})
onShow(() => {
if (!poi.value)
return
try {
hasCheckedIn.value = getCheckInProfile().checkIns.some(record => record.poiId === poi.value?.id)
checkInStorageError.value = false
}
catch (error) {
console.error('Failed to refresh POI check-in state', error)
hasCheckedIn.value = false
checkInStorageError.value = true
}
})
</script>
<template>
<view class="poi-detail-page">
<PageState v-if="state === 'loading'" state="loading" title="正在加载点位信息…" />
<PageState
v-else-if="state === 'invalid'"
state="empty"
title="点位不存在或已下线"
description="该点位可能已更新,请返回地图重新选择"
action-label="返回地图"
@action="returnToMap"
/>
<PageState
v-else-if="state === 'error'"
state="error"
title="点位信息加载失败"
description="暂时无法读取该点位,请稍后返回重试"
action-label="返回地图"
@action="returnToMap"
/>
<template v-else-if="poi">
<PoiHeroGallery :images="heroImages" :name="poi.name" />
<view class="poi-detail-page__content">
<view class="poi-detail-page__header">
<text class="poi-detail-page__category">{{ poi.category.name }}</text>
<text class="poi-detail-page__name">{{ poi.name }}</text>
<view class="poi-detail-page__recommendation">
{{ formatRecommendationLabel(poi.recommendationSource, poi.recommendationIndex) }}
</view>
<PoiTagList class="poi-detail-page__tags" :tags="poi.tags" />
</view>
<view class="check-in-entry" :class="{ 'check-in-entry--done': hasCheckedIn }">
<view class="check-in-entry__mark">{{ hasCheckedIn ? '✓' : '◎' }}</view>
<view class="check-in-entry__copy">
<text class="check-in-entry__title">{{ checkInStorageError ? '打卡数据需要处理' : hasCheckedIn ? '已留下光明足迹' : '到点打卡' }}</text>
<text>{{ checkInStorageError ? '本机打卡数据异常,请先处理' : hasCheckedIn ? '该点位已在本机完成打卡' : '到达点位附近后,使用本次真实定位打卡' }}</text>
</view>
<button @click="checkInStorageError ? openCheckInRecords() : openCheckIn()">{{ checkInStorageError ? '去处理' : hasCheckedIn ? '查看' : '去打卡' }}</button>
</view>
<view class="info-card">
<view class="info-row">
<text class="info-row__label">开放时间</text>
<text class="info-row__value">{{ poi.openingHoursText }}</text>
</view>
<view class="info-row">
<text class="info-row__label">地址</text>
<text class="info-row__value">{{ poi.address }}</text>
</view>
<view class="info-row info-row--coordinates">
<text class="info-row__label">经纬度</text>
<view class="info-row__value info-row__coordinates">
<text>经度 {{ formatCoordinate(poi.longitude) }}</text>
<text>纬度 {{ formatCoordinate(poi.latitude) }}</text>
<text class="info-row__coordinate-system">坐标系 {{ poi.coordinateSystem }}</text>
</view>
</view>
<view class="info-row">
<text class="info-row__label">数据更新</text>
<text class="info-row__value">{{ formatUpdatedAt(poi.updatedAt) }}</text>
</view>
</view>
<view class="description-section">
<text class="description-section__title">点位简介</text>
<text class="description-section__body">{{ poi.description }}</text>
</view>
<view class="poc-notice">
<text class="poc-notice__title">POC 数据说明</text>
<text class="poc-notice__body">当前内容用于产品和技术可行性验证正式开放信息请以后续审核版本为准</text>
</view>
</view>
</template>
</view>
</template>
<route lang="yaml">
layout: map
style:
navigationBarTitleText: 点位详情
navigationBarBackgroundColor: '#ffffff'
navigationBarTextStyle: black
backgroundColor: '#f5f7f6'
</route>
<style scoped lang="scss">
.poi-detail-page {
min-height: 100vh;
background: #f5f7f6;
}
.poi-detail-page__content {
padding: 32rpx 32rpx calc(32rpx + env(safe-area-inset-bottom));
}
.poi-detail-page__header {
display: flex;
flex-direction: column;
}
.poi-detail-page__category {
align-self: flex-start;
padding: 8rpx 18rpx;
font-size: 24rpx;
font-weight: 600;
line-height: 34rpx;
color: #0e6247;
background: #e8f4ee;
border-radius: 24rpx;
}
.poi-detail-page__name {
margin-top: 16rpx;
font-size: 40rpx;
font-weight: 700;
line-height: 56rpx;
color: #18201d;
}
.poi-detail-page__recommendation {
align-self: flex-start;
padding: 8rpx 16rpx;
margin-top: 16rpx;
font-size: 26rpx;
font-weight: 600;
line-height: 38rpx;
color: #934b00;
background: #fff3e0;
border-radius: 10rpx;
}
.poi-detail-page__tags {
margin-top: 18rpx;
}
.info-card,
.check-in-entry,
.description-section,
.poc-notice {
padding: 28rpx;
margin-top: 24rpx;
background: #fff;
border: 1rpx solid #dde5e1;
border-radius: 20rpx;
}
.check-in-entry {
display: flex;
gap: 18rpx;
align-items: center;
padding: 24rpx;
}
.check-in-entry--done {
background: #e8f4ee;
border-color: #cfe4d9;
}
.check-in-entry__mark {
display: flex;
flex: none;
align-items: center;
justify-content: center;
width: 62rpx;
height: 62rpx;
font-size: 28rpx;
font-weight: 800;
color: #fff;
background: #167a5b;
border-radius: 50%;
}
.check-in-entry__copy {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
font-size: 21rpx;
line-height: 32rpx;
color: #5e6b65;
}
.check-in-entry__title {
margin-bottom: 3rpx;
font-size: 27rpx;
font-weight: 750;
color: #18201d;
}
.check-in-entry button {
flex: none;
width: auto;
height: 60rpx;
padding: 0 22rpx;
margin: 0;
font-size: 23rpx;
font-weight: 700;
line-height: 60rpx;
color: #fff;
background: #167a5b;
border: 0;
border-radius: 30rpx;
}
.check-in-entry button::after {
border: 0;
}
.info-row {
display: flex;
gap: 24rpx;
align-items: flex-start;
padding: 20rpx 0;
border-bottom: 1rpx solid #edf1ef;
}
.info-row:first-child {
padding-top: 0;
}
.info-row:last-child {
padding-bottom: 0;
border-bottom: 0;
}
.info-row__label {
flex: none;
width: 128rpx;
font-size: 26rpx;
line-height: 40rpx;
color: #5e6b65;
}
.info-row__value {
flex: 1;
font-size: 28rpx;
line-height: 40rpx;
color: #18201d;
}
.info-row__coordinates {
display: flex;
flex-direction: column;
min-width: 0;
}
.info-row__coordinate-system {
margin-top: 6rpx;
font-size: 24rpx;
color: #8a9690;
}
.description-section__title,
.poc-notice__title {
display: block;
font-size: 30rpx;
font-weight: 700;
line-height: 42rpx;
color: #18201d;
}
.description-section__body,
.poc-notice__body {
display: block;
margin-top: 16rpx;
font-size: 28rpx;
line-height: 44rpx;
color: #425049;
white-space: pre-wrap;
}
.poc-notice {
background: #e8f4ee;
border-color: #cfe4d9;
}
.poc-notice__title {
font-size: 26rpx;
color: #0e6247;
}
.poc-notice__body {
font-size: 24rpx;
line-height: 38rpx;
color: #3d5a4d;
}
</style>
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB