forked from zhouruizhe/gmTouringMiniApp
三个问题,前两个各有独立根因。
1) 首次打开定位到几内亚湾、底图变成 HERE
regionchange 处理里读的是 `event.detail.longitude`,但微信的 regionchange
载荷没有这个字段,`Number(undefined)` 恒为 NaN,于是每次都落到异步的
getCenterLocation。冷启动时地图 SDK 还没拿到有效中心点,它会回传 (0, 0),
而两个 store 的坐标校验只查 Number.isFinite,(0, 0) 直接通过。
(0, 0) 落在几内亚湾(Null Island),微信 <map> 一到境外坐标就切 HERE 底图。
全程不需要用户点过定位按钮。
数据集校验器本来就在拒绝零坐标,只是运行时 store 没套用同一条规则。现把它
提成共享守卫 isTrustworthyCoordinate,validateCoordinates 改为复用,并铺到
map store、location store 和打卡定位服务。regionchange 一侧改为优先读微信
真正给的 detail.centerLocation,回落路径校验后才写入,且 mapReady 为 false
时不接受任何回写。
2) 冷启动白屏、左上角只剩回首页的小房子
f8ad5e1 把详情页从 pages/poi/detail 移进 pages-poi 分包,源码调用点都改了,
但旧路由本身从 app.json 消失。任何还攥着旧路径的入口 —— 开发者工具模拟器上次
停留的路由、预览启动页、已发出的分享卡片或二维码 —— 冷启动会落到微信解析不出
的路由,渲染成白屏;因为不是 tabBar 页且栈深为 1,左上角只剩小房子。
旧路径补一个只做重定向的跳板页,带 poiId 就 redirectTo 新路径,不带就 reLaunch
回地图。用 redirectTo 是为了让跳板不留在页面栈里。主包 +1 KB。等所有入口都刷新
过可以删掉。
3) 跳转详情页时地图仍可拖动几毫秒
<map> 是渲染在 webview 之上的原生组件,销毁是异步的,页面转场开始了它还在。
navigating 之前只用于防重复点击,没接到地图上。现在接上 enable-scroll /
enable-zoom,并给 selectMarker、openCheckInRecords 补同一个守卫(后者原先完全
没有防护),regionchange 在跳转期间也不再回写 viewport。onShow 里直接复位
navigating,返回时立刻恢复交互,不等 500ms 兜底定时器。
原生组件的异步销毁消不掉,但那几毫秒里地图不再响应拖动和缩放。
验证:测试 100 passed(map store 新增零坐标、越界、resetViewport 兜底用例),
type-check 干净。build 主包 1187 KB / 分包 1841 KB,dev 1527 KB / 1847 KB,
均过 verify:mp-weixin。
258 lines
11 KiB
JavaScript
258 lines
11 KiB
JavaScript
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
import { resolve } from 'node:path'
|
|
import process from 'node:process'
|
|
|
|
const projectRoot = process.cwd()
|
|
const outputDirectory = process.argv[2] ?? 'dist/dev/mp-weixin'
|
|
const outputRoot = resolve(projectRoot, outputDirectory)
|
|
const expectedPages = [
|
|
'pages/map/index',
|
|
'pages/assistant/index',
|
|
'pages/itinerary/index',
|
|
'pages/planner/index',
|
|
'pages/check-in/index',
|
|
'pages/check-in/records',
|
|
// 旧详情页路由的兼容跳板,只做重定向。缺了它,还攥着旧路径的入口
|
|
// (模拟器上次停留的路由、预览启动页、已发出的分享卡片)会白屏。
|
|
'pages/poi/detail',
|
|
]
|
|
// 点位详情页放在 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',
|
|
'pages/planner/index',
|
|
]
|
|
const expectedTabIcons = [
|
|
'static/tabbar/map.png',
|
|
'static/tabbar/map-selected.png',
|
|
'static/tabbar/assistant.png',
|
|
'static/tabbar/assistant-selected.png',
|
|
'static/tabbar/planner.png',
|
|
'static/tabbar/planner-selected.png',
|
|
]
|
|
const requiredFiles = [
|
|
'app.json',
|
|
'app.js',
|
|
'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,
|
|
]
|
|
|
|
const missingFiles = requiredFiles.filter(file => !existsSync(resolve(outputRoot, file)))
|
|
if (missingFiles.length > 0) {
|
|
console.error(`微信小程序产物不完整:${outputRoot}`)
|
|
missingFiles.forEach(file => console.error(`- 缺少 ${file}`))
|
|
process.exit(1)
|
|
}
|
|
|
|
const appConfig = JSON.parse(readFileSync(resolve(outputRoot, 'app.json'), 'utf8'))
|
|
if (appConfig.lazyCodeLoading !== 'requiredComponents') {
|
|
console.error(`组件按需注入配置错误:期望 requiredComponents,实际为 ${appConfig.lazyCodeLoading ?? '未配置'}`)
|
|
process.exit(1)
|
|
}
|
|
const checkInPageScript = readFileSync(resolve(outputRoot, 'pages/check-in/index.js'), 'utf8')
|
|
const checkInPageMarkup = readFileSync(resolve(outputRoot, 'pages/check-in/index.wxml'), 'utf8')
|
|
if (!checkInPageScript.includes('requirePrivacyAuthorize')
|
|
|| !checkInPageMarkup.includes('open-type="agreePrivacyAuthorization"')) {
|
|
console.error('打卡页缺少微信位置隐私授权兼容链。')
|
|
process.exit(1)
|
|
}
|
|
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)
|
|
const checkInEntryTag = checkInEntryIndex >= 0 && checkInEntryStart >= 0 && checkInEntryEnd >= 0
|
|
? poiDetailMarkup.slice(checkInEntryStart, checkInEntryEnd + 1)
|
|
: ''
|
|
const visibilityAttributes = ['wx:if=', 'wx:elif=', 'wx:else', 'hidden=']
|
|
if (!checkInEntryTag || visibilityAttributes.some(attribute => checkInEntryTag.includes(attribute))) {
|
|
console.error('景点详情页必须无条件渲染打卡入口。')
|
|
process.exit(1)
|
|
}
|
|
const expectedPrivateInfos = ['getLocation', 'startLocationUpdate', 'onLocationChange']
|
|
const configuredPrivateInfos = appConfig.requiredPrivateInfos ?? []
|
|
if (JSON.stringify(configuredPrivateInfos) !== JSON.stringify(expectedPrivateInfos)) {
|
|
console.error(`定位隐私接口声明错误:期望 ${expectedPrivateInfos.join(', ')},实际为 ${configuredPrivateInfos.join(', ') || '未配置'}`)
|
|
process.exit(1)
|
|
}
|
|
|
|
const locationPermissionDescription = appConfig.permission?.['scope.userLocation']?.desc ?? ''
|
|
const locationPermissionDescriptionLength = Array.from(locationPermissionDescription).length
|
|
if (locationPermissionDescriptionLength < 1 || locationPermissionDescriptionLength > 30) {
|
|
console.error('app.json 缺少 scope.userLocation 用途说明。')
|
|
process.exit(1)
|
|
}
|
|
if (!locationPermissionDescription.includes('打卡')) {
|
|
console.error('scope.userLocation 用途说明未覆盖到点打卡。')
|
|
process.exit(1)
|
|
}
|
|
|
|
const missingPages = expectedPages.filter(page => !appConfig.pages?.includes(page))
|
|
if (missingPages.length > 0) {
|
|
console.error('app.json 未注册全部业务页面:')
|
|
missingPages.forEach(page => console.error(`- ${page}`))
|
|
process.exit(1)
|
|
}
|
|
|
|
const unexpectedPages = appConfig.pages?.filter(page => !expectedPages.includes(page)) ?? []
|
|
if (unexpectedPages.length > 0 || appConfig.pages?.length !== expectedPages.length) {
|
|
console.error('app.json 包含未纳入本期范围的页面:')
|
|
unexpectedPages.forEach(page => console.error(`- ${page}`))
|
|
process.exit(1)
|
|
}
|
|
|
|
if (appConfig.pages?.[0] !== 'pages/map/index') {
|
|
console.error(`微信小程序启动页错误:期望 pages/map/index,实际为 ${appConfig.pages?.[0] ?? '未配置'}`)
|
|
process.exit(1)
|
|
}
|
|
|
|
const forbiddenPages = appConfig.pages?.filter(page => [
|
|
'pages/index/index',
|
|
'pages/hi',
|
|
'pages/unocss/index',
|
|
'pages/uview-plus/index',
|
|
].includes(page)) ?? []
|
|
|
|
if (forbiddenPages.length > 0) {
|
|
console.error('微信小程序产物仍包含模板演示页面:')
|
|
forbiddenPages.forEach(page => console.error(`- ${page}`))
|
|
process.exit(1)
|
|
}
|
|
|
|
const tabPages = appConfig.tabBar?.list?.map(item => item.pagePath) ?? []
|
|
if (JSON.stringify(tabPages) !== JSON.stringify(expectedTabPages)) {
|
|
console.error(`tabBar 页面错误:期望 ${expectedTabPages.join(', ')},实际为 ${tabPages.join(', ') || '未配置'}`)
|
|
process.exit(1)
|
|
}
|
|
|
|
if (!['black', 'white'].includes(appConfig.tabBar?.borderStyle)) {
|
|
console.error(`tabBar.borderStyle 只能为 black 或 white,实际为 ${appConfig.tabBar?.borderStyle ?? '未配置'}`)
|
|
process.exit(1)
|
|
}
|
|
|
|
const configuredIcons = appConfig.tabBar.list.flatMap(item => [item.iconPath, item.selectedIconPath])
|
|
if (JSON.stringify(configuredIcons) !== JSON.stringify(expectedTabIcons)) {
|
|
console.error('tabBar 图标配置与预期不一致。')
|
|
process.exit(1)
|
|
}
|
|
|
|
const ideConfigPath = resolve(projectRoot, 'project.config.json')
|
|
if (!existsSync(ideConfigPath)) {
|
|
console.error('项目根目录缺少 project.config.json,微信开发者工具无法按项目根目录导入。')
|
|
process.exit(1)
|
|
}
|
|
|
|
const ideConfig = JSON.parse(readFileSync(ideConfigPath, 'utf8'))
|
|
if (ideConfig.compileType !== 'miniprogram') {
|
|
console.error(`project.config.json 的 compileType 必须为 miniprogram,实际为 ${ideConfig.compileType ?? '未配置'}`)
|
|
process.exit(1)
|
|
}
|
|
|
|
if (ideConfig.miniprogramRoot !== 'dist/dev/mp-weixin/') {
|
|
console.error(`project.config.json 的 miniprogramRoot 错误:${ideConfig.miniprogramRoot ?? '未配置'}`)
|
|
process.exit(1)
|
|
}
|
|
|
|
if (ideConfig.srcMiniprogramRoot !== ideConfig.miniprogramRoot) {
|
|
console.error('project.config.json 的 srcMiniprogramRoot 必须与 miniprogramRoot 一致。')
|
|
process.exit(1)
|
|
}
|
|
|
|
const outputIdeConfig = JSON.parse(readFileSync(resolve(outputRoot, 'project.config.json'), 'utf8'))
|
|
if (outputIdeConfig.compileType !== 'miniprogram') {
|
|
console.error(`编译产物 project.config.json 的 compileType 必须为 miniprogram,实际为 ${outputIdeConfig.compileType ?? '未配置'}`)
|
|
process.exit(1)
|
|
}
|
|
|
|
if (!ideConfig.appid || !outputIdeConfig.appid || ideConfig.appid !== outputIdeConfig.appid) {
|
|
console.error('根目录与编译产物的微信 AppID 不一致,请同步 project.config.json 与 .env.local。')
|
|
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}`)
|