生娃计算器:新增「保存账单图片」,canvas 画整张小票长图,长按保存

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
deploy
2026-11-01 02:23:23 +00:00
parent b44fcb8021
commit 72131f98c6

View File

@@ -11,7 +11,8 @@
② 问题(按所选阶段出现):你是女生/男生 → 算哪一段(多选,默认全部)→ 城市 → 头胎二胎 → 0~3 岁谁带 → 上学路线 → 课外班 → 家庭月收入(可跳过);
③ 账单只列所选阶段,每段 12~24 项明细,单项取中间值、小计合计能加对;1/2/3 岁累计只在含 0~1 / 1~3 岁时显示;
④ 填了收入:账单里给「≈ 你家 X 个月 / X 年收入」+ 所选阶段平均每月占收入比例条;收尾卡、生成卡片呼应一句;收入只存本机;
⑤ 收尾卡、复制、生成卡片、推荐工具、打印动画同 v4。改价格只改脚本里的 COST 表。
⑤ 收尾卡、复制、生成卡片、推荐工具、打印动画同 v4;
⑥ v6(11-01):账单下加「保存账单图片」,canvas 把整张小票画成长图(宽 750,高按内容,超 15800px 先压行高再整体缩小),复用卡片弹层、长图可滚动。改价格只改脚本里的 COST 表。
存储 localStorage「mmbox.huafei.v5」(兼容读取 v4)。推荐工具走 ../_shared/tools.js;自成一页:不引 app.js;
返回走 history.back 或相对路径 ../../ 并带回 search + hash。v4 备份 /tmp/huafei5/huafei-v4-before-v5.html。 -->
<style>
@@ -245,7 +246,8 @@ h1 .mk{background:linear-gradient(transparent 64%,rgba(24,160,88,.24) 0)}
.bc{display:block;width:78%;height:40px;margin:10px auto 2px}
.rc-no{font-size:10.5px;letter-spacing:.3em;color:var(--s)}
.racts{display:flex;gap:10px;margin-top:6px}
.savebill{margin-top:6px}
.racts{display:flex;gap:10px;margin-top:10px}
.racts .btn{flex:1;height:44px;font-size:15px}
/* ============ 情感收尾 ============ */
@@ -277,7 +279,10 @@ h1 .mk{background:linear-gradient(transparent 64%,rgba(24,160,88,.24) 0)}
.toast.on{opacity:1;transform:translate(-50%,0)}
.mask{position:fixed;inset:0;z-index:50;display:none;flex-direction:column;align-items:center;justify-content:center;padding:24px 28px calc(24px + env(safe-area-inset-bottom));background:rgba(18,28,22,.82)}
.mask.on{display:flex;animation:fd .25s ease both}
.mask img{max-width:100%;max-height:calc(100% - 96px);border-radius:16px;box-shadow:0 12px 32px rgba(0,0,0,.25);-webkit-touch-callout:default;user-select:auto}
.mwrap{max-width:100%;max-height:calc(100% - 96px);display:flex;border-radius:16px;box-shadow:0 12px 32px rgba(0,0,0,.25)}
.mask img{max-width:100%;max-height:100%;border-radius:16px;-webkit-touch-callout:default;user-select:auto}
.mask.long .mwrap{display:block;width:100%;overflow-y:auto;-webkit-overflow-scrolling:touch;overscroll-behavior:contain;background:#EAF7F0}
.mask.long img{width:100%;max-height:none;border-radius:0}
.mask p{margin-top:14px;color:#fff;font-size:14.5px;font-weight:600;display:flex;align-items:center;gap:6px}
.mask .row{display:flex;gap:16px;margin-top:10px}
.mask .row button,.mask .row a{height:34px;padding:0 18px;border-radius:99px;border:1px solid rgba(255,255,255,.6);color:#fff;font-size:13.5px;line-height:32px;text-decoration:none}
@@ -370,6 +375,7 @@ h1 .mk{background:linear-gradient(transparent 64%,rgba(24,160,88,.24) 0)}
<div class="printer" id="printer"><span class="lb">BILL</span><span class="led"></span></div>
<div class="feed" id="feed"><div class="paper" id="paper"></div></div>
<button class="btn press savebill" id="billBtn"><svg class="ic"><use href="#i-img" xlink:href="#i-img"/></svg>保存账单图片</button>
<div class="racts">
<button class="btn ghost press" id="reRoute"><svg class="ic"><use href="#i-refresh" xlink:href="#i-refresh"/></svg>换条路线再算</button>
<button class="btn ghost press" id="reAns"><svg class="ic"><use href="#i-edit" xlink:href="#i-edit"/></svg>改一下答案</button>
@@ -398,8 +404,8 @@ h1 .mk{background:linear-gradient(transparent 64%,rgba(24,160,88,.24) 0)}
</div>
<div class="mask" id="mask">
<img id="maskImg" alt="给妈妈的卡片">
<p><svg class="ic"><use href="#i-hand" xlink:href="#i-hand"/></svg>长按图片,保存或发给妈妈</p>
<div class="mwrap" id="mwrap"><img id="maskImg" alt="给妈妈的卡片"></div>
<p><svg class="ic"><use href="#i-hand" xlink:href="#i-hand"/></svg><span id="maskTip">长按图片,保存或发给妈妈</span></p>
<div class="row"><a id="dlBtn" download="给妈妈的卡片.png" hidden>下载图片</a><button id="maskClose">关闭</button></div>
</div>
<div class="toast" id="toast"></div>
@@ -1110,12 +1116,216 @@ h1 .mk{background:linear-gradient(transparent 64%,rgba(24,160,88,.24) 0)}
$('#cardBtn').addEventListener('click', function () {
if (!bill) return;
try {
var url = drawCard();
$('#maskImg').src = url;
var dl = $('#dlBtn'); dl.href = url; dl.hidden = isWx;
$('#mask').classList.add('on');
openMask(drawCard(), '给妈妈的卡片', '给妈妈的卡片.png', '长按图片,保存或发给妈妈', false);
} catch (e) { toast('生成失败,截个图保存也可以'); }
});
/* ================= 保存账单图片:把整张小票原样画成长图(宽 750,高按内容算) =================
iOS 单张 canvas 建议 ≤ 约 16000px 高 / 1600 万像素:先按标准行高量一遍,超了就压行高(最低 32),仍超就整体缩小。 */
var BILL_MAX_H = 15800;
function drawBill() {
var b = bill, R = ROUTES[route], s = b.s, st = sel(), inc = incomeOf(b);
var sexW = A.sex === 1 ? '准爸爸' : '准妈妈';
var nodes = [];
if (b.n1 != null) nodes.push(['养到 1 岁累计', b.n1]);
if (b.n2 != null) { nodes.push(['养到 2 岁累计', b.n2]); nodes.push(['养到 3 岁累计', b.n3]); }
var tipNodes = nodes.length && ([0, 1, 2].some(function (i) { return st.indexOf(i) < 0; }) || (b.n2 != null && st.indexOf(3) < 0));
var meta = [['账单号', billNo], ['日期', billAt], ['路线', '0' + (route + 1) + ' ' + R.who + ' · ' + R.how],
['家庭', CITY_S[s.city] + ' · ' + (s.kid ? '二胎' : '头胎') + ' · ' + sexW], ['范围', b.range + ' · ' + b.n + ' 项'], ['收银员', '兜爸']];
var W = 750, PX = 34, PW = W - PX * 2, X0 = PX + 34, X1 = W - PX - 34;
var mc = document.createElement('canvas').getContext('2d');
// 画一遍(dry=true 时只量高度)
function paint(c, dry, rh) {
var y = 0;
function T(text, x, yy, font, color, align) { if (dry) return; c.font = font; c.fillStyle = color; c.textAlign = align || 'left'; c.fillText(text, x, yy); }
function tw(text, font) { mc.font = font; return mc.measureText(text).width; }
function dash(yy, solid, dbl) {
if (dry) return;
c.save(); c.strokeStyle = solid ? '#2B2F36' : '#C9CED4'; c.lineWidth = solid ? 2 : 2.5;
if (!solid) c.setLineDash([9, 7]);
c.beginPath(); c.moveTo(X0, yy); c.lineTo(X1, yy); c.stroke();
if (dbl) { c.beginPath(); c.moveTo(X0, yy + 6); c.lineTo(X1, yy + 6); c.stroke(); }
c.restore();
}
function row(name, val, yy, o) { // 名称左、金额右对齐,中间点线
o = o || {};
var fn = (o.bold ? '800 ' : '400 ') + (o.fs || 26) + 'px ' + FONT, fv = (o.bold ? '800 ' : '500 ') + (o.vs || o.fs || 26) + 'px ' + MONO;
T(name, X0, yy, fn, o.c || '#3B4048');
T(val, X1, yy, fv, o.vc || o.c || '#2B2F36', 'right');
if (!dry && o.dots !== false) {
var a = X0 + tw(name, fn) + 12, z = X1 - tw(val, fv) - 12;
if (z > a) { c.save(); c.fillStyle = '#C3C8CE'; for (var x = a; x < z; x += 7) c.fillRect(x, yy - 8, 2.5, 2.5); c.restore(); }
}
}
// 纸外顶部
y = 40;
var paperTop = y;
if (!dry) { // 纸先画出来(高度在 dry 阶段已量好)
var ph = paint.ph, n = Math.round(PW / 22), tw2 = PW / n;
c.save(); c.shadowColor = 'rgba(30,60,45,.16)'; c.shadowBlur = 28; c.shadowOffsetY = 8; c.fillStyle = '#fff';
c.beginPath(); c.moveTo(PX, paperTop);
for (var i = 0; i < n; i++) { c.lineTo(PX + tw2 * i + tw2 / 2, paperTop - 11); c.lineTo(PX + tw2 * (i + 1), paperTop); }
c.lineTo(PX + PW, paperTop + ph);
for (var j = n; j > 0; j--) { c.lineTo(PX + tw2 * j - tw2 / 2, paperTop + ph + 11); c.lineTo(PX + tw2 * (j - 1), paperTop + ph); }
c.closePath(); c.fill(); c.restore();
}
// 表头
y += 74;
if (!dry) {
var lg = '兜爸心选 · 养娃账单', lf = '800 36px ' + FONT, lw = tw(lg, lf);
T(lg, W / 2 + 20, y, lf, '#1F2329', 'center');
c.save(); c.strokeStyle = '#18A058'; c.lineWidth = 3.4; c.lineCap = 'round'; // 小叶子
var lx = W / 2 + 20 - lw / 2 - 30, ly = y - 13;
c.beginPath(); c.moveTo(lx - 12, ly + 12); c.quadraticCurveTo(lx - 12, ly - 12, lx + 14, ly - 14); c.quadraticCurveTo(lx + 12, ly + 12, lx - 12, ly + 12); c.stroke();
c.beginPath(); c.moveTo(lx - 12, ly + 12); c.lineTo(lx + 2, ly - 2); c.stroke(); c.restore();
}
y += 42;
T((b.isAll ? '从怀孕一直算到 18 岁' : b.range).split('').join(' '), W / 2, y, '400 22px ' + FONT, '#86909C', 'center');
y += 30;
meta.forEach(function (m) { y += 40; row(m[0], m[1], y, { fs: 24, c: '#4E5969', vc: '#4E5969', dots: false }); });
y += 26; dash(y, true);
y += 40; row('项目', '金额(元)', y, { fs: 22, c: '#86909C', vc: '#86909C', dots: false });
// 各段
b.segs.forEach(function (sg) {
y += 22; dash(y);
y += 54;
if (!dry) {
var no = '0' + (sg.i + 1), nf = '700 20px ' + MONO, nw = tw(no, nf) + 16;
c.fillStyle = '#18A058'; rr(c, X0, y - 26, nw, 32, 7); c.fill();
T(no, X0 + nw / 2, y - 3, nf, '#fff', 'center');
var tf = '800 28px ' + FONT;
T(sg.t, X0 + nw + 12, y, tf, '#1F2329');
T(sg.a + ' · ' + sg.it.length + ' 项', X0 + nw + 12 + tw(sg.t, tf) + 14, y, '400 21px ' + FONT, '#86909C');
}
y += 10;
sg.it.forEach(function (x) {
y += rh;
var neg = x.m < 0;
row(x.n, money(x.m), y, { c: neg ? '#18A058' : '#3B4048', vc: neg ? '#18A058' : '#2B2F36' });
});
y += 20;
if (!dry) { c.fillStyle = '#EEF0F3'; c.fillRect(X0, y, X1 - X0, 2); }
y += 42; row('小计', money(sg.m), y, { bold: true, fs: 26, vs: 28, c: '#1F2329', dots: false });
});
// 合计
y += 30; dash(y, true, true);
y += 82;
T('合 计', X0, y - 6, '800 30px ' + FONT, '#1F2329');
T('¥' + money(b.m), X1, y, '800 60px ' + MONO, '#1F2329', 'right');
y += 42;
if (!dry) {
var rg = ',区间 ' + wr(b.lo, b.hi), rf = '400 24px ' + FONT, ef = '800 24px ' + FONT, em = w(b.m) + ' 万';
T(rg, X1, y, rf, '#4E5969', 'right');
var x2 = X1 - tw(rg, rf);
T(em, x2, y, ef, '#18A058', 'right'); x2 -= tw(em, ef) + 4;
T('约', x2, y, rf, '#4E5969', 'right');
}
if (nodes.length) {
y += 28;
var bh = 104, cw = (X1 - X0) / nodes.length;
if (!dry) {
c.save(); c.strokeStyle = '#C9CED4'; c.lineWidth = 2.5; c.setLineDash([9, 7]);
rr(c, X0, y, X1 - X0, bh, 18); c.stroke();
for (var k = 1; k < nodes.length; k++) { c.beginPath(); c.moveTo(X0 + cw * k, y); c.lineTo(X0 + cw * k, y + bh); c.stroke(); }
c.restore();
nodes.forEach(function (nd, k2) {
var cx = X0 + cw * k2 + cw / 2;
T(nd[0], cx, y + 38, '400 21px ' + FONT, '#86909C', 'center');
T(w(nd[1]) + '万', cx, y + 80, '800 30px ' + MONO, '#1F2329', 'center');
});
}
y += bh;
if (tipNodes) { y += 34; T('累计只算你选的阶段,没选的不计入', X0, y, '400 21px ' + FONT, '#86909C'); }
}
if (inc) {
y += 28;
var ih = 236, iy = y, pw2 = Math.min(100, inc.pct), hot = inc.pct > 50;
if (!dry) {
c.save(); c.fillStyle = '#F6FAF8'; rr(c, X0, iy, X1 - X0, ih, 18); c.fill();
c.strokeStyle = '#BFE3CE'; c.lineWidth = 2.5; c.setLineDash([9, 7]); c.stroke(); c.restore();
var ix = X0 + 24, ix1 = X1 - 24;
var h1 = '按你家月收入 ', h1f = '400 22px ' + FONT;
T(h1, ix, iy + 44, h1f, '#86909C');
var hx = ix + tw(h1, h1f);
T('¥' + money(inc.inc), hx, iy + 44, '800 22px ' + MONO, '#1F2329');
T(' 算', hx + tw('¥' + money(inc.inc), '800 22px ' + MONO), iy + 44, h1f, '#86909C');
var a1 = '这张账单 ≈ 你家 ', af = '800 28px ' + FONT, bf = '800 38px ' + FONT;
T(a1, ix, iy + 94, af, '#1F2329');
var ax = ix + tw(a1, af);
T(inc.txt, ax, iy + 94, bf, '#18A058');
T('的收入', ax + tw(inc.txt, bf) + 4, iy + 94, af, '#1F2329');
c.fillStyle = '#E3EEE8'; rr(c, ix, iy + 116, ix1 - ix, 18, 9); c.fill();
var gr = c.createLinearGradient(ix, 0, ix1, 0);
if (hot) { gr.addColorStop(0, '#F2B57A'); gr.addColorStop(1, '#D9823B'); } else { gr.addColorStop(0, '#79CC9E'); gr.addColorStop(1, '#18A058'); }
c.fillStyle = gr; rr(c, ix, iy + 116, Math.max(18, (ix1 - ix) * pw2 / 100), 18, 9); c.fill();
T('所选阶段平均每月花 ¥' + money(r100(inc.avg)), ix, iy + 170, '400 23px ' + FONT, '#4E5969');
T('占收入 ' + Math.round(inc.pct) + '%', ix1, iy + 170, '800 23px ' + FONT, hot ? '#B4733A' : '#1F2329', 'right');
T(inc.pct <= 20 ? '压力不大,按自己的节奏来。' : inc.pct <= 35 ? '要花心思规划,但扛得住。' : inc.pct <= 50 ? '会比较吃紧,提前存一笔会从容很多。' : '超出不少,可以换条路线看看,或把大项往后挪一挪。',
ix, iy + 210, '400 21px ' + FONT, '#86909C');
}
y += ih;
}
y += 24;
if (!inc) { y += 34; row(b.isAll ? '平均每月' : '所选阶段平均每月', money(r100(b.m / (b.months || 1))), y, { fs: 23, c: '#4E5969', vc: '#4E5969' }); }
if (has(st, [8])) {
y += 40; row('如果还上大学(' + b.col.n + ')', '+' + wr(b.col.v[0], b.col.v[1]), y, { fs: 23, c: '#4E5969', vc: '#4E5969' });
y += 34; T('(大学不计入合计)', X0, y, '400 21px ' + FONT, '#B5BAC2');
}
y += 42; row('时间、心思和拥抱', '无法计价', y, { fs: 24, bold: true, c: '#B4733A', vc: '#B4733A' });
y += 28; dash(y);
y += 56; T('谢 谢 惠 顾', W / 2, y, '800 28px ' + FONT, '#1F2329', 'center');
y += 38; T('宝宝一经签收,不退不换', W / 2, y, '400 22px ' + FONT, '#86909C', 'center');
y += 32; T('账单按行情中间值估算,仅供参考', W / 2, y, '400 22px ' + FONT, '#86909C', 'center');
y += 26;
if (!dry) { // 条码(与页面同一个种子)
var bx = W / 2 - 230, sd = +billNo.slice(-5) || 7, xx = 0, k3 = 460 / 236;
c.fillStyle = '#2B2F36';
while (xx < 236) { sd = (sd * 9301 + 49297) % 233280; var bw = 1 + (sd % 3), gp = 1 + ((sd >> 3) % 3); c.fillRect(bx + xx * k3, y, bw * k3, 78); xx += bw + gp; }
}
y += 78 + 32;
T(billNo.split('').join(' '), W / 2, y, '500 20px ' + MONO, '#86909C', 'center');
y += 34;
paint.ph = y - paperTop;
// 纸外落款
y += 11 + 56;
T('兜爸心选', W / 2, y, '800 28px ' + FONT, '#18A058', 'center');
y += 36;
T('孕期专题 · 生娃计算器 · 估算仅供参考', W / 2, y, '400 21px ' + FONT, '#86909C', 'center');
y += 44;
return y;
}
var nItems = b.n, rh = 46, H = paint(null, true, rh);
if (H > BILL_MAX_H) { rh = Math.max(32, rh - Math.ceil((H - BILL_MAX_H) / nItems)); H = paint(null, true, rh); }
var k = Math.min(1, BILL_MAX_H / H, Math.sqrt(16000000 / (W * H))); // 仍超就整体缩小
var cv = document.createElement('canvas');
cv.width = Math.round(W * k); cv.height = Math.round(H * k);
var c = cv.getContext('2d');
c.scale(k, k);
var g = c.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, '#EAF7F0'); g.addColorStop(.5, '#F5FBF8'); g.addColorStop(1, '#EAF7F0');
c.fillStyle = g; c.fillRect(0, 0, W, H);
c.textBaseline = 'alphabetic';
paint(c, false, rh);
return cv.toDataURL('image/png');
}
function openMask(url, alt, name, tip, long) {
var im = $('#maskImg'); im.src = url; im.alt = alt;
$('#maskTip').textContent = tip;
var dl = $('#dlBtn'); dl.href = url; dl.download = name; dl.hidden = isWx;
var m = $('#mask'); m.classList.toggle('long', !!long); m.classList.add('on');
$('#mwrap').scrollTop = 0;
}
$('#billBtn').addEventListener('click', function () {
if (!bill) return;
toast('正在生成账单图片…');
setTimeout(function () {
try {
var url = drawBill();
$('#toast').classList.remove('on');
openMask(url, '我的养娃账单', '养娃账单.png', '长按图片,保存到相册', true);
} catch (e) { toast('生成失败,截个图保存也可以'); }
}, 30);
});
$('#maskClose').addEventListener('click', function () { $('#mask').classList.remove('on'); });
$('#mask').addEventListener('click', function (e) { if (e.target === this) this.classList.remove('on'); });
@@ -1191,7 +1401,7 @@ h1 .mk{background:linear-gradient(transparent 64%,rgba(24,160,88,.24) 0)}
var dy = e.touches[0].clientY - y0;
if ((sc.scrollTop <= 0 && dy > 0) || (sc.scrollTop + sc.clientHeight >= sc.scrollHeight - 1 && dy < 0)) e.preventDefault();
}, { passive: false });
document.addEventListener('touchmove', function (e) { if (!sc.contains(e.target)) e.preventDefault(); }, { passive: false });
document.addEventListener('touchmove', function (e) { if (!sc.contains(e.target) && !(e.target.closest && e.target.closest('.mask.long .mwrap'))) e.preventDefault(); }, { passive: false });
})();
</script>
</body>