diff --git a/.gitignore b/.gitignore index f100992..2f3053b 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,7 @@ server/**/*.pyc # cache .eslintcache .stylelintcache + +# POI 照片原图投放目录:只作为 scripts/process-poi-photos.mjs 的输入, +# 产出的两层图片(分包大图 + 主包缩略图)才提交仓库。 +src/photo-intake/ diff --git a/scripts/process-poi-photos.mjs b/scripts/process-poi-photos.mjs index a92b377..06cffa4 100644 --- a/scripts/process-poi-photos.mjs +++ b/scripts/process-poi-photos.mjs @@ -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/.jpg 800×600 分包大图(详情页 hero) + * src/static/poi/photos/.jpg 400×300 主包缩略图(各处卡片) + * + * 新增一张照片: + * 1. 原图放入 src/photo-intake/(该目录不提交),命名为 .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 +}) diff --git a/scripts/verify-weixin-output.mjs b/scripts/verify-weixin-output.mjs index b64c77b..d437cfb 100644 --- a/scripts/verify-weixin-output.mjs +++ b/scripts/verify-weixin-output.mjs @@ -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('', 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}`) diff --git a/src/data/poi/photos.ts b/src/data/poi/photos.ts index b6e50e4..de60f6d 100644 --- a/src/data/poi/photos.ts +++ b/src/data/poi/photos.ts @@ -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. 命名为 .jpg(例如 poi_gm_culture_center.jpg) - * 3. 放入 src/static/poi/photos/ 目录 - * 4. 将 poiId 加入下方 POI_COVER_PHOTOS_READY 集合 + * 1. 原图放入 src/photo-intake/(不提交仓库),命名为 .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 = new Set([ '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 { diff --git a/src/pages/poi/detail.vue b/src/pages-poi/detail.vue similarity index 93% rename from src/pages/poi/detail.vue rename to src/pages-poi/detail.vue index 2e600a0..e2f2c31 100644 --- a/src/pages/poi/detail.vue +++ b/src/pages-poi/detail.vue @@ -1,9 +1,10 @@