feat(今日脚印): AI 气泡改说真话 —— 文案取接口 digest,引导取 lead+unused (v26092217)

配合 box_app 的 B 方案(今日脚印换源到数胎动用户面 /user/daily/footprint)。
接口现在一次给全:事件 + digest(气泡文案)+ lead/unused(引导),于是首页不用再编数据。

- **AI 气泡**:文案由 fpRender() 从 `digest.text` 写入(真实文案,宝宝口气的 AI 句子),
  `digest.warn` 为真时整句标红(.ai-warn)。取不到 digest 就整颗气泡收起。
  ⚠️ index.html 里那句写死的「妈妈,今天你数到我动了 3 次…」已删除 —— 它对任何用户都显示,
     而且把 today 的假次数和真实时间轴摆在同一屏。现在这条规则是 **data-driven**(有文案才显示),
     不再靠"有没有事件"猜。
- **引导占位**:文案用接口的 `lead`(如「宝妈,先从这几件事记起吧。」),应用用 `unused[]` ——
  能对上 App 表的渲染成**可点芯片**(点击 jumpApp 进该应用),对不上(如不在 App 表的宝宝来信)只显示名字。
- **三种显隐模式**(fpRender 是模块显隐的唯一 owner):
    rows  拿到事件 → 时间轴;guide 一条都没有(上游查无此人 / kind=fallback 今天没活动 /
    只浏览没产出记录)→ 引导;hide 接口读不到 / degraded → 整块不显示。
  原来「有档案但今天没记」的「还没有足迹」空态并入 guide(上游的 lead + 推荐应用比一句空态有用)。
- 删掉不再使用的 emptyFootprintStep()。

本地验证(桩后端,五种返回 × 资料延迟):rows → 时间轴 + 气泡显示**真实 digest 文案**;
guide → 上游 lead + 芯片(数胎动/胎儿估重可点、宝宝来信纯文本);linked=false → 引导兜底标题;
degraded / 接口 500 → 整块不显示。全程无 JS 异常。
This commit is contained in:
2026-09-23 00:19:00 +08:00
parent 0f72863d10
commit 77fb75796d
3 changed files with 85 additions and 55 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:26092216 缓存版本标记:改 css/js 时同步 bump 该值(与 js/app.js 的 VER 同源),静态资源按 mtime 走 ETag 自动刷新 --> <!-- V:26092217 缓存版本标记:改 css/js 时同步 bump 该值(与 js/app.js 的 VER 同源),静态资源按 mtime 走 ETag 自动刷新 -->
<link rel="stylesheet" href="mama-box.css"> <link rel="stylesheet" href="mama-box.css">
<script src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script> <script src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>
<!-- 共享助手:授权/跳商城/位置上报(分享卡片同步,见 docs/h5-auth-standard.md 第四节) --> <!-- 共享助手:授权/跳商城/位置上报(分享卡片同步,见 docs/h5-auth-standard.md 第四节) -->
@@ -316,10 +316,14 @@
aria-label="宝宝的今日脚印,查看完整档案"> aria-label="宝宝的今日脚印,查看完整档案">
<div class="step"><span class="st-time">今天</span><span class="st-dot"></span><span class="st-txt">正在获取今天的脚印…</span><span class="st-from"></span></div> <div class="step"><span class="st-time">今天</span><span class="st-dot"></span><span class="st-txt">正在获取今天的脚印…</span><span class="st-from"></span></div>
</div> </div>
<!-- 无档案时的引导占位(fpApply('guide') 才显示;此时 #tlSteps 收起) --> <!-- 引导占位(fpApply('guide') 才显示,此时 #tlSteps 收起):
文案与应用**都来自接口** —— lead 是上游自己的引导语(如「宝妈,先从这几件事记起吧。」),
unused[] 是今天没用过的应用,能对上 App 表的会渲染成可点芯片(见 js 的 renderGuide)。
这里的默认文案只在接口没给 lead 时兜底。 -->
<div class="tl-guide" id="tlGuide" style="display:none"> <div class="tl-guide" id="tlGuide" style="display:none">
<div class="tl-guide-t">还没有你的脚印</div> <div class="tl-guide-t" id="tlGuideTitle">还没有你的脚印</div>
<div class="tl-guide-s">去「数胎动」记一笔,这里就会长出你今天的脚印</div> <div class="tl-guide-s" id="tlGuideSub">去上面的工具记一笔,这里就会长出你今天的脚印</div>
<div class="tl-guide-apps" id="tlGuideApps"></div>
</div> </div>
<!-- 这一天的收尾节点。三个直接子元素刻意和 .step 一样排 <!-- 这一天的收尾节点。三个直接子元素刻意和 .step 一样排
(42px 的左格 + 圆点 + 剩下的),点才落得进同一条竖线里。 (42px 的左格 + 圆点 + 剩下的),点才落得进同一条竖线里。
@@ -335,11 +339,15 @@
<!-- 这句是 app.js 里 AI_LOGS[0].txt 的手写副本(这里要 <b> 和 <br> 排版, <!-- 这句是 app.js 里 AI_LOGS[0].txt 的手写副本(这里要 <b> 和 <br> 排版,
没法直接共用那个字符串)。那边改口吻,这里必须跟着改, 没法直接共用那个字符串)。那边改口吻,这里必须跟着改,
否则首页气泡说「小家伙」、点进去小结说「我」,像两个人在写。 否则首页气泡说「小家伙」、点进去小结说「我」,像两个人在写。
⚠️ 它是**演示文案**(数字写死),所以只有拿到真实事件的当天才显示: ✅ 2026-09-23 已接真源:句子的数据来自 box_app 的 /footprint/today
.today-line 挂上 .no-ai 时整颗气泡收起(见 JS 的 fpApply)。 (上游数胎动平台的用户面接口 /user/daily/footprint 的 `digest.text`),
待办:把「3 次」接到真实次数(卡片接口 kick.value / events 里数胎动那条), 由 fpRender() 写入;`digest.warn` 为真时整句标红(.ai-warn)。
并给「今天没记」定一句不说假话的文案 —— 产品确认口径后再改。 --> ⚠️ 但 AI 小结面板(详情页 renderAI → AI_LOGS)里那份仍是**演示文案**,
<span class="ab-say">妈妈,今天你数到我动了 <b>3</b> 次,<br>我今天精神特别好。</span> 与这里是两套数据 —— 面板要一并用真数据,得先有 mood/stats/建议的字段。 -->
<!-- ⚠️ 文案由 js 的 fpRender() 从接口 `digest.text` 写入(今日脚印接口给的真文案)。
原来这里写死「妈妈,今天你数到我动了 3 次…」,任何用户都看到 —— 已删。
接口没给 digest(今天没活动 / 读不到)时整颗气泡由 .no-ai 收起,不留空壳。 -->
<span class="ab-say"></span>
</span> </span>
<!-- 「AI自动汇总你每日记录」按用户要求删了,这行现在只剩右边的入口。 <!-- 「AI自动汇总你每日记录」按用户要求删了,这行现在只剩右边的入口。
✅ 位置不用额外处理:.ab-foot em 本来就写着 margin-left:auto, ✅ 位置不用额外处理:.ab-foot em 本来就写着 margin-left:auto,

106
js/app.js
View File

@@ -3,7 +3,7 @@
(function () { (function () {
'use strict'; 'use strict';
/* 页面版本号(YYMMDD+序号):显示在「关于兜爸心选」页底部;bump 时同步 head 注释与 ?v= */ /* 页面版本号(YYMMDD+序号):显示在「关于兜爸心选」页底部;bump 时同步 head 注释与 ?v= */
var VER = '26092216'; var VER = '26092217';
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));
@@ -3105,44 +3105,70 @@
⚠️ 竞态:本函数与 applyRemoteProfile 并行。profile 回来若判 pregEmpty 会 ⚠️ 竞态:本函数与 applyRemoteProfile 并行。profile 回来若判 pregEmpty 会
隐藏时间轴,所以这里落笔前再看一眼 state.pregEmpty,别把刚藏好的放出来。 */ 隐藏时间轴,所以这里落笔前再看一眼 state.pregEmpty,别把刚藏好的放出来。 */
var _footprintApplied = false; var _footprintApplied = false;
/* 今日脚印的显示规则(2026-09-22 产品口径)—— **四种模式,全部由 fpApply 一处决定**: /* 今日脚印(2026-09-23,B 方案:数据全部来自 box_app 的 /footprint/today,
rows 有档案 + 拿到事件 → 渲染真实事件 它的上游是数胎动平台的**用户面**接口 /user/daily/footprint):
empty 有档案 + 今天没记 → 「还没有足迹」 rows 拿到事件 → 时间轴;AI 气泡用接口给的 digest.text(**真文案**)
guide **无档案**(上游查无此人 / 本地未关联)→ 引导占位 #tlGuide,收起时间轴 guide 今天没有任何活动 / 上游查无此人 → 引导占位:文案用接口的 lead、应用用 unused[],
hide 有档案但**接口读不到**(网络 / 非 200 / 上游故障且无旧值)→ **整块不显示** 对得上 App 表的还能直接点进该应用
⚠️ 原来「读不到」是回退到 4 条**写死的演示行**(胎动 12 次 / 体重 58.2kg…)—— hide 接口读不到(网络 / 非 200 / 上游故障且无旧值)→ **整块不显示**
上游一抖页面就开始编数据。那 4 行已从 index.html 删掉,换成一行为中性「正在获取…」。 ⚠️ AI 气泡不再挂写死的那句「今天你数到我动了 3 次」:文案一律取 digest.text,取不到就收起气泡
⚠️ 除「已建档 + rows」外一律给 .today-line 挂 .no-ai:AI 气泡里的次数是写死的演示文案, —— **data-driven**,不靠"有没有事件"猜(那句演示文案已从 index.html 删掉)。
没有真实事件(或还没建档)时显示它等于说假话(见 index.html 里那段注释与待办)。 ⚠️ 未建档(pregEmpty)不再整块隐藏:照接口结论显示,新用户也能看到引导。
⚠️ **未建档(pregEmpty)不再整块隐藏**:照接口结论显示(无档案→引导), ⚠️ 模块显隐只有 fpRender 一个 owner(原来 syncPregnancy 也直接改 display,会撞竞态)。 */
所以新用户首页也能看到「去「数胎动」记一笔」的引导。 */ let _fpMode = 'pending'; // pending(加载中) | rows | guide | hide
/* 当前模式 + 事件缓存在这里 —— fpRender() 是**模块显隐的唯一 owner**。 let _fpData = null; // 接口 result(events / digest / lead / unused…)
⚠️ 之前 syncPregnancy 也直接改 .today-line 的 display,两个 owner 会撞竞态: function fpApply(mode, r) { _fpMode = mode; _fpData = r || null; fpRender(); }
接口先判「不显示」(hide),profile 后回来又把模块显示回来 → degraded 场景就是这么漏出来的。 */
let _fpMode = 'pending'; // pending(加载中) | rows | empty | guide | hide
let _fpEvents = [];
function fpApply(mode, events) { _fpMode = mode; _fpEvents = events || []; fpRender(); }
function fpRender() { function fpRender() {
const tl = document.querySelector('.today-line'), steps = $('#tlSteps'), guide = $('#tlGuide'); const tl = document.querySelector('.today-line'), steps = $('#tlSteps'), guide = $('#tlGuide');
if (!tl || !steps) return; if (!tl || !steps) return;
/* ⚠️ **未建档不再整块隐藏**(2026-09-22 产品口径):模块照接口给的结论显示 —— const mode = _fpMode, r = _fpData || {};
无档案→引导、有档案→事件/空态、读不到→不显示。原来那道 pregEmpty/postpartum 硬门槛已去掉。 */
const mode = _fpMode;
if (mode === 'hide') { tl.style.display = 'none'; return; } if (mode === 'hide') { tl.style.display = 'none'; return; }
tl.style.display = ''; tl.style.display = '';
/* ⚠️ AI 气泡里的次数是**写死的演示文案**,所以只有「已建档 + 拿到真实事件」才显示它: /* AI 气泡:文案来自接口 digest.text;没有就整体收起。上游文案是**纯文本**,用 textContent 写。 */
未建档(还没孕产期,页面本来就全是占位)/ 今天没记 / 无档案 / 读不到 都不显示, const say = document.querySelector('.ab-say');
否则同一屏上会出现「还没有足迹」与「动了 3 次」并存这种自相矛盾。 const txt = (r.digest && r.digest.text) || '';
(数字接真实次数是另一件事,等产品定文案,见 index.html 那段注释与待办。) */ if (say) say.textContent = txt;
tl.classList.toggle('no-ai', mode !== 'rows' || state.pregEmpty); tl.classList.toggle('no-ai', !txt);
tl.classList.toggle('ai-warn', !!(r.digest && r.digest.warn));
if (guide) guide.style.display = (mode === 'guide') ? '' : 'none'; if (guide) guide.style.display = (mode === 'guide') ? '' : 'none';
if (mode === 'guide') { steps.style.display = 'none'; steps.innerHTML = ''; return; } if (mode === 'guide') { steps.style.display = 'none'; steps.innerHTML = ''; renderGuide(r); return; }
steps.style.display = ''; steps.style.display = '';
steps.innerHTML = ''; steps.innerHTML = '';
if (mode === 'empty') { steps.append(emptyFootprintStep()); return; }
if (mode === 'pending') { steps.append(pendingFootprintStep()); return; } if (mode === 'pending') { steps.append(pendingFootprintStep()); return; }
_fpEvents.forEach(e => steps.append(buildFootprintStep(e))); (r.events || []).forEach(e => steps.append(buildFootprintStep(e)));
} }
/* 引导占位:文案与应用都来自上游 —— lead 是上游自己的引导语,unused[] 是今天没用过的应用。
能对上 App 表的(identifier 由 box_app 按 url 路径解析)渲染成可点芯片,其余只显示名字。 */
function renderGuide(r) {
const title = $('#tlGuideTitle'), sub = $('#tlGuideSub'), box = $('#tlGuideApps');
if (title) title.textContent = (r && r.lead) || '还没有你的脚印';
if (!box) return;
box.innerHTML = '';
((r && r.unused) || []).slice(0, 5).forEach(u => {
const ident = u.identifier || '';
const app = ident ? APP_BY_ID[ident] : null;
if (app && app.url) {
const b = document.createElement('button');
b.type = 'button';
b.className = 'tl-chip ripple';
b.textContent = u.appName || app.name || ident;
b.addEventListener('click', () => jumpApp(app.url, app.identifier));
box.appendChild(b);
} else if (u.appName) {
const sp = document.createElement('span');
sp.className = 'tl-chip is-plain';
sp.textContent = u.appName;
box.appendChild(sp);
}
});
if (sub) {
sub.textContent = box.childElementCount
? '从下面这些记一笔,今天的脚印就会长出来'
: '去上面的工具记一笔,这里就会长出你今天的脚印';
}
}
/* 加载中的中性占位(不是假数据):与 index.html 里那条首屏占位同构 */ /* 加载中的中性占位(不是假数据):与 index.html 里那条首屏占位同构 */
function pendingFootprintStep() { function pendingFootprintStep() {
const d = document.createElement('div'); d.className = 'step'; const d = document.createElement('div'); d.className = 'step';
@@ -3168,12 +3194,12 @@
.then(j => { .then(j => {
const r = (j && j.code === 200 && j.result) ? j.result : null; const r = (j && j.code === 200 && j.result) ? j.result : null;
if (!r) { fpApply('hide'); return; } // 读不到 → 不显示(不再编数据) if (!r) { fpApply('hide'); return; } // 读不到 → 不显示(不再编数据)
/* 未建档不再拦(2026-09-22 产品口径):照接口结论走,未建档也能看到引导占位 */ if (r.degraded) { fpApply('hide'); return; } // 上游故障且无旧值 → 不显示
if (!r.linked) { fpApply('guide'); return; } // 无档案 → 引导占位
if (r.degraded) { fpApply('hide'); return; } // 有档案、上游故障且无旧值 → 不显示
const events = (r.events || []).slice(-8); // 升序取最近 8 条 const events = (r.events || []).slice(-8); // 升序取最近 8 条
if (!events.length) { fpApply('empty'); return; } // 有档案、今天没记 → 「还没有足迹」 /* 有事件 → 时间轴;一条都没有(上游查无此人 linked=false、今天 kind=fallback 没活动、
fpApply('rows', events); // 有档案 + 有事件(含 stale 旧值)→ 真数据 或只浏览没产出记录)→ 统一走引导:文案与应用名都由接口给,不再自己编。 */
if (events.length) { fpApply('rows', r); return; }
fpApply('guide', r);
}) })
.catch(function () { fpApply('hide'); }); // 读不到 → 不显示 .catch(function () { fpApply('hide'); }); // 读不到 → 不显示
} }
@@ -3202,18 +3228,6 @@
} }
/* 无足迹占位行:同构的一行,读屏和眼睛都不会把空卡当成坏卡。 */ /* 无足迹占位行:同构的一行,读屏和眼睛都不会把空卡当成坏卡。 */
function emptyFootprintStep() {
const d = document.createElement('div'); d.className = 'step';
const time = document.createElement('span'); time.className = 'st-time';
time.textContent = '今天';
const dot = document.createElement('span'); dot.className = 'st-dot';
const txt = document.createElement('span'); txt.className = 'st-txt';
txt.textContent = '还没有足迹';
const from = document.createElement('span'); from.className = 'st-from';
from.textContent = '去逛逛上面的工具吧';
d.append(time, dot, txt, from);
return d;
}
/* ---------- 进商城(web-view 内跳商城原生页) ---------- /* ---------- 进商城(web-view 内跳商城原生页) ----------
兜爸心选 H5 由小程序 web-view 承载;点「商城」跳回商城原生页。 兜爸心选 H5 由小程序 web-view 承载;点「商城」跳回商城原生页。

View File

@@ -643,6 +643,14 @@ a.skip:focus{left:16px;top:16px}
.tl-guide{background:#fff;border-radius:22px;padding:20px 18px;box-shadow:var(--sh);text-align:center} .tl-guide{background:#fff;border-radius:22px;padding:20px 18px;box-shadow:var(--sh);text-align:center}
.tl-guide-t{font-size:15px;font-weight:800;color:var(--ink)} .tl-guide-t{font-size:15px;font-weight:800;color:var(--ink)}
.tl-guide-s{font-size:12.5px;color:var(--ink-2);margin-top:6px;line-height:1.6} .tl-guide-s{font-size:12.5px;color:var(--ink-2);margin-top:6px;line-height:1.6}
/* 引导里可点的应用芯片(名字来自接口 unused[];identifier 是 box_app 按 App 表 url 路径解析的) */
.tl-guide-apps{display:flex;flex-wrap:wrap;gap:8px;justify-content:center;margin-top:12px}
.tl-guide-apps:empty{display:none}
.tl-chip{font:inherit;font-size:12.5px;font-weight:700;color:var(--brand-dk);
background:#FFF1EC;border:1px solid #FFDFD5;border-radius:999px;padding:7px 13px;line-height:1}
.tl-chip.is-plain{color:var(--muted);background:#F6F1ED;border-color:var(--line)}
/* 接口说这句"需要标红"时(digest.warn)整句变告警色 */
.today-line.ai-warn .ab-say{color:var(--alert)}
/* 没有真实事件的当天不显示 AI 气泡:那句话里的次数是**写死的**(见 index.html 的注释), /* 没有真实事件的当天不显示 AI 气泡:那句话里的次数是**写死的**(见 index.html 的注释),
今天没记 / 未关联时显示它等于编数据。拿到真实事件(fpApply('rows'))时才摘掉这个类。 */ 今天没记 / 未关联时显示它等于编数据。拿到真实事件(fpApply('rows'))时才摘掉这个类。 */
.today-line.no-ai .ai-bubble{display:none} .today-line.no-ai .ai-bubble{display:none}