feat: 洗车管理系统 v2.8 — 个人 detailer 单用户全栈应用

- 车辆 / 洗车 / 加油 / 充电 / 保养 / 保险 完整 CRUD + 软删
- AI 截图识别(5 类型 OCR schema):OpenAI 兼容 + MiniMax M3
- 化学品 / Grocy 实例对接 + 库存镜像同步
- 仪表盘:30 天频次 + 健康度 + 同比环比 + 油价趋势 + 年均养护
- 月度报表:Excel 6 sheet + PDF
- PWA:manifest / SW / 离线缓存 / iOS 引导
- 安全:bcrypt + CSRF + 登录锁定(IP/用户/全局三级)+ 401 自动跳登录 + 表单草稿
- 高 ROI 8 功能:里程/提醒/成本/搜索/标签/通知/同比/成就
- 3 个新 migration(0016/0017/0018)+ 18 个迁移全幂等
- 101/101 测试通过(含 ipRateLimit / CSRF / retry / stats / tags / notifications)
- 部署:宝塔面板文档 + PM2 + Nginx
This commit is contained in:
2026-06-20 21:11:54 +08:00
commit fe17886ac4
176 changed files with 40003 additions and 0 deletions
+166
View File
@@ -0,0 +1,166 @@
/**
* PWA 安装性验证脚本
* 检查:
* 1. manifest.webmanifest 合法
* 2. Service Worker 注册成功
* 3. 图标全部能加载
* 4. PWA 必需字段(name, icons[192/512], start_url, display, theme_color, background_color
* 5. 离线 fallback/offline 或 navigateFallback 命中)
*/
import puppeteer from 'puppeteer';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const URL_TO_TEST = process.env.PWA_URL || 'http://localhost:4173/login';
const CHROME_PATH =
process.env.CHROME_PATH ||
'/Users/yabozi/.cache/puppeteer/chrome/mac-148.0.7778.97/chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing';
const checks = [];
let pass = 0;
let fail = 0;
function ok(name, detail) {
checks.push({ status: '✅', name, detail });
pass++;
}
function ko(name, detail) {
checks.push({ status: '❌', name, detail });
fail++;
}
async function fetchStatus(url) {
try {
const r = await fetch(url, { redirect: 'follow' });
return r.status;
} catch (e) {
return 0;
}
}
async function main() {
console.log(`🔍 PWA check: ${URL_TO_TEST}\n`);
// 1. manifest 文件可访问 + 合法 JSON
const manifestUrl = new URL('/manifest.webmanifest', URL_TO_TEST).toString();
const manifestStatus = await fetchStatus(manifestUrl);
if (manifestStatus === 200) {
ok('manifest 200', manifestUrl);
const r = await fetch(manifestUrl);
const m = await r.json();
const required = ['name', 'short_name', 'start_url', 'display', 'icons'];
const missing = required.filter((k) => !m[k]);
if (missing.length === 0) {
ok('manifest 必备字段', Object.keys(m).join(', '));
} else {
ko('manifest 必备字段缺失', missing.join(', '));
}
// 图标尺寸
const sizes = (m.icons || []).map((i) => i.sizes).filter(Boolean);
const has192 = sizes.some((s) => s.includes('192'));
const has512 = sizes.some((s) => s.includes('512'));
const hasMaskable = (m.icons || []).some((i) => i.purpose === 'maskable');
const hasApple = (m.icons || []).some((i) =>
(i.src || '').includes('apple-touch')
);
if (has192 && has512) ok('icons 192+512', sizes.join(', '));
else ko('icons 192/512 缺失', sizes.join(', '));
if (hasMaskable) ok('maskable icon', 'present');
else ko('maskable icon', 'missing');
if (hasApple) ok('apple-touch icon', 'present');
else ko('apple-touch icon', 'missing');
} else {
ko('manifest 不可访问', `status=${manifestStatus}, url=${manifestUrl}`);
}
// 2. Service Worker 注册
const browser = await puppeteer.launch({
executablePath: CHROME_PATH,
headless: 'new',
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
`--user-data-dir=/tmp/lh-cache-pwa-check-${Date.now()}`,
],
});
const page = await browser.newPage();
const swLogs = [];
page.on('console', (m) => {
const t = m.text();
if (t.includes('[PWA]') || t.includes('ServiceWorker')) swLogs.push(t);
});
await page.goto(URL_TO_TEST, { waitUntil: 'networkidle0', timeout: 30000 });
// 等几秒给 SW 机会注册
await new Promise((r) => setTimeout(r, 3000));
const swInfo = await page.evaluate(async () => {
if (!('serviceWorker' in navigator)) return { supported: false };
const regs = await navigator.serviceWorker.getRegistrations();
return {
supported: true,
count: regs.length,
scopes: regs.map((r) => r.scope),
active: regs.map((r) => !!r.active),
scripts: regs.map((r) => r.active && r.active.scriptURL),
};
});
if (swInfo.supported && swInfo.count > 0 && swInfo.active.every(Boolean)) {
ok('Service Worker 已注册', `scope=${swInfo.scopes.join(',')}`);
} else if (swInfo.supported && swInfo.count > 0) {
ko('Service Worker 未激活', JSON.stringify(swInfo));
} else {
ko('Service Worker 未注册', 'getRegistrations() 为空');
}
// 3. 关键 meta 标签
const meta = await page.evaluate(() => {
const get = (sel) => document.querySelector(sel)?.getAttribute('content') || null;
return {
themeColor: get('meta[name="theme-color"]'),
appleCapable: get('meta[name="apple-mobile-web-app-capable"]'),
appleTitle: get('meta[name="apple-mobile-web-app-title"]'),
viewport: get('meta[name="viewport"]'),
manifestLink: !!document.querySelector('link[rel="manifest"]'),
appleTouchIcon: !!document.querySelector('link[rel="apple-touch-icon"]'),
};
});
if (meta.themeColor) ok('theme-color', meta.themeColor);
else ko('theme-color', 'missing');
if (meta.appleCapable === 'yes') ok('apple-mobile-web-app-capable', 'yes');
else ko('apple-mobile-web-app-capable', meta.appleCapable || 'missing');
if (meta.manifestLink) ok('manifest link', 'present');
else ko('manifest link', 'missing');
if (meta.appleTouchIcon) ok('apple-touch-icon link', 'present');
else ko('apple-touch-icon link', 'missing');
// 4. SW 日志确认 offline ready
const offlineReady = swLogs.some((l) => l.includes('离线缓存就绪') || l.includes('offline'));
if (offlineReady) ok('PWA offline log', '检测到 offline ready');
// 离线就绪不是强校验,标 warn 即可
if (!offlineReady) {
checks.push({
status: '⚠️',
name: 'PWA offline 日志',
detail: '运行后无离线 ready 日志(可能 SW 还没预缓存完,可忽略)',
});
}
await browser.close();
// 输出
for (const c of checks) {
console.log(`${c.status} ${c.name}${c.detail ? `${c.detail}` : ''}`);
}
console.log(`\n总计: ✅ ${pass}${fail}`);
process.exit(fail > 0 ? 1 : 0);
}
main().catch((e) => {
console.error('PWA check 失败:', e);
process.exit(2);
});
+42
View File
@@ -0,0 +1,42 @@
// 用 sharp 把 SVG 转为多尺寸 PNG
import sharp from 'sharp';
import { readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const svg = await readFile(path.join(__dirname, '../public/pwa-icon.svg'));
const targets = [
// 标准 PWA icons
{ out: 'public/pwa/pwa-192x192.png', size: 192, type: 'normal' },
{ out: 'public/pwa/pwa-512x512.png', size: 512, type: 'normal' },
// maskable:四周留 20% 安全区,logo 居中缩到 60%
{ out: 'public/pwa/pwa-maskable-512x512.png', size: 512, type: 'maskable' },
// apple touch
{ out: 'public/pwa/apple-touch-icon.png', size: 180, type: 'normal' },
// favicon
{ out: 'public/favicon-32x32.png', size: 32, type: 'normal' },
{ out: 'public/favicon-16x16.png', size: 16, type: 'normal' },
];
const bgColor = { r: 27, g: 110, b: 243 }; // 渐变起始色 #1B6EF3
for (const { out, size, type } of targets) {
let buffer = await sharp(svg).resize(size, size).png().toBuffer();
if (type === 'maskable') {
// maskable 重新画:蓝底全填 + logo 60%
const inner = await sharp(svg).resize(Math.floor(size * 0.6), Math.floor(size * 0.6)).png().toBuffer();
buffer = await sharp({
create: { width: size, height: size, channels: 4, background: bgColor },
})
.composite([{ input: inner, gravity: 'center' }])
.png()
.toBuffer();
}
await writeFile(path.join(__dirname, '..', out), buffer);
console.log('✓', out, `${size}x${size}`, type);
}
console.log('Done.');