首页工具区下新增「我的孕期数据」:体重/血压/血糖自动读取 v26103104

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
deploy
2026-10-31 11:46:08 +00:00
parent 750be14823
commit fc67dd8628
2 changed files with 158 additions and 3 deletions

View File

@@ -7,7 +7,7 @@
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<title>兜爸心选 · 孕育陪伴</title> <title>兜爸心选 · 孕育陪伴</title>
<!-- V:26103103 缓存版本标记:改 css/js 时同步 bump 该值(与 js/app.js 的 VER 同源),静态资源按 mtime 走 ETag 自动刷新 --> <!-- V:26103104 缓存版本标记:改 css/js 时同步 bump 该值(与 js/app.js 的 VER 同源),静态资源按 mtime 走 ETag 自动刷新 -->
<link rel="stylesheet" href="mama-box.css"> <link rel="stylesheet" href="mama-box.css">
<link rel="stylesheet" href="night.css"> <link rel="stylesheet" href="night.css">
<!-- 夜晚模式:首屏绘制前就按上次的选择加 class,避免先白后黑闪一下(开关在首页右上角 #nightBtn,逻辑见 app.js) --> <!-- 夜晚模式:首屏绘制前就按上次的选择加 class,避免先白后黑闪一下(开关在首页右上角 #nightBtn,逻辑见 app.js) -->
@@ -285,6 +285,64 @@
</button> </button>
</div> </div>
</div> </div>
<!-- 2026-10-31 用户定:工具宫格下面加「我的孕期数据」——体重 / 血压 / 血糖最近一次的数值,
妈妈在对应工具里填完,回到首页自动刷新(app.js loadVitals:各工具自己的云端存储,只读、只认非示例记录)。
有身份 + 孕期才显示;产后 / 访客整块隐藏。点哪格就进哪个工具。 -->
<div class="vt hide" id="vitals">
<div class="vt-h"><b>我的孕期数据</b><span>在工具里记一笔,这里自动更新</span></div>
<div class="vt-row">
<button type="button" class="vt-it vt-w ripple" data-vt="weight">
<span class="vt-top"><i class="vt-ic"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3.5" y="4" width="17" height="16" rx="5" fill="currentColor" opacity=".18"/><rect x="3.5" y="4" width="17" height="16" rx="5" fill="none" stroke="currentColor" stroke-width="2"/><path d="M8.5 10.5a3.5 3.5 0 017 0" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><path d="M12 10.5l1.6-1.8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></i><span class="vt-l">体重<small>kg</small></span></span>
<span class="vt-v"><b id="vtW">--</b></span>
<span class="vt-s" id="vtWs">去记一笔</span>
<span class="vt-d" id="vtWd"></span>
</button>
<button type="button" class="vt-it vt-p ripple" data-vt="bp">
<span class="vt-top"><i class="vt-ic"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 20.5s-7.5-4.6-7.5-10.2A4.3 4.3 0 0112 7.6a4.3 4.3 0 017.5 2.7c0 5.6-7.5 10.2-7.5 10.2z" fill="currentColor" opacity=".18"/><path d="M12 20.5s-7.5-4.6-7.5-10.2A4.3 4.3 0 0112 7.6a4.3 4.3 0 017.5 2.7c0 5.6-7.5 10.2-7.5 10.2z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><path d="M6.5 12.5h3l1.3-2.3 2 4.3 1.3-2h3.4" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg></i><span class="vt-l">血压<small>mmHg</small></span></span>
<span class="vt-v"><b id="vtP">--</b></span>
<span class="vt-s" id="vtPs">去记一笔</span>
<span class="vt-d" id="vtPd"></span>
</button>
<button type="button" class="vt-it vt-g ripple" data-vt="glu">
<span class="vt-top"><i class="vt-ic"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3.5s6 6.6 6 10.9a6 6 0 01-12 0C6 10.1 12 3.5 12 3.5z" fill="currentColor" opacity=".18"/><path d="M12 3.5s6 6.6 6 10.9a6 6 0 01-12 0C6 10.1 12 3.5 12 3.5z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><path d="M9.3 14.6a2.8 2.8 0 002.7 2.6" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg></i><span class="vt-l">血糖<small>mmol/L</small></span></span>
<span class="vt-v"><b id="vtG">--</b></span>
<span class="vt-s" id="vtGs">去记一笔</span>
<span class="vt-d" id="vtGd"></span>
</button>
</div>
</div>
<style>
.vt{margin-top:12px;padding:12px;border-radius:20px;background:linear-gradient(180deg,#FFF3EE,#FFFDFB);border:1px solid #F7E0D7}
.vt.hide{display:none}
.vt-h{display:flex;align-items:baseline;gap:8px;margin:0 2px 10px}
.vt-h b{font-size:15.5px;color:#3B2A2A}
.vt-h span{font-size:11.5px;color:#A88E89}
.vt-row{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}
.vt-it{display:flex;flex-direction:column;align-items:flex-start;gap:3px;padding:10px 10px 10px;border-radius:16px;background:#fff;box-shadow:0 4px 12px rgba(201,120,96,.09);text-align:left;min-width:0;border:0;font:inherit;color:inherit}
.vt-top{display:flex;align-items:center;gap:5px;width:100%}
.vt-ic{flex:none;width:24px;height:24px;border-radius:8px;display:grid;place-items:center}
.vt-ic svg{width:17px;height:17px}
.vt-l{display:flex;flex-direction:column;font-size:12.5px;line-height:1.15;color:#6E5A57;font-weight:600;white-space:nowrap;min-width:0}
.vt-l small{font-size:9.5px;line-height:1.2;color:#B7A29D;font-weight:500}
.vt-v{margin-top:2px;white-space:nowrap}
.vt-v b{font-size:23px;line-height:1.15;font-weight:800;letter-spacing:-.5px;color:#3B2A2A;font-variant-numeric:tabular-nums}
.vt-it.empty .vt-v b{color:#DCCBC6}
.vt-s{font-size:11px;line-height:1.35;padding:1px 8px;border-radius:99px;background:#FFEDE6;color:#D9573A;white-space:nowrap}
.vt-s:empty{display:none}
.vt-it.empty .vt-s{background:none;padding:1px 0;color:#E0775C;font-weight:600}
.vt-s.ok{background:#E6F5EC;color:#2F8A5E}
.vt-s.hi{background:#FDE7E5;color:#D2453B}
.vt-s.lo{background:#FFF1DC;color:#C77A1E}
.vt-d{font-size:10.5px;color:#B09C98;white-space:nowrap;max-width:100%;overflow:hidden;text-overflow:ellipsis}
.vt-d:empty{display:none}
.vt-w .vt-ic{background:#FFE9E1;color:#F4593C}
.vt-p .vt-ic{background:#F1EAFB;color:#8A63C9}
.vt-g .vt-ic{background:#E4F1FC;color:#2F86C9}
html.night .vt{background:#2C2433;border-color:#3D3346}
html.night .vt-it{background:#372E40;box-shadow:none}
html.night .vt-h b,html.night .vt-v b{color:#F3E8F0}
html.night .vt-l{color:#CDBBC8}
</style>
<!-- 此刻(2026-10-01 需求文档 v1.0 第二节;同日两次改版)。数据 GET /api/mp/public/mama-box/pulse,渲染在 app.js pulseCard()。 <!-- 此刻(2026-10-01 需求文档 v1.0 第二节;同日两次改版)。数据 GET /api/mp/public/mama-box/pulse,渲染在 app.js pulseCard()。
⚠️ 模拟数据是产品负责人 2026-10-01 拍板的例外(AGENTS.md §4.12),只限这一条。 ⚠️ 模拟数据是产品负责人 2026-10-01 拍板的例外(AGENTS.md §4.12),只限这一条。
用户 2026-10-01:「把此刻跟上面的孕育工具结合到一起……此刻相当于上面那个卡片的一个轻量化的展示」 用户 2026-10-01:「把此刻跟上面的孕育工具结合到一起……此刻相当于上面那个卡片的一个轻量化的展示」
@@ -2068,7 +2126,7 @@
<!-- 2026-10-05 首页头图:宝宝时不时动一动小手小脚(独立文件,删掉这一行即下线) --> <!-- 2026-10-05 首页头图:宝宝时不时动一动小手小脚(独立文件,删掉这一行即下线) -->
<script src="js/baby-limbs.js?v=26101201"></script> <script src="js/baby-limbs.js?v=26101201"></script>
<script src="js/coach.js?v=26101211"></script> <script src="js/coach.js?v=26101211"></script>
<script src="js/birth-flow.js?v=26103103"></script> <script src="js/birth-flow.js?v=26103104"></script>
<!-- 禁止下拉回弹(2026-10-11 统一规则,与孕期体重同一段):竖向拖动时从手指下往上找还能朝该方向滚的容器,找不到才拦;横滑、双指、可滚列表不受影响 --> <!-- 禁止下拉回弹(2026-10-11 统一规则,与孕期体重同一段):竖向拖动时从手指下往上找还能朝该方向滚的容器,找不到才拦;横滑、双指、可滚列表不受影响 -->
<script> <script>

View File

@@ -3,7 +3,7 @@
(function () { (function () {
'use strict'; 'use strict';
/* 页面版本号(YYMMDD+序号):显示在「关于兜爸心选」页底部;bump 时同步 head 注释与 ?v= */ /* 页面版本号(YYMMDD+序号):显示在「关于兜爸心选」页底部;bump 时同步 head 注释与 ?v= */
var VER = '26103103'; var VER = '26103104';
const $ = (s, c) => (c || document).querySelector(s); const $ = (s, c) => (c || document).querySelector(s);
const $$ = (s, c) => Array.from((c || document).querySelectorAll(s)); const $$ = (s, c) => Array.from((c || document).querySelectorAll(s));
@@ -3355,6 +3355,7 @@
applyRemoteAll(); applyRemoteAll();
applyRemoteFootprint(); applyRemoteFootprint();
applyRemoteCardsFetch(); applyRemoteCardsFetch();
loadVitals();
syncLaixinReminder(); syncLaixinReminder();
loadBlindbox().then(() => { // 孕期盲盒:要等 ② 的预产期落地(服务端按同一份上游视图算孕周) loadBlindbox().then(() => { // 孕期盲盒:要等 ② 的预产期落地(服务端按同一份上游视图算孕周)
/* 2026-10-07 用户要盲盒直达链接:地址带 open=box(# 或 ? 里都行)→ 数据到了直接弹盲盒面板。 /* 2026-10-07 用户要盲盒直达链接:地址带 open=box(# 或 ? 里都行)→ 数据到了直接弹盲盒面板。
@@ -5148,6 +5149,102 @@
], ],
} : null; } : null;
/* ---------- 我的孕期数据(体重 / 血压 / 血糖,2026-10-31 用户定) ----------
上游 /user/app-cards 只聚合了五张工具卡,没有血压、血糖 → 这里直接读三个工具自己的数据(只读):
· 体重 yunqi-tizhong:GET /taidong/api/state → pw_data_v1.weights(跳过 demo:true 示例记录)
· 血糖 yunqi-xuetang:GET /taidong/api/state → tmrj.v1.readings(跳过 demo:1 与老种子 id 如 r29fasting)
· 血压 yunqi-xueya :GET /taidong/api/xueya/records(服务端只存真实记录)
每个工具单独 openid-login 换 token(state 按 app_id 分区),token 在 sessionStorage 缓存 20 分钟。
回到前台 / bfcache 恢复时重拉:妈妈在工具里刚填完,回首页就看到新数。 */
const VT_API = '/taidong/api';
const VT_SLOT = { fasting: '空腹', b1: '早餐后1h', b2: '早餐后2h', l1: '午餐后1h', l2: '午餐后2h', d1: '晚餐后1h', d2: '晚餐后2h', bed: '睡前', night: '夜间' };
const VT_TGT = { fasting: [3.3, 5.3], b1: [3.3, 7.8], l1: [3.3, 7.8], d1: [3.3, 7.8], b2: [3.3, 6.7], l2: [3.3, 6.7], d2: [3.3, 6.7], bed: [4.4, 6.7], night: [3.3, 6.7] };
let _vtAt = 0;
function vtToken(app) {
const openid = hashParam('openid'), unionid = hashParam('unionid');
const ck = 'mmbox.vt.tok.' + app;
try { const c = JSON.parse(sessionStorage.getItem(ck) || 'null'); if (c && c.o === openid && Date.now() - c.t < 20 * 60e3) return Promise.resolve(c.k); } catch (e) {}
return fetch(VT_API + '/auth/openid-login', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ openid: openid, unionid: unionid || null, app_id: app })
}).then(r => (r.ok ? r.json() : null)).then(j => {
const k = j && (j.token || (j.data && j.data.token)); if (!k) return null;
try { sessionStorage.setItem(ck, JSON.stringify({ o: openid, k: k, t: Date.now() })); } catch (e) {}
return k;
});
}
function vtGet(app, path) {
return vtToken(app).then(k => k ? fetch(VT_API + path, { headers: { Authorization: 'Bearer ' + k } }).then(r => (r.ok ? r.json() : null)) : null).catch(() => null);
}
function vtVal(items, key) { /* state 条目可能是 {value:"…json…"} 或直接值 */
let v = items && items[key];
if (v && typeof v === 'object' && 'value' in v) v = v.value;
if (typeof v === 'string') { try { v = JSON.parse(v); } catch (e) { v = null; } }
return v && typeof v === 'object' ? v : null;
}
function vtDay(d) { /* 'YYYY-MM-DD…' 或时间戳 → 今天 / 昨天 / M月D日 */
const t = typeof d === 'number' ? new Date(d) : parseDay(String(d || '').slice(0, 10));
if (!t || isNaN(t)) return '';
const a = new Date(); a.setHours(0, 0, 0, 0);
const b = new Date(t); b.setHours(0, 0, 0, 0);
const n = Math.round((a - b) / DAY_MS);
return n === 0 ? '今天' : n === 1 ? '昨天' : (b.getMonth() + 1) + '月' + b.getDate() + '日';
}
function vtSet(key, val, pill, cls, when) {
const it = $('.vt-it[data-vt="' + key + '"]'); if (!it) return;
const id = { weight: 'vtW', bp: 'vtP', glu: 'vtG' }[key];
it.classList.toggle('empty', !val);
$('#' + id).textContent = val || '--';
const s = $('#' + id + 's'); s.textContent = val ? (pill || '') : '去记一笔 ›'; s.className = 'vt-s' + (val && cls ? ' ' + cls : '');
$('#' + id + 'd').textContent = val ? (when || '') : '';
}
function loadVitals() {
const box = $('#vitals'); if (!box) return;
if (!hashParam('openid') || state.postpartum) { box.classList.add('hide'); return; }
box.classList.remove('hide');
_vtAt = Date.now();
vtGet('yunqi-tizhong', '/state').then(j => {
const pw = vtVal(j && j.items, 'pw_data_v1');
const ws = ((pw && pw.weights) || []).filter(w => w && !w.demo && +w.weight > 0).sort((a, b) => String(a.date).localeCompare(String(b.date)) || (a.updatedAt || 0) - (b.updatedAt || 0));
const last = ws[ws.length - 1];
if (!last) { vtSet('weight', ''); return; }
const pre = pw && !pw.demo && pw.profile && +pw.profile.preWeight;
const gain = pre ? Math.round((last.weight - pre) * 10) / 10 : null;
vtSet('weight', (+last.weight).toFixed(1), gain != null ? '比孕前 ' + (gain >= 0 ? '+' : '') + gain : '', '', vtDay(last.date) + '记录');
});
vtGet('yunqi-xueya', '/xueya/records?limit=5').then(j => {
const rs = ((j && j.items) || []).filter(r => r && r.systolic && r.diastolic).sort((a, b) => String(b.measured_at).localeCompare(String(a.measured_at)));
const r = rs[0];
if (!r) { vtSet('bp', ''); return; }
const sy = +r.systolic, di = +r.diastolic;
/* 孕期口径:≥140/90 偏高(≥160/110 很高),<90/60 偏低 */
const st = (sy >= 160 || di >= 110) ? ['很高,请就医', 'hi'] : (sy >= 140 || di >= 90) ? ['偏高', 'hi'] : (sy < 90 || di < 60) ? ['偏低', 'lo'] : ['正常', 'ok'];
vtSet('bp', sy + '/' + di, st[0], st[1], vtDay(r.measured_at) + '记录');
});
vtGet('yunqi-xuetang', '/state').then(j => {
const tm = vtVal(j && j.items, 'tmrj.v1');
const rs = ((tm && tm.readings) || []).filter(r => r && !r.demo && !/^r\d{1,3}[a-z]/.test(String(r.id || '')) && +r.value > 0).sort((a, b) => (a.ts || 0) - (b.ts || 0));
const r = rs[rs.length - 1];
if (!r) { vtSet('glu', ''); return; }
const t = (tm.targets && tm.targets[r.slot]) || VT_TGT[r.slot] || [3.3, 7.8];
const st = r.value > t[1] ? ['偏高', 'hi'] : r.value < t[0] ? ['偏低', 'lo'] : ['达标', 'ok'];
vtSet('glu', (+r.value).toFixed(1), st[0], st[1], vtDay(r.ts) + (VT_SLOT[r.slot] ? ' · ' + VT_SLOT[r.slot] : ''));
});
}
$$('.vt-it[data-vt]').forEach(b => b.addEventListener('click', () => {
const k = b.dataset.vt;
if (k === 'weight') { const c = $('.mini-card[data-mini="weight"]'); if (c) { c.click(); return; } }
const id = { weight: 'yunqitizhong', bp: 'yun', glu: 'yunqixuetang' }[k];
const app = APP_BY_ID[id];
if (app && app.url) { jumpApp(app.url, app.identifier); return; }
const path = { weight: '/yunqi-tizhong/', bp: '/yunqi-xueya/', glu: '/yunqi-xuetang/' }[k];
haptic(); location.href = withIdentity(location.origin + path);
}));
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'visible' && _vtAt && Date.now() - _vtAt > 3000) loadVitals();
});
window.addEventListener('pageshow', function (e) { if (e.persisted && _vtAt) loadVitals(); });
function applyRemoteCardsFetch() { function applyRemoteCardsFetch() {
if (DEMO_CARDS) { applyRemoteCards(DEMO_CARDS); return; } if (DEMO_CARDS) { applyRemoteCards(DEMO_CARDS); return; }
const openid = hashParam('openid'); const openid = hashParam('openid');