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('', 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}`)