初始化兜爸心选 App 官网项目(构建与发布)

- src/:从线上 shop.douba.top/public/app 拉取的页面源码(v0.3.8)
- 版本号、更新日期、安装包大小改为模板变量,统一由 site.config.json 管理,
  安装包大小在构建时从 APK 实际字节数计算
- scripts/build.mjs:构建到 dist/,校验 APK、模板变量与资源引用
- scripts/deploy.mjs:与线上比对差异 → 上传 → 备份 → 原子切换 → 线上验证
- scripts/rollback.mjs、scripts/preview.mjs:回滚与本地预览
- AGENTS.md:项目说明、发版流程与服务器/nginx 约定

由 AI 助手协助完成。
This commit is contained in:
douba
2026-10-29 06:20:35 +00:00
commit 72648b4914
29 changed files with 1899 additions and 0 deletions

117
scripts/build.mjs Normal file
View File

@@ -0,0 +1,117 @@
// 构建:src/ → dist/,渲染 {{变量}},拷入 APK,并校验页面引用的本地资源都存在
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const SRC = path.join(ROOT, 'src');
const DIST = path.join(ROOT, 'dist');
const RELEASES = path.join(ROOT, 'releases');
export function loadConfig() {
return JSON.parse(fs.readFileSync(path.join(ROOT, 'site.config.json'), 'utf8'));
}
export function apkFileName(cfg) {
return `douba-v${cfg.version}.apk`;
}
// 2026-10-29 → 2026 年 10 月 29 日
function cnDate(iso) {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso || '');
if (!m) throw new Error(`日期格式应为 YYYY-MM-DD:${iso}`);
return `${m[1]} 年 ${Number(m[2])} 月 ${Number(m[3])} 日`;
}
// 字节 → "61.5 MB"(十进制,和手机系统显示一致)
function mb(bytes) {
return `${(bytes / 1e6).toFixed(1)} MB`;
}
function walk(dir) {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
const p = path.join(dir, e.name);
return e.isDirectory() ? walk(p) : [p];
});
}
function render(text, vars, file) {
return text.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, key) => {
if (!(key in vars)) throw new Error(`${file}: 未定义的模板变量 {{${key}}}`);
return vars[key];
});
}
// 找出 HTML 的 src/href 和 CSS 的 url(),确认指向 /app 内的文件都存在
function checkRefs(files) {
const missing = [];
for (const file of files) {
const ext = path.extname(file);
if (ext !== '.html' && ext !== '.css') continue;
const text = fs.readFileSync(file, 'utf8');
const re = ext === '.html' ? /\s(?:src|href|content)="([^"]+)"/g : /url\(["']?([^"')]+)["']?\)/g;
for (let [, raw] of text.matchAll(re)) {
raw = raw.replace(/^https:\/\/shop\.douba\.top(?=\/app)/, ''); // og:image 等写的是本站完整地址
if (/\s/.test(raw) || (!raw.includes('/') && !raw.includes('.'))) continue; // content 里的普通文本
const ref = raw.split(/[?#]/)[0];
if (!ref || /^(https?:|data:|mailto:|tel:|\/\/)/.test(raw) || /^(#|%23)/.test(raw)) continue; // %23 = 内联 SVG 里的 url(#id)
let target;
if (ref.startsWith('/app/')) target = path.join(DIST, ref.slice(5));
else if (ref === '/app' || ref === '/app/') target = path.join(DIST, 'index.html');
else if (ref.startsWith('/')) continue; // 站点根下的商城页面(如 /protocol.html),不归本项目管
else target = path.join(path.dirname(file), ref);
if (fs.existsSync(target) && fs.statSync(target).isDirectory()) target = path.join(target, 'index.html');
if (!fs.existsSync(target)) missing.push(`${path.relative(DIST, file)} → ${raw}`);
}
}
if (missing.length) throw new Error(`以下引用的文件不存在:\n ${missing.join('\n ')}`);
}
export function build() {
const cfg = loadConfig();
const apkFile = apkFileName(cfg);
const apkSrc = path.join(RELEASES, apkFile);
if (!fs.existsSync(apkSrc)) {
throw new Error(`找不到安装包 releases/${apkFile}(site.config.json 的 version 是 ${cfg.version})`);
}
const apkBytes = fs.statSync(apkSrc).size;
const vars = {
version: cfg.version,
channel: cfg.channel,
releaseDate: cfg.releaseDate,
screenshotVersion: cfg.screenshotVersion,
'permissions.version': cfg.permissions.version,
'permissions.updatedCn': cnDate(cfg.permissions.updated),
apkFile,
apkSize: mb(apkBytes),
};
cnDate(cfg.releaseDate); // 只做格式校验
fs.rmSync(DIST, { recursive: true, force: true });
for (const file of walk(SRC)) {
const out = path.join(DIST, path.relative(SRC, file));
fs.mkdirSync(path.dirname(out), { recursive: true });
if (path.extname(file) === '.html') {
fs.writeFileSync(out, render(fs.readFileSync(file, 'utf8'), vars, path.relative(ROOT, file)));
} else {
fs.copyFileSync(file, out);
}
}
fs.mkdirSync(path.join(DIST, 'download'), { recursive: true });
fs.copyFileSync(apkSrc, path.join(DIST, 'download', apkFile));
checkRefs(walk(DIST));
return { cfg, apkFile, apkBytes, vars };
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
try {
const { cfg, apkFile, vars } = build();
console.log(`构建完成 → dist/`);
console.log(` 版本 v${cfg.version}(${cfg.channel}) · ${apkFile} · ${vars.apkSize} · 更新时间 ${cfg.releaseDate}`);
} catch (e) {
console.error(`构建失败:${e.message}`);
process.exit(1);
}
}