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 无错。
@@ -43,3 +43,7 @@ server/**/*.pyc
|
||||
# cache
|
||||
.eslintcache
|
||||
.stylelintcache
|
||||
|
||||
# POI 照片原图投放目录:只作为 scripts/process-poi-photos.mjs 的输入,
|
||||
# 产出的两层图片(分包大图 + 主包缩略图)才提交仓库。
|
||||
src/photo-intake/
|
||||
|
||||
@@ -1,107 +1,191 @@
|
||||
import sharp from 'sharp'
|
||||
import { readdir, mkdir, unlink } from 'node:fs/promises'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { existsSync } from 'node:fs'
|
||||
/**
|
||||
* POI 照片处理:产出主包缩略图 + 分包大图两层。
|
||||
*
|
||||
* 为什么分两层:微信小程序主包上限 2 MB。30 张 800×600 照片约 2.6 MB,
|
||||
* 单独就超限。所以卡片场景(最大 192rpx ≈ 288 px)用小图放主包,
|
||||
* 详情页 hero(750×422rpx ≈ 1125×633 px)用大图放分包,随详情页按需下载。
|
||||
*
|
||||
* 两个产出目录都是提交进仓库的构建产物:
|
||||
* src/pages-poi/static/photos/<poiId>.jpg 800×600 分包大图(详情页 hero)
|
||||
* src/static/poi/photos/<poiId>.jpg 400×300 主包缩略图(各处卡片)
|
||||
*
|
||||
* 新增一张照片:
|
||||
* 1. 原图放入 src/photo-intake/(该目录不提交),命名为 <poiId>.jpg
|
||||
* 或用下方 NAME_TO_POI_ID 表里的中文名,例如「虹桥公园.jpg」
|
||||
* 2. 跑 `node scripts/process-poi-photos.mjs`
|
||||
* 3. 把 poiId 加入 src/data/poi/photos.ts 的 POI_COVER_PHOTOS_READY
|
||||
*
|
||||
* 不带 intake 直接跑:从分包大图重新生成全部主包缩略图(幂等)。
|
||||
*/
|
||||
import { mkdir, readdir, stat } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import sharp from 'sharp'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const projectRoot = join(__dirname, '..')
|
||||
const sourceDir = join(projectRoot, 'src/static/poi/photos')
|
||||
const outputDir = sourceDir // 处理后直接覆盖到同目录(原文件先删除)
|
||||
|
||||
// 中文名 → POI ID 映射(全部30个景点)
|
||||
/** 原图投放目录,不提交仓库 */
|
||||
const intakeDir = join(projectRoot, 'src/photo-intake')
|
||||
/** 分包大图:详情页 hero 使用,随分包按需下载 */
|
||||
const fullDir = join(projectRoot, 'src/pages-poi/static/photos')
|
||||
/** 主包缩略图:地图、行程、打卡等卡片使用 */
|
||||
const thumbDir = join(projectRoot, 'src/static/poi/photos')
|
||||
|
||||
/** 分包大图规格。q66 是在 2 MB 分包上限内能给到 hero 的最高质量 */
|
||||
const FULL = { width: 800, height: 600, quality: 66 }
|
||||
/** 主包缩略图规格。400×300 覆盖最大卡片 192rpx(≈288 px @DPR3) */
|
||||
const THUMB = { width: 400, height: 300, quality: 75 }
|
||||
|
||||
/** 微信包体上限 */
|
||||
const MAIN_PACKAGE_LIMIT_KB = 2048
|
||||
const SUB_PACKAGE_LIMIT_KB = 2048
|
||||
|
||||
// 中文名 → POI ID 映射(30 个景点)
|
||||
const NAME_TO_POI_ID = {
|
||||
// 文化场馆(已处理,保留映射以便确认)
|
||||
'文化艺术中心': 'poi_gm_culture_center',
|
||||
'光明区图书馆': 'poi_amap_b0hbtu4bfr',
|
||||
'光明区文化馆': 'poi_amap_b0ffk306gx',
|
||||
'少年儿童图书馆': 'poi_amap_b02f38meio',
|
||||
'深圳国际美术馆': 'poi_amap_b0jun773j3',
|
||||
'城市规划馆': 'poi_amap_b0jbbzx4oo',
|
||||
'茅洲河图书馆': 'poi_amap_b0k025w32e',
|
||||
// 文化场馆
|
||||
文化艺术中心: 'poi_gm_culture_center',
|
||||
光明区图书馆: 'poi_amap_b0hbtu4bfr',
|
||||
光明区文化馆: 'poi_amap_b0ffk306gx',
|
||||
少年儿童图书馆: 'poi_amap_b02f38meio',
|
||||
深圳国际美术馆: 'poi_amap_b0jun773j3',
|
||||
城市规划馆: 'poi_amap_b0jbbzx4oo',
|
||||
茅洲河图书馆: 'poi_amap_b0k025w32e',
|
||||
// 公园湿地
|
||||
'虹桥公园': 'poi_hongqiao_park',
|
||||
'红花山公园': 'poi_honghuashan_park',
|
||||
'科学公园': 'poi_amap_b0j2jc4n0w',
|
||||
'鹅颈水湿地公园': 'poi_amap_b0ffmdhve1',
|
||||
'明湖公园': 'poi_amap_b0fffwu1l5',
|
||||
'光明新城公园': 'poi_amap_b0ffhfbsje',
|
||||
'开明公园': 'poi_amap_b0ffjf1b20',
|
||||
'左岸科技公园': 'poi_amap_b0g0kzrt5s',
|
||||
'西田公园': 'poi_amap_b02f30abgs',
|
||||
'大雁山森林公园': 'poi_amap_b0jgbp18uf',
|
||||
'楼村湿地公园': 'poi_amap_b0grac6nzz',
|
||||
'大顶岭山林公园': 'poi_amap_b02f309r88',
|
||||
虹桥公园: 'poi_hongqiao_park',
|
||||
红花山公园: 'poi_honghuashan_park',
|
||||
科学公园: 'poi_amap_b0j2jc4n0w',
|
||||
鹅颈水湿地公园: 'poi_amap_b0ffmdhve1',
|
||||
明湖公园: 'poi_amap_b0fffwu1l5',
|
||||
光明新城公园: 'poi_amap_b0ffhfbsje',
|
||||
开明公园: 'poi_amap_b0ffjf1b20',
|
||||
左岸科技公园: 'poi_amap_b0g0kzrt5s',
|
||||
西田公园: 'poi_amap_b02f30abgs',
|
||||
大雁山森林公园: 'poi_amap_b0jgbp18uf',
|
||||
楼村湿地公园: 'poi_amap_b0grac6nzz',
|
||||
大顶岭山林公园: 'poi_amap_b02f309r88',
|
||||
// 田园休闲
|
||||
'光明农场大观园': 'poi_farm_grand_view',
|
||||
'华侨城光明欢乐田园': 'poi_happy_pastoral',
|
||||
'双晖稻田农场': 'poi_amap_b0ffkkmzsc',
|
||||
光明农场大观园: 'poi_farm_grand_view',
|
||||
华侨城光明欢乐田园: 'poi_happy_pastoral',
|
||||
双晖稻田农场: 'poi_amap_b0ffkkmzsc',
|
||||
// 人文地标
|
||||
'弘源寺': 'poi_amap_b02f38nj9k',
|
||||
'陈仙姑祠': 'poi_amap_b0ffgthbcf',
|
||||
'黄氏大宗祠': 'poi_amap_b0fffttiov',
|
||||
'玉律醒狮文化馆': 'poi_amap_b0kb4cxaby',
|
||||
弘源寺: 'poi_amap_b02f38nj9k',
|
||||
陈仙姑祠: 'poi_amap_b0ffgthbcf',
|
||||
黄氏大宗祠: 'poi_amap_b0fffttiov',
|
||||
玉律醒狮文化馆: 'poi_amap_b0kb4cxaby',
|
||||
// 绿道户外
|
||||
'大顶岭绿道': 'poi_dadingling_greenway',
|
||||
'光明湖碧道': 'poi_amap_b0ldbsbeea',
|
||||
'茅洲河碧道': 'poi_amap_b0kup558j4',
|
||||
'五指耙森林公园': 'poi_amap_b0jb2aurvw',
|
||||
大顶岭绿道: 'poi_dadingling_greenway',
|
||||
光明湖碧道: 'poi_amap_b0ldbsbeea',
|
||||
茅洲河碧道: 'poi_amap_b0kup558j4',
|
||||
五指耙森林公园: 'poi_amap_b0jb2aurvw',
|
||||
}
|
||||
|
||||
const TARGET_WIDTH = 800
|
||||
const TARGET_HEIGHT = 600
|
||||
const JPEG_QUALITY = 80
|
||||
const IMAGE_RE = /\.(jpe?g|png|webp)$/i
|
||||
|
||||
async function process() {
|
||||
const files = await readdir(sourceDir)
|
||||
const jpgFiles = files.filter(f => f.endsWith('.jpg') || f.endsWith('.jpeg') || f.endsWith('.png'))
|
||||
/** 把源文件名解析成 poiId:优先当作已经是 poiId,否则查中文名映射 */
|
||||
function resolvePoiId(fileName) {
|
||||
const base = fileName.replace(IMAGE_RE, '')
|
||||
if (base.startsWith('poi_'))
|
||||
return base
|
||||
return NAME_TO_POI_ID[base] ?? null
|
||||
}
|
||||
|
||||
console.log(`Found ${jpgFiles.length} source images\n`)
|
||||
async function render(inputPath, outputPath, spec) {
|
||||
await sharp(inputPath)
|
||||
.rotate() // 按 EXIF 摆正
|
||||
.resize(spec.width, spec.height, {
|
||||
fit: 'cover',
|
||||
position: 'attention', // 智能裁剪,尽量保留主体
|
||||
})
|
||||
.jpeg({
|
||||
quality: spec.quality,
|
||||
mozjpeg: true,
|
||||
chromaSubsampling: '4:2:0',
|
||||
})
|
||||
.toFile(outputPath)
|
||||
}
|
||||
|
||||
for (const file of jpgFiles) {
|
||||
const nameWithoutExt = file.replace(/\.(jpg|jpeg|png)$/i, '')
|
||||
const poiId = NAME_TO_POI_ID[nameWithoutExt]
|
||||
async function dirTotalKb(dir) {
|
||||
if (!existsSync(dir))
|
||||
return { count: 0, kb: 0 }
|
||||
const files = (await readdir(dir)).filter(f => f.endsWith('.jpg'))
|
||||
let bytes = 0
|
||||
for (const f of files)
|
||||
bytes += (await stat(join(dir, f))).size
|
||||
return { count: files.length, kb: Math.round(bytes / 1024) }
|
||||
}
|
||||
|
||||
async function listIntake() {
|
||||
if (!existsSync(intakeDir))
|
||||
return []
|
||||
return (await readdir(intakeDir)).filter(f => IMAGE_RE.test(f))
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await mkdir(fullDir, { recursive: true })
|
||||
await mkdir(thumbDir, { recursive: true })
|
||||
|
||||
const intake = await listIntake()
|
||||
const processed = new Set()
|
||||
|
||||
// 1) 有 intake 原图的:同时生成大图和缩略图
|
||||
for (const file of intake) {
|
||||
const poiId = resolvePoiId(file)
|
||||
if (!poiId) {
|
||||
console.log(`SKIP: "${file}" — no matching POI ID found`)
|
||||
console.log(`SKIP ${file} — 文件名既不是 poiId 也不在中文名映射表里`)
|
||||
continue
|
||||
}
|
||||
|
||||
const inputPath = join(sourceDir, file)
|
||||
const outputName = `${poiId}.jpg`
|
||||
const outputPath = join(outputDir, outputName)
|
||||
|
||||
console.log(`Processing: ${file} → ${outputName}`)
|
||||
|
||||
const inputPath = join(intakeDir, file)
|
||||
try {
|
||||
await sharp(inputPath)
|
||||
.rotate() // 根据 EXIF 自动旋转
|
||||
.resize(TARGET_WIDTH, TARGET_HEIGHT, {
|
||||
fit: 'cover',
|
||||
position: 'attention', // 智能裁剪,尽量保留重要区域
|
||||
})
|
||||
.jpeg({
|
||||
quality: JPEG_QUALITY,
|
||||
mozjpeg: true,
|
||||
chromaSubsampling: '4:2:0',
|
||||
})
|
||||
.toFile(outputPath)
|
||||
|
||||
// 删除原始中文名文件
|
||||
if (file !== outputName) {
|
||||
await unlink(inputPath)
|
||||
}
|
||||
|
||||
console.log(` ✓ Done`)
|
||||
} catch (err) {
|
||||
console.error(` ✗ Failed: ${err.message}`)
|
||||
await render(inputPath, join(fullDir, `${poiId}.jpg`), FULL)
|
||||
await render(inputPath, join(thumbDir, `${poiId}.jpg`), THUMB)
|
||||
processed.add(poiId)
|
||||
console.log(`OK ${file} → ${poiId}.jpg(大图 + 缩略图)`)
|
||||
}
|
||||
catch (error) {
|
||||
console.error(`FAIL ${file}: ${error.message}`)
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\nDone! Listing output directory:')
|
||||
const outputFiles = await readdir(outputDir)
|
||||
for (const f of outputFiles.filter(f => f.endsWith('.jpg')).sort()) {
|
||||
console.log(` ${f}`)
|
||||
// 2) 其余已有大图的:从大图重新生成缩略图,保证两层始终同步
|
||||
const fullFiles = (await readdir(fullDir)).filter(f => f.endsWith('.jpg'))
|
||||
for (const file of fullFiles) {
|
||||
const poiId = file.replace(/\.jpg$/, '')
|
||||
if (processed.has(poiId))
|
||||
continue
|
||||
try {
|
||||
await render(join(fullDir, file), join(thumbDir, file), THUMB)
|
||||
console.log(`THUMB ${poiId}.jpg ← 由分包大图重新生成`)
|
||||
}
|
||||
catch (error) {
|
||||
console.error(`FAIL ${file}: ${error.message}`)
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 报告包体占用,超限直接失败
|
||||
const full = await dirTotalKb(fullDir)
|
||||
const thumb = await dirTotalKb(thumbDir)
|
||||
|
||||
console.log('\n包体占用:')
|
||||
console.log(` 分包大图 ${String(full.count).padStart(2)} 张 ${String(full.kb).padStart(4)} KB (分包上限 ${SUB_PACKAGE_LIMIT_KB} KB)`)
|
||||
console.log(` 主包缩略图 ${String(thumb.count).padStart(2)} 张 ${String(thumb.kb).padStart(4)} KB (主包上限 ${MAIN_PACKAGE_LIMIT_KB} KB,还需容纳代码)`)
|
||||
|
||||
if (full.kb >= SUB_PACKAGE_LIMIT_KB) {
|
||||
console.error(`\n分包大图已达 ${full.kb} KB,超过分包上限。请下调 FULL.quality 后重跑。`)
|
||||
process.exitCode = 1
|
||||
}
|
||||
if (thumb.kb >= MAIN_PACKAGE_LIMIT_KB) {
|
||||
console.error(`\n主包缩略图已达 ${thumb.kb} KB,超过主包上限。请下调 THUMB 规格后重跑。`)
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
process().catch(console.error)
|
||||
main().catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import process from 'node:process'
|
||||
|
||||
@@ -10,10 +10,14 @@ const expectedPages = [
|
||||
'pages/assistant/index',
|
||||
'pages/itinerary/index',
|
||||
'pages/planner/index',
|
||||
'pages/poi/detail',
|
||||
'pages/check-in/index',
|
||||
'pages/check-in/records',
|
||||
]
|
||||
// 点位详情页放在 pages-poi 分包里,连带 800×600 大图一起按需下载,
|
||||
// 否则 30 张大图会把主包顶过 2 MB 上限。
|
||||
const expectedSubPackageRoot = 'pages-poi'
|
||||
const expectedSubPackagePages = ['detail']
|
||||
const poiDetailPath = `${expectedSubPackageRoot}/detail`
|
||||
const expectedTabPages = [
|
||||
'pages/map/index',
|
||||
'pages/assistant/index',
|
||||
@@ -33,6 +37,9 @@ const requiredFiles = [
|
||||
'app.wxss',
|
||||
'project.config.json',
|
||||
...expectedPages.flatMap(page => ['json', 'js', 'wxml', 'wxss'].map(extension => `${page}.${extension}`)),
|
||||
...expectedSubPackagePages.flatMap(page => ['json', 'js', 'wxml', 'wxss'].map(
|
||||
extension => `${expectedSubPackageRoot}/${page}.${extension}`,
|
||||
)),
|
||||
...expectedTabIcons,
|
||||
]
|
||||
|
||||
@@ -55,7 +62,7 @@ if (!checkInPageScript.includes('requirePrivacyAuthorize')
|
||||
console.error('打卡页缺少微信位置隐私授权兼容链。')
|
||||
process.exit(1)
|
||||
}
|
||||
const poiDetailMarkup = readFileSync(resolve(outputRoot, 'pages/poi/detail.wxml'), 'utf8')
|
||||
const poiDetailMarkup = readFileSync(resolve(outputRoot, `${poiDetailPath}.wxml`), 'utf8')
|
||||
const checkInEntryIndex = poiDetailMarkup.indexOf('check-in-entry')
|
||||
const checkInEntryStart = poiDetailMarkup.lastIndexOf('<view', checkInEntryIndex)
|
||||
const checkInEntryEnd = poiDetailMarkup.indexOf('>', checkInEntryIndex)
|
||||
@@ -167,4 +174,81 @@ if (!ideConfig.appid || !outputIdeConfig.appid || ideConfig.appid !== outputIdeC
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const subPackages = appConfig.subPackages ?? appConfig.subpackages ?? []
|
||||
const poiSubPackage = subPackages.find(item => item.root === expectedSubPackageRoot)
|
||||
if (!poiSubPackage) {
|
||||
console.error(`app.json 未注册 ${expectedSubPackageRoot} 分包,点位详情页与大图会落进主包。`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const missingSubPages = expectedSubPackagePages.filter(page => !poiSubPackage.pages?.includes(page))
|
||||
if (missingSubPages.length > 0) {
|
||||
console.error(`${expectedSubPackageRoot} 分包未注册全部页面:`)
|
||||
missingSubPages.forEach(page => console.error(`- ${page}`))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// 分包大图必须落在分包内,否则等于没分包
|
||||
const fullPhotoDir = resolve(outputRoot, expectedSubPackageRoot, 'static/photos')
|
||||
if (!existsSync(fullPhotoDir)) {
|
||||
console.error(`分包缺少大图目录:${expectedSubPackageRoot}/static/photos`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const thumbPhotoDir = resolve(outputRoot, 'static/poi/photos')
|
||||
if (!existsSync(thumbPhotoDir)) {
|
||||
console.error('主包缺少缩略图目录:static/poi/photos')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const fullPhotos = readdirSync(fullPhotoDir).filter(file => file.endsWith('.jpg'))
|
||||
const thumbPhotos = readdirSync(thumbPhotoDir).filter(file => file.endsWith('.jpg'))
|
||||
if (fullPhotos.length !== thumbPhotos.length) {
|
||||
console.error(`两层照片数量不一致:分包大图 ${fullPhotos.length} 张,主包缩略图 ${thumbPhotos.length} 张。请重跑 node scripts/process-poi-photos.mjs`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
function dirBytes(dir, skipDirs = []) {
|
||||
let total = 0
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) {
|
||||
if (skipDirs.includes(entry.name))
|
||||
continue
|
||||
total += dirBytes(resolve(dir, entry.name), skipDirs)
|
||||
}
|
||||
else {
|
||||
total += statSync(resolve(dir, entry.name)).size
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// 微信主包与单个分包各自上限 2 MB。留 64 KB 余量,避免刚好卡线时上传被拒。
|
||||
const PACKAGE_LIMIT_KB = 2048
|
||||
const PACKAGE_WARN_KB = PACKAGE_LIMIT_KB - 64
|
||||
const subPackageRoots = subPackages.map(item => item.root)
|
||||
const mainPackageKb = Math.round(dirBytes(outputRoot, subPackageRoots) / 1024)
|
||||
const subPackageSizes = subPackages.map(item => ({
|
||||
root: item.root,
|
||||
kb: Math.round(dirBytes(resolve(outputRoot, item.root)) / 1024),
|
||||
}))
|
||||
|
||||
console.log(`主包 ${mainPackageKb} KB / ${PACKAGE_LIMIT_KB} KB`)
|
||||
subPackageSizes.forEach(({ root, kb }) => {
|
||||
console.log(`分包 ${root} ${kb} KB / ${PACKAGE_LIMIT_KB} KB`)
|
||||
})
|
||||
|
||||
if (mainPackageKb > PACKAGE_WARN_KB) {
|
||||
console.error(`主包 ${mainPackageKb} KB 已超过 ${PACKAGE_WARN_KB} KB 安全线(微信上限 ${PACKAGE_LIMIT_KB} KB),上传会被拒。`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const oversizedSubPackages = subPackageSizes.filter(({ kb }) => kb > PACKAGE_WARN_KB)
|
||||
if (oversizedSubPackages.length > 0) {
|
||||
oversizedSubPackages.forEach(({ root, kb }) => {
|
||||
console.error(`分包 ${root} ${kb} KB 已超过 ${PACKAGE_WARN_KB} KB 安全线(微信上限 ${PACKAGE_LIMIT_KB} KB)。`)
|
||||
})
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`微信开发者工具产物检查通过:${outputRoot}`)
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
/**
|
||||
* POI 封面照片注册表
|
||||
*
|
||||
* 照片分两层存放,因为微信小程序主包上限 2 MB,30 张大图单独就超限:
|
||||
* - 缩略图 400×300 放主包,供地图、行程、打卡等卡片使用(最大 192rpx ≈ 288 px)
|
||||
* - 大图 800×600 放 pages-poi 分包,供详情页 hero 使用,随分包按需下载
|
||||
*
|
||||
* 如何添加一张真实照片:
|
||||
* 1. 压缩图片:800×600 px(4:3 横版),JPEG 质量 75-82,文件 ≤ 120 KB
|
||||
* 2. 命名为 <poiId>.jpg(例如 poi_gm_culture_center.jpg)
|
||||
* 3. 放入 src/static/poi/photos/ 目录
|
||||
* 4. 将 poiId 加入下方 POI_COVER_PHOTOS_READY 集合
|
||||
* 1. 原图放入 src/photo-intake/(不提交仓库),命名为 <poiId>.jpg
|
||||
* 2. 跑 `node scripts/process-poi-photos.mjs` 生成两层图片
|
||||
* 3. 将 poiId 加入下方 POI_COVER_PHOTOS_READY 集合
|
||||
*
|
||||
* 未在此集合中的 POI 会继续使用占位图 /static/poi/placeholder.png
|
||||
*/
|
||||
@@ -49,10 +52,26 @@ export const POI_COVER_PHOTOS_READY: ReadonlySet<string> = new Set<string>([
|
||||
'poi_amap_b0jb2aurvw', // 五指耙森林公园(光明片区)
|
||||
])
|
||||
|
||||
const POI_PHOTOS_BASE_URL = '/static/poi/photos'
|
||||
/** 主包缩略图目录(400×300) */
|
||||
const POI_THUMB_BASE_URL = '/static/poi/photos'
|
||||
/** pages-poi 分包大图目录(800×600),仅分包内页面可引用 */
|
||||
const POI_FULL_BASE_URL = '/pages-poi/static/photos'
|
||||
|
||||
/**
|
||||
* 卡片用缩略图 URL。主包内任何页面都可用。
|
||||
*/
|
||||
export function coverPhotoUrlFor(poiId: string): string {
|
||||
return `${POI_PHOTOS_BASE_URL}/${poiId}.jpg`
|
||||
return `${POI_THUMB_BASE_URL}/${poiId}.jpg`
|
||||
}
|
||||
|
||||
/**
|
||||
* 详情页 hero 用大图 URL。
|
||||
*
|
||||
* 只有 pages-poi 分包内的页面能引用 —— 分包资源要等分包下载后才存在,
|
||||
* 主包页面拿到这个路径会加载失败。
|
||||
*/
|
||||
export function coverPhotoFullUrlFor(poiId: string): string {
|
||||
return `${POI_FULL_BASE_URL}/${poiId}.jpg`
|
||||
}
|
||||
|
||||
export function hasCoverPhoto(poiId: string): boolean {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import type { PoiResolved } from '@/domain/poi'
|
||||
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'
|
||||
|
||||
@@ -12,6 +13,24 @@ 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
|
||||
@@ -124,7 +143,7 @@ onShow(() => {
|
||||
/>
|
||||
|
||||
<template v-else-if="poi">
|
||||
<PoiHeroGallery :images="poi.images" :name="poi.name" />
|
||||
<PoiHeroGallery :images="heroImages" :name="poi.name" />
|
||||
|
||||
<view class="poi-detail-page__content">
|
||||
<view class="poi-detail-page__header">
|
||||
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 89 KiB |
@@ -83,17 +83,6 @@
|
||||
"navigationBarTextStyle": "black",
|
||||
"backgroundColor": "#f4f8f6"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/poi/detail",
|
||||
"type": "page",
|
||||
"layout": "map",
|
||||
"style": {
|
||||
"navigationBarTitleText": "点位详情",
|
||||
"navigationBarBackgroundColor": "#ffffff",
|
||||
"navigationBarTextStyle": "black",
|
||||
"backgroundColor": "#f5f7f6"
|
||||
}
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
@@ -131,5 +120,22 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"subPackages": []
|
||||
"subPackages": [
|
||||
{
|
||||
"root": "pages-poi",
|
||||
"pages": [
|
||||
{
|
||||
"path": "detail",
|
||||
"type": "page",
|
||||
"layout": "map",
|
||||
"style": {
|
||||
"navigationBarTitleText": "点位详情",
|
||||
"navigationBarBackgroundColor": "#ffffff",
|
||||
"navigationBarTextStyle": "black",
|
||||
"backgroundColor": "#f5f7f6"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -66,7 +66,7 @@ function openItinerary() {
|
||||
}
|
||||
|
||||
function openDetail(poiId: string) {
|
||||
uni.navigateTo({ url: `/pages/poi/detail?poiId=${encodeURIComponent(poiId)}` })
|
||||
uni.navigateTo({ url: `/pages-poi/detail?poiId=${encodeURIComponent(poiId)}` })
|
||||
}
|
||||
|
||||
function focusOnMap(poiId: string) {
|
||||
|
||||
@@ -53,7 +53,7 @@ function openPoi(poiId: string) {
|
||||
})
|
||||
return
|
||||
}
|
||||
uni.navigateTo({ url: `/pages/poi/detail?poiId=${encodeURIComponent(poiId)}` })
|
||||
uni.navigateTo({ url: `/pages-poi/detail?poiId=${encodeURIComponent(poiId)}` })
|
||||
}
|
||||
|
||||
function loadProfile() {
|
||||
|
||||
@@ -284,7 +284,7 @@ function openPlanner() {
|
||||
}
|
||||
|
||||
function openDetail(poiId: string) {
|
||||
uni.navigateTo({ url: `/pages/poi/detail?poiId=${encodeURIComponent(poiId)}` })
|
||||
uni.navigateTo({ url: `/pages-poi/detail?poiId=${encodeURIComponent(poiId)}` })
|
||||
}
|
||||
|
||||
function focusOnMap(poiId: string) {
|
||||
|
||||
@@ -475,7 +475,7 @@ function openDetail(poiId: string) {
|
||||
return
|
||||
navigating.value = true
|
||||
uni.navigateTo({
|
||||
url: `/pages/poi/detail?poiId=${encodeURIComponent(poiId)}`,
|
||||
url: `/pages-poi/detail?poiId=${encodeURIComponent(poiId)}`,
|
||||
complete: () => {
|
||||
setTimeout(() => {
|
||||
navigating.value = false
|
||||
|
||||
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 145 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 29 KiB |
@@ -10,7 +10,7 @@ interface NavigateToOptions {
|
||||
"/pages/check-in/records" |
|
||||
"/pages/itinerary/index" |
|
||||
"/pages/planner/index" |
|
||||
"/pages/poi/detail";
|
||||
"/pages-poi/detail";
|
||||
}
|
||||
interface RedirectToOptions extends NavigateToOptions {}
|
||||
|
||||
|
||||
@@ -37,6 +37,9 @@ export default defineConfig((configEnv) => {
|
||||
UniPages({
|
||||
// 忽略页面内组件目录
|
||||
exclude: ['**/components/**/*.*'],
|
||||
// 点位详情页单独分包:它的 hero 大图(src/pages-poi/static/photos,约 1.8 MB)
|
||||
// 随分包按需下载,否则主包会超微信 2 MB 上限。
|
||||
subPackages: ['src/pages-poi'],
|
||||
}),
|
||||
/**
|
||||
* unplugin-auto-import 按需 import
|
||||
|
||||