生娃计算器:新增「保存账单图片」,canvas 画整张小票长图,长按保存
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user